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