Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Revoke any pending invitation, drop grants on leaving an org, linked images follow their repository (#37)

- Repository and site invitations can now be revoked like organization ones: on the
  access page, Settings -> People and invitations, GET/DELETE .../invitations in the
  REST API, and the MCP tools list_invitations and revoke_invitation.
- Removing someone from an organization removes their (and their agents') grants on
  its repositories, including the admin grant on repositories they created.
- An organization image linked to a repository follows that repository's access only,
  so with base permission none a member can't pull images of private repositories they
  can't see; the registry catalog leaves them out too.
- PATCH /orgs/{org} answers 422 for a non-string name, and the API and settings form
  check every field before one write, so a bad base permission doesn't rename.
- Members' agents see default_repository_permission; MCP get_organization exposes it.
- Docs: base permission, revoking invitations, owner-only org pictures, package access.


Claude-Session: https://claude.ai/code/session_01QMFviVKD61QaDPxdrh7C3f

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent ffe649fcommit 95a6a14ba1131be9ba7694118a689d3544a3a890Browse files
19 files changed+447−96
Files (19)
modified.github/workflows/live-test.yml+8−2View file
@@ -270,7 +270,7 @@
270270 git push -q "https://x-token:${TOKEN}@${DIFFINERY#https://}/$OWNER/$REPO.git" main
271271 git lfs ls-files
272272
273- - name: Web UI features (highlighting, settings pages, view transitions, base permission)
273+ - name: Web UI features (highlighting, settings pages, view transitions, base permission, invitations)
274274 if: steps.token.outputs.skip != 'true'
275275 run: |
276276 set -euo pipefail
@@ -289,8 +289,14 @@
289289 if [ -n "$org" ]; then
290290 perm=$(page "/api/v3/orgs/$org" | jq -r '.default_repository_permission // empty')
291291 case "$perm" in none|read|write|admin) echo "$org's base permission: $perm" ;; *) echo "::error::GET /orgs/$org has no default_repository_permission"; exit 1 ;; esac
292+ # A malformed rename is refused before anything is written (422 for owners, 403 for everyone else).
293+ code=$(curl -sS -o /tmp/out -w '%{http_code}' -X PATCH "$DIFFINERY/api/v3/orgs/$org" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" --data '{"name":123}')
294+ case "$code" in 422|403) ;; *) echo "::error::PATCH /orgs/$org with a numeric name got $code"; cat /tmp/out; exit 1 ;; esac
292295 fi
293- echo "highlighting, Appearance, notification settings link, view transitions and visibility checks passed"
296+ page "/api/v3/repos/$OWNER/$REPO/invitations" | jq -e 'type == "array"' >/dev/null || { echo "::error::GET /repos/$OWNER/$REPO/invitations doesn't list pending invitations"; exit 1; }
297+ tools=$(curl -fsS -X POST "$DIFFINERY/mcp" -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" -H "accept: application/json, text/event-stream" --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name')
298+ for t in get_organization list_invitations revoke_invitation; do grep -qx "$t" <<<"$tools" || { echo "::error::the MCP server has no $t tool"; exit 1; }; done
299+ echo "highlighting, Appearance, notification settings link, view transitions, visibility, organization and invitation checks passed"
294300
295301 - name: Hosted Actions jobs (and Docker) run in Cloudflare Containers
296302 if: steps.token.outputs.skip != 'true'
modifiedapps/diffinery/docs/accounts/invitations.md+18−4View file
@@ -56,12 +56,16 @@
5656
5757 You can send only one pending invitation per email address and organization from this form. To send a new one, revoke the old one first ("friend@example.com already has a pending invitation; revoke it first to send a new one").
5858
59-### Revoke an organization invitation
59+### Revoke an invitation
6060
61-On the organization's **People** tab, under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired". The person isn't notified.
61+Every pending invitation can be revoked until it's used:
6262
63-Only organization invitations can be revoked. Repository and instance invitations can't be withdrawn; they stop working once used or after 14 days.
63+- organization invitations on the organization's **People** tab (owners and admins);
64+- repository invitations on the repository's **Settings → Collaborators** page (repository admins);
65+- instance invitations on **Settings → People and invitations** (site administrators).
6466
67+Under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired". The person isn't notified, and you can invite the same address again.
68+
6569 ### Add people to a repository
6670
6771 Repository admins. The **Add people** form on the repository's **Settings → Collaborators** page (`/acme/website/settings/access`) works the same way, and also accepts agents:
@@ -146,6 +150,16 @@
146150
147151 `POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only.
148152
153+### List and revoke pending invitations
154+
155+| Method and path | Who |
156+| --- | --- |
157+| `GET /api/v3/orgs/{org}/invitations`, `DELETE /api/v3/orgs/{org}/invitations/{id}` | The organization's owners and admins |
158+| `GET /api/v3/repos/{owner}/{repo}/invitations`, `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` | The repository's admins |
159+| `GET /api/v3/invitations`, `DELETE /api/v3/invitations/{id}` | Site administrators (instance invitations only) |
160+
161+Each listed invitation has an `id`, `email`, `role`, `inviter`, `created_at` and `expires_at`. `DELETE` returns `204 No Content`, or `404 no pending invitation with this id` when the invitation was already used or revoked, or belongs to another organization, repository or kind. Revoking organization and instance invitations needs an `admin` token; revoking a repository invitation needs admin on the repository. Agents use the MCP tools `list_invitations` and `revoke_invitation` with `owner` and `repo`, `org`, or neither.
162+
149163 ### API errors
150164
151165 | Status | Message | Cause |
@@ -244,4 +258,4 @@
244258 - On GitHub anyone can create an account. On Diffinery the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance.
245259 - GitHub organization and repository invitations go to existing GitHub users. Diffinery invites go to an email address, and the same link creates the account.
246260 - **Add people** takes a username or an email address, as on GitHub, but someone who already has an account is added immediately, with no invitation to accept.
247-- Organization invitations can be revoked from the **People** tab. Repository and instance invitations can't be cancelled; they expire after 14 days.
261+- Pending invitations of every kind can be revoked, in the web UI, the API or over MCP; otherwise they expire after 14 days.
modifiedapps/diffinery/docs/accounts/organizations.md+24−15View file
@@ -11,10 +11,10 @@
1111
1212 | Capability | member | admin | owner |
1313 | --- | --- | --- | --- |
14-| Role on every organization repository | write | admin | admin |
15-| See the organization's private repositories | Yes | Yes | Yes |
16-| Create repositories in the organization | Yes | Yes | Yes |
17-| Create container images in the organization and pull its images | Yes | Yes | Yes |
14+| Role on every organization repository | the [base permission](#base-permission) (read by default) | admin | admin |
15+| See the organization's private repositories | Unless the base permission is none | Yes | Yes |
16+| Create repositories in the organization (and administer the ones they create) | Yes | Yes | Yes |
17+| Create container images in the organization and pull its images that aren't linked to a repository | Yes | Yes | Yes |
1818 | Push to, delete and change the settings of an organization image that isn't linked to a repository | Only images they created | Yes | Yes |
1919 | See the **People** and **Agents** tabs | Yes | Yes | Yes |
2020 | **Add people** (by username or email) | No | Yes (as member or admin) | Yes (any role) |
@@ -22,16 +22,21 @@
2222 | Change roles, remove members | No | Yes, except owners | Yes, including owners |
2323 | Create and manage the organization's agents | No | Yes | Yes |
2424 | Manage organization Actions secrets | No | Yes | Yes |
25-| Change the organization's picture through the API | No | Yes | Yes |
26-| Open the **Settings** tab: rename, display name, picture | No | No | Yes |
25+| Open the **Settings** tab: rename, display name, base permission, picture | No | No | Yes |
2726 | Get emails when repositories are created or deleted in the organization | No | Yes | Yes |
2827
2928 Site administrators can do everything an owner can in every organization, and have admin on every repository.
3029
31-A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes). See [Container registry](/docs/packages/container-registry).
30+A container image linked to a repository follows that repository's roles instead (read pulls, write pushes, admin deletes), so a member who can't see a private repository can't pull its images either. See [Container registry](/docs/packages/container-registry).
3231
3332 A member's role on a repository is the highest of their organization role's repository role and any role given to them directly on that repository. A direct collaborator role can raise an organization member's access (for example to `maintain` or `admin`) but never lowers it. See [Permissions](/docs/accounts/permissions) for what each repository role allows.
3433
34+### Base permission
35+
36+The base permission is the role every member gets on every repository the organization owns, like GitHub's base permissions: **None**, **Read**, **Write** or **Admin**. New organizations start at **Read**; organizations created before the setting existed keep **Write**. With **None**, members only see the repositories they were given access to and the ones they created. Owners and admins always get admin, and a collaborator role on a repository adds to the base permission.
37+
38+Owners change it on the **Settings** tab under **Base permission**, or with `PATCH /api/v3/orgs/{org}` and `{"default_repository_permission": "write"}`. Members and their agents read it as `default_repository_permission` from `GET /api/v3/orgs/{org}`, or with the MCP tool `get_organization`.
39+
3540 ## Create an organization
3641
3742 Any active account can create an organization and becomes its first owner.
@@ -126,7 +131,7 @@
126131
127132 ### Remove a member
128133
129-Click **Remove** in the member's row and confirm "Remove octocat from acme?". The member loses the organization's repository access on their next request, including through their tokens, and gets an email saying they were removed. Their personal repositories and accounts are not affected.
134+Click **Remove** in the member's row and confirm "Remove octocat from acme?". The member loses the organization's repository access on their next request, including through their tokens, and gets an email saying they were removed. Any collaborator roles they or their agents had on the organization's repositories are removed too, including admin on repositories they created. Their personal repositories and accounts are not affected.
130135
131136 Rules:
132137
@@ -190,7 +195,7 @@
190195 2. Click **Save picture**. You see "Organization picture updated."
191196 3. To go back to initials, click **Remove picture** and confirm "Remove the picture of acme?". You see "Organization picture removed."
192197
193-Owners and admins (and site administrators) can also change it through the API, with a token that has the `write` or `admin` scope and isn't restricted to repositories. The body is the raw image file:
198+Owners (and site administrators) can also change it through the API, with a token that has the `write` or `admin` scope and isn't restricted to repositories. The body is the raw image file:
194199
195200 ```sh
196201 curl -X PUT https://diffinery.com/api/v3/orgs/acme/avatar \
@@ -201,11 +206,11 @@
201206 -H "Authorization: Bearer dfy_..."
202207 ```
203208
204-Both return `200` with the picture's URL, for example `{"avatar_url": "https://diffinery.com/api/avatars/acme?v=1760350000"}` after an upload and `{"avatar_url": "https://diffinery.com/api/avatars/acme"}` after removing it. Errors include `403 only organization owners and admins can change its picture` for members, and `404 not found` when you aren't in the organization. See [Profile and preferences](/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching.
209+Both return `200` with the picture's URL, for example `{"avatar_url": "https://diffinery.com/api/avatars/acme?v=1760350000"}` after an upload and `{"avatar_url": "https://diffinery.com/api/avatars/acme"}` after removing it. Errors include `403 only organization owners can change its picture` for admins and members, and `404 not found` when you aren't in the organization. See [Profile and preferences](/docs/accounts/profile-and-preferences#profile-pictures) for the limits, error messages, the public picture URL and caching.
205210
206211 ## Rename an organization
207212
208-Owners open the **Settings** tab, change **Organization name** or **Display name** and click **Save**. You see "Organization updated." Renaming changes every repository URL in the organization, so existing clones need a new remote URL:
213+Owners open the **Settings** tab, change **Organization name**, **Display name** or **Base permission** and click **Save**. You see "Organization updated." Every field is checked before anything is saved, so a value that's refused changes nothing. Renaming changes every repository URL in the organization, so existing clones need a new remote URL:
209214
210215 ```sh
211216 git remote set-url origin https://diffinery.com/acme-inc/website.git
@@ -219,24 +224,28 @@
219224 | --- | --- |
220225 | `GET /api/v3/user/orgs` | Organizations you belong to, each with your `role` |
221226 | `POST /api/v3/user/orgs` | Create an organization: `{"login": "acme", "name": "Acme Inc."}`. Needs an `admin` token. |
227+| `GET /api/v3/orgs/{org}` | The organization; members, their agents and site administrators also get `default_repository_permission` |
228+| `PATCH /api/v3/orgs/{org}` | Change `name` or `default_repository_permission` (`none`, `read`, `write` or `admin`). Owners only; needs an `admin` token. |
222229 | `GET /api/v3/orgs/{org}/members` | Members and their roles. Members and site administrators only; others get `404`. |
223230 | `POST /api/v3/orgs/{org}/invitations` | Invite by email: `{"email": "...", "role": "member"}`. Needs an `admin` token. See [Invitations](/docs/accounts/invitations#invite-with-the-api). |
231+| `GET /api/v3/orgs/{org}/invitations` | Pending invitations, each with an `id`. Owners and admins. |
232+| `DELETE /api/v3/orgs/{org}/invitations/{id}` | Revoke a pending invitation; returns `204`. Owners and admins; needs an `admin` token. |
224233 | `GET /api/v3/orgs/{org}/repos` | Repositories you can see in the organization |
225234 | `POST /api/v3/orgs/{org}/repos` | Create a repository in the organization. Needs a `write` or `admin` token. |
226235 | `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | Organization secrets. Needs an `admin` token. |
227-| `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners and admins. |
228-| `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners and admins. |
236+| `PUT /api/v3/orgs/{org}/avatar` | Upload the organization's picture (raw image body). Owners only. |
237+| `DELETE /api/v3/orgs/{org}/avatar` | Remove the organization's picture. Owners only. |
229238
230239 Organization objects in API responses follow GitHub's shape, with `"type": "Organization"`, `html_url` and `avatar_url`.
231240
232241 Every endpoint in this table that changes something is an account-level action: besides the scope noted, the token must not be restricted to repositories, or the request gets `403 this token is limited to repositories and can't change account settings` (the picture endpoints answer `403 changing a profile picture needs a write or admin token that is not limited to repositories`). See [Personal access tokens](/docs/accounts/tokens#account-level-actions).
233242
234-Adding existing people directly, changing a member's role, removing a member and revoking invitations are done in the web UI. The full list of endpoints is in the [API reference](/docs/api).
243+Adding existing people directly, changing a member's role and removing a member are done in the web UI. The full list of endpoints is in the [API reference](/docs/api).
235244
236245 ## How organizations differ from GitHub
237246
238247 - Diffinery has three organization roles: owner, admin and member. Admins manage members, invitations, agents and secrets but can't manage owners or open the organization's **Settings** tab.
239-- Members get write access to every organization repository. GitHub's default base permission is configurable; Diffinery's is fixed.
248+- The base permission works like GitHub's (none, read, write or admin) and starts at read; organizations created before Diffinery had it keep write.
240249 - There are no teams. Give extra access per repository under **Settings → Collaborators**.
241250 - **Add people** works like GitHub's: enter a username or an email address. Unlike GitHub, someone who already has an account joins immediately, with no invitation to accept; only email addresses without an account get an invitation.
242251 - Organization pages need you to be signed in.
modifiedapps/diffinery/docs/accounts/permissions.md+4−4View file
@@ -74,7 +74,7 @@
7474 - You can't approve or request changes on your own pull request, and you can't dismiss reviews on it.
7575 - Pull requests opened by agents always need at least one approval from a person.
7676
77-Container images have their own permissions. An image linked to a repository follows that repository's roles: `read` pulls, `write` pushes, `admin` deletes. In an organization, every member can create images and pull the organization's images, but an image that isn't linked to a repository can be pushed to and managed only by the member who created it and the organization's owners and admins. See [Container registry](/docs/packages/container-registry).
77+Container images have their own permissions. An image linked to a repository follows that repository's roles: `read` pulls, `write` pushes, `admin` deletes. In an organization, every member can create images and pull the organization's images that aren't linked to a repository, but an image that isn't linked to a repository can be pushed to and managed only by the member who created it and the organization's owners and admins. See [Container registry](/docs/packages/container-registry).
7878
7979 ## Where your role comes from
8080
@@ -84,7 +84,7 @@
8484 | --- | --- |
8585 | You own the repository (a personal repository) | admin |
8686 | You are an owner or admin of the organization that owns it | admin |
87-| You are a member of the organization that owns it | write |
87+| You are a member of the organization that owns it | the organization's base permission: `read` by default, or `none`, `write` or `admin` as its owners set it |
8888 | You were added as a collaborator on the repository (**Settings → Collaborators**, an accepted repository invite, or `PUT /repos/{owner}/{repo}/collaborators/{username}`) | the role you were given |
8989 | The repository is public | read |
9090 | You are a site administrator | admin on every repository |
@@ -120,7 +120,7 @@
120120
121121 | Organization role | Role on organization repositories | Can also |
122122 | --- | --- | --- |
123-| member | write | Create repositories and container images in the organization |
123+| member | the base permission (`read` by default) | Create repositories (which they administer) and container images in the organization |
124124 | admin | admin | Manage members (except owners), invites, agents and organization secrets |
125125 | owner | admin | Everything an admin can, plus manage owners and the organization's **Settings** tab (name, display name, picture) |
126126
@@ -218,7 +218,7 @@
218218
219219 - The five repository roles match GitHub's names. Diffinery's `maintain` adds the ability to move or delete tags and to edit other people's comments; repository settings stay with `admin`.
220220 - Opening a pull request needs `write`: its head branch must be a branch in the same repository.
221-- Organization members get `write` on every organization repository, and organization admins get `admin`. There are no teams or custom roles.
221+- Organization members get the organization's base permission on every organization repository (`read` unless its owners change it), and organization admins get `admin`. There are no teams or custom roles.
222222 - Personal access tokens have three scopes (`read`, `write`, `admin`) instead of GitHub's fine-grained permissions; the scope caps your role.
223223 - `GITHUB_TOKEN` in a workflow can read the repository and use the container registry; it can't push commits or call write endpoints.
224224 - Agents are a separate account type with their own caps and guardrails.
modifiedapps/diffinery/docs/agents/mcp.md+3−0View file
@@ -131,6 +131,9 @@
131131 | --- | --- | --- | --- |
132132 | `whoami` | Yes | The account the token acts as (`login`, `kind`) and, for an agent, `on_behalf_of` | Any token |
133133 | `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token |
134+| `get_organization` | Yes | An organization's `login` and `name`; to its members, its agents, agents of its members and site administrators also `default_repository_permission` (the base permission members get on its repositories) | Any token |
135+| `list_invitations` | Yes | Pending invitations to a repository (`owner` and `repo`), an organization (`org`) or the instance (neither), each with an `id` | Admin on the repository; organization owner or admin; site administrator |
136+| `revoke_invitation` | No | Revoke a pending invitation by `invitation_id`, with the same `owner` and `repo`, `org` or neither; the address can then be invited again | Same as `list_invitations`; organization and instance invitations need an `admin` token |
134137
135138 ### Code
136139
modifiedapps/diffinery/docs/packages/container-registry.md+3−3View file
@@ -106,7 +106,7 @@
106106
107107 | Visibility | Who can pull |
108108 | --- | --- |
109-| **Private** (default) | The owning user, the members of the owning organization, and people with access to the linked repository. See [Permissions](#permissions). |
109+| **Private** (default) | The owning user, people with access to the linked repository, and, for a package that isn't linked to a repository, the members of the owning organization. See [Permissions](#permissions). |
110110 | **Public** | Anyone, without signing in. |
111111
112112 There are only these two settings. Visibility only controls pulling. Pushing and deleting always need the permissions described below.
@@ -175,7 +175,7 @@
175175 | --- | --- |
176176 | The package belongs to your user account | Admin |
177177 | You're an owner or admin of the organization that owns the package | Admin |
178-| You're a member of the organization that owns the package | Pull, and you can create new packages under the organization |
178+| You're a member of the organization that owns the package | Pull on packages that aren't linked to a repository (a linked one follows your role on its repository), and you can create new packages under the organization |
179179 | You're the organization member who created the package (or whose agent did), and it isn't linked to a repository | Admin |
180180 | The package is linked to a repository where you have `read` | Pull |
181181 | The package is linked to a repository where you have `write` or `maintain` | Push |
@@ -194,7 +194,7 @@
194194 | Owned by the user that owns the package | Push |
195195 | Owned by the organization that owns the package | Push |
196196 | Owned by an owner or admin of the organization that owns the package | Push |
197-| Owned by a member of the organization that owns the package | Pull, and it can create new packages under the organization. Push on unlinked packages that its owning user, or one of that user's agents, created. |
197+| Owned by a member of the organization that owns the package | Pull on packages that aren't linked to a repository, and it can create new packages under the organization. Push on unlinked packages that its owning user, or one of that user's agents, created. |
198198
199199 Linked repositories give an agent the same roles as a person, based on the agent's own access to the repository, so an agent with `admin` on the linked repository administers the package.
200200
modifiedapps/diffinery/src/docs/api-meta.ts+35−0View file
@@ -35,6 +35,11 @@
3535 "POST /user/orgs": { scope: "admin" },
3636 "POST /orgs/{org}/invitations": { scope: "admin" },
3737 "POST /invitations": { scope: "admin" },
38+ "PATCH /orgs/{org}": { scope: "admin" },
39+ "GET /orgs/{org}/invitations": { scope: "read" },
40+ "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" },
41+ "GET /invitations": { scope: "read" },
42+ "DELETE /invitations/{id}": { scope: "admin" },
3843 "POST /user/repos": { scope: "write" },
3944 "POST /orgs/{org}/repos": { scope: "write" },
4045 "GET /orgs/{org}/actions/secrets": { scope: "admin" },
@@ -85,6 +90,20 @@
8590 status: 201,
8691 body: { login: ["string", "Organization login (letters, digits and dashes).", true], name: ["string", "Display name (default: the login)."] },
8792 },
93+ "GET /orgs/{org}": {
94+ summary: "Get an organization",
95+ description: "Members, the organization's agents, agents of members, and site admins also see `default_repository_permission`: the base permission every member gets on its repositories.",
96+ access: "account",
97+ },
98+ "PATCH /orgs/{org}": {
99+ summary: "Update an organization",
100+ description: "Owners only (or site admins). Every field is checked before anything is saved, so a bad value changes nothing. Audited.",
101+ access: "org-admin",
102+ body: {
103+ name: ["string", "Display name; blank goes back to the login."],
104+ default_repository_permission: ["string", "Base permission members get on every repository: `none`, `read`, `write` or `admin`. Owners and admins always get admin, and per-repository grants add to it."],
105+ },
106+ },
88107 "GET /orgs/{org}/members": { summary: "List organization members", description: "Members only (or site admins). Each entry carries the member's `role`.", access: "account" },
89108 "POST /orgs/{org}/invitations": {
90109 summary: "Invite someone to an organization",
@@ -93,6 +112,13 @@
93112 status: 201,
94113 body: { email: ["string", "Email address to invite.", true], role: ["string", "`member` (default), `admin` or `owner`."] },
95114 },
115+ "GET /orgs/{org}/invitations": { summary: "List an organization's pending invitations", description: "Owners and admins (or site admins). Each has an `id` to revoke it with.", access: "org-admin" },
116+ "DELETE /orgs/{org}/invitations/{id}": {
117+ summary: "Revoke an organization invitation",
118+ description: "The link stops working and the address can be invited again. 404 when no pending invitation has this id. Audited.",
119+ access: "org-admin",
120+ status: 204,
121+ },
96122 "PUT /orgs/{org}/avatar": {
97123 summary: "Set an organization's picture",
98124 description:
@@ -107,6 +133,8 @@
107133 status: 201,
108134 body: { email: ["string", "Email address to invite.", true] },
109135 },
136+ "GET /invitations": { summary: "List pending invitations to this instance", description: "Site-wide invites only, not those to an organization or repository. Site admins only.", access: "site-admin" },
137+ "DELETE /invitations/{id}": { summary: "Revoke an invitation to this instance", description: "Site admins only. 404 when no pending site-wide invitation has this id. Audited.", access: "site-admin", status: 204 },
110138
111139 // ------------------------------------------------------------ search
112140 "GET /search/issues": {
@@ -182,6 +210,13 @@
182210 body: { permission: ["string", "`read`, `triage`, `write` (default), `maintain` or `admin`; GitHub's `pull` and `push` work too."] },
183211 },
184212 "DELETE /repos/{owner}/{repo}/collaborators/{login}": { summary: "Remove a collaborator", access: "admin", status: 204 },
213+ "GET /repos/{owner}/{repo}/invitations": { summary: "List pending repository invitations", description: "Invitations sent by email to people without an account. Each has an `id` to revoke it with.", access: "admin" },
214+ "DELETE /repos/{owner}/{repo}/invitations/{id}": {
215+ summary: "Revoke a repository invitation",
216+ description: "The link stops working and the address can be invited again. 404 when no pending invitation to this repository has this id. Audited.",
217+ access: "admin",
218+ status: 204,
219+ },
185220 "GET /repos/{owner}/{repo}/assignees": { summary: "List assignable people", description: "Collaborators with triage access or more, plus the owner when the owner is a user.", access: "read", query: { ...page } },
186221 "GET /repos/{owner}/{repo}/assignees/{login}": {
187222 summary: "Check if someone can be assigned",
modifiedapps/diffinery/src/http/api.ts+59−11View file
@@ -2,7 +2,7 @@
22 import { fromUtf8 } from "@diffinery/git-core";
33 import type { Env } from "../env.js";
44 import { callerFor, parseScopes, requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js";
5-import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js";
5+import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
66 import { encryptSecret } from "../lib/crypto.js";
77 import { publicJwk } from "../lib/jwt.js";
88 import { HttpError, b64, fromB64 } from "../lib/util.js";
@@ -73,14 +73,13 @@
7373 api.post("/user/orgs", async (c) => {
7474 requireAccountScope(c, "admin");
7575 const a = requireAccount(c);
76- const b = await body<{ login: string; name?: string }>(c);
77- return c.json(userJson(c, await c.get("dir").createOrg(b.login, b.name ?? b.login, a)), 201);
76+ const b = await body<{ login: string; name?: unknown }>(c);
77+ if (typeof b.login !== "string") throw new HttpError(422, "login is required", { errors: [{ resource: "Organization", code: "missing_field", field: "login" }] });
78+ if (b.name !== undefined && b.name !== null && typeof b.name !== "string") throw new HttpError(422, "name must be a string", { errors: [{ resource: "Organization", code: "invalid", field: "name" }] });
79+ return c.json(userJson(c, await c.get("dir").createOrg(b.login, (b.name as string | null | undefined) || b.login, a)), 201);
7880 });
79-// GitHub's organization object; members also see the base permission (default_repository_permission).
80-const orgJson = async (c: AppContext, org: Account, me: Account) => {
81- const role = await c.get("dir").orgRole(org.id, me.id);
82- return { ...userJson(c, org), ...(role || me.site_admin ? { default_repository_permission: baseRole(org) } : {}) };
83-};
81+// GitHub's organization object; members and their agents also see the base permission (default_repository_permission).
82+const orgJson = async (c: AppContext, org: Account, me: Account) => ({ ...userJson(c, org), ...((await c.get("dir").inOrg(org.id, me)) ? { default_repository_permission: baseRole(org) } : {}) });
8483 api.get("/orgs/:org", async (c) => {
8584 const a = requireAccount(c);
8685 const org = await c.get("dir").accountByLogin(c.req.param("org"));
@@ -96,9 +95,8 @@
9695 const role = await dir.orgRole(org.id, a.id);
9796 if (!role && !a.site_admin) throw new HttpError(404, "not found");
9897 if (role !== "owner" && !a.site_admin) throw new HttpError(403, "only organization owners can change its settings");
99- const b = await body<{ name?: string; default_repository_permission?: string }>(c);
100- if (b.default_repository_permission !== undefined) await dir.setBaseRole(org.id, b.default_repository_permission);
101- if (b.name !== undefined) await dir.updateAccount(org.id, { name: b.name });
98+ const b = await body<{ name?: unknown; default_repository_permission?: unknown }>(c);
99+ await dir.updateAccount(org.id, { name: b.name, baseRole: b.default_repository_permission });
102100 c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.update", target: org.login, detail: { name: b.name, base_role: b.default_repository_permission } }));
103101 return c.json(await orgJson(c, (await dir.account(org.id))!, a));
104102 });
@@ -138,6 +136,56 @@
138136 return c.json({ id: inv.id, url: inv.url }, 201);
139137 });
140138
139+// Pending invitations: list and revoke, for an organization's owners and admins, a repository's admins,
140+// and site admins (invitations to the site itself).
141+const inviteJson = (i: Awaited<ReturnType<Directory["invites"]>>[number]) => ({ id: i.id, email: i.email, role: i.role, inviter: i.invitedBy ? { login: i.invitedBy } : null, created_at: i.createdAt, expires_at: i.expiresAt });
142+const orgForInvites = async (c: AppContext) => {
143+ requireAccountScope(c, "read");
144+ const a = requireAccount(c);
145+ const dir = c.get("dir");
146+ const org = await dir.accountByLogin(c.req.param("org") ?? "");
147+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
148+ const role = await dir.orgRole(org.id, a.id);
149+ if (!role && !a.site_admin) throw new HttpError(404, "not found");
150+ if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can manage invitations");
151+ return { a, dir, org };
152+};
153+const revoked = async (c: AppContext, ok: boolean, action: string, target?: string) => {
154+ if (!ok) throw new HttpError(404, "no pending invitation with this id");
155+ const a = requireAccount(c);
156+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action, target, detail: { id: c.req.param("id"), via: "api" } }));
157+ return c.body(null, 204);
158+};
159+api.get("/orgs/:org/invitations", async (c) => {
160+ const { dir, org } = await orgForInvites(c);
161+ return c.json((await dir.invites({ orgId: org.id, pending: true })).map(inviteJson));
162+});
163+api.delete("/orgs/:org/invitations/:id", async (c) => {
164+ requireAccountScope(c, "admin");
165+ const { dir, org } = await orgForInvites(c);
166+ return revoked(c, await dir.revokeInvite(c.req.param("id"), { orgId: org.id }), "org.invite.revoke", org.login);
167+});
168+api.get("/repos/:owner/:repo/invitations", async (c) => {
169+ const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
170+ return c.json((await c.get("dir").invites({ repoId: repo.id, pending: true })).map(inviteJson));
171+});
172+api.delete("/repos/:owner/:repo/invitations/:id", async (c) => {
173+ const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
174+ if (!(await c.get("dir").revokeInvite(c.req.param("id"), { repoId: repo.id }))) throw new HttpError(404, "no pending invitation with this id");
175+ auditRepo(c, caller, repo, "repo.invite.revoke", { id: c.req.param("id") });
176+ return c.body(null, 204);
177+});
178+api.get("/invitations", async (c) => {
179+ requireAccountScope(c, "read");
180+ if (!requireAccount(c).site_admin) throw new HttpError(403, "only site admins can list site invitations");
181+ return c.json((await c.get("dir").invites({ site: true, pending: true })).map(inviteJson));
182+});
183+api.delete("/invitations/:id", async (c) => {
184+ requireAccountScope(c, "admin");
185+ if (!requireAccount(c).site_admin) throw new HttpError(403, "only site admins can revoke site invitations");
186+ return revoked(c, await c.get("dir").revokeInvite(c.req.param("id"), {}), "invite.revoke");
187+});
188+
141189 // ---------------------------------------------------------------- repositories
142190
143191 api.get("/user/repos", async (c) => c.json(paginate(c, (await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r)))));
modifiedapps/diffinery/src/lib/directory.ts+30−13View file
@@ -119,17 +119,20 @@
119119 return (await this.account(id))!;
120120 }
121121
122- async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> {
122+ /** Rename an account and change its display name (and an organization's base permission) in one write, after checking every field. */
123+ async updateAccount(id: string, patch: { login?: string; name?: unknown; baseRole?: unknown }): Promise<void> {
124+ if (patch.name !== undefined && patch.name !== null && typeof patch.name !== "string") throw new HttpError(422, "name must be a string", { errors: [{ resource: "Account", code: "invalid", field: "name" }] });
125+ if (patch.baseRole !== undefined && !BASE_ROLES.includes(patch.baseRole as BaseRole)) throw new HttpError(422, "base permission must be none, read, write or admin", { errors: [{ resource: "Organization", code: "invalid", field: "default_repository_permission" }] });
123126 if (patch.login && !isValidLogin(patch.login)) throw new HttpError(422, "invalid or reserved login");
124127 if (patch.login) {
125128 const clash = await this.accountByLogin(patch.login);
126129 if (clash && clash.id !== id) throw new HttpError(422, "login already taken");
127130 }
128131 // A blank display name clears it, so pages fall back to the login.
129- const name = patch.name === undefined ? undefined : patch.name.trim() || null;
132+ const name = patch.name === undefined ? undefined : (patch.name as string | null)?.trim() || null;
130133 await this.db
131- .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END WHERE id = ?")
132- .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, id)
134+ .prepare("UPDATE accounts SET login = COALESCE(?, login), name = CASE WHEN ? THEN ? ELSE name END, base_role = CASE WHEN kind = 'org' THEN COALESCE(?, base_role) ELSE base_role END WHERE id = ?")
135+ .bind(patch.login ?? null, name === undefined ? 0 : 1, name ?? null, (patch.baseRole as string | undefined) ?? null, id)
133136 .run();
134137 if (patch.login) {
135138 // Repos keep their ids; their Durable Objects cache the owner login, so refresh them.
@@ -159,9 +162,12 @@
159162 return (await this.account(id))!;
160163 }
161164
162- async setBaseRole(orgId: string, role: string): Promise<void> {
163- if (!BASE_ROLES.includes(role as BaseRole)) throw new HttpError(422, "base permission must be none, read, write or admin", { errors: [{ resource: "Organization", code: "invalid", field: "default_repository_permission" }] });
164- await this.db.prepare("UPDATE accounts SET base_role = ? WHERE id = ? AND kind = 'org'").bind(role, orgId).run();
165+ /** Whether an account sees an organization's settings such as its base permission: members, the
166+ * organization's own agents, agents of members, and site admins. */
167+ async inOrg(orgId: string, account: Account): Promise<boolean> {
168+ if (account.site_admin) return true;
169+ if (account.kind === "agent") return account.owner_id === orgId || (!!account.owner_id && !!(await this.orgRole(orgId, account.owner_id)));
170+ return !!(await this.orgRole(orgId, account.id));
165171 }
166172
167173 async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> {
@@ -236,8 +242,13 @@
236242 return { invited: { email: q, url: inv.url } };
237243 }
238244
239- async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<void> {
240- await this.db.prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND (org_id IS ? OR ? IS NULL) AND (repo_id IS ? OR ? IS NULL)").bind(id, scope.orgId ?? null, scope.orgId ?? null, scope.repoId ?? null, scope.repoId ?? null).run();
245+ /** Revoke a pending invitation to an organization, a repository, or (neither) the site. False if there was none. */
246+ async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<boolean> {
247+ const r = await this.db
248+ .prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND org_id IS ? AND repo_id IS ?")
249+ .bind(id, scope.orgId ?? null, scope.repoId ?? null)
250+ .run();
251+ return (r.meta?.changes ?? 0) > 0;
241252 }
242253
243254 private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
@@ -247,8 +258,14 @@
247258 if (!owners?.n) throw new HttpError(422, "an organization needs at least one owner; make someone else an owner first");
248259 }
249260 if (role === null) {
250- await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run();
251- // Removing someone revokes their tokens' reach immediately: roles are computed per request.
261+ // Their grants on the organization's repositories (including the admin grant for repositories they
262+ // created) go too, for them and their agents. Roles are computed per request, so tokens lose access at once.
263+ await this.db.batch([
264+ this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId),
265+ this.db
266+ .prepare("DELETE FROM repo_access WHERE repo_id IN (SELECT id FROM repos WHERE owner_id = ?) AND account_id IN (SELECT ? UNION SELECT id FROM accounts WHERE kind = 'agent' AND owner_id = ?)")
267+ .bind(orgId, userId, userId),
268+ ]);
252269 return;
253270 }
254271 await this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role").bind(orgId, userId, role, now()).run();
@@ -383,10 +400,10 @@
383400 return { id, url, token };
384401 }
385402
386- async invites(filter: { orgId?: string; repoId?: string; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> {
403+ async invites(filter: { orgId?: string; repoId?: string; site?: boolean; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> {
387404 const r = await this.db
388405 .prepare(
389- `SELECT i.*, a.login AS inviter FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`,
406+ `SELECT i.*, a.login AS inviter FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.site ? " AND i.org_id IS NULL AND i.repo_id IS NULL" : ""}${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`,
390407 )
391408 .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : []))
392409 .all<Record<string, string | null>>();
modifiedapps/diffinery/src/mcp/server.ts+56−2View file
@@ -1,9 +1,9 @@
11 import { Hono } from "hono";
22 import type { Env } from "../env.js";
3-import { requireAccount, requireScope, type AppContext, type AppVars } from "../auth/principal.js";
3+import { requireAccount, requireAccountScope, requireScope, type AppContext, type AppVars } from "../auth/principal.js";
44 import { HttpError, b64, fromB64 } from "../lib/util.js";
55 import type { Role } from "../repo/types.js";
6-import type { TokenScope } from "../lib/directory.js";
6+import { baseRole, type TokenScope } from "../lib/directory.js";
77 import { loadRepo } from "../http/common.js";
88 import { RELEASE_TOOLS } from "./releases.js";
99 import { DOCS_TOOLS } from "./docs.js";
@@ -328,6 +328,42 @@
328328 return stub.dispatchWorkflow(str(a, "workflow"), optStr(a, "ref") ?? repo.default_branch, (a.inputs as Record<string, unknown>) ?? {}, caller);
329329 },
330330 },
331+ get_organization: {
332+ description: "Show an organization: its name and, to its members and their agents, the base permission every member gets on its repositories (default_repository_permission: none, read, write or admin).",
333+ input: { org: { type: "string", description: "Organization login" } },
334+ required: ["org"],
335+ readOnly: true,
336+ run: async (c, a) => {
337+ const me = requireAccount(c);
338+ const dir = c.get("dir");
339+ const org = await dir.accountByLogin(str(a, "org"));
340+ if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
341+ return { login: org.login, name: org.name || null, ...((await dir.inOrg(org.id, me)) ? { default_repository_permission: baseRole(org) } : {}) };
342+ },
343+ },
344+ list_invitations: {
345+ description: "List pending invitations to a repository (owner and repo, for its admins), an organization (org, for its owners and admins), or the site (neither, for site admins).",
346+ input: { ...OWNER_REPO, org: { type: "string", description: "Organization login" } },
347+ readOnly: true,
348+ run: async (c, a) => {
349+ const scope = await inviteScope(c, a, "read");
350+ return (await c.get("dir").invites({ ...scope, site: !scope.orgId && !scope.repoId, pending: true })).map((i) => ({ id: i.id, email: i.email, role: i.role, inviter: i.invitedBy, created_at: i.createdAt, expires_at: i.expiresAt }));
351+ },
352+ },
353+ revoke_invitation: {
354+ description: "Revoke a pending invitation by id (from list_invitations), so it can't be accepted and the address can be invited again. Takes the same owner and repo, org, or neither as list_invitations.",
355+ input: { ...OWNER_REPO, org: { type: "string", description: "Organization login" }, invitation_id: { type: "string" } },
356+ required: ["invitation_id"],
357+ run: async (c, a) => {
358+ const scope = await inviteScope(c, a, "admin");
359+ const dir = c.get("dir");
360+ const id = str(a, "invitation_id");
361+ if (!(await dir.revokeInvite(id, scope))) throw new HttpError(404, "no pending invitation with this id");
362+ const me = requireAccount(c);
363+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: scope.repoId ? "repo.invite.revoke" : scope.orgId ? "org.invite.revoke" : "invite.revoke", target: optStr(a, "org") ?? (scope.repoId ? `${optStr(a, "owner")}/${optStr(a, "repo")}` : undefined), detail: { id } }));
364+ return { revoked: true };
365+ },
366+ },
331367 sandbox_create: {
332368 description: "Start an isolated Linux sandbox with the repository checked out (git push from inside it uses your permissions). Returns a sandbox id.",
333369 input: { ...OWNER_REPO, ref: { type: "string" } },
@@ -435,6 +471,24 @@
435471 return p.type === "account" ? p.scopes : [];
436472 }
437473
474+/** Which invitations a caller may manage: a repository's (admins), an organization's (owners and admins) or the site's (site admins). */
475+async function inviteScope(c: AppContext, a: Args, need: "read" | "admin"): Promise<{ orgId?: string; repoId?: string }> {
476+ if (optStr(a, "owner") || optStr(a, "repo")) return { repoId: (await repoOf(c, a, "admin")).repo.id };
477+ // Organization and site invitations are account settings, as in the REST API.
478+ requireAccountScope(c, need);
479+ const me = requireAccount(c);
480+ const dir = c.get("dir");
481+ if (optStr(a, "org")) {
482+ const org = await dir.accountByLogin(str(a, "org"));
483+ if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
484+ const role = me.kind === "agent" ? (me.owner_id ? await dir.orgRole(org.id, me.owner_id) : null) : await dir.orgRole(org.id, me.id);
485+ if (role !== "owner" && role !== "admin" && !me.site_admin) throw new HttpError(403, "only organization owners and admins can manage its invitations");
486+ return { orgId: org.id };
487+ }
488+ if (!me.site_admin) throw new HttpError(403, "only site admins can manage invitations to the site; pass owner and repo, or org");
489+ return {};
490+}
491+
438492 async function sandboxCall(c: AppContext, a: Args, path: string, payload: unknown) {
439493 const { stub, caller } = await repoOf(c, a, "write");
440494 const id = str(a, "sandbox_id");
modifiedapps/diffinery/src/registry/access.ts+5−3View file
@@ -10,7 +10,7 @@
1010 * - A package linked to a repository shares its permissions: readers pull, writers push,
1111 * admins delete. The owner (the user, or the organization's owners and admins) always
1212 * administers its packages. Organization members can create packages and pull the
13- * organization's packages; an unlinked package is administered by whoever created it.
13+ * organization's unlinked packages; an unlinked package is administered by whoever created it.
1414 * - Packages are private unless made public.
1515 * - A workflow's GITHUB_TOKEN follows the job's `packages` permission. It pulls packages
1616 * of its own owner, pushes to its repository's packages (creating them, linked to the
@@ -82,7 +82,7 @@
8282 const subject = a.kind === "agent" ? a.owner_id : a.id;
8383
8484 // The owner administers its packages. Organization members create packages and pull the
85- // organization's; an unlinked package is administered by whoever created it (like ghcr.io,
85+ // organization's unlinked ones; an unlinked package is administered by whoever created it (like ghcr.io,
8686 // membership alone doesn't let one member push to another's package).
8787 let role: PackageRole = "none";
8888 if (a.site_admin) role = "admin";
@@ -96,7 +96,9 @@
9696 // The creator, or the member whose agent created it.
9797 let creator = !!pkg?.created_by && (pkg.created_by === a.id || pkg.created_by === subject);
9898 if (pkg?.created_by && !pkg.repo_id && !creator) creator = (await dir.account(pkg.created_by))?.owner_id === subject;
99- role = !pkg ? "push" : !pkg.repo_id && creator ? (a.kind === "agent" ? "push" : "admin") : "pull";
99+ // A package linked to a repository follows that repository's access alone (below), so
100+ // a member without access to a private repository can't pull its images either.
101+ role = !pkg ? "push" : pkg.repo_id ? "none" : creator ? (a.kind === "agent" ? "push" : "admin") : "pull";
100102 }
101103 }
102104 }
modifiedapps/diffinery/src/registry/http.ts+3−0View file
@@ -564,6 +564,9 @@
564564 const orgs = (await dir.orgsFor(subject)).map((o) => o.id);
565565 const repos = (await dir.reposVisibleTo(p.account)).map((r) => r.id);
566566 rows = await store.packages({ ownerIds: p.repoIds ? [] : [subject, ...orgs], repoIds: p.repoIds ?? repos, limit: 5000 });
567+ // An organization's packages linked to a repository are listed only to those who can see it.
568+ const seen = new Set(repos);
569+ rows = rows.filter((r) => r.owner_id === subject || !r.repo_id || seen.has(r.repo_id));
567570 } else {
568571 const repo = await dir.repoById(p.repoId);
569572 rows = repo ? await store.packages({ ownerIds: [repo.owner_id], limit: 5000 }) : [];
modifiedapps/diffinery/src/web/account.tsx+21−35View file
@@ -13,7 +13,7 @@
1313 import { repoCreated } from "../mail/notify.js";
1414 import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js";
1515 import { deleteCookie, setCookie } from "hono/cookie";
16-import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, LOGIN_PATTERN, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
16+import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, LOGIN_PATTERN, PendingInvites, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
1717 import type { FormData } from "./csrf.js";
1818 import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
1919 import { MAX_AVATAR_BYTES } from "../lib/image.js";
@@ -1285,7 +1285,7 @@
12851285 const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => {
12861286 const dir = c.get("dir");
12871287 const t = csrfToken(c);
1288- const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]);
1288+ const [users, requests, invites] = await Promise.all([dir.users(), dir.accessRequests(), dir.invites({ site: true, pending: true })]);
12891289 return page(
12901290 c,
12911291 me,
@@ -1349,6 +1349,7 @@
13491349 <div class="hint">We email them a link. Opening it lets them pick a username and create a passkey.</div>
13501350 </div>
13511351 </Form>
1352+ <PendingInvites invites={invites} revoke={(id) => `/settings/admin/invites/${id}/revoke`} t={t} roles={false} />
13521353 <div class="box">
13531354 <div class="head">
13541355 <h2>Users</h2>
@@ -1437,6 +1438,20 @@
14371438 return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: `We emailed it to ${email}; you can also send it yourself. It expires in 14 days and works once: they pick a username and create a passkey.` });
14381439 });
14391440
1441+ post(web, "/settings/admin/invites/:id/revoke", async (c) => {
1442+ const me = requireSiteAdmin(c);
1443+ const dir = c.get("dir");
1444+ return act(
1445+ c,
1446+ "/settings/admin",
1447+ async () => {
1448+ if (!(await dir.revokeInvite(prm(c, "id"), {}))) throw new HttpError(404, "this invitation was already used, revoked or never existed");
1449+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "invite.revoke", detail: { id: prm(c, "id") } }));
1450+ },
1451+ "Invitation revoked.",
1452+ );
1453+ });
1454+
14401455 web.get("/settings/audit", async (c) => {
14411456 const me = requireSiteAdmin(c);
14421457 const rows = await c.get("dir").auditLog(200);
@@ -1760,36 +1775,7 @@
17601775 </tbody>
17611776 </table>
17621777 </div>
1763- {invites.length ? (
1764- <div class="box">
1765- <div class="head">
1766- <h3>{raw(ICONS.envelope)} Pending invitations</h3>
1767- </div>
1768- <table class="tbl">
1769- <tbody>
1770- {invites.map((i) => (
1771- <tr>
1772- <td>
1773- <b>{i.email}</b>
1774- <div class="muted small">
1775- Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""}
1776- <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}
1777- </div>
1778- </td>
1779- <td>
1780- <span class="badge">{ROLE_LABEL[i.role] ?? i.role}</span>
1781- </td>
1782- <td class="right">
1783- <Form action={`${base}/-/org/invites/${i.id}/revoke`} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}>
1784- <button class="btn sm danger">Revoke</button>
1785- </Form>
1786- </td>
1787- </tr>
1788- ))}
1789- </tbody>
1790- </table>
1791- </div>
1792- ) : null}
1778+ <PendingInvites invites={invites} revoke={(id) => `${base}/-/org/invites/${id}/revoke`} t={t} />
17931779 </>
17941780 );
17951781 } else if (tab === "agents") {
@@ -1892,7 +1878,7 @@
18921878 `/${org.login}?tab=people`,
18931879 async () => {
18941880 if (!isAdmin) throw new HttpError(403, "organization admins only");
1895- await dir.revokeInvite(prm(c, "id"), { orgId: org.id });
1881+ if (!(await dir.revokeInvite(prm(c, "id"), { orgId: org.id }))) throw new HttpError(404, "this invitation was already used, revoked or never existed");
18961882 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite.revoke", target: org.login, detail: { id: prm(c, "id") } }));
18971883 },
18981884 "Invitation revoked.",
@@ -1921,8 +1907,8 @@
19211907 return act(c, `/${org.login}?tab=settings`, async () => {
19221908 if (!isOwner) throw new HttpError(403, "organization owners only");
19231909 const login = f.s("login").trim();
1924- await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() });
1925- if (f.s("base_role")) await dir.setBaseRole(org.id, f.s("base_role"));
1910+ // One validated write: a bad base permission doesn't leave a half-applied rename behind.
1911+ await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim(), baseRole: f.s("base_role") || undefined });
19261912 if (login && login !== org.login) c.executionCtx.waitUntil(purgeAvatarCache(c, org.login, [org.avatar_version]));
19271913 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login, base_role: f.s("base_role") || undefined } }));
19281914 return `/${login || org.login}?tab=settings`;
modifiedapps/diffinery/src/web/repo-settings.tsx+11−2View file
@@ -8,7 +8,7 @@
88 import { ICONS } from "./assets.js";
99 import { KCheckbox, KRadio, KSelect } from "./kumo.js";
1010 import { csrfToken, type FormData } from "./csrf.js";
11-import { Actor, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
11+import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
1212
1313 type Loaded = Awaited<ReturnType<typeof loadRepo>>;
1414 type Tab = "general" | "access" | "branches" | "secrets" | "webhooks";
@@ -262,7 +262,7 @@
262262 const { repo } = l;
263263 const base = repoUrl(repo);
264264 const t = csrfToken(c);
265- const collaborators = await c.get("dir").collaborators(repo.id);
265+ const [collaborators, invites] = await Promise.all([c.get("dir").collaborators(repo.id), c.get("dir").invites({ repoId: repo.id, pending: true })]);
266266 return page(
267267 c,
268268 l,
@@ -338,6 +338,7 @@
338338 <div class="empty">No direct collaborators.</div>
339339 )}
340340 </div>
341+ <PendingInvites invites={invites} revoke={(id) => `${base}/settings/access/invites/${id}/revoke`} t={t} />
341342 <div class="box">
342343 <div class="body small muted">
343344 <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access.
@@ -645,6 +646,14 @@
645646 });
646647 });
647648
649+ post(web, "/:owner/:repo/settings/access/invites/:id/revoke", async (c) => {
650+ const l = await load(c);
651+ return act(c, `${repoUrl(l.repo)}/settings/access`, async () => {
652+ if (!(await c.get("dir").revokeInvite(prm(c, "id"), { repoId: l.repo.id }))) throw new HttpError(404, "this invitation was already used, revoked or never existed");
653+ audit(c, l, "repo.invite.revoke", { id: prm(c, "id") });
654+ }, "Invitation revoked.");
655+ });
656+
648657 post(web, "/:owner/:repo/settings/access/people", async (c, f) => {
649658 const l = await load(c);
650659 const me = requireAccount(c);
modifiedapps/diffinery/src/web/ui.tsx+37−0View file
@@ -888,5 +888,42 @@
888888 /** "write" → "Write", for showing a stored value as a label. */
889889 export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
890890
891+export type PendingInvite = { id: string; email: string; role: string; createdAt: string; expiresAt: string; invitedBy: string | null };
892+
893+/** Pending invitations with a Revoke button each; `revoke` gives the form action for an invitation id. */
894+export const PendingInvites: FC<{ invites: PendingInvite[]; revoke: (id: string) => string; t: string; roles?: boolean }> = ({ invites, revoke, t, roles = true }) =>
895+ invites.length ? (
896+ <div class="box">
897+ <div class="head">
898+ <h3>{raw(ICONS.envelope)} Pending invitations</h3>
899+ </div>
900+ <table class="tbl">
901+ <tbody>
902+ {invites.map((i) => (
903+ <tr>
904+ <td>
905+ <b>{i.email}</b>
906+ <div class="muted small">
907+ Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""}
908+ <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}
909+ </div>
910+ </td>
911+ {roles ? (
912+ <td>
913+ <span class="badge">{cap(i.role)}</span>
914+ </td>
915+ ) : null}
916+ <td class="right">
917+ <Form action={revoke(i.id)} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}>
918+ <button class="btn sm danger">Revoke</button>
919+ </Form>
920+ </td>
921+ </tr>
922+ ))}
923+ </tbody>
924+ </table>
925+ </div>
926+ ) : null;
927+
891928 /** The browser's check for usernames, organization and agent names; the server's isValidLogin agrees (no dots). */
892929 export const LOGIN_PATTERN = "[A-Za-z0-9][A-Za-z0-9_\\-]{0,99}";
modifiedapps/diffinery/test/e2e/e2e.test.mjs+41−0View file
@@ -1808,6 +1808,47 @@
18081808 assert.equal(second.status, 303);
18091809 assert.match(flashOf(second), /already has a pending invitation/);
18101810
1811+ // ...and it can be revoked: on the access page, in the API and over MCP. Then the address can be invited again.
1812+ const mcp = async (name, args) => {
1813+ const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
1814+ const { result } = await r.json();
1815+ assert.equal(result.isError, false, result.content[0].text);
1816+ return result.structuredContent;
1817+ };
1818+ const repoInvites = async () => ok("GET", `/repos/${me.login}/people-rules/invitations`);
1819+ const [pending] = await repoInvites();
1820+ assert.equal(pending.email, "newcomer@example.com");
1821+ assert.match(await (await fetch(`${base}${access}`)).text(), new RegExp(`action="${access}/invites/${pending.id}/revoke"`));
1822+ const revoked = await submit(access, `${access}/invites/${pending.id}/revoke`, {});
1823+ assert.match(flashOf(revoked), /Invitation revoked/);
1824+ assert.deepEqual(await repoInvites(), []);
1825+ assert.equal((await submit(access, `${access}/people`, { who: "newcomer@example.com", role: "read" })).status, 200);
1826+ const [again] = await repoInvites();
1827+ assert.equal((await api("DELETE", `/repos/${me.login}/people-rules/invitations/${again.id}`)).status, 204);
1828+ assert.equal((await api("DELETE", `/repos/${me.login}/people-rules/invitations/${again.id}`)).status, 404);
1829+ await submit(access, `${access}/people`, { who: "newcomer@example.com", role: "read" });
1830+ const listed = (await mcp("list_invitations", { owner: me.login, repo: "people-rules" })).result;
1831+ assert.deepEqual(listed.map((i) => i.email), ["newcomer@example.com"]);
1832+ assert.equal((await mcp("revoke_invitation", { owner: me.login, repo: "people-rules", invitation_id: listed[0].id })).revoked, true);
1833+ assert.deepEqual(await repoInvites(), []);
1834+
1835+ // Organization and site invitations too; each revoke only reaches its own kind.
1836+ await ok("POST", "/user/orgs", { login: "revokers" });
1837+ const orgInvite = await ok("POST", "/orgs/revokers/invitations", { email: "joiner@example.com" });
1838+ const siteInvite = await ok("POST", "/invitations", { email: "visitor@example.com" });
1839+ assert.deepEqual((await ok("GET", "/orgs/revokers/invitations")).map((i) => i.id), [orgInvite.id]);
1840+ const site = await ok("GET", "/invitations");
1841+ assert.ok(site.some((i) => i.id === siteInvite.id) && !site.some((i) => i.id === orgInvite.id));
1842+ assert.equal((await api("DELETE", `/invitations/${orgInvite.id}`)).status, 404);
1843+ assert.equal((await api("DELETE", `/orgs/revokers/invitations/${siteInvite.id}`)).status, 404);
1844+ assert.equal((await api("DELETE", `/orgs/revokers/invitations/${orgInvite.id}`)).status, 204);
1845+ await ok("POST", "/orgs/revokers/invitations", { email: "joiner@example.com" });
1846+ const [orgAgain] = (await mcp("list_invitations", { org: "revokers" })).result;
1847+ assert.equal((await mcp("revoke_invitation", { org: "revokers", invitation_id: orgAgain.id })).revoked, true);
1848+ assert.match(await (await fetch(`${base}/settings/admin`)).text(), new RegExp(`/settings/admin/invites/${siteInvite.id}/revoke`));
1849+ assert.match(flashOf(await submit("/settings/admin", `/settings/admin/invites/${siteInvite.id}/revoke`, {})), /Invitation revoked/);
1850+ assert.ok(!(await ok("GET", "/invitations")).some((i) => i.id === siteInvite.id));
1851+
18111852 // A blank display name falls back to the username.
18121853 const saved = await submit("/settings", "/settings/profile", { login: me.login, name: " " });
18131854 assert.match(flashOf(saved), /Profile saved\./);
modifiedapps/diffinery/test/e2e/registry.test.mjs+17−0View file
@@ -385,6 +385,23 @@
385385 }
386386 });
387387
388+test("registry: an organization's package linked to a private repository follows that repository's access", async () => {
389+ // With the base permission at none, members only reach the repositories they're given, and so their images.
390+ await ok("PATCH", "/orgs/crew", { default_repository_permission: "none" }, CREW.olga);
391+ await ok("POST", "/orgs/crew/repos", { name: "secret", private: true }, CREW.olga);
392+ await pushImage("crew/secret", "v1", { tok: CREW.olga });
393+ assert.equal((await ok("GET", "/orgs/crew/packages/container/secret", undefined, CREW.olga)).repository.full_name, "crew/secret");
394+ assert.equal((await reg("GET", "crew/secret/manifests/v1", { tok: CREW.mira })).status, 404);
395+ assert.ok(!(await reg("GET", "_catalog", { tok: CREW.mira })).json.repositories.includes("crew/secret"));
396+ assert.ok(!(await ok("GET", "/orgs/crew/packages", undefined, CREW.mira)).some((p) => p.name === "secret"));
397+ // Unlinked packages are still the members' to pull, and a grant on the repository opens its image.
398+ assert.equal((await reg("GET", "crew/nils-tool/manifests/v1", { tok: CREW.mira })).status, 200);
399+ await ok("PUT", "/repos/crew/secret/collaborators/mira", { permission: "read" }, CREW.olga);
400+ assert.equal((await reg("GET", "crew/secret/manifests/v1", { tok: CREW.mira })).status, 200);
401+ assert.ok((await reg("GET", "_catalog", { tok: CREW.mira })).json.repositories.includes("crew/secret"));
402+ await ok("PATCH", "/orgs/crew", { default_repository_permission: "read" }, CREW.olga);
403+});
404+
388405 function git(cwd, args) {
389406 const a = Buffer.from(`x-token:${token}`).toString("base64");
390407 return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${a}`, "-c", "protocol.version=2", ...args], {
modifiedapps/diffinery/test/e2e/security.test.mjs+70−1View file
@@ -34,7 +34,7 @@
3434
3535 const hash = (s) => createHash("sha256").update(s).digest("hex");
3636 const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
37-const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo") };
37+const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam") };
3838 const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}` };
3939 const OWNER = "olive";
4040
@@ -123,6 +123,10 @@
123123 session("s1", "u-penny", S.penny),
124124 session("s2", "u-dora", S.dora),
125125 session("s3", "u-olive", S.olive),
126+ account("o-shop", "shop", "org", "active"),
127+ account("u-sam", "sam", "user", "active"),
128+ token("t9", "u-sam", T.sam, ["admin"]),
129+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`,
126130 ].join("; "),
127131 );
128132 dev = spawn(
@@ -584,3 +588,68 @@
584588 assert.deepEqual(await can("/repos/guild/milo-tools", T.milo), { admin: true, push: true, pull: true });
585589 await ok("PATCH", "/orgs/guild", { default_repository_permission: "read" });
586590 });
591+
592+/** A web form post as olive (signed in by session), with the CSRF pair taken from `page`. */
593+async function oliveSubmits(page, action, fields) {
594+ const cookie = `__Host-dfy_session=${S.olive}`;
595+ const res = await fetch(`${base}${page}`, { headers: { cookie } });
596+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
597+ const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
598+ assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`);
599+ const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } });
600+ return { status: r.status, flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) };
601+}
602+
603+/** Call an MCP tool with a token. */
604+async function mcpTool(as, name, args) {
605+ const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${as}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }) });
606+ const { result } = await r.json();
607+ return { isError: result.isError, text: result.content[0].text, data: result.structuredContent };
608+}
609+
610+test("security: leaving an organization takes its repository access along", async () => {
611+ // sam, a member of shop, creates a repository (and so administers it) and is given write on another,
612+ // as is the agent he works with.
613+ await ok("PATCH", "/orgs/shop", { default_repository_permission: "none" });
614+ await ok("POST", "/orgs/shop/repos", { name: "sam-lab", private: true }, T.sam);
615+ await ok("POST", "/orgs/shop/repos", { name: "tools", private: true });
616+ const helper = (await ok("POST", "/diffinery/agents", { login: "sam-helper" }, T.sam)).agent.login;
617+ await status(204, "PUT", "/repos/shop/tools/collaborators/sam", { permission: "write" });
618+ await status(204, "PUT", `/repos/shop/tools/collaborators/${helper}`, { permission: "write" });
619+ assert.equal((await ok("GET", "/repos/shop/sam-lab", undefined, T.sam)).permissions.admin, true);
620+ assert.equal((await ok("GET", "/repos/shop/tools", undefined, T.sam)).permissions.push, true);
621+
622+ // Removing him from the organization removes those grants, his agent's included.
623+ const removed = await oliveSubmits("/shop?tab=people", "/shop/-/org/members", { login: "sam", role: "remove" });
624+ assert.equal(removed.status, 303, removed.flash);
625+ assert.doesNotMatch(removed.flash, /error/i);
626+ await status(404, "GET", "/repos/shop/sam-lab", undefined, T.sam);
627+ await status(404, "GET", "/repos/shop/tools", undefined, T.sam);
628+ const logins = async (repo) => (await ok("GET", `/repos/shop/${repo}/collaborators`)).map((u) => u.login);
629+ assert.ok(!(await logins("sam-lab")).includes("sam"));
630+ assert.deepEqual((await logins("tools")).filter((l) => l === "sam" || l === helper), []);
631+});
632+
633+test("security: organization settings are checked before anything is saved, and agents see the base permission", async () => {
634+ // A non-string name is a 422, not a crash; a bad base permission doesn't let the rename through.
635+ const bad = await status(422, "PATCH", "/orgs/guild", { name: 123 });
636+ assert.match(bad.message, /name must be a string/);
637+ await status(422, "PATCH", "/orgs/guild", { name: "Halfway", default_repository_permission: "superuser" });
638+ assert.equal((await ok("GET", "/orgs/guild")).name, "guild");
639+ // The settings form does the same.
640+ const form = await oliveSubmits("/guild?tab=settings", "/guild/-/org/settings", { login: "guild", name: "Halfway", base_role: "superuser" });
641+ assert.match(form.flash, /base permission must be/);
642+ assert.equal((await ok("GET", "/orgs/guild")).name, "guild");
643+ const saved = await oliveSubmits("/guild?tab=settings", "/guild/-/org/settings", { login: "guild", name: "The Guild", base_role: "read" });
644+ assert.match(saved.flash, /Organization updated/);
645+ assert.equal((await ok("GET", "/orgs/guild")).name, "The Guild");
646+
647+ // olive-bot works for an owner of guild: it sees the base permission, in the API and over MCP.
648+ assert.equal((await ok("GET", "/orgs/guild", undefined, T.bot)).default_repository_permission, "read");
649+ const org = await mcpTool(T.bot, "get_organization", { org: "guild" });
650+ assert.equal(org.isError, false, org.text);
651+ assert.equal(org.data.default_repository_permission, "read");
652+ // Outsiders don't.
653+ assert.equal((await mcpTool(T.sam, "get_organization", { org: "guild" })).data.default_repository_permission, undefined);
654+ assert.equal((await ok("GET", "/orgs/guild", undefined, T.sam)).default_repository_permission, undefined);
655+});
modifieddocs/container-registry.md+2−1View file
@@ -105,7 +105,8 @@
105105 A package linked to a repository shares the repository's roles: readers pull,
106106 writers push, admins delete versions and change settings. The owner (a user,
107107 or an organization's owners and admins) always administers its packages.
108-Organization members can create packages and pull the organization's packages;
108+Organization members can create packages and pull the organization's packages
109+that aren't linked to a repository (a linked one follows the member's role on it);
109110 a package that isn't linked to a repository is administered by the member who
110111 created it, and other members can't push to it.
111112