markdown · 10791 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  /orgs/{org}/repositories      repository list with filters + pagination
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  /organizations/{org}/settings/profile
POST /organizations/{org}/settings/profile
POST /organizations/{org}/settings/profile/avatar
POST /organizations/{org}/settings/profile/avatar/remove
POST /organizations/{org}/settings/delete
GET  /organizations/{org}/settings/import
POST /organizations/{org}/settings/import
GET  /organizations/{org}/imports/{importID}
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.

GET /organizations/{org}/settings/profile renders the owner-only organization settings profile page. The page uses the GitHub settings shape: org pagehead + underline nav, left settings sidebar, General profile form, profile-picture aside, in-product message rows, and a Danger zone. POST /organizations/{org}/settings/profile updates the persisted org fields (display_name, description, website, location, billing_email, and allow_member_repo_create) with friendly length, URL, and email validation before writing through the org sqlc queries. Avatar upload/removal stores object keys through the avatar pipeline and object store. POST /organizations/{org}/settings/delete soft-deletes the org through orgs.SoftDelete after an owner confirms the slug; the hard-delete worker still owns permanent removal after the grace window.

GET /organizations/{org}/settings/import renders the owner-only GitHub organization import page. Owners can provide a GitHub organization name or github.com/<org> URL and, optionally, a token. Untokened imports discover public repositories only. Token-backed imports use GitHub's type=all repo listing, persist the token encrypted with the server secret box, and keep private upstream repos private on shithub.

POST /organizations/{org}/settings/import creates an org_github_imports row and enqueues worker.KindOrgGitHubImportDiscover. GET /organizations/{org}/imports/{importID} shows a polling progress page with per-repo queued/importing/imported/skipped/failed state. The discover job pages through the GitHub API, records one org_github_import_repos row per repository, and enqueues an import job per repo. Each repo job creates the org-owned shithub repository, saves the GitHub source remote, fetches heads/tags with a temporary Git askpass helper when a token is present, updates the default branch from fetched refs, and enqueues indexing + size recalculation. Existing active repositories in the organization are skipped instead of overwritten.

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.

GET /orgs/{org}/repositories is the dedicated organization repositories surface, matching GitHub's current org route shape. It uses the same policy-filtered visible repo set as the overview, then applies query, type, language, sort, and page parameters in the handler. The page renders 30 repositories per page with bordered GitHub-style rows, topics, language/license/star/fork metadata, default-branch activity sparklines, and numbered pagination.

