markdown · 11865 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/plan           plan picker (auth required)
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  /organizations/{org}/settings/billing
POST /organizations/{org}/billing/checkout
POST /organizations/{org}/billing/portal
GET  /organizations/{org}/billing/success
GET  /organizations/{org}/billing/cancel
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 paid shithub surface. orgs.plan remains the human-facing summary, but product behavior goes through internal/entitlements and the billing projection in org_billing_states; production handlers must not branch directly on orgs.plan for paid feature access.

/organizations/plan is the canonical Free / Team / Enterprise plan picker. Existing "New organization" links route there. When Stripe Billing is not fully configured, Team remains visible but disabled; site admins see operator setup copy. Choosing Team creates the organization first and then redirects the owner to hosted Stripe Checkout; webhook processing, not checkout creation, activates paid entitlements. Stripe success and cancel returns render explicit post-checkout pages: success explains that Team activation waits for webhook processing, and cancel leaves the organization on Free with a path to retry checkout. Existing owner-managed orgs also link to that billing page from /settings/organizations for plan comparison.

Billing routes are mounted only when Stripe Billing is configured. When billing is disabled, local billing rows remain in the database but paid onboarding links stay unavailable. The durable product and implementation contract is tracked in billing.md.

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