markdown · 7294 bytes Raw Blame History

Organizations (S30)

Organizations as first-class repo owners. The principals table unifies /{slug} resolution across users + orgs so a slug collision is structurally impossible at the DB layer.

Schema (migration 0034)

orgs              — slug citext, plan, allow_member_repo_create, suspended/deleted
org_members       — (org_id, user_id) → role enum('owner','member')
org_invitations   — pending invites by username OR email; HMAC-hashed tokens
principals        — (slug citext PK, kind enum('user','org'), id) maintained
                    by AFTER triggers on users + orgs

The repos.owner_org_id FK to orgs.id is added now (the column was already present from 0017 with the XOR CHECK).

Routing

GET  /organizations/new            create form (auth required)
POST /organizations                create submit
GET  /{slug}                       /{user-or-org} — dispatched via principals.Resolve
POST /{slug}/pins                  owner-only org profile pin customization
GET  /{org}/people                 members + (owner-only) invite form
POST /{org}/people/invite          invite by username OR email
POST /{org}/people/{userID}/role   change role (owner-only)
POST /{org}/people/{userID}/remove remove member (owner-only)
GET  /invitations/{token}          accept/decline view (auth required)
POST /invitations/{token}/accept
POST /invitations/{token}/decline

Organization profile

GET /{slug} renders a GitHub-style organization overview when principals.Resolve returns kind='org'.

The overview data is built in internal/web/handlers/profile:

  • org identity header with slug, display name, description, location, website, avatar fallback, and owner/member view state.
  • org underline nav with Overview active, repository and member counts, links to the shipped people/teams surfaces, and disabled parity tabs for deferred GitHub org sections.
  • pinned repo cards backed by profile_pin_sets / profile_pins after an owner customizes them. Until then, the overview falls back to public org repos sorted by stars and recent update time.
  • recent visible repositories, sorted by updated_at, with visibility badges, language, license, star/fork counts, topics, update time, and a read-only weekly commit-activity sparkline for the default branch.
  • right rail aggregates for people, top primary languages, and most used topics.

Owner/member viewers who can create repositories see org homepage New links to /new?owner=<org-slug>. The repo-create handler only honors that hint after matching it against the viewer's allowed owner picker entries, so unauthorized org hints fall back to the viewer's personal namespace.

GET /{org}/people uses the same GitHub-style org pagehead and underline navigation, then renders the People surface as a permissions layout: a left-side "Organization permissions" menu, a member search toolbar, bordered member rows with avatars, and an owner-only Invite member action. The query URL parameter filters members by username, display name, or role without changing the membership management routes.

Organization owners see a "Customize pins" modal on the overview. The picker mirrors GitHub's public-profile rule: it offers only public org-owned repos, has a live text filter, caps selections at six, and persists the ordered set transactionally. Saving no selected repos is a real customized state and suppresses the automatic fallback.

Repo visibility is filtered through policy.IsVisibleTo using an actor constructed from middleware.CurrentUser, including suspension, site-admin, and impersonation write-mode fields. Anonymous viewers only see public repositories; members and owners see whatever the policy layer grants them.

There is no dedicated /orgs/{org}/repositories page yet. The Overview nav's Repositories item anchors to the homepage repository list until a full org repositories tab lands.

/{slug} resolution flow inside internal/web/handlers/profile/profile.go:

  1. Reserved-name check (defense in depth — chi already matches static routes first).
  2. orgs.Resolve(slug) against principals. On kind='org', dispatch to serveOrgProfile. On kind='user', fall through to the existing user path. On miss, fall through to username_redirects for renamed users.

Member roles

  • owner → implicit admin on every org-owned repo through policy.Can.
  • member → org-membership badge; no implicit access to private repos. Repo-level access is granted via direct collaboration (S15) or teams (S31).

Last-owner protection

ChangeRole and RemoveMember both call CountOrgOwners and refuse the operation if it would leave the org with zero owners (ErrLastOwner). UI must surface this; the orchestrator is the canonical enforcer.

Invitation flow

  • By username: orchestrator resolves the username → user_id and checks for existing membership before issuing the invite.
  • By email: stores the email; recipient claims by signing in with any account that owns the verified email (or by signing up later with that email — pending invites surface in the inbox via ListPendingInvitationsForEmail).

Both paths use a 7-day expiry. Tokens are sha256-hashed at rest; token_hash is the column we look up by.

Principals trigger

