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_pinsafter 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:
- Reserved-name check (defense in depth — chi already matches static routes first).
orgs.Resolve(slug)againstprincipals. Onkind='org', dispatch toserveOrgProfile. Onkind='user', fall through to the existing user path. On miss, fall through tousername_redirectsfor renamed users.
Member roles
owner→ implicitadminon every org-owned repo throughpolicy.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_redirectsrename toprincipal_redirects. The rename +kindcolumn 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
principalsPK, tested byTestCreate_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'starget_emailcan claim. - Duplicate invitations: idempotency check returns
ErrInvitationDuplicaterather than minting a new token, so re-clicking Invite doesn't spam the recipient. - Reserved slugs:
auth.IsReservedfilter 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_ciphertextand 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. |