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_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.
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 / billing / 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/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 | ## What we deferred from the spec |
| 186 | |
| 187 | * **`username_redirects` rename to `principal_redirects`**. The |
| 188 | rename + `kind` column would cascade through every sqlc bundle |
| 189 | (each one gets a regenerated model). Org renames aren't in the |
| 190 | S30 DoD; deferred to a follow-up sprint that owns the rename |
| 191 | refactor end to end. |
| 192 | * **Org-level audit log surface**, **suspension UI**, **org rename / |
| 193 | archive settings actions**, **email notifications for role-change / |
| 194 | remove / suspension / deletion**. Schema columns are present; UI and |
| 195 | notification fan-out land in follow-ups. |
| 196 | * **Org renaming via `principal_redirects`** — depends on the |
| 197 | rename refactor. |
| 198 | * **Daily digest / billing / SAML** — post-MVP per spec. |
| 199 | |
| 200 | ## Pitfalls noted in code |
| 201 | |
| 202 | * **Slug-vs-username collision**: enforced by `principals` PK, |
| 203 | tested by `TestCreate_RejectsCollisionWithUsername`. |
| 204 | * **Last-owner orphaning**: tested by |
| 205 | `TestChangeRole_LastOwnerProtection`. |
| 206 | * **Email-invite claim by wrong user**: tested by |
| 207 | `TestInvite_AcceptByEmailRejectsWrongUser` — only an account |
| 208 | that owns the verified email matching the invite's `target_email` |
| 209 | can claim. |
| 210 | * **Duplicate invitations**: idempotency check returns |
| 211 | `ErrInvitationDuplicate` rather than minting a new token, so |
| 212 | re-clicking Invite doesn't spam the recipient. |
| 213 | * **Reserved slugs**: `auth.IsReserved` filter applies to org |
| 214 | slugs the same way it applies to usernames. |
| 215 | * **Org import token storage**: the token never enters a Git URL, logs, |
| 216 | or plaintext database column. It is encrypted in |
| 217 | `org_github_imports.token_ciphertext` and supplied to git via a |
| 218 | temporary askpass script scoped to the fetch process. |