Two AFTER triggers (tg_principals_user_sync, tg_principals_org_sync) maintain principals on every users/orgs INSERT/UPDATE/DELETE. The slug PK on principals enforces global uniqueness across both tables — a slug collision either with another user or another org is rejected with SQLSTATE 23505, which the create path translates to ErrSlugTaken.

Soft-deleted users/orgs are dropped from principals so their slug becomes available — the username_redirects table still preserves the old slug for 301s during the rename cooldown.

What we deferred from the spec

  • username_redirects rename to principal_redirects. The rename + kind column would cascade through every sqlc bundle (each one gets a regenerated model). Org renames aren't in the S30 DoD; deferred to a follow-up sprint that owns the rename refactor end to end.
  • Org-level audit log surface, suspension UI, org settings page, avatar upload, email notifications for role-change / remove / suspension / deletion. Schema columns are present; UI and notification fan-out land in follow-ups.
  • Org renaming via principal_redirects — depends on the rename refactor.
  • Daily digest / billing / SAML — post-MVP per spec.

Pitfalls noted in code

  • Slug-vs-username collision: enforced by principals PK, tested by TestCreate_RejectsCollisionWithUsername.
  • Last-owner orphaning: tested by TestChangeRole_LastOwnerProtection.
  • Email-invite claim by wrong user: tested by TestInvite_AcceptByEmailRejectsWrongUser — only an account that owns the verified email matching the invite's target_email can claim.
  • Duplicate invitations: idempotency check returns ErrInvitationDuplicate rather than minting a new token, so re-clicking Invite doesn't spam the recipient.
  • Reserved slugs: auth.IsReserved filter applies to org slugs the same way it applies to usernames.
