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.

Adding someone by username sends an invitation they accept, as on GitHub (#113)

A person added to an organization or repository by username now gets an invitation (in-app and by email, at Settings → Invitations) and joins once they accept. Agents, existing collaborators, members of the owning organization and admins adding themselves still get access at once. New REST endpoints /user/repository_invitations and /user/memberships/orgs; PUT collaborators answers 201 with the invitation or 204. Also fixes deleting a repository that has invitations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo
Canberk Demirci authored · GitHub committed signed1 parent 33e1ecfcommit 1d56710ff9beb0e889c484a664eca90ba6546fa3Browse files
25 files changed+621−115
Files (25)
modifiedapps/diffinery/docs/accounts/invitations.md+91−20View file
@@ -1,9 +1,9 @@
11 ---
22 title: Invitations
3-description: How people join an invite-only Diffinery instance, who can invite, Add people by username or email, revoking invitations, the CLI and API, and access requests.
3+description: How people join an invite-only Diffinery instance, who can invite, Add people by username or email, accepting or declining invitations, revoking them, the CLI and API, and access requests.
44 ---
55
6-Diffinery is invite-only: nobody can sign up from the sign-in page. A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address: people who already have an account are added right away, and email addresses without an account get an invitation.
6+Diffinery is invite-only: nobody can sign up from the sign-in page. A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address. Either way the person gets an invitation, as on GitHub: someone added by username accepts it under **Settings → Invitations**, and an email address gets an invite link that also creates the account if there is none.
77
88 ## How sign-up works
99
@@ -22,7 +22,7 @@
2222 | Site administrators | The instance (no organization or repository), and any organization | **Settings → People and invitations**, an organization's **People** tab, `diffinery invite`, the API |
2323 | Organization owners | Their organization, as member, admin or owner | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API |
2424 | Organization admins | Their organization, as member or admin | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API |
25-| Repository admins (including the owner of a personal repository) | One repository, with a repository role from read to admin | The repository's **Settings → Collaborators** page (**Add people**) |
25+| Repository admins (including the owner of a personal repository) | One repository, with a repository role from read to admin | The repository's **Settings → Collaborators** page (**Add people**), `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` |
2626
2727 Everyone else, including organization members and people with a lower repository role, can't create invites.
2828
@@ -38,7 +38,7 @@
3838
3939 The page shows "Invite created for <email>" with the link and a copy button: "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."
4040
41-An address can have only one pending instance invitation at a time (letter case doesn't matter). A second one is refused with "friend@example.com already has a pending invitation; revoke it first to send a new one"; instance invitations can't be revoked, so send the person the first link again, or wait until it expires after 14 days.
41+An address can have only one pending instance invitation at a time (letter case doesn't matter). A second one is refused with "friend@example.com already has a pending invitation; revoke it first to send a new one"; revoke the first one under **Pending invitations** on the same page, then invite the address again.
4242
4343 ### Add people to an organization
4444
@@ -51,10 +51,10 @@
5151
5252 | You entered | What happens |
5353 | --- | --- |
54-| A username | They join right away, with no invitation to accept: "Added diffy to acme as member. We let them know." They get an email and an in-app notification. |
54+| A username | Diffinery sends them an invitation: "Invited diffy to acme as member. They join once they accept." They get an email and an in-app notification, and join when they accept it under **Settings → Invitations** (see [Invitations you receive](#invitations-you-receive)). If they already have a pending invitation to the organization, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers the admin role." |
5555 | An email address | Diffinery emails an invitation, whether or not the address has an account, so the form never tells you who an address belongs to and nobody joins without agreeing. The page shows **Invitation sent to friend@example.com** with the link and a copy button, and the note "We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one." |
5656
57-The invitation then appears under **Pending invitations** on the same tab, with who sent it, when, the expiry date and the role. See [Organizations](/docs/accounts/organizations#add-people) for every message the form can show.
57+A site administrator who enters their own username joins at once. The invitation then appears under **Pending invitations** on the same tab, with who sent it, when, the expiry date and the role. An invitation sent to a username shows as `@diffy`, never the person's email address. See [Organizations](/docs/accounts/organizations#add-people) for every message the form can show.
5858
5959 An email address can have only one pending invitation per organization, whether it came from this form, the CLI or the API. Letter case doesn't matter: `Friend@Example.com` and `friend@example.com` are the same address. 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").
6060
@@ -66,7 +66,7 @@
6666 - repository invitations on the repository's **Settings → Collaborators** page (repository admins);
6767 - instance invitations on **Settings → People and invitations** (site administrators).
6868
69-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.
69+Under **Pending invitations**, click **Revoke** next to the invitation and confirm "Revoke the invitation for friend@example.com? The link stops working." (for an invitation sent to a username: "Revoke the invitation for @diffy?"). You see "Invitation revoked." Opening the link afterwards shows "this invite is invalid, already used, or expired", and an invitation sent to a username disappears from the person's **Settings → Invitations**. The person isn't notified, and you can invite the same address or username again.
7070
7171 ### Add people to a repository
7272
@@ -78,7 +78,8 @@
7878
7979 | You entered | What happens |
8080 | --- | --- |
81-| The username of a person or an agent | They get the role right away as a direct collaborator: "diffy now has write access. We let them know." People get an email and an in-app notification. If they already were a collaborator, their role changes to the one you chose. |
81+| The username of a person | Diffinery sends them an invitation: "Invited diffy with write access. They get access once they accept." They get an email and an in-app notification, and get the role when they accept it under **Settings → Invitations** (see [Invitations you receive](#invitations-you-receive)). If they already have a pending invitation to the repository, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers admin access." |
82+| The username of an agent, of a person who already is a direct collaborator, of a member of the organization that owns the repository, or your own | They get the role right away as a direct collaborator, with no invitation: "diffy now has write access. We let them know." People get an email and an in-app notification. A collaborator's role changes to the one you chose. |
8283 | An email address | Diffinery emails an invitation, whether or not the address has an account, and shows **Invitation sent to friend@example.com** with the link to send yourself. When the person accepts it, they get that role on the repository. |
8384
8485 | Message | Cause |
@@ -87,9 +88,9 @@
8788 | "acme is an organization; add its people instead" | Organizations can't be collaborators. |
8889 | "diffy's account is disabled" | A site administrator disabled the account. |
8990 | "enter a valid email address" | The text has an `@` but isn't a complete address. |
90-| "friend@example.com already has a pending invitation; revoke it first to send a new one" | The address already has an open invitation to this repository (letter case doesn't matter). Repository invitations can't be revoked, so send the person the first link again, or wait until it is used or expires after 14 days. |
91+| "friend@example.com already has a pending invitation; revoke it first to send a new one" | The address already has an open invitation to this repository (letter case doesn't matter). Revoke it under **Pending invitations**, then add the address again. |
9192
92-The repository's **Settings → Collaborators** page doesn't list pending invitations. See [Permissions](/docs/accounts/permissions) for what each role allows.
93+Pending invitations are listed under **Pending invitations** on the same page, where you can revoke them. See [Permissions](/docs/accounts/permissions) for what each role allows.
9394
9495 ## Invite from the CLI
9596
@@ -119,13 +120,13 @@
119120 | `-r, --role ROLE` | `member` (default), `admin` or `owner`. Needs `--org`. |
120121 | `--json` | Print the API response (`id` and `url`) as JSON. |
121122
122-The command prints the invite link and reminds you that it is valid for 14 days. Like the web form, `diffinery invite` always creates an invitation, even when the address already has an account (they accept it with **Accept invite**; see [Accept an invite with an existing account](#accept-an-invite-with-an-existing-account)). It refuses an address that already has a pending invitation to the organization (see [API errors](#api-errors)). The CLI can't create repository invites; use the repository's **Settings → Collaborators** page. See [CLI reference](/docs/reference/cli) for every command.
123+The command prints the invite link and reminds you that it is valid for 14 days. Like the web form, `diffinery invite` always creates an invitation, even when the address already has an account (they accept it with **Accept invite**; see [Accept an invite with an existing account](#accept-an-invite-with-an-existing-account)). It refuses an address that already has a pending invitation to the organization (see [API errors](#api-errors)). The CLI can't create repository invites; use the repository's **Settings → Collaborators** page or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}`. See [CLI reference](/docs/reference/cli) for every command.
123124
124125 ## Invite with the API
125126
126-Both endpoints take a JSON body and return `201 Created` with the invite's `id` and `url`. Diffinery also emails the link.
127+The organization and instance endpoints take a JSON body and return `201 Created` with the invite's `id` and `url`. Diffinery also emails the link.
127128
128-Inviting is an account-level action, so both endpoints need a personal access token with the `admin` scope that isn't restricted to repositories (or a browser session). A token with only `read` or `write` gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. See [Personal access tokens](/docs/accounts/tokens#account-level-actions).
129+Inviting to an organization or the instance is an account-level action, so both endpoints need a personal access token with the `admin` scope that isn't restricted to repositories (or a browser session). A token with only `read` or `write` gets `403 this token needs the admin scope`, and a token restricted to repositories gets `403 this token is limited to repositories and can't change account settings`. See [Personal access tokens](/docs/accounts/tokens#account-level-actions).
129130
130131 ### Organization invite
131132
@@ -153,6 +154,22 @@
153154
154155 `POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only.
155156
157+### Repository invite by username
158+
159+`PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` with `{"permission": "write"}` works like **Add people** on the repository's **Settings → Collaborators** page. It needs admin on the repository, through an `admin` token (it may be restricted to that repository) or a browser session.
160+
161+- When the person gets an invitation, the answer is `201 Created` with the invitation: `id`, `repository` (`full_name`, `name`, `owner.login`), `invitee`, `inviter` (`login`), `permissions`, `created_at`, `expires_at` and `html_url` (their **Settings → Invitations** page). If they already had a pending invitation to the repository, the same invitation now offers the new role.
162+- When the role applies at once (an agent, someone who already is a direct collaborator, a member of the organization that owns the repository, or yourself), the answer is `204 No Content`.
163+
164+```sh
165+curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \
166+ -H "Authorization: Bearer dfy_..." \
167+ -H "Content-Type: application/json" \
168+ -d '{"permission": "write"}'
169+```
170+
171+It answers `404 user not found` when no account has that username, `422 diffy's account is disabled` for a disabled account, and `429 you can send at most 50 invitations in 24 hours; try again later` like the other ways of inviting.
172+
156173 ### List and revoke pending invitations
157174
158175 | Method and path | Who |
@@ -161,7 +178,7 @@
161178 | `GET /api/v3/repos/{owner}/{repo}/invitations`, `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` | The repository's admins |
162179 | `GET /api/v3/invitations`, `DELETE /api/v3/invitations/{id}` | Site administrators (instance invitations only) |
163180
164-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.
181+Each listed invitation has an `id`, `email`, `login`, `role`, `inviter`, `created_at` and `expires_at`. An invitation sent to an email address has the `email` and a `login` of `null`; one sent to a username has the `login` and an `email` of `null`. `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.
165182
166183 ### API errors
167184
@@ -197,6 +214,8 @@
197214
198215 If the address already belongs to a Diffinery account, the email goes to that account.
199216
217+An invitation sent to a username has the same subject and details, but its button is **View invitation** and opens **Settings → Invitations** (`/settings/invitations`), where you accept or decline it. It carries no link that accepts the invitation by itself. See [Invitations you receive](#invitations-you-receive).
218+
200219 ## Invite link rules
201220
202221 | Rule | Value |
@@ -205,7 +224,7 @@
205224 | Uses | Once. A used, revoked or expired link shows "this invite is invalid, already used, or expired" (HTTP 404). If you are already signed in, the page still shows **Accept invite**, and clicking it answers "this invite is invalid or has expired" (HTTP 404). |
206225 | Who can use it | Only the email address it was sent to |
207226 | What joining accepts | Every open invite for that email address, not just the one you opened |
208-| Withdrawing | Organization invitations can be revoked from the organization's **People** tab. Repository and instance invitations can't; they stop working once used or after 14 days. |
227+| Withdrawing | Pending invitations can be revoked: organization invitations on the organization's **People** tab, repository invitations on the repository's **Settings → Collaborators** page, instance invitations on **Settings → People and invitations**. Otherwise they stop working once used or after 14 days. |
209228
210229 If you sent a repository or instance invite to the wrong address, a site administrator can disable the account it creates (see [Site administration](/docs/admin/site-administration#people)).
211230
@@ -235,15 +254,67 @@
235254
236255 You are taken to the repository or organization you were invited to. The invite only works when the signed-in account's email address matches; otherwise you get "this invite was sent to <email>; sign in with that address" (HTTP 403).
237256
238-When someone accepts your invite, you get an "@<username> accepted your invitation" email (part of the **Organizations** email group, which you can turn off; see [Notifications](/docs/accounts/notifications)).
257+When someone accepts your invite, you get an "@<username> accepted your invitation" email (part of the **Organizations** email group, which you can turn off; see [Notifications](/docs/accounts/notifications)). For an invitation you sent to a username, the email doesn't show the person's email address.
239258
259+## Invitations you receive
260+
261+When an organization owner or admin, or a repository admin, adds you by username, you get an invitation instead of access, as on GitHub. You get an in-app notification (kind `invitation`, for example "diffy invited you to acme as member" or "diffy invited you to acme/website as write") and an email with a **View invitation** button. Both lead to **Settings → Invitations**. You have no access until you accept.
262+
263+### Accept or decline in the web UI
264+
265+Open **Settings → Invitations** (`/settings/invitations`). It lists your pending invitations to organizations and repositories: those sent to your username, and those sent to your email address. Each one shows the organization or repository, the role, who invited you, when, and when it expires. With none, the page shows "No pending invitations."
266+
267+- **Accept** gives you the role and takes you to the repository or organization.
268+- **Decline** deletes the invitation. You see "Invitation declined." Nobody is told, and they can invite you again.
269+
270+An invitation expires after 14 days. If the admin adds you again while one is pending, that invitation offers the new role; you don't get a second one.
271+
272+If your account is waiting for an invite (see [Pending accounts](#pending-accounts)), your invitations appear under **Your invitations** on the **Waiting for an invite** page instead, with the same **Accept** and **Decline** buttons. Accepting one makes your account active.
273+
274+### Accept or decline with the API
275+
276+The endpoints follow GitHub's. Listing needs a token with at least the `read` scope; accepting and declining need `write` or `admin`. All of them refuse tokens restricted to repositories (`403 this token is limited to repositories and can't change account settings`).
277+
278+| Method and path | What it does |
279+| --- | --- |
280+| `GET /api/v3/user/repository_invitations` | Your pending repository invitations: `id`, `repository` (`full_name`, `name`, `owner.login`), `invitee`, `inviter` (`login`), `permissions`, `created_at`, `expires_at`, `html_url` |
281+| `PATCH /api/v3/user/repository_invitations/{id}` | Accept a repository invitation. Returns `204 No Content`. |
282+| `DELETE /api/v3/user/repository_invitations/{id}` | Decline a repository invitation. Returns `204 No Content`. |
283+| `GET /api/v3/user/memberships/orgs` | Your organization memberships, pending invitations first. Add `?state=active` or `?state=pending` for only one kind. |
284+| `GET /api/v3/user/memberships/orgs/{org}` | Your membership of one organization, active or pending. `404 not found` when you have neither. |
285+| `PATCH /api/v3/user/memberships/orgs/{org}` | With `{"state": "active"}`, accept your pending invitation to the organization. Returns the membership. |
286+
287+A membership has `url`, `state` (`active` or `pending`), `role` (`admin` or `member`, as on GitHub, where owners count as `admin`), `diffinery_role` (`owner`, `admin` or `member`), `organization` (`login`, `url`) and `user`.
288+
289+```sh
290+# List your repository invitations
291+curl https://diffinery.com/api/v3/user/repository_invitations -H "Authorization: Bearer dfy_..."
292+
293+# Accept one
294+curl -X PATCH https://diffinery.com/api/v3/user/repository_invitations/3f1c0e8a9b7d4c2e8f6a5b4c3d2e1f0a -H "Authorization: Bearer dfy_..."
295+
296+# Accept an invitation to the acme organization
297+curl -X PATCH https://diffinery.com/api/v3/user/memberships/orgs/acme \
298+ -H "Authorization: Bearer dfy_..." \
299+ -H "Content-Type: application/json" \
300+ -d '{"state": "active"}'
301+```
302+
303+| Status | Message | Cause |
304+| --- | --- | --- |
305+| 404 | `no pending invitation with this id` | The id is unknown, the invitation was already accepted, declined, revoked or expired, or it is someone else's. |
306+| 404 | `no pending invitation to this organization` | `PATCH /user/memberships/orgs/{org}` when you have no pending invitation to it and aren't a member. If you already are a member, the answer is your active membership. |
307+| 422 | `state must be "active"` | The `PATCH` body has another `state`, or none. |
308+
309+An account that is waiting for an invite can't use the API; it accepts on the **Waiting for an invite** page.
310+
240311 ## Pending accounts
241312
242313 An account can exist without being active. Its status is **pending** when someone verified their email address through email recovery (`/auth/access`) without having an invite. A pending account:
243314
244-- can sign in, but only sees a **Waiting for an invite** page,
315+- can sign in, but only sees a **Waiting for an invite** page, which lists its pending invitations to organizations and repositories under **Your invitations**,
245316 - can't see repositories, create organizations or tokens, or use the API (requests return "your account is waiting for an invite"),
246-- becomes active as soon as anyone creates an invite for its email address, an organization or repository admin adds the person by username or email with **Add people**, or a site administrator approves it.
317+- becomes active as soon as anyone creates an invite for its email address (including with **Add people**), the person accepts an invitation on the **Waiting for an invite** page, or a site administrator approves it. Being invited by username doesn't make the account active by itself.
247318
248319 > **Note:** If email recovery is set up on the instance and an invited person verifies their address at `/auth/access` instead of opening the invite link, Diffinery creates their account right away with a username based on their email address, accepts their invites and asks them to add a passkey.
249320
@@ -261,6 +332,6 @@
261332 ## How invitations differ from GitHub
262333
263334 - 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.
264-- GitHub organization and repository invitations go to existing GitHub users. Diffinery invites go to an email address, and the same link creates the account.
265-- **Add people** takes a username or an email address, as on GitHub, but someone added by username joins immediately, with no invitation to accept. An email address always gets an invitation, whether or not it has an account.
335+- GitHub organization and repository invitations go to existing GitHub users. Diffinery invites can also go to an email address, and the same link creates the account.
336+- **Add people** takes a username or an email address, as on GitHub. Someone added by username gets an invitation to accept under **Settings → Invitations**, as on GitHub. An email address always gets an invitation link, whether or not it has an account.
266337 - 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/notifications.md+7−7View file
@@ -18,7 +18,7 @@
1818
1919 ## The notifications list
2020
21-The bell in the header opens **Notifications** (`/notifications`). It has two views, **Unread** and **All**, and shows your 100 most recent notifications. Each entry has an icon for its kind (a pull request, an issue, a comment, a failed run in red, an organization or a repository), a title such as `diffy commented on acme/website#42: Cache build artifacts`, its kind (for example `issue comment`, `pull request review` or `run failed`) and when it happened.
21+The bell in the header opens **Notifications** (`/notifications`). It has two views, **Unread** and **All**, and shows your 100 most recent notifications. Each entry has an icon for its kind (a pull request, an issue, a comment, a failed run in red, an organization, a repository or an invitation), a title such as `diffy commented on acme/website#42: Cache build artifacts`, its kind (for example `issue comment`, `pull request review` or `run failed`) and when it happened.
2222
2323 - Click an entry to open what it is about; that marks it read.
2424 - Click the check mark (**Mark as read**) next to an unread entry to mark only that one read.
@@ -32,12 +32,12 @@
3232 - issue and pull request activity you take part in, when **Participating → On Diffinery** is on,
3333 - activity in repositories you watch with **All activity**, when **Watching → On Diffinery** is on,
3434 - failed workflow runs you started, when **Actions → On Diffinery** is on,
35-- someone adding you to an organization with **Add people** (kind `org membership`, for example "<admin> added you to acme as a member"),
36-- someone giving you access to a repository with **Add people** (kind `repo access`, for example "<admin> gave you write access to acme/website").
35+- someone inviting you by username to an organization or a repository (kind `invitation`, for example "<admin> invited you to acme as member"); it opens **Settings → Invitations** (see [Invitations you receive](/docs/accounts/invitations#invitations-you-receive)),
36+- someone giving you a repository role at once, without an invitation, because you are a member of the organization that owns the repository or already a collaborator on it (kind `repo access`, for example "<admin> gave you write access to acme/website"). This comes from **Add people**, the role menu on **Settings → Collaborators** or the API.
3737
38-The last two are always sent, whatever your settings; the matching emails belong to the **Organizations** and **Repositories** groups.
38+The last two are always sent, whatever your settings. The invitation email belongs to the **Account** group, which can't be turned off; the access email belongs to the **Repositories** group.
3939
40-Your own actions never create a notification for you. Other repository and organization events (role changes, removals, repositories created or deleted), and security and account events, are sent by email only.
40+Your own actions never create a notification for you. Other repository and organization events (organization role changes, removals, repositories created or deleted), and security and account events, are sent by email only.
4141
4242 ## Email groups
4343
@@ -215,7 +215,7 @@
215215 | `your_activity` | Something you did on Diffinery (your own updates, a repository you created). |
216216 | `ci_activity` | A workflow run triggered by you, or in a repository you watch. |
217217 | `security_alert` | A security notice about your account. These can't be turned off. |
218-| `invitation` | Someone invited this email address to Diffinery. |
218+| `invitation` | Someone invited this email address or your account to Diffinery, an organization or a repository. |
219219 | `admin` | You are an administrator (of the organization, repository or instance). |
220220 | `account` | You have a Diffinery account (account and membership changes). |
221221
@@ -237,7 +237,7 @@
237237 | `push_blocked` | `[acme/website] Your push was blocked: possible secrets` | Security | `security_alert` | Push protection rejected your `git push`, or a commit made in the web editor, through the REST API or over MCP (subject `Your commit was blocked`). For an agent, its owner is told. |
238238 | `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection, or committed a flagged file through the commits API with `allow_secrets`; sent to that admin and the repository's admins |
239239 | `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again |
240-| `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance) |
240+| `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) |
241241 | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite |
242242 | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you |
243243 | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) |
modifiedapps/diffinery/docs/accounts/organizations.md+6−6View file
@@ -195,10 +195,10 @@
195195
196196 | You entered | Result |
197197 | --- | --- |
198-| The username of a person | They become a member right away, with no invitation to accept. You see "Added diffy to acme as member. We let them know." They get an email ("You're now a member of acme", naming who added them) and an in-app notification ("<your username> added you to acme as a member"). |
198+| The username of a person | Diffinery sends them an invitation, as on GitHub. You see "Invited diffy to acme as member. They join once they accept." They get an email ("@<your username> invited you to join the acme organization on Diffinery", with a **View invitation** button) and an in-app notification ("<your username> invited you to acme as member"), and become a member when they accept it under **Settings → Invitations** (see [Invitations you receive](/docs/accounts/invitations#invitations-you-receive)). If they already have a pending invitation to the organization, it offers the role you chose instead of sending another: "diffy already had a pending invitation; it now offers the admin role." |
199199 | An email address | Diffinery creates an invitation and emails it, whether or not the address has an account: the form doesn't reveal who an address belongs to, and they join only by accepting. The page shows **Invitation sent to friend@example.com** with the link and a copy button: "We emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: they join when they accept it, creating an account and passkey first if they don't have one." |
200200
201-A person whose account is waiting for an invite (pending) becomes active when you add them by username or invite their email address. See [Invitations](/docs/accounts/invitations) for how invitations work, including inviting from the CLI and API.
201+A site administrator who enters their own username joins at once. A person whose account is waiting for an invite (pending) becomes active when you invite their email address, or when they accept an invitation sent to their username. See [Invitations](/docs/accounts/invitations) for how invitations work, including inviting from the CLI and API.
202202
203203 | Message | Cause |
204204 | --- | --- |
@@ -214,9 +214,9 @@
214214
215215 ### Pending invitations
216216
217-Owners and admins see a **Pending invitations** list on the **People** tab with each invitation's email address, who invited them and when, the expiry date and the role. Accepted and expired invitations aren't listed.
217+Owners and admins see a **Pending invitations** list on the **People** tab with each invitation's email address (or `@diffy` for an invitation sent to a username, never the person's email address), who invited them and when, the expiry date and the role. Accepted, declined and expired invitations aren't listed.
218218
219-To withdraw an invitation, click **Revoke** and confirm "Revoke the invitation for friend@example.com? The link stops working." You see "Invitation revoked." Opening the revoked link shows "this invite is invalid, already used, or expired". The person isn't told.
219+To withdraw an invitation, click **Revoke** and confirm "Revoke the invitation for friend@example.com? The link stops working." (or "Revoke the invitation for @diffy?"). You see "Invitation revoked." Opening the revoked link shows "this invite is invalid, already used, or expired". The person isn't told.
220220
221221 ### Change a member's role
222222
@@ -410,7 +410,7 @@
410410
411411 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).
412412
413-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).
413+Adding people by username, changing a member's role and removing a member are done in the web UI. A person accepts an invitation to an organization with `PATCH /api/v3/user/memberships/orgs/{org}` and `{"state": "active"}` (see [Invitations you receive](/docs/accounts/invitations#accept-or-decline-with-the-api)). The full list of endpoints is in the [API reference](/docs/api).
414414
415415 ## How organizations differ from GitHub
416416
@@ -418,7 +418,7 @@
418418 - The base permission works like GitHub's, with the same four values (`none`, `read`, `write`, `admin`) and the same default for new organizations (`read`). Organizations created before Diffinery had the setting use `write`.
419419 - A member who creates a repository gets `admin` on it, as on GitHub.
420420 - There are no teams. Give extra access per repository under **Settings → Collaborators**.
421-- **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.
421+- **Add people** works like GitHub's: enter a username or an email address. Someone added by username gets an invitation and joins when they accept it under **Settings → Invitations**. An email address gets an invitation link, which also creates the account if there is none.
422422 - Organization pages need you to be signed in.
423423 - Organization secrets and variables apply to every repository in the organization; there is no per-repository selection.
424424 - Deleting an organization is refused while it owns repositories or container images; GitHub deletes them with it. Transfer or delete them first.
modifiedapps/diffinery/docs/accounts/permissions.md+5−4View file
@@ -98,13 +98,14 @@
9898
9999 Repository admins manage collaborators under **Settings → Collaborators** (`/acme/website/settings/access`):
100100
101-- **Add people**: enter a **Username or email** (a person, an agent or an email address), pick a **Role** (**Read**, **Triage**, **Write**, **Maintain** or **Admin**; default **Write**) and click **Add**. Accounts get the role immediately ("diffy now has write access. We let them know."). An email address without an account gets an invitation; see [Invitations](/docs/accounts/invitations#add-people-to-a-repository).
101+- **Add people**: enter a **Username or email** (a person, an agent or an email address), pick a **Role** (**Read**, **Triage**, **Write**, **Maintain** or **Admin**; default **Write**) and click **Add**. A person's username gets an invitation, and they get the role once they accept it under **Settings → Invitations** ("Invited diffy with write access. They get access once they accept."). Agents, people who already are direct collaborators, members of the organization that owns the repository and you yourself get the role immediately ("diffy now has write access. We let them know."). An email address gets an invitation link; see [Invitations](/docs/accounts/invitations#add-people-to-a-repository).
102102 - Change a collaborator's role with the **Role** menu and **Save**, or click **Remove** and confirm.
103103
104-The person gets an email when their access is granted, changed or removed, and an in-app notification when someone adds them with **Add people**. With the API:
104+The person gets an email when their access is granted, changed or removed, and an in-app notification when someone adds or invites them with **Add people**. With the API:
105105
106106 ```sh
107-# Give diffy the triage role (GitHub's "pull" and "push" also work: they mean read and write)
107+# Invite diffy with the triage role, or change their role if they already are a collaborator
108+# (GitHub's "pull" and "push" also work: they mean read and write)
108109 curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \
109110 -H "Authorization: Bearer dfy_..." \
110111 -H "Content-Type: application/json" \
@@ -115,7 +116,7 @@
115116 -H "Authorization: Bearer dfy_..."
116117 ```
117118
118-Without a body, `PUT` gives `write`. Both need admin on the repository and a token with the `admin` scope. Any other `permission` than `pull`, `read`, `triage`, `push`, `write`, `maintain` or `admin` answers `422 permission must be pull, triage, push, maintain or admin`, and an organization's login answers `422` too: add its members instead, or transfer the repository to it.
119+Without a body, `PUT` gives `write`. `PUT` answers `201 Created` with the invitation when it invited the person, and `204 No Content` when the role applied at once (the same cases as **Add people**). Both need admin on the repository and a token with the `admin` scope. Any other `permission` than `pull`, `read`, `triage`, `push`, `write`, `maintain` or `admin` answers `422 permission must be pull, triage, push, maintain or admin`, and an organization's login answers `422` too: add its members instead, or transfer the repository to it.
119120
120121 ## Organization roles
121122
modifiedapps/diffinery/docs/accounts/tokens.md+1−0View file
@@ -52,6 +52,7 @@
5252 | Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused |
5353 | Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused |
5454 | Invite people (`POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations`, `diffinery invite`) | `admin` | Refused |
55+| Accept or decline an invitation you received (`PATCH`/`DELETE /api/v3/user/repository_invitations/{id}`, `PATCH /api/v3/user/memberships/orgs/{org}`) | `write` or `admin` | Refused |
5556 | List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused |
5657 | Create an agent or a token for an agent (`POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens`, `diffinery agent create`) | No token can: a browser session only | Refused |
5758 | Disable an agent (`DELETE /api/v3/diffinery/agents/{login}`) | `admin` | Refused |
modifiedapps/diffinery/docs/admin/site-administration.md+2−2View file
@@ -48,7 +48,7 @@
4848
4949 People who verified their email address at `/auth/access` without an invite have a pending account and can click **Request access** with a note. Each request is listed with the person's email address, when they asked and their note. Diffinery also emails up to 20 active site administrators about each request.
5050
51-- **Approve** makes the account active. The person can then use Diffinery, but isn't added to any organization or repository; invite them to one or add them by username.
51+- **Approve** makes the account active. The person can then use Diffinery, but isn't added to any organization or repository; invite them to one, by email address or by username.
5252 - **Deny** disables the account. The person gets a "Your account was disabled" email.
5353
5454 See [Invitations](/docs/accounts/invitations#access-requests) for the requester's side.
@@ -65,7 +65,7 @@
6565
6666 You can't change your own status ("you cannot change your own status").
6767
68-Pending accounts also become active without you when someone invites their email address, or when an organization or repository admin adds them by username or email with **Add people**.
68+Pending accounts also become active without you when someone invites their email address (including with **Add people**), or when the person accepts an invitation sent to their username on the **Waiting for an invite** page. Being invited by username doesn't make the account active by itself.
6969
7070 ### Disable an account
7171
modifiedapps/diffinery/docs/agents/mcp.md+1−1View file
@@ -141,7 +141,7 @@
141141 | `project_posts` | Yes | The project's conversation after `after`, or the latest 100 lines | Write |
142142 | `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token |
143143 | `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 |
144-| `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 |
144+| `list_invitations` | Yes | Pending invitations to a repository (`owner` and `repo`), an organization (`org`) or the instance (neither), each with an `id`, and the `email` or, for an invitation sent to a username, the `login` | Admin on the repository; organization owner or admin; site administrator |
145145 | `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 |
146146
147147 ### Code
modifiedapps/diffinery/docs/reference/rest-api.md+14−1View file
@@ -187,6 +187,8 @@
187187 | `DELETE /api/v3/diffinery/tokens/{id}` | An `admin` token not restricted to repositories. |
188188 | `POST /api/v3/user/orgs`, `PATCH /api/v3/orgs/{org}` | An `admin` token not restricted to repositories. |
189189 | `POST /api/v3/orgs/{org}/invitations`, `POST /api/v3/invitations` | An `admin` token not restricted to repositories. |
190+| `GET /api/v3/user/repository_invitations`, `GET /api/v3/user/memberships/orgs[/{org}]` | A token not restricted to repositories (any scope). |
191+| `PATCH` and `DELETE /api/v3/user/repository_invitations/{id}`, `PATCH /api/v3/user/memberships/orgs/{org}` | A `write` or `admin` token not restricted to repositories. |
190192 | `GET`, `PUT` and `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | An `admin` token not restricted to repositories. |
191193 | `POST /api/v3/diffinery/agents`, `POST /api/v3/diffinery/agents/{login}/tokens` | A browser session (they mint tokens). Every token gets `403 creating tokens needs a browser session: sign in at https://diffinery.com/settings/tokens`. |
192194 | `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. |
@@ -432,13 +434,24 @@
432434 - Get, create (`POST /user/repos`, `POST /orgs/{org}/repos`), update (`PATCH`, admin) and delete (`DELETE`, admin, people only). `visibility` is `public` or `private`; `private: false` makes a repository public.
433435 - Topics: `GET` and `PUT /repos/{owner}/{repo}/topics` (admin). At most 20 topics, each starting with a lowercase letter or digit, up to 50 characters of lowercase letters, digits and hyphens.
434436 - `GET /languages`: bytes per language, by file extension, leaving out vendored directories such as `node_modules`, `vendor` and `dist`.
435-- Collaborators: list, add or change (`PUT` with `permission`), remove (admin).
437+- Collaborators: list, add or change (`PUT` with `permission`), remove (admin). As on GitHub, `PUT` invites a person who doesn't have access yet and answers `201` with the invitation (`id`, `repository`, `invitee`, `inviter`, `permissions`, `created_at`, `expires_at`, `html_url`); they get the role once they accept it. For an agent, an existing direct collaborator, a member of the organization that owns the repository or yourself, the role applies at once and `PUT` answers `204`. See [Add people to a repository](/docs/accounts/invitations#add-people-to-a-repository).
438+- Pending invitations: `GET /repos/{owner}/{repo}/invitations` lists them and `DELETE /repos/{owner}/{repo}/invitations/{id}` revokes one (admin). Each has `id`, `email` (`null` for an invitation sent to a username), `login` (`null` for one sent to an email address), `role`, `inviter`, `created_at` and `expires_at`.
436439 - Branches (with `protected=true|false`), one branch, tags, README (`GET /readme` and `/readme/{dir}`), commits, one commit with its files and stats, compare (`base...head`), and `GET /commits/{sha}/pulls`.
437440 - `POST /merges` merges a branch or commit into a branch (`201` with the merge commit, `204` when there was nothing to merge, `409 Merge conflict`).
438441 - `POST /dispatches` sends a `repository_dispatch` event: `event_type` (up to 100 characters) and an optional `client_payload` object (at most 10 top-level properties, 64 KiB). It returns `204` and starts workflows with `on: repository_dispatch`.
439442 - `POST /transfer` moves the repository to `new_owner` (your own account, or an organization you own or administer), optionally under `new_name`, and returns `202` with the repository. People only, with an `admin` token that isn't limited to repositories. See [Transfer a repository](/docs/repositories/overview#transfer-a-repository).
440443 - Archived repositories are read-only through the API, for everyone, except unarchiving, deleting, transferring and watching. See [Errors](#errors). `PATCH` and the collaborator endpoints are recorded in the audit log, like changes in the web settings pages.
441444
445+### Your invitations and organization memberships
446+
447+As on GitHub, a person invited by username accepts or declines the invitation:
448+
449+- `GET /user/repository_invitations` lists your pending repository invitations, those sent to your username and those sent to your email address. `PATCH /user/repository_invitations/{id}` accepts one and `DELETE` declines it (both `204`).
450+- `GET /user/memberships/orgs` lists your organization memberships, pending invitations first (`?state=active` or `?state=pending` for one kind), and `GET /user/memberships/orgs/{org}` returns one. Each has `state` (`active` or `pending`), `role` (`admin` or `member`; owners count as `admin`, as on GitHub), `diffinery_role` (`owner`, `admin` or `member`), `organization` and `user`.
451+- `PATCH /user/memberships/orgs/{org}` with `{"state": "active"}` accepts a pending invitation to the organization and returns the membership.
452+
453+An unknown id, or someone else's invitation, answers `404 no pending invitation with this id`. See [Invitations you receive](/docs/accounts/invitations#accept-or-decline-with-the-api).
454+
442455 ### Contents
443456
444457 - `GET /contents/{path}` returns a file (base64, with `sha`), a directory listing, or a symlink (with `target`). `ref` picks the branch, tag or commit (default: the default branch). `Accept: application/vnd.github.raw` returns the bytes.
modifiedapps/diffinery/docs/repositories/overview.md+10−6View file
@@ -219,9 +219,9 @@
219219
220220 | You entered | Result |
221221 | --- | --- |
222-| The username of a person or agent | They get the role right away, a notification on Diffinery and an email. The page confirms with "diffy now has write access. We let them know." |
223-| The email address of someone who has an account | Same as their username. |
224-| An email address nobody uses yet | Diffinery emails an invitation and shows the link once ("Invitation sent to ..."), so you can also send it yourself. The link works once and expires in 14 days. Opening it creates the person's account and passkey and gives them the role on this repository. See [Invitations](/docs/accounts/invitations). |
222+| The username of a person | Diffinery sends them an invitation, as on GitHub: "Invited diffy with write access. They get access once they accept." They get a notification on Diffinery and an email, and get the role when they accept it under **Settings → Invitations**. The invitation expires after 14 days. If they already have a pending invitation to this repository, it offers the role you chose instead: "diffy already had a pending invitation; it now offers admin access." |
223+| The username of an agent, of a person who already is a direct collaborator, of a member of the organization that owns the repository, or your own | They get the role right away, with no invitation. People get a notification on Diffinery and an email. The page confirms with "diffy now has write access. We let them know." |
224+| An email address | Diffinery emails an invitation, whether or not the address has an account, and shows the link once ("Invitation sent to ..."), so you can also send it yourself. The link works once and expires in 14 days. Opening it gives the person the role on this repository, creating their account and passkey first if they don't have one. See [Invitations](/docs/accounts/invitations). |
225225
226226 Errors you may see:
227227
@@ -231,10 +231,12 @@
231231 | `<name> is an organization; add its people instead` | Organizations can't be collaborators. Add the people one by one. |
232232 | `<name>'s account is disabled` | A site administrator disabled that account. |
233233 | `enter a valid email address` | The text contains `@` but isn't a valid address. |
234-| `<email> already has a pending invitation; revoke it first to send a new one` | That address already has an open invitation to this repository (letter case doesn't matter). Repository invitations can't be revoked: send the person the first link again, or wait until it is used or expires after 14 days. |
234+| `<email> already has a pending invitation; revoke it first to send a new one` | That address already has an open invitation to this repository (letter case doesn't matter). Revoke it under **Pending invitations**, then add the address again. |
235235
236236 The table lists direct collaborators. Change someone's role with the role list and **Save**, or remove them with **Remove**. People get an email when their access to a repository is granted or changed.
237237
238+Invitations that haven't been accepted yet are listed under **Pending invitations**, with who sent them, when, the expiry date and the role. An invitation sent to a username shows as `@diffy`, never the person's email address. Click **Revoke** to withdraw one; the person isn't told. See [Invitations](/docs/accounts/invitations#revoke-an-invitation).
239+
238240 Organization members don't appear in this table: they get their access from the organization's base permission. The exception is a member who created the repository, who is listed with **Admin**.
239241
240242 ### Manage collaborators with the API
@@ -245,7 +247,7 @@
245247 # List collaborators (any reader can list)
246248 curl https://diffinery.com/api/v3/repos/acme/website/collaborators -H "Authorization: Bearer dfy_..."
247249
248-# Add or change a collaborator
250+# Invite a collaborator, or change a collaborator's role
249251 curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \
250252 -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \
251253 -d '{"permission": "maintain"}'
@@ -256,6 +258,8 @@
256258
257259 `permission` accepts `read`, `triage`, `write`, `maintain` or `admin`, and GitHub's `pull` (read) and `push` (write). It defaults to `write`. The list response gives each account's `role_name`.
258260
261+As on GitHub, `PUT` invites a person who doesn't have access yet: it answers `201 Created` with the invitation (`id`, `repository`, `invitee`, `inviter`, `permissions`, `created_at`, `expires_at`, `html_url`), and they get the role once they accept it. For an agent, someone who already is a direct collaborator, a member of the organization that owns the repository, or yourself, the role changes at once and `PUT` answers `204 No Content`. `GET /api/v3/repos/{owner}/{repo}/invitations` lists pending invitations and `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` revokes one. See [Invitations](/docs/accounts/invitations#repository-invite-by-username).
262+
259263 ## Archive a repository
260264
261265 Archiving makes a repository read-only, as on GitHub: nobody can push, open or change issues and pull requests, comment, review, create or edit releases, start runs or change settings, and scheduled workflows stop. Everything stays readable, cloneable and downloadable as an archive.
@@ -384,7 +388,7 @@
384388
385389 ## Delete a repository
386390
387-Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments and webhooks, its LFS files and release assets, and its Actions caches, artifacts, job logs and cached source archives. It can't be undone. An archived repository can be deleted without unarchiving it first.
391+Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments, webhooks and pending invitations, its LFS files and release assets, and its Actions caches, artifacts, job logs and cached source archives. It can't be undone. An archived repository can be deleted without unarchiving it first.
388392
389393 - **Web UI**: **Settings → General → Danger zone → Delete this repository**. Type the full name (for example `acme/website`) and click **Delete this repository**.
390394 - **API**: `DELETE /api/v3/repos/acme/website` returns `204 No Content`. It needs an admin role. Agents can't delete repositories (`agents cannot delete repositories`, HTTP 403).
modifiedapps/diffinery/docs/security/audit-log.md+6−5View file
@@ -52,7 +52,8 @@
5252 | `passkey.add` | A passkey is added from **Passkeys and sessions**, or through a one-time sign-in link | none | `name`, `passkeyId`, `device`, `ip`, `location`; `via: "signin-link"` when added through a link |
5353 | `passkey.remove` | A passkey is removed | none | `name` |
5454 | `session.revoke` | You sign out one of your other browsers with **Sign out** on **Passkeys and sessions** | none | none |
55-| `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite | for an existing user, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location` |
55+| `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite (from its link, **Settings → Invitations**, the **Waiting for an invite** page or the API) | for an existing user who opened the link, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location`; from **Settings → Invitations** or the API: `id` (the invitation id), and `via: "api"` for the API |
56+| `invite.decline` | Someone declines an invitation on **Settings → Invitations**, the **Waiting for an invite** page, or with `DELETE /api/v3/user/repository_invitations/{id}` | none | `id` (the invitation id), and `via: "api"` for the API |
5657 | `token.create` | A personal access token is created on **Access tokens** or with `POST /api/v3/diffinery/tokens` | your login (web) | `name` and `scope`/`scopes`, and for the web the number of repositories it is restricted to |
5758 | `token.revoke` | A token is revoked on **Access tokens** or with `DELETE /api/v3/diffinery/tokens/{id}` | the token id | `via: "api"` for the API |
5859 | `signin_link.create` | A site admin creates a one-time sign-in link for someone | that user's login | none |
@@ -99,9 +100,9 @@
99100 | `org.secret.set`, `org.secret.delete` | An organization secret is saved or deleted with `PUT`/`DELETE /api/v3/orgs/{org}/actions/secrets/{name}` (or `diffinery secret ... --org`) | the organization's login | `name` (never the value), and for `set` its `visibility` and any selected `repositories`; `via: "api"` |
100101 | `org.secret.repositories`, `org.secret.repository.add`, `org.secret.repository.remove` | An organization secret's repository selection is replaced, or one repository is added or removed | the organization's login | `name`, and `repositories` or `repository`; `via: "api"` |
101102 | `org.variable_set`, `org.variable_delete` | An organization Actions variable is created, changed or deleted with `POST`, `PATCH` or `DELETE /api/v3/orgs/{org}/actions/variables[/{name}]`, or added by a GitHub import of an organization | the organization's login (API), or the GitHub organization (import) | `name` (upper-cased); `via: "api"` for API changes; an import adds `import` (its id) and `org` |
102-| `org.invite` | Someone without an account is invited to an organization: an email address entered in **Add people** on the organization's **People** tab, or `POST /api/v3/orgs/{org}/invitations` | the organization's login | `email`, `role` |
103+| `org.invite` | Someone is invited to an organization: an email address or a username entered in **Add people** on the organization's **People** tab, or `POST /api/v3/orgs/{org}/invitations` | the organization's login | `email` or `user`, and `role` |
103104 | `org.invite.revoke` | A pending organization invitation is revoked with **Revoke** | the organization's login | `id` (the invitation id) |
104-| `org.member.set`, `org.member.remove` | Someone with an account is added with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` |
105+| `org.member.set`, `org.member.remove` | A site administrator adds themselves with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` |
105106 | `invite.create` | A site admin creates an invite from **Site administration** | the email address | none |
106107 | `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` |
107108
@@ -117,8 +118,8 @@
117118 | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none |
118119 | `repo.purge_unreachable` | Unreachable objects are purged (**Settings → Danger zone**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`) | `objects`, `bytes` |
119120 | `repo.transfer` | The repository is transferred to another owner (web or `POST /api/v3/repos/{owner}/{repo}/transfer`); the target is the new name | `from`, `to` |
120-| `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: a person or agent with an account is added with **Add people**, or a collaborator's role is changed, or a collaborator is removed. Through the API: `PUT` or `DELETE /api/v3/repos/{owner}/{repo}/collaborators/{username}` | `login`, `role` |
121-| `repo.invite` | An email address without an account is entered in **Add people**, which sends an invitation | `email`, `role` |
121+| `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: an account gets a role at once with **Add people** (an agent, an existing collaborator, a member of the owning organization, or yourself), a collaborator's role is changed, or a collaborator is removed. Through the API: `PUT` (when the role applied at once) or `DELETE /api/v3/repos/{owner}/{repo}/collaborators/{username}` | `login`, `role` |
122+| `repo.invite` | **Add people** or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` sends an invitation, to an email address or a username | `email` or `login`, and `role` |
122123 | `repo.topics` | The topics are replaced with `PUT /api/v3/repos/{owner}/{repo}/topics` | `names` |
123124 | `repo.protection.set`, `repo.protection.delete` | A branch protection rule is saved or deleted on **Branch protection**, or with `PUT`/`DELETE /api/v3/repos/{owner}/{repo}/branches/{branch}/protection` | `pattern`, and on the web `rules` when saved |
124125 | `repo.protection.agent_auto_merge` | **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`) is turned on or off for a rule, from the web UI or the API | `pattern`, `enabled`, `requiredChecks` |
addedapps/diffinery/migrations/0017_account_invites.sql+4−0View file
@@ -1,0 +1,4 @@
1+-- Invitations to an existing account by username: the person accepts them (Settings → Invitations, or
2+-- the API) before they join an organization or get access to a repository.
3+ALTER TABLE invites ADD COLUMN account_id TEXT REFERENCES accounts(id);
4+CREATE INDEX IF NOT EXISTS invites_account ON invites(account_id, accepted_at);
modifiedapps/diffinery/src/docs/api-meta.ts+16−3View file
@@ -39,6 +39,12 @@
3939 "DELETE /orgs/{org}": { scope: "admin" },
4040 "POST /repos/{owner}/{repo}/transfer": { scope: "admin" },
4141 "GET /orgs/{org}/invitations": { scope: "read" },
42+ "GET /user/repository_invitations": { scope: "read" },
43+ "PATCH /user/repository_invitations/{id}": { scope: "write" },
44+ "DELETE /user/repository_invitations/{id}": { scope: "write" },
45+ "GET /user/memberships/orgs": { scope: "read" },
46+ "GET /user/memberships/orgs/{org}": { scope: "read" },
47+ "PATCH /user/memberships/orgs/{org}": { scope: "write" },
4248 "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" },
4349 "GET /invitations": { scope: "read" },
4450 "DELETE /invitations/{id}": { scope: "admin" },
@@ -119,6 +125,12 @@
119125 "DELETE /user/avatar": { summary: "Remove your profile picture", description: "Diffinery only. Goes back to generated initials and returns the `avatar_url`. Same token rules as setting one. Audited.", access: "account" },
120126 "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login.", access: "account" },
121127 "GET /user/orgs": { summary: "List your organizations", description: "Each organization carries your `role` in it (member, admin or owner).", access: "account" },
128+ "GET /user/repository_invitations": { summary: "List your repository invitations", description: "Pending invitations to repositories, sent to your username or your email address.", access: "account" },
129+ "PATCH /user/repository_invitations/{id}": { summary: "Accept a repository invitation", description: "You get the role it offers. Audited as `invite.accept`.", access: "account", status: 204 },
130+ "DELETE /user/repository_invitations/{id}": { summary: "Decline a repository invitation", description: "The invitation is deleted; nobody is told. Audited as `invite.decline`.", access: "account", status: 204 },
131+ "GET /user/memberships/orgs": { summary: "List your organization memberships", description: "Active memberships and pending invitations, each with `state` (`active` or `pending`), `role` (GitHub's `admin` or `member`) and `diffinery_role` (`owner`, `admin` or `member`).", access: "account", query: { state: ["string", "`active` or `pending`."] } },
132+ "GET /user/memberships/orgs/{org}": { summary: "Get your organization membership", description: "404 when you are neither a member nor invited.", access: "account" },
133+ "PATCH /user/memberships/orgs/{org}": { summary: "Accept an organization invitation", description: "Send `{\"state\": \"active\"}` to join with the role the invitation offers. Audited as `invite.accept`.", access: "account", body: { state: ["string", "`active`.", true] } },
122134 "POST /user/orgs": {
123135 summary: "Create an organization",
124136 access: "account",
@@ -261,10 +273,11 @@
261273 },
262274 "GET /repos/{owner}/{repo}/collaborators": { summary: "List collaborators", description: "Each entry carries `role_name`.", access: "read" },
263275 "PUT /repos/{owner}/{repo}/collaborators/{login}": {
264- summary: "Add or change a collaborator",
265- description: "Audited as `repo.collaborator.set`.",
276+ summary: "Invite a collaborator, or change one's role",
277+ description:
278+ "As on GitHub, a person without access gets an invitation and has access once they accept it: the answer is `201` with the invitation (`id`, `repository`, `invitee`, `inviter`, `permissions`, `expires_at`), audited as `repo.invite`; asking again while it's pending changes its role. Agents, existing collaborators, members of the owning organization and yourself get the role at once: `204`, audited as `repo.collaborator.set`.",
266279 access: "admin",
267- status: 204,
280+ status: 201,
268281 body: { permission: ["string", "`read`, `triage`, `write` (default), `maintain` or `admin`; GitHub's `pull` and `push` work too."] },
269282 },
270283 "DELETE /repos/{owner}/{repo}/collaborators/{login}": { summary: "Remove a collaborator", description: "Audited as `repo.collaborator.remove`.", access: "admin", status: 204 },
modifiedapps/diffinery/src/http/api.ts+86−2View file
@@ -162,7 +162,7 @@
162162
163163 // Pending invitations: list and revoke, for an organization's owners and admins, a repository's admins,
164164 // and site admins (invitations to the site itself).
165-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 });
165+const inviteJson = (i: Awaited<ReturnType<Directory["invites"]>>[number]) => ({ id: i.id, email: i.email, login: i.login, role: i.role, inviter: i.invitedBy ? { login: i.invitedBy } : null, created_at: i.createdAt, expires_at: i.expiresAt });
166166 const orgForInvites = async (c: AppContext) => {
167167 requireAccountScope(c, "read");
168168 const a = requireAccount(c);
@@ -308,10 +308,94 @@
308308 const map: Record<string, Role> = { pull: "read", push: "write" };
309309 const role = map[b.permission ?? ""] ?? (b.permission as Role) ?? "write";
310310 if (!(role in ROLE_LEVEL)) throw new HttpError(422, "permission must be pull, triage, push, maintain or admin");
311- await c.get("dir").setCollaborator(repo.id, who.id, role);
311+ // As on GitHub: someone new gets an invitation (201) and has access once they accept it; an
312+ // existing collaborator's role changes at once (204).
313+ const r = await c.get("dir").grantRepoAccess(repo, requireAccount(c), who, role);
314+ if ("invitedUser" in r) {
315+ auditRepo(c, caller, repo, "repo.invite", { login: who.login, role });
316+ const inv = (await c.get("dir").invites({ repoId: repo.id, pending: true })).find((i) => i.id === r.id);
317+ return c.json({ id: r.id, repository: { full_name: `${repo.owner_login}/${repo.name}`, name: repo.name, owner: { login: repo.owner_login } }, invitee: userJson(c, who), inviter: inv?.invitedBy ? { login: inv.invitedBy } : null, permissions: role, created_at: inv?.createdAt, expires_at: inv?.expiresAt, html_url: `${baseOf(c)}/settings/invitations` }, 201);
318+ }
312319 auditRepo(c, caller, repo, "repo.collaborator.set", { login: who.login, role });
313320 return c.body(null, 204);
314321 });
322+
323+// Invitations to the caller, as on GitHub: repository invitations, and organization memberships
324+// (pending until accepted). Accepting one gives access, so it needs a write token that isn't limited
325+// to repositories.
326+const myInvites = async (c: AppContext) => {
327+ requireAccountScope(c, "read");
328+ requireUnlimitedToken(c, "list invitations");
329+ return c.get("dir").invitesFor(requireAccount(c));
330+};
331+const answer = async (c: AppContext, id: string, accept: boolean) => {
332+ requireAccountScope(c, "write");
333+ requireUnlimitedToken(c, accept ? "accept invitations" : "decline invitations");
334+ const me = requireAccount(c);
335+ const dir = c.get("dir");
336+ await dir.answerInvite(id, me, accept);
337+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: accept ? "invite.accept" : "invite.decline", detail: { id, via: "api" } }));
338+};
339+const repoInvitationJson = (c: AppContext, me: Account, i: Awaited<ReturnType<Directory["invitesFor"]>>[number]) => {
340+ const [owner, name] = (i.repo ?? "/").split("/");
341+ return { id: i.id, repository: { full_name: i.repo, name, owner: { login: owner } }, invitee: userJson(c, me), inviter: i.invitedBy ? { login: i.invitedBy } : null, permissions: i.role, created_at: i.createdAt, expires_at: i.expiresAt, html_url: `${baseOf(c)}/settings/invitations` };
342+};
343+api.get("/user/repository_invitations", async (c) => {
344+ const me = requireAccount(c);
345+ return c.json((await myInvites(c)).filter((i) => i.repoId && i.repo).map((i) => repoInvitationJson(c, me, i)));
346+});
347+api.patch("/user/repository_invitations/:id", async (c) => {
348+ await answer(c, c.req.param("id"), true);
349+ return c.body(null, 204);
350+});
351+api.delete("/user/repository_invitations/:id", async (c) => {
352+ await answer(c, c.req.param("id"), false);
353+ return c.body(null, 204);
354+});
355+const membershipJson = (c: AppContext, me: Account, org: string, role: string, state: "active" | "pending") => ({
356+ url: `${baseOf(c)}/api/v3/user/memberships/orgs/${org}`,
357+ state,
358+ role: role === "owner" ? "admin" : role,
359+ diffinery_role: role,
360+ organization: { login: org, url: `${baseOf(c)}/api/v3/orgs/${org}` },
361+ user: userJson(c, me),
362+});
363+const myMemberships = (c: AppContext) => {
364+ requireAccountScope(c, "read");
365+ requireUnlimitedToken(c, "list organization memberships");
366+ return requireAccount(c);
367+};
368+api.get("/user/memberships/orgs", async (c) => {
369+ const me = myMemberships(c);
370+ const state = c.req.query("state");
371+ const active = state === "pending" ? [] : (await c.get("dir").orgsFor(me.id)).map((o) => membershipJson(c, me, o.login, o.role, "active"));
372+ const pending = state === "active" ? [] : (await myInvites(c)).filter((i) => i.org).map((i) => membershipJson(c, me, i.org!, i.role, "pending"));
373+ return c.json([...pending, ...active]);
374+});
375+api.get("/user/memberships/orgs/:org", async (c) => {
376+ const me = myMemberships(c);
377+ const org = c.req.param("org").toLowerCase();
378+ const active = (await c.get("dir").orgsFor(me.id)).find((o) => o.login.toLowerCase() === org);
379+ if (active) return c.json(membershipJson(c, me, active.login, active.role, "active"));
380+ const pending = (await myInvites(c)).find((i) => i.org?.toLowerCase() === org);
381+ if (pending) return c.json(membershipJson(c, me, pending.org!, pending.role, "pending"));
382+ throw new HttpError(404, "not found");
383+});
384+api.patch("/user/memberships/orgs/:org", async (c) => {
385+ requireAccountScope(c, "write");
386+ const me = myMemberships(c);
387+ const b = await body<{ state?: string }>(c).catch(() => ({ state: undefined }));
388+ if (b.state !== "active") throw new HttpError(422, 'state must be "active"');
389+ const org = c.req.param("org").toLowerCase();
390+ const pending = (await myInvites(c)).find((i) => i.org?.toLowerCase() === org);
391+ if (!pending) {
392+ const active = (await c.get("dir").orgsFor(me.id)).find((o) => o.login.toLowerCase() === org);
393+ if (active) return c.json(membershipJson(c, me, active.login, active.role, "active"));
394+ throw new HttpError(404, "no pending invitation to this organization");
395+ }
396+ await answer(c, pending.id, true);
397+ return c.json(membershipJson(c, me, pending.org!, pending.role, "active"));
398+});
315399 api.delete("/repos/:owner/:repo/collaborators/:login", async (c) => {
316400 const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
317401 const who = await c.get("dir").accountByLogin(c.req.param("login"));
modifiedapps/diffinery/src/lib/directory.ts+123−27View file
@@ -66,6 +66,12 @@
6666 /** Invitations one person (not a site admin) may send in 24 hours. */
6767 export const MAX_INVITES_PER_DAY = 50;
6868
69+/** What adding someone did: added them at once, invited them by username, or sent an invitation link to an email address. */
70+export type AddResult = { added: Account } | { invitedUser: Account; id: string; renewed: boolean } | { invited: { email: string; url: string } };
71+
72+/** A person's own pending invitations (binds: now, account id, account email). */
73+const MINE = "i.accepted_at IS NULL AND i.expires_at > ? AND (i.org_id IS NOT NULL OR i.repo_id IS NOT NULL) AND (i.account_id = ? OR (i.account_id IS NULL AND i.email = ?))";
74+
6975 export class Directory {
7076 constructor(private env: Env) {}
7177
@@ -247,11 +253,12 @@
247253 }
248254
249255 /**
250- * Add someone to an organization by username or email, like GitHub's "Add people": a username joins
251- * right away and gets a heads-up; an email address gets an invitation that joins the organization
252- * when accepted (creating the account if there is none).
256+ * Add someone to an organization by username or email, like GitHub's "Add people". Either way they
257+ * get an invitation and join when they accept it: a username's invitation waits in their
258+ * Settings → Invitations (with an email and a notification), an email address's invitation is a
259+ * link that also creates the account if there is none. Only an admin adding themselves joins at once.
253260 */
254- async addOrgPerson(org: Account, by: Account, who: string, role: "owner" | "admin" | "member"): Promise<{ added: Account } | { invited: { email: string; url: string } }> {
261+ async addOrgPerson(org: Account, by: Account, who: string, role: "owner" | "admin" | "member"): Promise<AddResult> {
255262 const q = who.trim().replace(/^@/, "");
256263 if (!q) throw new HttpError(422, "enter a username or an email address");
257264 const email = q.includes("@");
@@ -267,16 +274,17 @@
267274 if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; agents belong to an organization through Settings → Agents`);
268275 if (user.status === "disabled") throw new HttpError(422, `${user.login}'s account is disabled`);
269276 if (await this.orgRole(org.id, user.id)) throw new HttpError(422, `${user.login} is already a member of ${org.login}`);
270- // An admin vouching for someone waiting for an invite lets them in.
271- if (user.status === "pending") await this.setStatus(user.id, "active");
272- await this.setOrgMember(org.id, user.id, role, by);
273- return { added: user };
277+ if (user.id === by.id) {
278+ await this.setOrgMember(org.id, user.id, role, by);
279+ return { added: user };
280+ }
281+ return { invitedUser: user, ...(await this.inviteAccount(by, user, { orgId: org.id, role })) };
274282 }
275283 throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`);
276284 }
277285
278- /** Add a collaborator by username (a person or an agent), or invite an email address. */
279- async addRepoPerson(repo: Repo, by: Account, who: string, role: Role): Promise<{ added: Account } | { invited: { email: string; url: string } }> {
286+ /** Add a collaborator by username (a person or an agent; see grantRepoAccess), or invite an email address. */
287+ async addRepoPerson(repo: Repo, by: Account, who: string, role: Role): Promise<AddResult> {
280288 const q = who.trim().replace(/^@/, "");
281289 if (!q) throw new HttpError(422, "enter a username or an email address");
282290 const email = q.includes("@");
@@ -287,17 +295,29 @@
287295 return { invited: { email: q, url: inv.url } };
288296 }
289297 const account = await this.accountByLogin(q);
290- if (account) {
291- if (account.kind === "org") throw new HttpError(422, `${account.login} is an organization; add its people instead`);
292- if (account.status === "disabled") throw new HttpError(422, `${account.login}'s account is disabled`);
293- if (account.status === "pending") await this.setStatus(account.id, "active");
294- await this.setCollaborator(repo.id, account.id, role);
295- if (account.id !== by.id) await this.notify([account.id], { repoId: repo.id, kind: "repo_access", title: `${by.login} gave you ${role} access to ${repo.owner_login}/${repo.name}`, url: `/${repo.owner_login}/${repo.name}` });
296- return { added: account };
297- }
298+ if (account) return this.grantRepoAccess(repo, by, account, role);
298299 throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`);
299300 }
300301
302+ /**
303+ * Give an account a role on a repository, as GitHub does: agents, people who already have a direct
304+ * grant (a role change), members of the organization that owns the repository and the admin
305+ * themselves get it at once; anyone else gets an invitation and has access only once they accept it.
306+ */
307+ async grantRepoAccess(repo: Repo, by: Account, account: Account, role: Role): Promise<{ added: Account } | { invitedUser: Account; id: string; renewed: boolean }> {
308+ if (account.kind === "org") throw new HttpError(422, `${account.login} is an organization; add its people instead`);
309+ if (account.status === "disabled") throw new HttpError(422, `${account.login}'s account is disabled`);
310+ const direct =
311+ account.kind === "agent" ||
312+ account.id === by.id ||
313+ !!(await this.db.prepare("SELECT 1 FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first()) ||
314+ (repo.owner_kind === "org" && !!(await this.orgRole(repo.owner_id, account.id)));
315+ if (!direct) return { invitedUser: account, ...(await this.inviteAccount(by, account, { repoId: repo.id, role })) };
316+ await this.setCollaborator(repo.id, account.id, role);
317+ if (account.id !== by.id) await this.notify([account.id], { repoId: repo.id, kind: "repo_access", title: `${by.login} gave you ${role} access to ${repo.owner_login}/${repo.name}`, url: `/${repo.owner_login}/${repo.name}` });
318+ return { added: account };
319+ }
320+
301321 /** Revoke a pending invitation to an organization, a repository, or (neither) the site. False if there was none. */
302322 async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<boolean> {
303323 const r = await this.db
@@ -419,20 +439,26 @@
419439
420440 // ---------------------------------------------------------------- invites
421441
422- async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> {
442+ /** The role is one an organization invitation can have and the inviter may give, and (for a new invitation) the inviter is under the daily limit. */
443+ private async checkInvite(inviter: Account, input: { orgId?: string; repoId?: string; role: string }, isNew = true): Promise<void> {
423444 if (input.orgId) {
424445 if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner");
425446 if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners");
426447 }
448+ if (input.repoId && !(input.role in ROLE_LEVEL)) throw new HttpError(422, "permission must be read, triage, write, maintain or admin");
427449 // Invitations are email to any address: a daily limit per inviter keeps an account from being used to spam.
428- if (!inviter.site_admin) {
450+ if (isNew && !inviter.site_admin) {
429451 const sent = (await this.db.prepare("SELECT count(*) AS n FROM invites WHERE invited_by = ? AND created_at > ?").bind(inviter.id, new Date(Date.now() - 86400_000).toISOString()).first<{ n: number }>())?.n ?? 0;
430452 if (sent >= MAX_INVITES_PER_DAY) throw new HttpError(429, `you can send at most ${MAX_INVITES_PER_DAY} invitations in 24 hours; try again later`);
431453 }
454+ }
455+
456+ async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> {
457+ await this.checkInvite(inviter, input);
432458 // One open invitation per address and organization or repository. The email column is NOCASE,
433459 // so Bob@x.com and bob@x.com count as the same person.
434460 const pending = await this.db
435- .prepare("SELECT 1 FROM invites WHERE email = ? AND org_id IS ? AND repo_id IS ? AND accepted_at IS NULL AND expires_at > ?")
461+ .prepare("SELECT 1 FROM invites WHERE email = ? AND account_id IS NULL AND org_id IS ? AND repo_id IS ? AND accepted_at IS NULL AND expires_at > ?")
436462 .bind(input.email, input.orgId ?? null, input.repoId ?? null, now())
437463 .first();
438464 if (pending) throw new HttpError(422, `${input.email} already has a pending invitation; revoke it first to send a new one`);
@@ -453,6 +479,46 @@
453479 }
454480
455481 /**
482+ * Invite an existing person by username to an organization or a repository. They join when they
483+ * accept it in Settings → Invitations or through the API; the email and notification only point
484+ * there, so the invitation carries no link that works for anyone else. Inviting someone who already
485+ * has a pending invitation changes its role instead of sending another.
486+ */
487+ async inviteAccount(inviter: Account, user: Account, input: { orgId?: string; repoId?: string; role: string }): Promise<{ id: string; renewed: boolean }> {
488+ const pending = await this.db
489+ .prepare("SELECT id FROM invites WHERE account_id = ? AND org_id IS ? AND repo_id IS ? AND accepted_at IS NULL AND expires_at > ?")
490+ .bind(user.id, input.orgId ?? null, input.repoId ?? null, now())
491+ .first<{ id: string }>();
492+ if (pending) {
493+ await this.checkInvite(inviter, input, false);
494+ await this.db.prepare("UPDATE invites SET role = ? WHERE id = ?").bind(input.role, pending.id).run();
495+ return { id: pending.id, renewed: true };
496+ }
497+ await this.checkInvite(inviter, input);
498+ const id = newId();
499+ const expiresAt = new Date(Date.now() + 14 * 86400_000).toISOString();
500+ // The token column is required and unique; nobody is given this one; the invitation is accepted by id.
501+ await this.db
502+ .prepare("INSERT INTO invites (id, org_id, repo_id, email, account_id, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
503+ .bind(id, input.orgId ?? null, input.repoId ?? null, user.email ?? null, user.id, input.role, await sha256(randomToken(24)), inviter.id, now(), expiresAt)
504+ .run();
505+ const org = input.orgId ? await this.account(input.orgId) : null;
506+ const repo = input.repoId ? await this.repoById(input.repoId) : null;
507+ const where = org ? org.login : repo ? `${repo.owner_login}/${repo.name}` : "Diffinery";
508+ await this.notify([user.id], { repoId: input.repoId, kind: "invitation", title: `${inviter.login} invited you to ${where} as ${input.role}`, url: "/settings/invitations" });
509+ await enqueueMail(this.env, [
510+ {
511+ kind: "invite",
512+ data: { inviter: inviter.login, url: `${this.env.PUBLIC_URL}/settings/invitations`, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: input.role, expiresAt, existing: true },
513+ to: { accountId: user.id },
514+ reason: "invitation",
515+ at: now(),
516+ },
517+ ]);
518+ return { id, renewed: false };
519+ }
520+
521+ /**
456522 * Email an invitation to its address. Only this emailed link carries the proof that lets
457523 * someone create an account for the address (auth/invite-proof.ts); the plain link shown to
458524 * the inviter leads to a page that sends this email.
@@ -475,27 +541,55 @@
475541 ]);
476542 }
477543
478- 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 }>> {
544+ /** Invitations sent from an organization, a repository or the site. One sent to a username shows the username, never the person's email address. */
545+ async invites(filter: { orgId?: string; repoId?: string; site?: boolean; pending?: boolean }): Promise<Array<{ id: string; email: string | null; login: string | null; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> {
479546 const r = await this.db
480547 .prepare(
481- `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`,
548+ `SELECT i.*, a.login AS inviter, u.login AS invitee FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by LEFT JOIN accounts u ON u.id = i.account_id 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`,
482549 )
483550 .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : []))
484551 .all<Record<string, string | null>>();
485- return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null, invitedBy: i.inviter ?? null }));
552+ return r.results.map((i) => ({ id: i.id!, email: i.account_id ? null : i.email!, login: i.account_id ? i.invitee ?? null : null, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null, invitedBy: i.inviter ?? null }));
486553 }
487554
555+ /**
556+ * Pending invitations to an organization or a repository for this person: those sent to their
557+ * username, and those sent to their email address.
558+ */
559+ async invitesFor(user: Account): Promise<Array<{ id: string; orgId: string | null; repoId: string | null; org: string | null; repo: string | null; role: string; invitedBy: string | null; createdAt: string; expiresAt: string }>> {
560+ const r = await this.db
561+ .prepare(
562+ `SELECT i.*, a.login AS inviter, o.login AS org_login, ro.login || '/' || r.name AS repo_full FROM invites i
563+ LEFT JOIN accounts a ON a.id = i.invited_by LEFT JOIN accounts o ON o.id = i.org_id LEFT JOIN repos r ON r.id = i.repo_id LEFT JOIN accounts ro ON ro.id = r.owner_id
564+ WHERE ${MINE} ORDER BY i.created_at DESC LIMIT 100`,
565+ )
566+ .bind(now(), user.id, user.email ?? null)
567+ .all<Record<string, string | null>>();
568+ return r.results.map((i) => ({ id: i.id!, orgId: i.org_id ?? null, repoId: i.repo_id ?? null, org: i.org_login ?? null, repo: i.repo_full ?? null, role: i.role!, invitedBy: i.inviter ?? null, createdAt: i.created_at!, expiresAt: i.expires_at! }));
569+ }
570+
571+ /** Accept or decline one of this person's pending invitations (see invitesFor). */
572+ async answerInvite(id: string, user: Account, accept: boolean): Promise<{ orgId: string | null; repoId: string | null }> {
573+ const inv = await this.db.prepare(`SELECT i.* FROM invites i WHERE ${MINE} AND i.id = ?`).bind(now(), user.id, user.email ?? null, id).first<Record<string, string | null>>();
574+ if (!inv) throw new HttpError(404, "no pending invitation with this id");
575+ if (accept) await this.applyInvite(inv, user);
576+ else await this.db.prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL").bind(id).run();
577+ return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
578+ }
579+
488580 async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> {
489581 const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
490582 if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired");
491- if (inv.email && inv.email.toLowerCase() !== (user.email ?? "").toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
583+ if (inv.account_id) {
584+ if (inv.account_id !== user.id) throw new HttpError(403, "this invitation is for someone else");
585+ } else if (inv.email && inv.email.toLowerCase() !== (user.email ?? "").toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
492586 await this.applyInvite(inv, user);
493587 return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
494588 }
495589
496590 private async acceptInvitesForEmail(userId: string, email: string): Promise<void> {
497591 const user = (await this.account(userId))!;
498- const r = await this.db.prepare("SELECT * FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).all<Record<string, string | null>>();
592+ const r = await this.db.prepare("SELECT * FROM invites WHERE email = ? AND account_id IS NULL AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).all<Record<string, string | null>>();
499593 for (const inv of r.results) await this.applyInvite(inv, user);
500594 }
501595
@@ -508,7 +602,7 @@
508602 const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null;
509603 if (inv.invited_by && inv.invited_by !== user.id) {
510604 await enqueueMail(this.env, [
511- { kind: "invite_accepted", data: { login: user.login, email: user.email ?? inv.email ?? "", org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined }, to: { accountId: inv.invited_by }, reason: "account", at: now() },
605+ { kind: "invite_accepted", data: { login: user.login, email: inv.account_id ? "" : (user.email ?? inv.email ?? ""), org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined }, to: { accountId: inv.invited_by }, reason: "account", at: now() },
512606 ]);
513607 }
514608 }
@@ -634,6 +728,8 @@
634728 async deleteRepo(repo: Repo): Promise<void> {
635729 await this.db.batch([
636730 this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id),
731+ // Invitations reference the repository without ON DELETE CASCADE, so they'd block the delete.
732+ this.db.prepare("DELETE FROM invites WHERE repo_id = ?").bind(repo.id),
637733 this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id),
638734 // A deleted repository's old names stop redirecting.
639735 this.db.prepare("DELETE FROM repo_redirects WHERE repo_id = ?").bind(repo.id),
modifiedapps/diffinery/src/mail/samples.ts+1−0View file
@@ -73,6 +73,7 @@
7373 item("account-enabled", "Account enabled", { kind: "account_status", reason: "account", data: { status: "active" } }),
7474 item("invite-org", "Invitation (organization)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, org: repo.split("/")[0], role: "member", expiresAt: exp } }),
7575 item("invite-repo", "Invitation (repository)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, repo, role: "write", expiresAt: exp } }),
76+ item("invite-account", "Invitation (by username)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/settings/invitations`, repo, role: "write", expiresAt: exp, existing: true } }),
7677 item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }),
7778 item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }),
7879 item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
modifiedapps/diffinery/src/mail/templates.ts+7−5View file
@@ -548,14 +548,16 @@
548548 const what = d.org ? `join the ${d.org} organization` : d.repo ? `collaborate on ${d.repo}` : "join Diffinery";
549549 return {
550550 subject: d.org ? `@${d.inviter} invited you to join the ${d.org} organization on Diffinery` : d.repo ? `@${d.inviter} invited you to collaborate on ${d.repo}` : `@${d.inviter} invited you to Diffinery`,
551- preheader: `Accept by ${day(d.expiresAt)}. You'll sign in with a passkey, no password needed.`,
551+ preheader: d.existing ? `Accept or decline it in Diffinery by ${day(d.expiresAt)}.` : `Accept by ${day(d.expiresAt)}. You'll sign in with a passkey, no password needed.`,
552552 eyebrow: { text: "Invitation", tone: "blue" },
553553 title: [{ b: user(d.inviter) }, ` invited you to ${what}`],
554554 blocks: [
555555 { p: ["Diffinery is where the team keeps its code: Git repositories, pull requests, issues and GitHub Actions–compatible CI, with AI agents as first-class collaborators."] },
556556 { kv: [["Invited by", user(d.inviter)], ...(d.org ? ([["Organization", d.org]] as Array<[string, string]>) : []), ...(d.repo ? ([["Repository", d.repo]] as Array<[string, string]>) : []), ...(d.org || d.repo ? ([["Role", roleText(d.role)]] as Array<[string, string]>) : []), ["Expires", day(d.expiresAt)]] },
557- { button: { label: "Accept invitation", url: d.url } },
558- { p: ["You'll pick a username and create a passkey with your fingerprint, face or screen lock. This invitation only works for this email address."], muted: true },
557+ { button: { label: d.existing ? "View invitation" : "Accept invitation", url: d.url } },
558+ d.existing
559+ ? { p: ["You'll have access once you accept. Sign in to accept or decline it; if you don't recognize this, decline it or let it expire."], muted: true }
560+ : { p: ["You'll pick a username and create a passkey with your fingerprint, face or screen lock. This invitation only works for this email address."], muted: true },
559561 ],
560562 url: d.url,
561563 };
@@ -565,10 +567,10 @@
565567 const where = d.org ? `the ${d.org} organization` : d.repo ? d.repo : "Diffinery";
566568 return {
567569 subject: `[Diffinery] @${d.login} accepted your invitation`,
568- preheader: `${d.email} joined ${where}.`,
570+ preheader: `${d.email || `@${d.login}`} joined ${where}.`,
569571 eyebrow: { text: "Invitation accepted", tone: "green" },
570572 title: [{ b: user(d.login) }, ` accepted your invitation to ${where}`],
571- blocks: [{ kv: [["Username", user(d.login)], ["Email", d.email]] }, { button: { label: "View profile", url: `${pub}/${d.login}` } }],
573+ blocks: [{ kv: [["Username", user(d.login)], ...(d.email ? ([["Email", d.email]] as Array<[string, string]>) : [])] }, { button: { label: "View profile", url: `${pub}/${d.login}` } }],
572574 url: `${pub}/${d.login}`,
573575 };
574576 }
modifiedapps/diffinery/src/mail/types.ts+1−1View file
@@ -81,7 +81,7 @@
8181 | { kind: "push_bypassed"; data: { repo: string; pusher: string; kinds: string[]; reason: string; refs?: string[] } }
8282 | { kind: "push_blocked"; data: { repo: string; kinds: string[]; refs?: string[]; paths?: string[] } }
8383 | { kind: "account_status"; data: { status: "disabled" | "active" } }
84- | { kind: "invite"; data: { inviter: string; url: string; org?: string; repo?: string; role: string; expiresAt: string } }
84+ | { kind: "invite"; data: { inviter: string; url: string; org?: string; repo?: string; role: string; expiresAt: string; existing?: boolean } }
8585 | { kind: "invite_accepted"; data: { login: string; email: string; org?: string; repo?: string } }
8686 | { kind: "welcome"; data: { login: string } }
8787 | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } }
modifiedapps/diffinery/src/mcp/server.ts+1−1View file
@@ -390,7 +390,7 @@
390390 readOnly: true,
391391 run: async (c, a) => {
392392 const scope = await inviteScope(c, a, "read");
393- 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 }));
393+ return (await c.get("dir").invites({ ...scope, site: !scope.orgId && !scope.repoId, pending: true })).map((i) => ({ id: i.id, email: i.email, login: i.login, role: i.role, inviter: i.invitedBy, created_at: i.createdAt, expires_at: i.expiresAt }));
394394 },
395395 },
396396 revoke_invitation: {
modifiedapps/diffinery/src/web/account.tsx+104−4View file
@@ -2,7 +2,7 @@
22 import { raw } from "hono/html";
33 import type { Child, FC } from "hono/jsx";
44 import { requireAccount, requireRecentSignIn, type AppContext } 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 { loadRepo } from "../http/common.js";
77 import { movedLoginPath } from "../lib/redirects.js";
88 import { HttpError } from "../lib/util.js";
@@ -182,9 +182,42 @@
182182 if (kind.endsWith("comment")) return ICONS.comment;
183183 if (kind === "org_membership") return ICONS.org;
184184 if (kind === "repo_access") return ICONS.repo;
185+ if (kind === "invitation") return ICONS.envelope;
185186 return ICONS.bell;
186187 }
187188
189+/** A person's pending invitations to organizations and repositories, each with Accept and Decline. */
190+const MyInvitations: FC<{ invites: Awaited<ReturnType<Directory["invitesFor"]>>; t: string }> = ({ invites, t }) => (
191+ <div class="box">
192+ {invites.length ? (
193+ <ul class="list">
194+ {invites.map((i) => (
195+ <li>
196+ <div class="grow">
197+ <b>{i.org ?? i.repo}</b>
198+ <span class="badge" style="margin-left:6px">
199+ {cap(i.role)}
200+ </span>
201+ <div class="muted small">
202+ {i.org ? "Organization" : "Repository"} · invited {i.invitedBy ? `by ${i.invitedBy} ` : ""}
203+ <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}
204+ </div>
205+ </div>
206+ <Form action={`/settings/invitations/${encodeURIComponent(i.id)}/accept`} t={t} class="inline">
207+ <button class="btn sm primary">Accept</button>
208+ </Form>
209+ <Form action={`/settings/invitations/${encodeURIComponent(i.id)}/decline`} t={t} class="inline">
210+ <button class="btn sm">Decline</button>
211+ </Form>
212+ </li>
213+ ))}
214+ </ul>
215+ ) : (
216+ <div class="empty">No pending invitations.</div>
217+ )}
218+ </div>
219+);
220+
188221 export function registerAccount(web: WebApp): void {
189222 web.get("/", async (c) => {
190223 const p = c.get("principal");
@@ -202,6 +235,7 @@
202235 );
203236 }
204237 if (me.status === "pending") {
238+ const invites = await c.get("dir").invitesFor(me);
205239 return render(
206240 c,
207241 { title: "Waiting for an invite" },
@@ -211,6 +245,12 @@
211245 <p class="muted">
212246 You are signed in as <b>{me.email ?? me.login}</b>, but this Diffinery instance is invite-only. Ask someone with access to invite your email address, or send a request to the administrators.
213247 </p>
248+ {invites.length ? (
249+ <>
250+ <h3>Your invitations</h3>
251+ <MyInvitations invites={invites} t={t} />
252+ </>
253+ ) : null}
214254 <Form action="/request-access" t={t}>
215255 <div class="field">
216256 <label for="note">Note to the administrators</label>
@@ -601,7 +641,7 @@
601641
602642 // ---------------------------------------------------------------- /settings
603643
604-export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "notifications" | "linked" | "admin" | "email" | "audit";
644+export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "linked" | "admin" | "email" | "audit";
605645
606646 export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
607647 <aside class="side vt-side">
@@ -635,6 +675,9 @@
635675 <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}>
636676 Organizations
637677 </a>
678+ <a href="/settings/invitations" class={tab === "invitations" ? "on" : ""}>
679+ Invitations
680+ </a>
638681 <a href="/settings/notifications" class={tab === "notifications" ? "on" : ""}>
639682 Notifications
640683 </a>
@@ -1257,6 +1300,52 @@
12571300 });
12581301
12591302 // Organizations
1303+ // Invitations to organizations and repositories: someone added by username joins once they accept.
1304+ web.get("/settings/invitations", async (c) => {
1305+ const p = c.get("principal");
1306+ // Someone still waiting for an invite sees theirs on the home page.
1307+ if (p.type === "account" && p.account.status === "pending") return c.redirect("/", 303);
1308+ const me = requireAccount(c);
1309+ const invites = await c.get("dir").invitesFor(me);
1310+ return page(
1311+ c,
1312+ me,
1313+ "invitations",
1314+ "Invitations",
1315+ <>
1316+ <h1>Invitations</h1>
1317+ <p class="muted">Invitations to join an organization or collaborate on a repository. You get access once you accept; declining tells nobody, and the invitation goes away.</p>
1318+ <MyInvitations invites={invites} t={csrfToken(c)} />
1319+ </>,
1320+ );
1321+ });
1322+
1323+ for (const accept of [true, false]) {
1324+ post(web, `/settings/invitations/:id/${accept ? "accept" : "decline"}`, async (c) => {
1325+ const p = c.get("principal");
1326+ if (p.type !== "account") throw new HttpError(401, "sign in required");
1327+ // Someone waiting for an invite may accept one; that lets them in.
1328+ if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled");
1329+ const dir = c.get("dir");
1330+ const back = p.account.status === "pending" ? "/" : "/settings/invitations";
1331+ return act(c, back, async () => {
1332+ const res = await dir.answerInvite(prm(c, "id"), p.account, accept);
1333+ c.executionCtx.waitUntil(dir.audit({ actorId: p.account.id, actorLogin: p.account.login, action: accept ? "invite.accept" : "invite.decline", detail: { id: prm(c, "id") } }));
1334+ if (!accept) {
1335+ flash(c, "notice", "Invitation declined.");
1336+ return;
1337+ }
1338+ if (res.repoId) {
1339+ const r = await dir.repoById(res.repoId);
1340+ if (r) return repoUrl(r);
1341+ } else if (res.orgId) {
1342+ const o = await dir.account(res.orgId);
1343+ if (o) return `/${o.login}`;
1344+ }
1345+ });
1346+ });
1347+ }
1348+
12601349 web.get("/settings/orgs", async (c) => {
12611350 const me = requireAccount(c);
12621351 const t = csrfToken(c);
@@ -1784,7 +1873,7 @@
17841873 </div>
17851874 <button class="btn primary">Add</button>
17861875 </div>
1787- <div class="hint">A username joins right away and gets a heads-up by email and on Diffinery. An email address gets an invitation: they join {acct.login} when they accept it, creating an account first if they don't have one.</div>
1876+ <div class="hint">Everyone gets an invitation and joins {acct.login} when they accept it: a username in their Settings → Invitations (with an email and a notification), an email address by a link that also creates an account if they don't have one.</div>
17881877 </div>
17891878 </Form>
17901879 ) : null}
@@ -1917,7 +2006,7 @@
19172006
19182007 web.get("/:owner", async (c) => profile(c));
19192008
1920- // Add people: a username or an email. Existing accounts join now; new emails get an invitation.
2009+ // Add people: a username or an email. Both get an invitation to accept (Directory.addOrgPerson).
19212010 post(web, "/:owner/-/org/people", async (c, f) => {
19222011 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
19232012 const back = `/${org.login}?tab=people`;
@@ -1930,6 +2019,9 @@
19302019 if ("added" in r) {
19312020 flash(c, "notice", `Added ${r.added.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. We let them know.`);
19322021 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.member.set", target: org.login, detail: { user: r.added.login, role } }));
2022+ } else if ("invitedUser" in r) {
2023+ flash(c, "notice", r.renewed ? `${r.invitedUser.login} already had a pending invitation; it now offers the ${ROLE_LABEL[role]!.toLowerCase()} role.` : `Invited ${r.invitedUser.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. They join once they accept.`);
2024+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { user: r.invitedUser.login, role } }));
19332025 } else {
19342026 invited = r.invited;
19352027 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email: r.invited.email, role } }));
@@ -1965,6 +2057,14 @@
19652057 const current = await dir.orgRole(org.id, user.id);
19662058 const role = f.s("role");
19672059 if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners");
2060+ // Someone who isn't a member yet gets an invitation, as with Add people.
2061+ if (!current && role !== "remove") {
2062+ if (role !== "owner" && role !== "admin" && role !== "member") throw new HttpError(422, "invalid role");
2063+ const r = await dir.addOrgPerson(org, me, user.login, role);
2064+ if ("invitedUser" in r) flash(c, "notice", r.renewed ? `${user.login} already had a pending invitation; it now offers the ${ROLE_LABEL[role]!.toLowerCase()} role.` : `Invited ${user.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. They join once they accept.`);
2065+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "added" in r ? "org.member.set" : "org.invite", target: org.login, detail: { user: user.login, role } }));
2066+ return;
2067+ }
19682068 if (role === "remove") await dir.setOrgMember(org.id, user.id, null, me);
19692069 else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role, me);
19702070 else throw new HttpError(422, "invalid role");
modifiedapps/diffinery/src/web/repo-settings.tsx+15−3View file
@@ -362,7 +362,7 @@
362362 </div>
363363 <button class="btn primary">Add</button>
364364 </div>
365- <div class="hint">A username (a person or an agent) gets access right away and a heads-up. An email address gets an invitation: they get access when they accept it, creating an account first if they don't have one.</div>
365+ <div class="hint">A person's username gets an invitation in their Settings → Invitations; they get access once they accept it. Agents, organization members and existing collaborators get the role at once. An email address gets an invitation link that also creates an account if they don't have one.</div>
366366 </div>
367367 </Form>
368368 <div class="box">
@@ -812,8 +812,17 @@
812812 const roleIn = f.s("role");
813813 const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null);
814814 if (roleIn !== "remove" && !role) throw new HttpError(422, "invalid role");
815- await dir.setCollaborator(l.repo.id, who.id, role);
816- audit(c, l, role ? "repo.collaborator.set" : "repo.collaborator.remove", { login: who.login, role });
815+ if (!role) {
816+ await dir.setCollaborator(l.repo.id, who.id, null);
817+ audit(c, l, "repo.collaborator.remove", { login: who.login });
818+ return;
819+ }
820+ // Someone without access yet gets an invitation to accept (see Directory.grantRepoAccess).
821+ const r = await dir.grantRepoAccess(l.repo, requireAccount(c), who, role);
822+ if ("invitedUser" in r) {
823+ flash(c, "notice", r.renewed ? `${who.login} already had a pending invitation; it now offers ${role} access.` : `Invited ${who.login} with ${role} access. They get access once they accept.`);
824+ audit(c, l, "repo.invite", { login: who.login, role });
825+ } else audit(c, l, "repo.collaborator.set", { login: who.login, role });
817826 });
818827 });
819828
@@ -835,6 +844,9 @@
835844 if ("added" in r) {
836845 flash(c, "notice", `${r.added.login} now has ${role} access. We let them know.`);
837846 audit(c, l, "repo.collaborator.set", { login: r.added.login, role });
847+ } else if ("invitedUser" in r) {
848+ flash(c, "notice", r.renewed ? `${r.invitedUser.login} already had a pending invitation; it now offers ${role} access.` : `Invited ${r.invitedUser.login} with ${role} access. They get access once they accept.`);
849+ audit(c, l, "repo.invite", { login: r.invitedUser.login, role });
838850 } else {
839851 invited = r.invited;
840852 audit(c, l, "repo.invite", { email: r.invited.email, role });
modifiedapps/diffinery/src/web/ui.tsx+3−3View file
@@ -904,7 +904,7 @@
904904 /** "write" → "Write", for showing a stored value as a label. */
905905 export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
906906
907-export type PendingInvite = { id: string; email: string; role: string; createdAt: string; expiresAt: string; invitedBy: string | null };
907+export type PendingInvite = { id: string; email: string | null; login?: string | null; role: string; createdAt: string; expiresAt: string; invitedBy: string | null };
908908
909909 /** Pending invitations with a Revoke button each; `revoke` gives the form action for an invitation id. */
910910 export const PendingInvites: FC<{ invites: PendingInvite[]; revoke: (id: string) => string; t: string; roles?: boolean }> = ({ invites, revoke, t, roles = true }) =>
@@ -918,7 +918,7 @@
918918 {invites.map((i) => (
919919 <tr>
920920 <td>
921- <b>{i.email}</b>
921+ <b>{i.login ? `@${i.login}` : i.email}</b>
922922 <div class="muted small">
923923 Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""}
924924 <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}
@@ -930,7 +930,7 @@
930930 </td>
931931 ) : null}
932932 <td class="right">
933- <Form action={revoke(i.id)} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}>
933+ <Form action={revoke(i.id)} t={t} class="inline" confirm={i.login ? `Revoke the invitation for @${i.login}?` : `Revoke the invitation for ${i.email}? The link stops working.`}>
934934 <button class="btn sm danger">Revoke</button>
935935 </Form>
936936 </td>
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+9−3View file
@@ -286,15 +286,21 @@
286286 await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("tr:has-text('newbie@example.com') button:has-text('Revoke')")]);
287287 assert.match(await text(boss.page), /Invitation revoked/);
288288 assert.doesNotMatch(await text(boss.page), /newbie@example\.com/);
289- // An existing account joins right away and hears about it on Diffinery.
289+ // An existing account is invited by username, hears about it on Diffinery, and joins once they accept.
290290 await page.goto(`${base}/settings/orgs`);
291291 await Promise.all([page.waitForLoadState("load"), page.fill("#olog", "palorg")]);
292292 await Promise.all([page.waitForURL(/\/palorg/), page.click("button:has-text('Create organization')")]);
293293 await page.fill("#who", "boss");
294294 await Promise.all([page.waitForLoadState("load"), page.click("form:has(#who) button:has-text('Add')")]);
295- assert.match(await text(page), /Added boss to palorg as member/);
295+ assert.match(await text(page), /Invited boss to palorg as member\. They join once they accept/);
296+ assert.match(await text(page), /@boss/);
296297 await boss.page.goto(`${base}/notifications`);
297- assert.match(await text(boss.page), /pal added you to palorg as a member/);
298+ assert.match(await text(boss.page), /pal invited you to palorg as member/);
299+ await boss.page.goto(`${base}/settings/invitations`);
300+ assert.match(await text(boss.page), /palorg/);
301+ await Promise.all([boss.page.waitForURL(/\/palorg$/), boss.page.click("li:has-text('palorg') button:has-text('Accept')")]);
302+ await page.goto(`${base}/palorg?tab=people`);
303+ assert.match(await page.textContent("tr:has-text('boss')"), /Member/);
298304 // The last owner can't step down: the organization would have nobody to manage it.
299305 await page.goto(`${base}/palorg?tab=people`);
300306 await Promise.all([
modifiedapps/diffinery/test/e2e/registry.test.mjs+4−1View file
@@ -365,7 +365,10 @@
365365 await pushImage("crew/app", "v1", { tok: CREW.olga });
366366 assert.equal((await ok("GET", "/orgs/crew/packages/container/app", undefined, CREW.olga)).repository.full_name, "crew/app");
367367 assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 404);
368- await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "read" }, CREW.olga);
368+ // An outside collaborator is invited, and has access once he accepts (a member like mira is added at once).
369+ const invitation = await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "read" }, CREW.olga);
370+ assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 404);
371+ await ok("PATCH", `/user/repository_invitations/${invitation.id}`, undefined, CREW.otto);
369372 assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 200);
370373 assert.equal((await reg("POST", "crew/app/blobs/uploads/", { tok: CREW.otto })).status, 403);
371374 await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "write" }, CREW.olga);
modifiedapps/diffinery/test/e2e/security.test.mjs+102−9View file
@@ -92,6 +92,17 @@
9292 }
9393 const b64 = (s) => Buffer.from(s).toString("base64");
9494
95+/**
96+ * Give someone access to a repository as GitHub does: someone new gets an invitation (201), which
97+ * they accept with their own token; an existing collaborator's role changes at once (204).
98+ */
99+async function collaborator(repo, login, permission, invitee, as = T.olive) {
100+ const r = await api("PUT", `${repo}/collaborators/${login}`, { permission }, as);
101+ if (r.status === 204) return;
102+ assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`);
103+ await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee);
104+}
105+
95106 before(async () => {
96107 const port = await freePort();
97108 base = `http://127.0.0.1:${port}`;
@@ -183,7 +194,7 @@
183194 const R = `/repos/${OWNER}/guarded`;
184195 const mainSha = await repoWithReadme("guarded");
185196 await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" });
186- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "read" });
197+ await collaborator(R, "rita", "read", T.rita);
187198 await status(204, "PUT", `${R}/collaborators/gone-bot`, { permission: "write" });
188199 const issue = await ok("POST", `${R}/issues`, { title: "needle in a haystack" });
189200
@@ -585,13 +596,13 @@
585596 assert.deepEqual([edited.labels.length, edited.assignees.length, edited.milestone], [0, 0, null]);
586597
587598 // ------------------------------------------------------------ settings changed through the API are on the record
588- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "push" });
599+ await collaborator(R, "rita", "push", T.rita);
589600 await ok("PUT", `${R}/branches/main/protection`, { requiredApprovals: 1 });
590601 await status(204, "PUT", `${R}/actions/secrets/TOKEN`, { value: "s3cret" });
591602 await status(201, "POST", `${R}/actions/variables`, { name: "MODE", value: "x" });
592603 await status(201, "POST", `${R}/hooks`, { config: { url: "https://example.com/hook" } });
593604 await until("audit entries", async () => {
594- const want = ["repo.update", "repo.collaborator.set", "repo.protection.set", "repo.secret.set", "repo.variable.set", "repo.webhook.create"];
605+ const want = ["repo.update", "repo.invite", "repo.protection.set", "repo.secret.set", "repo.variable.set", "repo.webhook.create"];
595606 return want.every((a) => auditLog(a).some((e) => e.target === `${OWNER}/limits` && JSON.parse(e.detail).via === "api"));
596607 });
597608 const leak = auditLog("api.blocked_secret").find((e) => e.target === `${OWNER}/leaky`);
@@ -761,6 +772,7 @@
761772 await ok("POST", "/user/orgs", { login: "team-new" });
762773 await ok("PATCH", "/orgs/team-old", { default_repository_permission: "none" });
763774 assert.equal((await oliveSubmits("/team-old?tab=people", "/team-old/-/org/people", { who: "sam", role: "member" })).status, 303);
775+ assert.equal((await ok("PATCH", "/user/memberships/orgs/team-old", { state: "active" }, T.sam)).state, "active");
764776
765777 // ------------------------------------------------------------ a private repository with history, an issue, a secret, a variable and an image
766778 await repoWithReadme("moving");
@@ -768,7 +780,7 @@
768780 const issue = await ok("POST", `${R}/issues`, { title: "survives the move" });
769781 await status(204, "PUT", `${R}/actions/secrets/DEPLOY_KEY`, { value: "s3cret" });
770782 await status(204, "PUT", `${R}/actions/variables/REGION`, { value: "eu" });
771- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "read" });
783+ await collaborator(R, "rita", "read", T.rita);
772784 await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" });
773785 const repoId = (await ok("GET", R)).id;
774786 await pushTinyImage(`${OWNER}/moving`, "v1", T.olive);
@@ -858,7 +870,7 @@
858870 assert.doesNotMatch(JSON.stringify(probe.json), /team-old/);
859871
860872 // ------------------------------------------------------------ an organization that owns repositories can't be deleted
861- await status(204, "PUT", `${N}/collaborators/sam`, { permission: "write" });
873+ await collaborator(N, "sam", "write", T.sam);
862874 assert.equal((await ok("GET", N, undefined, T.sam)).permissions.push, true);
863875 await status(204, "DELETE", "/repos/team-old/clash");
864876 const refused = await status(422, "DELETE", "/orgs/team-old");
@@ -1582,7 +1594,7 @@
15821594 test("security: required checks gate direct pushes to a protected branch, as on GitHub", async () => {
15831595 const R = `/repos/${OWNER}/gated`;
15841596 const mainSha = await repoWithReadme("gated");
1585- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" });
1597+ await collaborator(R, "rita", "write", T.rita);
15861598 await ok("PUT", `${R}/branches/main/protection`, { requiredChecks: ["ci"] });
15871599 // A writer's commit straight to main hasn't been checked: refused, whichever way it comes.
15881600 const refused = await status(403, "PUT", `${R}/contents/direct.txt`, { message: "direct", content: b64("x\n"), branch: "main" }, T.rita);
@@ -1914,7 +1926,7 @@
19141926 const R = `/repos/${OWNER}/runner-home`;
19151927 await repoWithReadme("runner-home", { private: true });
19161928 await repoWithReadme("runner-other", { private: true });
1917- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" });
1929+ await collaborator(R, "rita", "write", T.rita);
19181930 // Only the repository's admins get a registration token.
19191931 await status(403, "POST", `${R}/actions/runners/registration-token`, undefined, T.rita);
19201932 const reg = await status(201, "POST", `${R}/actions/runners/registration-token`);
@@ -2004,7 +2016,7 @@
20042016 test("security: an admin can purge objects a force-push left unreachable", async () => {
20052017 const R = `/repos/${OWNER}/purge-me`;
20062018 const mainSha = await repoWithReadme("purge-me");
2007- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" });
2019+ await collaborator(R, "rita", "write", T.rita);
20082020 const clone = join(work, "purge-me");
20092021 const authed = `${base.replace("://", `://x:${T.olive}@`)}/${OWNER}/purge-me.git`;
20102022 execFileSync("git", ["clone", "-q", authed, clone], { stdio: "pipe" });
@@ -2077,7 +2089,7 @@
20772089 // A collaborator's permission is one of GitHub's, and an organization can't be one.
20782090 assert.match((await status(422, "PUT", `${R}/collaborators/rita`, { permission: "root" })).message, /permission must be/);
20792091 assert.match((await status(422, "PUT", `${R}/collaborators/guild`, { permission: "push" })).message, /organization can't be a collaborator/);
2080- await status(204, "PUT", `${R}/collaborators/rita`, { permission: "triage" });
2092+ await collaborator(R, "rita", "triage", T.rita);
20812093 // A check run's link is a web address.
20822094 for (const details_url of ["javascript:alert(1)", "data:text/html,hi", "//evil.example/x"]) await status(422, "POST", `${R}/check-runs`, { name: "ext", head_sha: sha, details_url });
20832095 await status(201, "POST", `${R}/check-runs`, { name: "ext", head_sha: sha, details_url: "https://ci.example/run/1" });
@@ -2091,3 +2103,84 @@
20912103 // Names people would trust aren't for the taking.
20922104 for (const login of ["ghost", "github-actions", "noreply", "support", "abuse"]) await status(422, "POST", "/user/orgs", { login });
20932105 });
2106+
2107+test("security: adding someone by username sends an invitation, and they get access only once they accept", async () => {
2108+ // ------------------------------------------------------------ a repository: milo is invited, not added
2109+ await repoWithReadme("by-name");
2110+ const R = `/repos/${OWNER}/by-name`;
2111+ const inv = await status(201, "PUT", `${R}/collaborators/milo`, { permission: "write" });
2112+ assert.equal(inv.invitee.login, "milo");
2113+ assert.equal(inv.permissions, "write");
2114+ await status(404, "GET", R, undefined, T.milo);
2115+ // The admin's list names the username, never the person's email address.
2116+ const listed = await ok("GET", `${R}/invitations`);
2117+ assert.deepEqual(listed.map((i) => [i.login, i.email, i.role]), [["milo", null, "write"]]);
2118+ // In-app and in milo's own list.
2119+ assert.ok((await ok("GET", "/notifications", undefined, T.milo)).some((n) => /olive invited you to olive\/by-name/.test(n.title)));
2120+ const mine = await ok("GET", "/user/repository_invitations", undefined, T.milo);
2121+ assert.deepEqual(mine.map((i) => [i.id, i.repository.full_name]), [[inv.id, `${OWNER}/by-name`]]);
2122+ // Nobody else can accept it, and a read token can't either.
2123+ await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.rita);
2124+ assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
2125+ await status(404, "GET", R, undefined, T.rita);
2126+ // Asking again changes the pending invitation's role rather than sending another.
2127+ assert.equal((await status(201, "PUT", `${R}/collaborators/milo`, { permission: "admin" })).id, inv.id);
2128+ // milo accepts and has the role offered.
2129+ await status(204, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo);
2130+ assert.equal((await ok("GET", R, undefined, T.milo)).permissions.admin, true);
2131+ await status(404, "PATCH", `/user/repository_invitations/${inv.id}`, undefined, T.milo);
2132+ // A collaborator's role then changes at once.
2133+ await status(204, "PUT", `${R}/collaborators/milo`, { permission: "read" });
2134+ assert.equal((await ok("GET", R, undefined, T.milo)).permissions.push, false);
2135+ // The settings form that sets a role invites someone new the same way.
2136+ const viaForm = await oliveSubmits(`/${OWNER}/by-name/settings/access`, `/${OWNER}/by-name/settings/access`, { login: "rita", role: "write" });
2137+ assert.equal(viaForm.status, 303);
2138+ assert.match(viaForm.flash, /Invited rita/);
2139+ await status(404, "GET", R, undefined, T.rita);
2140+ // rita declines: no access, and the invitation is gone.
2141+ const ritas = await ok("GET", "/user/repository_invitations", undefined, T.rita);
2142+ assert.equal(ritas.length, 1);
2143+ await status(204, "DELETE", `/user/repository_invitations/${ritas[0].id}`, undefined, T.rita);
2144+ await status(404, "GET", R, undefined, T.rita);
2145+ assert.deepEqual(await ok("GET", `${R}/invitations`), []);
2146+ // Agents are still added directly: they act for a person who already has access.
2147+ await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "write" });
2148+
2149+ // ------------------------------------------------------------ an organization: Add people and the role form both invite
2150+ await ok("POST", "/user/orgs", { login: "by-name-org" });
2151+ const members = async () => (await ok("GET", "/orgs/by-name-org/members")).map((u) => u.login);
2152+ const added = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/people", { who: "milo", role: "member" });
2153+ assert.equal(added.status, 303);
2154+ assert.match(added.flash, /Invited milo to by-name-org/);
2155+ const roleForm = await oliveSubmits("/by-name-org?tab=people", "/by-name-org/-/org/members", { login: "sam", role: "admin" });
2156+ assert.match(roleForm.flash, /Invited sam to by-name-org/);
2157+ assert.deepEqual(await members(), ["olive"]);
2158+ assert.equal((await ok("GET", "/user/memberships/orgs/by-name-org", undefined, T.milo)).state, "pending");
2159+ assert.deepEqual((await ok("GET", "/user/memberships/orgs?state=pending", undefined, T.milo)).map((m) => m.organization.login), ["by-name-org"]);
2160+ // milo accepts through the API, sam declines on the web.
2161+ assert.equal((await ok("PATCH", "/user/memberships/orgs/by-name-org", { state: "active" }, T.milo)).state, "active");
2162+ const samInvite = (await ok("GET", "/orgs/by-name-org/invitations")).find((i) => i.login === "sam");
2163+ const page = await (await fetch(`${base}/settings/invitations`, { headers: { cookie: `__Host-dfy_session=${S.sam}` } })).text();
2164+ assert.match(page, /by-name-org/);
2165+ const declined = await submitsAs(S.sam, "/settings/invitations", `/settings/invitations/${samInvite.id}/decline`, {});
2166+ assert.match(declined.flash, /Invitation declined/);
2167+ assert.deepEqual((await members()).sort(), ["milo", "olive"]);
2168+ await status(404, "GET", "/user/memberships/orgs/by-name-org", undefined, T.sam);
2169+
2170+ // ------------------------------------------------------------ someone waiting for an invite accepts it from the waiting page
2171+ await repoWithReadme("for-penny");
2172+ const pennyInvite = await status(201, "PUT", `/repos/${OWNER}/for-penny/collaborators/penny`, { permission: "read" });
2173+ const waiting = await (await fetch(base, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).text();
2174+ assert.match(waiting, /Waiting for an invite/);
2175+ assert.match(waiting, new RegExp(`${OWNER}/for-penny`));
2176+ const accepted = await submitsAs(S.penny, "/", `/settings/invitations/${pennyInvite.id}/accept`, {});
2177+ assert.equal(accepted.status, 303, accepted.flash);
2178+ assert.equal(accepted.location, `/${OWNER}/for-penny`);
2179+ assert.equal((await fetch(`${base}/${OWNER}/for-penny`, { headers: { cookie: `__Host-dfy_session=${S.penny}` } })).status, 200);
2180+
2181+ // ------------------------------------------------------------ a repository with invitations can still be deleted
2182+ await repoWithReadme("invited-then-deleted");
2183+ await status(201, "PUT", `/repos/${OWNER}/invited-then-deleted/collaborators/rita`, { permission: "read" });
2184+ await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`);
2185+ assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []);
2186+});
modifiedapps/diffinery/test/unit/rate-limit.test.ts+2−1View file
@@ -93,7 +93,8 @@
9393 });
9494
9595 describe("rate limits in the Worker", () => {
96- it("answers 429 with retry-after once an anonymous address is over its limit", async () => {
96+ // Importing the whole Worker takes a few seconds when the suite runs in parallel.
97+ it("answers 429 with retry-after once an anonymous address is over its limit", { timeout: 30_000 }, async () => {
9798 const { app } = await import("../../src/app.js");
9899 const env = { DB: {}, PUBLIC_URL: "https://d.example", RL_ANON: limiter(1) } as unknown as Env;
99100 const ctx = { waitUntil: () => {}, passThroughOnException: () => {}, props: {} } as unknown as ExecutionContext;