/{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.

Billing posture

Organizations are the first planned paid shithub surface. The orgs.plan and billing_email fields are present today, but payment processing and entitlement enforcement live in the PAYMENTS sprint series. The durable product and implementation contract is tracked in billing.md.

Until that series lands, production code must not branch on orgs.plan for feature access. Paid feature checks should go through the future entitlement package described in the billing doc.

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 rename / archive settings actions, 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.
  • Org import token storage: the token never enters a Git URL, logs, or plaintext database column. It is encrypted in org_github_imports.token_ciphertext and supplied to git via a temporary askpass script scoped to the fetch process.
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 /orgs/{org}/repositories repository list with filters + pagination
28 GET /{org}/people members + (owner-only) invite form
29 POST /{org}/people/invite invite by username OR email
30 POST /{org}/people/{userID}/role change role (owner-only)
31 POST /{org}/people/{userID}/remove remove member (owner-only)
32 GET /organizations/{org}/settings/profile
33 POST /organizations/{org}/settings/profile
34 POST /organizations/{org}/settings/profile/avatar
35 POST /organizations/{org}/settings/profile/avatar/remove
36 POST /organizations/{org}/settings/delete
37 GET /organizations/{org}/settings/import
38 POST /organizations/{org}/settings/import
39 GET /organizations/{org}/imports/{importID}
40 GET /invitations/{token} accept/decline view (auth required)
41 POST /invitations/{token}/accept
42 POST /invitations/{token}/decline
43 ```
44
45 ## Organization profile
46
47 `GET /{slug}` renders a GitHub-style organization overview when
48 `principals.Resolve` returns `kind='org'`.
49
50 The overview data is built in `internal/web/handlers/profile`:
51
52 * org identity header with slug, display name, description, location,
53 website, avatar fallback, and owner/member view state.
54 * org underline nav with Overview active, repository and member counts,
55 links to the shipped people/teams surfaces, and disabled parity tabs
56 for deferred GitHub org sections.
57 * pinned repo cards backed by `profile_pin_sets` / `profile_pins`
58 after an owner customizes them. Until then, the overview falls back
59 to public org repos sorted by stars and recent update time.
60 * recent visible repositories, sorted by `updated_at`, with visibility
61 badges, language, license, star/fork counts, topics, update time,
62 and a read-only weekly commit-activity sparkline for the default branch.
63 * right rail aggregates for people, top primary languages, and most
64 used topics.
65
66 Owner/member viewers who can create repositories see org homepage
67 **New** links to `/new?owner=<org-slug>`. The repo-create handler only
68 honors that hint after matching it against the viewer's allowed owner
69 picker entries, so unauthorized org hints fall back to the viewer's
70 personal namespace.
71
72 `GET /{org}/people` uses the same GitHub-style org pagehead and
73 underline navigation, then renders the People surface as a permissions
74 layout: a left-side "Organization permissions" menu, a member search
75 toolbar, bordered member rows with avatars, and an owner-only Invite
76 member action. The `query` URL parameter filters members by username,
77 display name, or role without changing the membership management
78 routes.
79
80 Organization owners see a "Customize pins" modal on the overview. The
81 picker mirrors GitHub's public-profile rule: it offers only public
82 org-owned repos, has a live text filter, caps selections at six, and
83 persists the ordered set transactionally. Saving no selected repos is a
84 real customized state and suppresses the automatic fallback.
85
86 `GET /organizations/{org}/settings/profile` renders the owner-only
87 organization settings profile page. The page uses the GitHub settings
88 shape: org pagehead + underline nav, left settings sidebar, General
89 profile form, profile-picture aside, in-product message rows, and a
90 Danger zone. `POST /organizations/{org}/settings/profile` updates the
91 persisted org fields (`display_name`, `description`, `website`,
92 `location`, `billing_email`, and `allow_member_repo_create`) with
93 friendly length, URL, and email validation before writing through the
94 org sqlc queries. Avatar upload/removal stores object keys through the
95 avatar pipeline and object store.
96 `POST /organizations/{org}/settings/delete` soft-deletes the org through
97 `orgs.SoftDelete` after an owner confirms the slug; the hard-delete
98 worker still owns permanent removal after the grace window.
99
100 `GET /organizations/{org}/settings/import` renders the owner-only
101 GitHub organization import page. Owners can provide a GitHub
102 organization name or `github.com/<org>` URL and, optionally, a token.
103 Untokened imports discover public repositories only. Token-backed
104 imports use GitHub's `type=all` repo listing, persist the token
105 encrypted with the server secret box, and keep private upstream repos
106 private on shithub.
107
108 `POST /organizations/{org}/settings/import` creates an
109 `org_github_imports` row and enqueues
110 `worker.KindOrgGitHubImportDiscover`. `GET
111 /organizations/{org}/imports/{importID}` shows a polling progress page
112 with per-repo queued/importing/imported/skipped/failed state. The
113 discover job pages through the GitHub API, records one
114 `org_github_import_repos` row per repository, and enqueues an import job
115 per repo. Each repo job creates the org-owned shithub repository, saves
116 the GitHub source remote, fetches heads/tags with a temporary Git
117 askpass helper when a token is present, updates the default branch from
118 fetched refs, and enqueues indexing + size recalculation. Existing
119 active repositories in the organization are skipped instead of
120 overwritten.
121
122 Repo visibility is filtered through `policy.IsVisibleTo` using an actor
123 constructed from `middleware.CurrentUser`, including suspension,
124 site-admin, and impersonation write-mode fields. Anonymous viewers only
125 see public repositories; members and owners see whatever the policy
126 layer grants them.
127
128 `GET /orgs/{org}/repositories` is the dedicated organization
129 repositories surface, matching GitHub's current org route shape. It
130 uses the same policy-filtered visible repo set as the overview, then
131 applies query, type, language, sort, and page parameters in the handler.
132 The page renders 30 repositories per page with bordered GitHub-style
133 rows, topics, language/license/star/fork metadata, default-branch
134 activity sparklines, and numbered pagination.
135
136 `/{slug}` resolution flow inside `internal/web/handlers/profile/profile.go`:
137
138 1. Reserved-name check (defense in depth — chi already matches static
139 routes first).
140 2. `orgs.Resolve(slug)` against `principals`. On `kind='org'`, dispatch
141 to `serveOrgProfile`. On `kind='user'`, fall through to the
142 existing user path. On miss, fall through to `username_redirects`
143 for renamed users.
144
145 ## Member roles
146
147 * `owner` → implicit `admin` on every org-owned repo through
148 `policy.Can`.
149 * `member` → org-membership badge; no implicit access to private repos.
150 Repo-level access is granted via direct collaboration (S15) or
151 teams (S31).
152
153 ## Last-owner protection
154
155 `ChangeRole` and `RemoveMember` both call `CountOrgOwners` and refuse
156 the operation if it would leave the org with zero owners
157 (`ErrLastOwner`). UI must surface this; the orchestrator is the
158 canonical enforcer.
159
160 ## Invitation flow
161
162 * By **username**: orchestrator resolves the username → user_id and
163 checks for existing membership before issuing the invite.
164 * By **email**: stores the email; recipient claims by signing in with
165 any account that owns the verified email (or by signing up later
166 with that email — pending invites surface in the inbox via
167 `ListPendingInvitationsForEmail`).
168
169 Both paths use a 7-day expiry. Tokens are sha256-hashed at rest;
170 `token_hash` is the column we look up by.
171
172 ## Principals trigger
173
174 Two AFTER triggers (`tg_principals_user_sync`, `tg_principals_org_sync`)
175 maintain `principals` on every users/orgs INSERT/UPDATE/DELETE. The
176 slug PK on `principals` enforces global uniqueness across both tables
177 — a slug collision either with another user or another org is
178 rejected with SQLSTATE 23505, which the create path translates to
179 `ErrSlugTaken`.
180
181 Soft-deleted users/orgs are dropped from `principals` so their slug
182 becomes available — the username_redirects table still preserves the
183 old slug for 301s during the rename cooldown.
184
185 ## Billing posture
186
187 Organizations are the first planned paid shithub surface. The
188 `orgs.plan` and `billing_email` fields are present today, but payment
189 processing and entitlement enforcement live in the PAYMENTS sprint
190 series. The durable product and implementation contract is tracked in
191 [`billing.md`](./billing.md).
192
193 Until that series lands, production code must not branch on
194 `orgs.plan` for feature access. Paid feature checks should go through
195 the future entitlement package described in the billing doc.
196
197 ## What we deferred from the spec
198
199 * **`username_redirects` rename to `principal_redirects`**. The
200 rename + `kind` column would cascade through every sqlc bundle
201 (each one gets a regenerated model). Org renames aren't in the
202 S30 DoD; deferred to a follow-up sprint that owns the rename
203 refactor end to end.
204 * **Org-level audit log surface**, **suspension UI**, **org rename /
205 archive settings actions**, **email notifications for role-change /
206 remove / suspension / deletion**. Schema columns are present; UI and
207 notification fan-out land in follow-ups.
208 * **Org renaming via `principal_redirects`** — depends on the
209 rename refactor.
210 * **Daily digest / billing / SAML** — post-MVP per spec.
211
212 ## Pitfalls noted in code
213
214 * **Slug-vs-username collision**: enforced by `principals` PK,
215 tested by `TestCreate_RejectsCollisionWithUsername`.
216 * **Last-owner orphaning**: tested by
217 `TestChangeRole_LastOwnerProtection`.
218 * **Email-invite claim by wrong user**: tested by
219 `TestInvite_AcceptByEmailRejectsWrongUser` — only an account
220 that owns the verified email matching the invite's `target_email`
221 can claim.
222 * **Duplicate invitations**: idempotency check returns
223 `ErrInvitationDuplicate` rather than minting a new token, so
224 re-clicking Invite doesn't spam the recipient.
225 * **Reserved slugs**: `auth.IsReserved` filter applies to org
226 slugs the same way it applies to usernames.
227 * **Org import token storage**: the token never enters a Git URL, logs,
228 or plaintext database column. It is encrypted in
229 `org_github_imports.token_ciphertext` and supplied to git via a
230 temporary askpass script scoped to the fetch process.