View source
1 # Organizations (S30)
2
3 Organizations as first-class repo owners. The `principals` table
4 unifies `/{slug}` resolution across users + orgs so a slug collision
5 is structurally impossible at the DB layer.
6
7 ## Schema (migration 0034)
8
9 ```
10 orgs — slug citext, plan, allow_member_repo_create, suspended/deleted
11 org_members — (org_id, user_id) → role enum('owner','member')
12 org_invitations — pending invites by username OR email; HMAC-hashed tokens
13 principals — (slug citext PK, kind enum('user','org'), id) maintained
14 by AFTER triggers on users + orgs
15 ```
16
17 The `repos.owner_org_id` FK to `orgs.id` is added now (the column was
18 already present from 0017 with the XOR CHECK).
19
20 ## Routing
21
22 ```
23 GET /organizations/new create form (auth required)
24 POST /organizations create submit
25 GET /{slug} /{user-or-org} — dispatched via principals.Resolve
26 POST /{slug}/pins owner-only org profile pin customization
27 GET /{org}/people members + (owner-only) invite form
28 POST /{org}/people/invite invite by username OR email
29 POST /{org}/people/{userID}/role change role (owner-only)
30 POST /{org}/people/{userID}/remove remove member (owner-only)
31 GET /invitations/{token} accept/decline view (auth required)
32 POST /invitations/{token}/accept
33 POST /invitations/{token}/decline
34 ```
35
36 ## Organization profile
37
38 `GET /{slug}` renders a GitHub-style organization overview when
39 `principals.Resolve` returns `kind='org'`.
40
41 The overview data is built in `internal/web/handlers/profile`:
42
43 * org identity header with slug, display name, description, location,
44 website, avatar fallback, and owner/member view state.
45 * org underline nav with Overview active, repository and member counts,
46 links to the shipped people/teams surfaces, and disabled parity tabs
47 for deferred GitHub org sections.
48 * pinned repo cards backed by `profile_pin_sets` / `profile_pins`
49 after an owner customizes them. Until then, the overview falls back
50 to public org repos sorted by stars and recent update time.
51 * recent visible repositories, sorted by `updated_at`, with visibility
52 badges, language, license, star/fork counts, topics, update time,
53 and a read-only weekly commit-activity sparkline for the default branch.
54 * right rail aggregates for people, top primary languages, and most
55 used topics.
56
57 Owner/member viewers who can create repositories see org homepage
58 **New** links to `/new?owner=<org-slug>`. The repo-create handler only
59 honors that hint after matching it against the viewer's allowed owner
60 picker entries, so unauthorized org hints fall back to the viewer's
61 personal namespace.
62
63 `GET /{org}/people` uses the same GitHub-style org pagehead and
64 underline navigation, then renders the People surface as a permissions
65 layout: a left-side "Organization permissions" menu, a member search
66 toolbar, bordered member rows with avatars, and an owner-only Invite
67 member action. The `query` URL parameter filters members by username,
68 display name, or role without changing the membership management
69 routes.
70
71 Organization owners see a "Customize pins" modal on the overview. The
72 picker mirrors GitHub's public-profile rule: it offers only public
73 org-owned repos, has a live text filter, caps selections at six, and
74 persists the ordered set transactionally. Saving no selected repos is a
75 real customized state and suppresses the automatic fallback.
76
77 Repo visibility is filtered through `policy.IsVisibleTo` using an actor
78 constructed from `middleware.CurrentUser`, including suspension,
79 site-admin, and impersonation write-mode fields. Anonymous viewers only
80 see public repositories; members and owners see whatever the policy
81 layer grants them.
82
83 There is no dedicated `/orgs/{org}/repositories` page yet. The Overview
84 nav's Repositories item anchors to the homepage repository list until a
85 full org repositories tab lands.
86
87 `/{slug}` resolution flow inside `internal/web/handlers/profile/profile.go`:
88
89 1. Reserved-name check (defense in depth — chi already matches static
90 routes first).
91 2. `orgs.Resolve(slug)` against `principals`. On `kind='org'`, dispatch
92 to `serveOrgProfile`. On `kind='user'`, fall through to the
93 existing user path. On miss, fall through to `username_redirects`
94 for renamed users.
95
96 ## Member roles
97
98 * `owner` → implicit `admin` on every org-owned repo through
99 `policy.Can`.
100 * `member` → org-membership badge; no implicit access to private repos.
101 Repo-level access is granted via direct collaboration (S15) or
102 teams (S31).
103
104 ## Last-owner protection
105
106 `ChangeRole` and `RemoveMember` both call `CountOrgOwners` and refuse
107 the operation if it would leave the org with zero owners
108 (`ErrLastOwner`). UI must surface this; the orchestrator is the
109 canonical enforcer.
110
111 ## Invitation flow
112
113 * By **username**: orchestrator resolves the username → user_id and
114 checks for existing membership before issuing the invite.
115 * By **email**: stores the email; recipient claims by signing in with
116 any account that owns the verified email (or by signing up later
117 with that email — pending invites surface in the inbox via
118 `ListPendingInvitationsForEmail`).
119
120 Both paths use a 7-day expiry. Tokens are sha256-hashed at rest;
121 `token_hash` is the column we look up by.
122
123 ## Principals trigger
124
125 Two AFTER triggers (`tg_principals_user_sync`, `tg_principals_org_sync`)
126 maintain `principals` on every users/orgs INSERT/UPDATE/DELETE. The
127 slug PK on `principals` enforces global uniqueness across both tables
128 — a slug collision either with another user or another org is
129 rejected with SQLSTATE 23505, which the create path translates to
130 `ErrSlugTaken`.
131
132 Soft-deleted users/orgs are dropped from `principals` so their slug
133 becomes available — the username_redirects table still preserves the
134 old slug for 301s during the rename cooldown.
135
136 ## What we deferred from the spec
137
138 * **`username_redirects` rename to `principal_redirects`**. The
139 rename + `kind` column would cascade through every sqlc bundle
140 (each one gets a regenerated model). Org renames aren't in the
141 S30 DoD; deferred to a follow-up sprint that owns the rename
142 refactor end to end.
143 * **Org-level audit log surface**, **suspension UI**, **org settings
144 page**, **avatar upload**, **email notifications for role-change /
145 remove / suspension / deletion**. Schema columns are present; UI and
146 notification fan-out land in follow-ups.
147 * **Org renaming via `principal_redirects`** — depends on the
148 rename refactor.
149 * **Daily digest / billing / SAML** — post-MVP per spec.
150
151 ## Pitfalls noted in code
152
153 * **Slug-vs-username collision**: enforced by `principals` PK,
154 tested by `TestCreate_RejectsCollisionWithUsername`.
155 * **Last-owner orphaning**: tested by
156 `TestChangeRole_LastOwnerProtection`.
157 * **Email-invite claim by wrong user**: tested by
158 `TestInvite_AcceptByEmailRejectsWrongUser` — only an account
159 that owns the verified email matching the invite's `target_email`
160 can claim.
161 * **Duplicate invitations**: idempotency check returns
162 `ErrInvitationDuplicate` rather than minting a new token, so
163 re-clicking Invite doesn't spam the recipient.
164 * **Reserved slugs**: `auth.IsReserved` filter applies to org
165 slugs the same way it applies to usernames.