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)
- apps/diffinery/docs/accounts/invitations.md+91−20
- apps/diffinery/docs/accounts/notifications.md+7−7
- apps/diffinery/docs/accounts/organizations.md+6−6
- apps/diffinery/docs/accounts/permissions.md+5−4
- apps/diffinery/docs/accounts/tokens.md+1−0
- apps/diffinery/docs/admin/site-administration.md+2−2
- apps/diffinery/docs/agents/mcp.md+1−1
- apps/diffinery/docs/reference/rest-api.md+14−1
- apps/diffinery/docs/repositories/overview.md+10−6
- apps/diffinery/docs/security/audit-log.md+6−5
- apps/diffinery/migrations/0017_account_invites.sql+4−0
- apps/diffinery/src/docs/api-meta.ts+16−3
- apps/diffinery/src/http/api.ts+86−2
- apps/diffinery/src/lib/directory.ts+123−27
- apps/diffinery/src/mail/samples.ts+1−0
- apps/diffinery/src/mail/templates.ts+7−5
- apps/diffinery/src/mail/types.ts+1−1
- apps/diffinery/src/mcp/server.ts+1−1
- apps/diffinery/src/web/account.tsx+104−4
- apps/diffinery/src/web/repo-settings.tsx+15−3
- apps/diffinery/src/web/ui.tsx+3−3
- apps/diffinery/test/e2e/passkeys.test.mjs+9−3
- apps/diffinery/test/e2e/registry.test.mjs+4−1
- apps/diffinery/test/e2e/security.test.mjs+102−9
- apps/diffinery/test/unit/rate-limit.test.ts+2−1
modifiedapps/diffinery/docs/accounts/invitations.md+91−20View file
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | 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. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 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. | |
| 7 | 7 | |
| 8 | 8 | ## How sign-up works |
| 9 | 9 | |
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | | Site administrators | The instance (no organization or repository), and any organization | **Settings → People and invitations**, an organization's **People** tab, `diffinery invite`, the API | |
| 23 | 23 | | Organization owners | Their organization, as member, admin or owner | The organization's **People** tab (**Add people**), `diffinery invite --org`, the API | |
| 24 | 24 | | 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}` | | |
| 26 | 26 | |
| 27 | 27 | Everyone else, including organization members and people with a lower repository role, can't create invites. |
| 28 | 28 | |
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | |
| 39 | 39 | 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." |
| 40 | 40 | |
| 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. | |
| 42 | 42 | |
| 43 | 43 | ### Add people to an organization |
| 44 | 44 | |
| @@ -51,10 +51,10 @@ | ||
| 51 | 51 | |
| 52 | 52 | | You entered | What happens | |
| 53 | 53 | | --- | --- | |
| 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." | | |
| 55 | 55 | | 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." | |
| 56 | 56 | |
| 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. | |
| 58 | 58 | |
| 59 | 59 | 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"). |
| 60 | 60 | |
| @@ -66,7 +66,7 @@ | ||
| 66 | 66 | - repository invitations on the repository's **Settings → Collaborators** page (repository admins); |
| 67 | 67 | - instance invitations on **Settings → People and invitations** (site administrators). |
| 68 | 68 | |
| 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. | |
| 70 | 70 | |
| 71 | 71 | ### Add people to a repository |
| 72 | 72 | |
| @@ -78,7 +78,8 @@ | ||
| 78 | 78 | |
| 79 | 79 | | You entered | What happens | |
| 80 | 80 | | --- | --- | |
| 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. | | |
| 82 | 83 | | 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. | |
| 83 | 84 | |
| 84 | 85 | | Message | Cause | |
| @@ -87,9 +88,9 @@ | ||
| 87 | 88 | | "acme is an organization; add its people instead" | Organizations can't be collaborators. | |
| 88 | 89 | | "diffy's account is disabled" | A site administrator disabled the account. | |
| 89 | 90 | | "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. | | |
| 91 | 92 | |
| 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. | |
| 93 | 94 | |
| 94 | 95 | ## Invite from the CLI |
| 95 | 96 | |
| @@ -119,13 +120,13 @@ | ||
| 119 | 120 | | `-r, --role ROLE` | `member` (default), `admin` or `owner`. Needs `--org`. | |
| 120 | 121 | | `--json` | Print the API response (`id` and `url`) as JSON. | |
| 121 | 122 | |
| 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. | |
| 123 | 124 | |
| 124 | 125 | ## Invite with the API |
| 125 | 126 | |
| 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. | |
| 127 | 128 | |
| 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). | |
| 129 | 130 | |
| 130 | 131 | ### Organization invite |
| 131 | 132 | |
| @@ -153,6 +154,22 @@ | ||
| 153 | 154 | |
| 154 | 155 | `POST /api/v3/invitations` with `{"email": "friend@example.com"}`. Site administrators only. |
| 155 | 156 | |
| 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 | + | |
| 156 | 173 | ### List and revoke pending invitations |
| 157 | 174 | |
| 158 | 175 | | Method and path | Who | |
| @@ -161,7 +178,7 @@ | ||
| 161 | 178 | | `GET /api/v3/repos/{owner}/{repo}/invitations`, `DELETE /api/v3/repos/{owner}/{repo}/invitations/{id}` | The repository's admins | |
| 162 | 179 | | `GET /api/v3/invitations`, `DELETE /api/v3/invitations/{id}` | Site administrators (instance invitations only) | |
| 163 | 180 | |
| 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. | |
| 165 | 182 | |
| 166 | 183 | ### API errors |
| 167 | 184 | |
| @@ -197,6 +214,8 @@ | ||
| 197 | 214 | |
| 198 | 215 | If the address already belongs to a Diffinery account, the email goes to that account. |
| 199 | 216 | |
| 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 | + | |
| 200 | 219 | ## Invite link rules |
| 201 | 220 | |
| 202 | 221 | | Rule | Value | |
| @@ -205,7 +224,7 @@ | ||
| 205 | 224 | | 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). | |
| 206 | 225 | | Who can use it | Only the email address it was sent to | |
| 207 | 226 | | 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. | | |
| 209 | 228 | |
| 210 | 229 | 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)). |
| 211 | 230 | |
| @@ -235,15 +254,67 @@ | ||
| 235 | 254 | |
| 236 | 255 | 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). |
| 237 | 256 | |
| 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. | |
| 239 | 258 | |
| 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 | + | |
| 240 | 311 | ## Pending accounts |
| 241 | 312 | |
| 242 | 313 | 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: |
| 243 | 314 | |
| 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**, | |
| 245 | 316 | - 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. | |
| 247 | 318 | |
| 248 | 319 | > **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. |
| 249 | 320 | |
| @@ -261,6 +332,6 @@ | ||
| 261 | 332 | ## How invitations differ from GitHub |
| 262 | 333 | |
| 263 | 334 | - 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. | |
| 266 | 337 | - 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 @@ | ||
| 18 | 18 | |
| 19 | 19 | ## The notifications list |
| 20 | 20 | |
| 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. | |
| 22 | 22 | |
| 23 | 23 | - Click an entry to open what it is about; that marks it read. |
| 24 | 24 | - Click the check mark (**Mark as read**) next to an unread entry to mark only that one read. |
| @@ -32,12 +32,12 @@ | ||
| 32 | 32 | - issue and pull request activity you take part in, when **Participating → On Diffinery** is on, |
| 33 | 33 | - activity in repositories you watch with **All activity**, when **Watching → On Diffinery** is on, |
| 34 | 34 | - 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. | |
| 37 | 37 | |
| 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. | |
| 39 | 39 | |
| 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. | |
| 41 | 41 | |
| 42 | 42 | ## Email groups |
| 43 | 43 | |
| @@ -215,7 +215,7 @@ | ||
| 215 | 215 | | `your_activity` | Something you did on Diffinery (your own updates, a repository you created). | |
| 216 | 216 | | `ci_activity` | A workflow run triggered by you, or in a repository you watch. | |
| 217 | 217 | | `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. | | |
| 219 | 219 | | `admin` | You are an administrator (of the organization, repository or instance). | |
| 220 | 220 | | `account` | You have a Diffinery account (account and membership changes). | |
| 221 | 221 | |
| @@ -237,7 +237,7 @@ | ||
| 237 | 237 | | `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. | |
| 238 | 238 | | `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 | |
| 239 | 239 | | `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**) | | |
| 241 | 241 | | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite | |
| 242 | 242 | | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you | |
| 243 | 243 | | `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 @@ | ||
| 195 | 195 | |
| 196 | 196 | | You entered | Result | |
| 197 | 197 | | --- | --- | |
| 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." | | |
| 199 | 199 | | 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." | |
| 200 | 200 | |
| 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. | |
| 202 | 202 | |
| 203 | 203 | | Message | Cause | |
| 204 | 204 | | --- | --- | |
| @@ -214,9 +214,9 @@ | ||
| 214 | 214 | |
| 215 | 215 | ### Pending invitations |
| 216 | 216 | |
| 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. | |
| 218 | 218 | |
| 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. | |
| 220 | 220 | |
| 221 | 221 | ### Change a member's role |
| 222 | 222 | |
| @@ -410,7 +410,7 @@ | ||
| 410 | 410 | |
| 411 | 411 | 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). |
| 412 | 412 | |
| 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). | |
| 414 | 414 | |
| 415 | 415 | ## How organizations differ from GitHub |
| 416 | 416 | |
| @@ -418,7 +418,7 @@ | ||
| 418 | 418 | - 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`. |
| 419 | 419 | - A member who creates a repository gets `admin` on it, as on GitHub. |
| 420 | 420 | - 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. | |
| 422 | 422 | - Organization pages need you to be signed in. |
| 423 | 423 | - Organization secrets and variables apply to every repository in the organization; there is no per-repository selection. |
| 424 | 424 | - 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 @@ | ||
| 98 | 98 | |
| 99 | 99 | Repository admins manage collaborators under **Settings → Collaborators** (`/acme/website/settings/access`): |
| 100 | 100 | |
| 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). | |
| 102 | 102 | - Change a collaborator's role with the **Role** menu and **Save**, or click **Remove** and confirm. |
| 103 | 103 | |
| 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: | |
| 105 | 105 | |
| 106 | 106 | ```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) | |
| 108 | 109 | curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ |
| 109 | 110 | -H "Authorization: Bearer dfy_..." \ |
| 110 | 111 | -H "Content-Type: application/json" \ |
| @@ -115,7 +116,7 @@ | ||
| 115 | 116 | -H "Authorization: Bearer dfy_..." |
| 116 | 117 | ``` |
| 117 | 118 | |
| 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. | |
| 119 | 120 | |
| 120 | 121 | ## Organization roles |
| 121 | 122 |
modifiedapps/diffinery/docs/accounts/tokens.md+1−0View file
| @@ -52,6 +52,7 @@ | ||
| 52 | 52 | | Revoke a token (`DELETE /api/v3/diffinery/tokens/{id}`, `diffinery token revoke`) | `admin` | Refused | |
| 53 | 53 | | Create an organization (`POST /api/v3/user/orgs`) | `admin` | Refused | |
| 54 | 54 | | 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 | | |
| 55 | 56 | | List, set and delete organization secrets (`/api/v3/orgs/{org}/actions/secrets`, `diffinery secret ... --org`) | `admin` | Refused | |
| 56 | 57 | | 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 | |
| 57 | 58 | | 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 @@ | ||
| 48 | 48 | |
| 49 | 49 | 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. |
| 50 | 50 | |
| 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. | |
| 52 | 52 | - **Deny** disables the account. The person gets a "Your account was disabled" email. |
| 53 | 53 | |
| 54 | 54 | See [Invitations](/docs/accounts/invitations#access-requests) for the requester's side. |
| @@ -65,7 +65,7 @@ | ||
| 65 | 65 | |
| 66 | 66 | You can't change your own status ("you cannot change your own status"). |
| 67 | 67 | |
| 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. | |
| 69 | 69 | |
| 70 | 70 | ### Disable an account |
| 71 | 71 |
modifiedapps/diffinery/docs/agents/mcp.md+1−1View file
| @@ -141,7 +141,7 @@ | ||
| 141 | 141 | | `project_posts` | Yes | The project's conversation after `after`, or the latest 100 lines | Write | |
| 142 | 142 | | `list_repos` | Yes | Repositories visible to you, optionally filtered by `owner` | Any token | |
| 143 | 143 | | `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 | | |
| 145 | 145 | | `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 | |
| 146 | 146 | |
| 147 | 147 | ### Code |
modifiedapps/diffinery/docs/reference/rest-api.md+14−1View file
| @@ -187,6 +187,8 @@ | ||
| 187 | 187 | | `DELETE /api/v3/diffinery/tokens/{id}` | An `admin` token not restricted to repositories. | |
| 188 | 188 | | `POST /api/v3/user/orgs`, `PATCH /api/v3/orgs/{org}` | An `admin` token not restricted to repositories. | |
| 189 | 189 | | `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. | | |
| 190 | 192 | | `GET`, `PUT` and `DELETE /api/v3/orgs/{org}/actions/secrets[/{name}]` | An `admin` token not restricted to repositories. | |
| 191 | 193 | | `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`. | |
| 192 | 194 | | `DELETE /api/v3/diffinery/agents/{login}` | An `admin` token not restricted to repositories. | |
| @@ -432,13 +434,24 @@ | ||
| 432 | 434 | - 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. |
| 433 | 435 | - 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. |
| 434 | 436 | - `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`. | |
| 436 | 439 | - 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`. |
| 437 | 440 | - `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`). |
| 438 | 441 | - `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`. |
| 439 | 442 | - `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). |
| 440 | 443 | - 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. |
| 441 | 444 | |
| 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 | + | |
| 442 | 455 | ### Contents |
| 443 | 456 | |
| 444 | 457 | - `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 @@ | ||
| 219 | 219 | |
| 220 | 220 | | You entered | Result | |
| 221 | 221 | | --- | --- | |
| 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). | | |
| 225 | 225 | |
| 226 | 226 | Errors you may see: |
| 227 | 227 | |
| @@ -231,10 +231,12 @@ | ||
| 231 | 231 | | `<name> is an organization; add its people instead` | Organizations can't be collaborators. Add the people one by one. | |
| 232 | 232 | | `<name>'s account is disabled` | A site administrator disabled that account. | |
| 233 | 233 | | `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. | | |
| 235 | 235 | |
| 236 | 236 | 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. |
| 237 | 237 | |
| 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 | + | |
| 238 | 240 | 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**. |
| 239 | 241 | |
| 240 | 242 | ### Manage collaborators with the API |
| @@ -245,7 +247,7 @@ | ||
| 245 | 247 | # List collaborators (any reader can list) |
| 246 | 248 | curl https://diffinery.com/api/v3/repos/acme/website/collaborators -H "Authorization: Bearer dfy_..." |
| 247 | 249 | |
| 248 | -# Add or change a collaborator | |
| 250 | +# Invite a collaborator, or change a collaborator's role | |
| 249 | 251 | curl -X PUT https://diffinery.com/api/v3/repos/acme/website/collaborators/diffy \ |
| 250 | 252 | -H "Authorization: Bearer dfy_..." -H "content-type: application/json" \ |
| 251 | 253 | -d '{"permission": "maintain"}' |
| @@ -256,6 +258,8 @@ | ||
| 256 | 258 | |
| 257 | 259 | `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`. |
| 258 | 260 | |
| 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 | + | |
| 259 | 263 | ## Archive a repository |
| 260 | 264 | |
| 261 | 265 | 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 @@ | ||
| 384 | 388 | |
| 385 | 389 | ## Delete a repository |
| 386 | 390 | |
| 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. | |
| 388 | 392 | |
| 389 | 393 | - **Web UI**: **Settings → General → Danger zone → Delete this repository**. Type the full name (for example `acme/website`) and click **Delete this repository**. |
| 390 | 394 | - **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 @@ | ||
| 52 | 52 | | `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 | |
| 53 | 53 | | `passkey.remove` | A passkey is removed | none | `name` | |
| 54 | 54 | | `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 | | |
| 56 | 57 | | `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 | |
| 57 | 58 | | `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 | |
| 58 | 59 | | `signin_link.create` | A site admin creates a one-time sign-in link for someone | that user's login | none | |
| @@ -99,9 +100,9 @@ | ||
| 99 | 100 | | `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"` | |
| 100 | 101 | | `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"` | |
| 101 | 102 | | `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` | | |
| 103 | 104 | | `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` | | |
| 105 | 106 | | `invite.create` | A site admin creates an invite from **Site administration** | the email address | none | |
| 106 | 107 | | `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` | |
| 107 | 108 | |
| @@ -117,8 +118,8 @@ | ||
| 117 | 118 | | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none | |
| 118 | 119 | | `repo.purge_unreachable` | Unreachable objects are purged (**Settings → Danger zone**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`) | `objects`, `bytes` | |
| 119 | 120 | | `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` | | |
| 122 | 123 | | `repo.topics` | The topics are replaced with `PUT /api/v3/repos/{owner}/{repo}/topics` | `names` | |
| 123 | 124 | | `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 | |
| 124 | 125 | | `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 @@ | ||
| 39 | 39 | "DELETE /orgs/{org}": { scope: "admin" }, |
| 40 | 40 | "POST /repos/{owner}/{repo}/transfer": { scope: "admin" }, |
| 41 | 41 | "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" }, | |
| 42 | 48 | "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" }, |
| 43 | 49 | "GET /invitations": { scope: "read" }, |
| 44 | 50 | "DELETE /invitations/{id}": { scope: "admin" }, |
| @@ -119,6 +125,12 @@ | ||
| 119 | 125 | "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" }, |
| 120 | 126 | "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login.", access: "account" }, |
| 121 | 127 | "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] } }, | |
| 122 | 134 | "POST /user/orgs": { |
| 123 | 135 | summary: "Create an organization", |
| 124 | 136 | access: "account", |
| @@ -261,10 +273,11 @@ | ||
| 261 | 273 | }, |
| 262 | 274 | "GET /repos/{owner}/{repo}/collaborators": { summary: "List collaborators", description: "Each entry carries `role_name`.", access: "read" }, |
| 263 | 275 | "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`.", | |
| 266 | 279 | access: "admin", |
| 267 | - status: 204, | |
| 280 | + status: 201, | |
| 268 | 281 | body: { permission: ["string", "`read`, `triage`, `write` (default), `maintain` or `admin`; GitHub's `pull` and `push` work too."] }, |
| 269 | 282 | }, |
| 270 | 283 | "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 @@ | ||
| 162 | 162 | |
| 163 | 163 | // Pending invitations: list and revoke, for an organization's owners and admins, a repository's admins, |
| 164 | 164 | // 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 }); | |
| 166 | 166 | const orgForInvites = async (c: AppContext) => { |
| 167 | 167 | requireAccountScope(c, "read"); |
| 168 | 168 | const a = requireAccount(c); |
| @@ -308,10 +308,94 @@ | ||
| 308 | 308 | const map: Record<string, Role> = { pull: "read", push: "write" }; |
| 309 | 309 | const role = map[b.permission ?? ""] ?? (b.permission as Role) ?? "write"; |
| 310 | 310 | 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 | + } | |
| 312 | 319 | auditRepo(c, caller, repo, "repo.collaborator.set", { login: who.login, role }); |
| 313 | 320 | return c.body(null, 204); |
| 314 | 321 | }); |
| 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 | +}); | |
| 315 | 399 | api.delete("/repos/:owner/:repo/collaborators/:login", async (c) => { |
| 316 | 400 | const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 317 | 401 | const who = await c.get("dir").accountByLogin(c.req.param("login")); |
modifiedapps/diffinery/src/lib/directory.ts+123−27View file
| @@ -66,6 +66,12 @@ | ||
| 66 | 66 | /** Invitations one person (not a site admin) may send in 24 hours. */ |
| 67 | 67 | export const MAX_INVITES_PER_DAY = 50; |
| 68 | 68 | |
| 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 | + | |
| 69 | 75 | export class Directory { |
| 70 | 76 | constructor(private env: Env) {} |
| 71 | 77 | |
| @@ -247,11 +253,12 @@ | ||
| 247 | 253 | } |
| 248 | 254 | |
| 249 | 255 | /** |
| 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. | |
| 253 | 260 | */ |
| 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> { | |
| 255 | 262 | const q = who.trim().replace(/^@/, ""); |
| 256 | 263 | if (!q) throw new HttpError(422, "enter a username or an email address"); |
| 257 | 264 | const email = q.includes("@"); |
| @@ -267,16 +274,17 @@ | ||
| 267 | 274 | if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; agents belong to an organization through Settings → Agents`); |
| 268 | 275 | if (user.status === "disabled") throw new HttpError(422, `${user.login}'s account is disabled`); |
| 269 | 276 | 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 })) }; | |
| 274 | 282 | } |
| 275 | 283 | throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`); |
| 276 | 284 | } |
| 277 | 285 | |
| 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> { | |
| 280 | 288 | const q = who.trim().replace(/^@/, ""); |
| 281 | 289 | if (!q) throw new HttpError(422, "enter a username or an email address"); |
| 282 | 290 | const email = q.includes("@"); |
| @@ -287,17 +295,29 @@ | ||
| 287 | 295 | return { invited: { email: q, url: inv.url } }; |
| 288 | 296 | } |
| 289 | 297 | 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); | |
| 298 | 299 | throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`); |
| 299 | 300 | } |
| 300 | 301 | |
| 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 | + | |
| 301 | 321 | /** Revoke a pending invitation to an organization, a repository, or (neither) the site. False if there was none. */ |
| 302 | 322 | async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<boolean> { |
| 303 | 323 | const r = await this.db |
| @@ -419,20 +439,26 @@ | ||
| 419 | 439 | |
| 420 | 440 | // ---------------------------------------------------------------- invites |
| 421 | 441 | |
| 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> { | |
| 423 | 444 | if (input.orgId) { |
| 424 | 445 | if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner"); |
| 425 | 446 | if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners"); |
| 426 | 447 | } |
| 448 | + if (input.repoId && !(input.role in ROLE_LEVEL)) throw new HttpError(422, "permission must be read, triage, write, maintain or admin"); | |
| 427 | 449 | // 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) { | |
| 429 | 451 | 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; |
| 430 | 452 | 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`); |
| 431 | 453 | } |
| 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); | |
| 432 | 458 | // One open invitation per address and organization or repository. The email column is NOCASE, |
| 433 | 459 | // so Bob@x.com and bob@x.com count as the same person. |
| 434 | 460 | 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 > ?") | |
| 436 | 462 | .bind(input.email, input.orgId ?? null, input.repoId ?? null, now()) |
| 437 | 463 | .first(); |
| 438 | 464 | 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 @@ | ||
| 453 | 479 | } |
| 454 | 480 | |
| 455 | 481 | /** |
| 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 | + /** | |
| 456 | 522 | * Email an invitation to its address. Only this emailed link carries the proof that lets |
| 457 | 523 | * someone create an account for the address (auth/invite-proof.ts); the plain link shown to |
| 458 | 524 | * the inviter leads to a page that sends this email. |
| @@ -475,27 +541,55 @@ | ||
| 475 | 541 | ]); |
| 476 | 542 | } |
| 477 | 543 | |
| 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 }>> { | |
| 479 | 546 | const r = await this.db |
| 480 | 547 | .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`, | |
| 482 | 549 | ) |
| 483 | 550 | .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : [])) |
| 484 | 551 | .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 })); | |
| 486 | 553 | } |
| 487 | 554 | |
| 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 | + | |
| 488 | 580 | async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> { |
| 489 | 581 | const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>(); |
| 490 | 582 | 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`); | |
| 492 | 586 | await this.applyInvite(inv, user); |
| 493 | 587 | return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null }; |
| 494 | 588 | } |
| 495 | 589 | |
| 496 | 590 | private async acceptInvitesForEmail(userId: string, email: string): Promise<void> { |
| 497 | 591 | 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>>(); | |
| 499 | 593 | for (const inv of r.results) await this.applyInvite(inv, user); |
| 500 | 594 | } |
| 501 | 595 | |
| @@ -508,7 +602,7 @@ | ||
| 508 | 602 | const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null; |
| 509 | 603 | if (inv.invited_by && inv.invited_by !== user.id) { |
| 510 | 604 | 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() }, | |
| 512 | 606 | ]); |
| 513 | 607 | } |
| 514 | 608 | } |
| @@ -634,6 +728,8 @@ | ||
| 634 | 728 | async deleteRepo(repo: Repo): Promise<void> { |
| 635 | 729 | await this.db.batch([ |
| 636 | 730 | 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), | |
| 637 | 733 | this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id), |
| 638 | 734 | // A deleted repository's old names stop redirecting. |
| 639 | 735 | 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 @@ | ||
| 73 | 73 | item("account-enabled", "Account enabled", { kind: "account_status", reason: "account", data: { status: "active" } }), |
| 74 | 74 | item("invite-org", "Invitation (organization)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, org: repo.split("/")[0], role: "member", expiresAt: exp } }), |
| 75 | 75 | 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 } }), | |
| 76 | 77 | item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }), |
| 77 | 78 | item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }), |
| 78 | 79 | 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 @@ | ||
| 548 | 548 | const what = d.org ? `join the ${d.org} organization` : d.repo ? `collaborate on ${d.repo}` : "join Diffinery"; |
| 549 | 549 | return { |
| 550 | 550 | 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.`, | |
| 552 | 552 | eyebrow: { text: "Invitation", tone: "blue" }, |
| 553 | 553 | title: [{ b: user(d.inviter) }, ` invited you to ${what}`], |
| 554 | 554 | blocks: [ |
| 555 | 555 | { 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."] }, |
| 556 | 556 | { 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 }, | |
| 559 | 561 | ], |
| 560 | 562 | url: d.url, |
| 561 | 563 | }; |
| @@ -565,10 +567,10 @@ | ||
| 565 | 567 | const where = d.org ? `the ${d.org} organization` : d.repo ? d.repo : "Diffinery"; |
| 566 | 568 | return { |
| 567 | 569 | subject: `[Diffinery] @${d.login} accepted your invitation`, |
| 568 | - preheader: `${d.email} joined ${where}.`, | |
| 570 | + preheader: `${d.email || `@${d.login}`} joined ${where}.`, | |
| 569 | 571 | eyebrow: { text: "Invitation accepted", tone: "green" }, |
| 570 | 572 | 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}` } }], | |
| 572 | 574 | url: `${pub}/${d.login}`, |
| 573 | 575 | }; |
| 574 | 576 | } |
modifiedapps/diffinery/src/mail/types.ts+1−1View file
| @@ -81,7 +81,7 @@ | ||
| 81 | 81 | | { kind: "push_bypassed"; data: { repo: string; pusher: string; kinds: string[]; reason: string; refs?: string[] } } |
| 82 | 82 | | { kind: "push_blocked"; data: { repo: string; kinds: string[]; refs?: string[]; paths?: string[] } } |
| 83 | 83 | | { 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 } } | |
| 85 | 85 | | { kind: "invite_accepted"; data: { login: string; email: string; org?: string; repo?: string } } |
| 86 | 86 | | { kind: "welcome"; data: { login: string } } |
| 87 | 87 | | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } } |
modifiedapps/diffinery/src/mcp/server.ts+1−1View file
| @@ -390,7 +390,7 @@ | ||
| 390 | 390 | readOnly: true, |
| 391 | 391 | run: async (c, a) => { |
| 392 | 392 | 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 })); | |
| 394 | 394 | }, |
| 395 | 395 | }, |
| 396 | 396 | revoke_invitation: { |
modifiedapps/diffinery/src/web/account.tsx+104−4View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { raw } from "hono/html"; |
| 3 | 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | 4 | 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"; | |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | 7 | import { movedLoginPath } from "../lib/redirects.js"; |
| 8 | 8 | import { HttpError } from "../lib/util.js"; |
| @@ -182,9 +182,42 @@ | ||
| 182 | 182 | if (kind.endsWith("comment")) return ICONS.comment; |
| 183 | 183 | if (kind === "org_membership") return ICONS.org; |
| 184 | 184 | if (kind === "repo_access") return ICONS.repo; |
| 185 | + if (kind === "invitation") return ICONS.envelope; | |
| 185 | 186 | return ICONS.bell; |
| 186 | 187 | } |
| 187 | 188 | |
| 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 | + | |
| 188 | 221 | export function registerAccount(web: WebApp): void { |
| 189 | 222 | web.get("/", async (c) => { |
| 190 | 223 | const p = c.get("principal"); |
| @@ -202,6 +235,7 @@ | ||
| 202 | 235 | ); |
| 203 | 236 | } |
| 204 | 237 | if (me.status === "pending") { |
| 238 | + const invites = await c.get("dir").invitesFor(me); | |
| 205 | 239 | return render( |
| 206 | 240 | c, |
| 207 | 241 | { title: "Waiting for an invite" }, |
| @@ -211,6 +245,12 @@ | ||
| 211 | 245 | <p class="muted"> |
| 212 | 246 | 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. |
| 213 | 247 | </p> |
| 248 | + {invites.length ? ( | |
| 249 | + <> | |
| 250 | + <h3>Your invitations</h3> | |
| 251 | + <MyInvitations invites={invites} t={t} /> | |
| 252 | + </> | |
| 253 | + ) : null} | |
| 214 | 254 | <Form action="/request-access" t={t}> |
| 215 | 255 | <div class="field"> |
| 216 | 256 | <label for="note">Note to the administrators</label> |
| @@ -601,7 +641,7 @@ | ||
| 601 | 641 | |
| 602 | 642 | // ---------------------------------------------------------------- /settings |
| 603 | 643 | |
| 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"; | |
| 605 | 645 | |
| 606 | 646 | export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( |
| 607 | 647 | <aside class="side vt-side"> |
| @@ -635,6 +675,9 @@ | ||
| 635 | 675 | <a href="/settings/orgs" class={tab === "orgs" ? "on" : ""}> |
| 636 | 676 | Organizations |
| 637 | 677 | </a> |
| 678 | + <a href="/settings/invitations" class={tab === "invitations" ? "on" : ""}> | |
| 679 | + Invitations | |
| 680 | + </a> | |
| 638 | 681 | <a href="/settings/notifications" class={tab === "notifications" ? "on" : ""}> |
| 639 | 682 | Notifications |
| 640 | 683 | </a> |
| @@ -1257,6 +1300,52 @@ | ||
| 1257 | 1300 | }); |
| 1258 | 1301 | |
| 1259 | 1302 | // 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 | + | |
| 1260 | 1349 | web.get("/settings/orgs", async (c) => { |
| 1261 | 1350 | const me = requireAccount(c); |
| 1262 | 1351 | const t = csrfToken(c); |
| @@ -1784,7 +1873,7 @@ | ||
| 1784 | 1873 | </div> |
| 1785 | 1874 | <button class="btn primary">Add</button> |
| 1786 | 1875 | </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> | |
| 1788 | 1877 | </div> |
| 1789 | 1878 | </Form> |
| 1790 | 1879 | ) : null} |
| @@ -1917,7 +2006,7 @@ | ||
| 1917 | 2006 | |
| 1918 | 2007 | web.get("/:owner", async (c) => profile(c)); |
| 1919 | 2008 | |
| 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). | |
| 1921 | 2010 | post(web, "/:owner/-/org/people", async (c, f) => { |
| 1922 | 2011 | const { me, org, isAdmin, isOwner, dir } = await orgContext(c); |
| 1923 | 2012 | const back = `/${org.login}?tab=people`; |
| @@ -1930,6 +2019,9 @@ | ||
| 1930 | 2019 | if ("added" in r) { |
| 1931 | 2020 | flash(c, "notice", `Added ${r.added.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. We let them know.`); |
| 1932 | 2021 | 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 } })); | |
| 1933 | 2025 | } else { |
| 1934 | 2026 | invited = r.invited; |
| 1935 | 2027 | 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 @@ | ||
| 1965 | 2057 | const current = await dir.orgRole(org.id, user.id); |
| 1966 | 2058 | const role = f.s("role"); |
| 1967 | 2059 | 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 | + } | |
| 1968 | 2068 | if (role === "remove") await dir.setOrgMember(org.id, user.id, null, me); |
| 1969 | 2069 | else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role, me); |
| 1970 | 2070 | else throw new HttpError(422, "invalid role"); |
modifiedapps/diffinery/src/web/repo-settings.tsx+15−3View file
| @@ -362,7 +362,7 @@ | ||
| 362 | 362 | </div> |
| 363 | 363 | <button class="btn primary">Add</button> |
| 364 | 364 | </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> | |
| 366 | 366 | </div> |
| 367 | 367 | </Form> |
| 368 | 368 | <div class="box"> |
| @@ -812,8 +812,17 @@ | ||
| 812 | 812 | const roleIn = f.s("role"); |
| 813 | 813 | const role: Role | null = roleIn === "remove" ? null : (ROLES.find((r) => r === roleIn) ?? null); |
| 814 | 814 | 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 }); | |
| 817 | 826 | }); |
| 818 | 827 | }); |
| 819 | 828 | |
| @@ -835,6 +844,9 @@ | ||
| 835 | 844 | if ("added" in r) { |
| 836 | 845 | flash(c, "notice", `${r.added.login} now has ${role} access. We let them know.`); |
| 837 | 846 | 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 }); | |
| 838 | 850 | } else { |
| 839 | 851 | invited = r.invited; |
| 840 | 852 | audit(c, l, "repo.invite", { email: r.invited.email, role }); |
modifiedapps/diffinery/src/web/ui.tsx+3−3View file
| @@ -904,7 +904,7 @@ | ||
| 904 | 904 | /** "write" → "Write", for showing a stored value as a label. */ |
| 905 | 905 | export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); |
| 906 | 906 | |
| 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 }; | |
| 908 | 908 | |
| 909 | 909 | /** Pending invitations with a Revoke button each; `revoke` gives the form action for an invitation id. */ |
| 910 | 910 | export const PendingInvites: FC<{ invites: PendingInvite[]; revoke: (id: string) => string; t: string; roles?: boolean }> = ({ invites, revoke, t, roles = true }) => |
| @@ -918,7 +918,7 @@ | ||
| 918 | 918 | {invites.map((i) => ( |
| 919 | 919 | <tr> |
| 920 | 920 | <td> |
| 921 | - <b>{i.email}</b> | |
| 921 | + <b>{i.login ? `@${i.login}` : i.email}</b> | |
| 922 | 922 | <div class="muted small"> |
| 923 | 923 | Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""} |
| 924 | 924 | <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })} |
| @@ -930,7 +930,7 @@ | ||
| 930 | 930 | </td> |
| 931 | 931 | ) : null} |
| 932 | 932 | <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.`}> | |
| 934 | 934 | <button class="btn sm danger">Revoke</button> |
| 935 | 935 | </Form> |
| 936 | 936 | </td> |
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+9−3View file
| @@ -286,15 +286,21 @@ | ||
| 286 | 286 | await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("tr:has-text('newbie@example.com') button:has-text('Revoke')")]); |
| 287 | 287 | assert.match(await text(boss.page), /Invitation revoked/); |
| 288 | 288 | 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. | |
| 290 | 290 | await page.goto(`${base}/settings/orgs`); |
| 291 | 291 | await Promise.all([page.waitForLoadState("load"), page.fill("#olog", "palorg")]); |
| 292 | 292 | await Promise.all([page.waitForURL(/\/palorg/), page.click("button:has-text('Create organization')")]); |
| 293 | 293 | await page.fill("#who", "boss"); |
| 294 | 294 | 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/); | |
| 296 | 297 | 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/); | |
| 298 | 304 | // The last owner can't step down: the organization would have nobody to manage it. |
| 299 | 305 | await page.goto(`${base}/palorg?tab=people`); |
| 300 | 306 | await Promise.all([ |
modifiedapps/diffinery/test/e2e/registry.test.mjs+4−1View file
| @@ -365,7 +365,10 @@ | ||
| 365 | 365 | await pushImage("crew/app", "v1", { tok: CREW.olga }); |
| 366 | 366 | assert.equal((await ok("GET", "/orgs/crew/packages/container/app", undefined, CREW.olga)).repository.full_name, "crew/app"); |
| 367 | 367 | 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); | |
| 369 | 372 | assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 200); |
| 370 | 373 | assert.equal((await reg("POST", "crew/app/blobs/uploads/", { tok: CREW.otto })).status, 403); |
| 371 | 374 | 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 @@ | ||
| 92 | 92 | } |
| 93 | 93 | const b64 = (s) => Buffer.from(s).toString("base64"); |
| 94 | 94 | |
| 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 | + | |
| 95 | 106 | before(async () => { |
| 96 | 107 | const port = await freePort(); |
| 97 | 108 | base = `http://127.0.0.1:${port}`; |
| @@ -183,7 +194,7 @@ | ||
| 183 | 194 | const R = `/repos/${OWNER}/guarded`; |
| 184 | 195 | const mainSha = await repoWithReadme("guarded"); |
| 185 | 196 | 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); | |
| 187 | 198 | await status(204, "PUT", `${R}/collaborators/gone-bot`, { permission: "write" }); |
| 188 | 199 | const issue = await ok("POST", `${R}/issues`, { title: "needle in a haystack" }); |
| 189 | 200 | |
| @@ -585,13 +596,13 @@ | ||
| 585 | 596 | assert.deepEqual([edited.labels.length, edited.assignees.length, edited.milestone], [0, 0, null]); |
| 586 | 597 | |
| 587 | 598 | // ------------------------------------------------------------ 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); | |
| 589 | 600 | await ok("PUT", `${R}/branches/main/protection`, { requiredApprovals: 1 }); |
| 590 | 601 | await status(204, "PUT", `${R}/actions/secrets/TOKEN`, { value: "s3cret" }); |
| 591 | 602 | await status(201, "POST", `${R}/actions/variables`, { name: "MODE", value: "x" }); |
| 592 | 603 | await status(201, "POST", `${R}/hooks`, { config: { url: "https://example.com/hook" } }); |
| 593 | 604 | 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"]; | |
| 595 | 606 | return want.every((a) => auditLog(a).some((e) => e.target === `${OWNER}/limits` && JSON.parse(e.detail).via === "api")); |
| 596 | 607 | }); |
| 597 | 608 | const leak = auditLog("api.blocked_secret").find((e) => e.target === `${OWNER}/leaky`); |
| @@ -761,6 +772,7 @@ | ||
| 761 | 772 | await ok("POST", "/user/orgs", { login: "team-new" }); |
| 762 | 773 | await ok("PATCH", "/orgs/team-old", { default_repository_permission: "none" }); |
| 763 | 774 | 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"); | |
| 764 | 776 | |
| 765 | 777 | // ------------------------------------------------------------ a private repository with history, an issue, a secret, a variable and an image |
| 766 | 778 | await repoWithReadme("moving"); |
| @@ -768,7 +780,7 @@ | ||
| 768 | 780 | const issue = await ok("POST", `${R}/issues`, { title: "survives the move" }); |
| 769 | 781 | await status(204, "PUT", `${R}/actions/secrets/DEPLOY_KEY`, { value: "s3cret" }); |
| 770 | 782 | 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); | |
| 772 | 784 | await status(204, "PUT", `${R}/collaborators/olive-bot`, { permission: "admin" }); |
| 773 | 785 | const repoId = (await ok("GET", R)).id; |
| 774 | 786 | await pushTinyImage(`${OWNER}/moving`, "v1", T.olive); |
| @@ -858,7 +870,7 @@ | ||
| 858 | 870 | assert.doesNotMatch(JSON.stringify(probe.json), /team-old/); |
| 859 | 871 | |
| 860 | 872 | // ------------------------------------------------------------ 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); | |
| 862 | 874 | assert.equal((await ok("GET", N, undefined, T.sam)).permissions.push, true); |
| 863 | 875 | await status(204, "DELETE", "/repos/team-old/clash"); |
| 864 | 876 | const refused = await status(422, "DELETE", "/orgs/team-old"); |
| @@ -1582,7 +1594,7 @@ | ||
| 1582 | 1594 | test("security: required checks gate direct pushes to a protected branch, as on GitHub", async () => { |
| 1583 | 1595 | const R = `/repos/${OWNER}/gated`; |
| 1584 | 1596 | const mainSha = await repoWithReadme("gated"); |
| 1585 | - await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" }); | |
| 1597 | + await collaborator(R, "rita", "write", T.rita); | |
| 1586 | 1598 | await ok("PUT", `${R}/branches/main/protection`, { requiredChecks: ["ci"] }); |
| 1587 | 1599 | // A writer's commit straight to main hasn't been checked: refused, whichever way it comes. |
| 1588 | 1600 | const refused = await status(403, "PUT", `${R}/contents/direct.txt`, { message: "direct", content: b64("x\n"), branch: "main" }, T.rita); |
| @@ -1914,7 +1926,7 @@ | ||
| 1914 | 1926 | const R = `/repos/${OWNER}/runner-home`; |
| 1915 | 1927 | await repoWithReadme("runner-home", { private: true }); |
| 1916 | 1928 | await repoWithReadme("runner-other", { private: true }); |
| 1917 | - await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" }); | |
| 1929 | + await collaborator(R, "rita", "write", T.rita); | |
| 1918 | 1930 | // Only the repository's admins get a registration token. |
| 1919 | 1931 | await status(403, "POST", `${R}/actions/runners/registration-token`, undefined, T.rita); |
| 1920 | 1932 | const reg = await status(201, "POST", `${R}/actions/runners/registration-token`); |
| @@ -2004,7 +2016,7 @@ | ||
| 2004 | 2016 | test("security: an admin can purge objects a force-push left unreachable", async () => { |
| 2005 | 2017 | const R = `/repos/${OWNER}/purge-me`; |
| 2006 | 2018 | const mainSha = await repoWithReadme("purge-me"); |
| 2007 | - await status(204, "PUT", `${R}/collaborators/rita`, { permission: "write" }); | |
| 2019 | + await collaborator(R, "rita", "write", T.rita); | |
| 2008 | 2020 | const clone = join(work, "purge-me"); |
| 2009 | 2021 | const authed = `${base.replace("://", `://x:${T.olive}@`)}/${OWNER}/purge-me.git`; |
| 2010 | 2022 | execFileSync("git", ["clone", "-q", authed, clone], { stdio: "pipe" }); |
| @@ -2077,7 +2089,7 @@ | ||
| 2077 | 2089 | // A collaborator's permission is one of GitHub's, and an organization can't be one. |
| 2078 | 2090 | assert.match((await status(422, "PUT", `${R}/collaborators/rita`, { permission: "root" })).message, /permission must be/); |
| 2079 | 2091 | 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); | |
| 2081 | 2093 | // A check run's link is a web address. |
| 2082 | 2094 | 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 }); |
| 2083 | 2095 | await status(201, "POST", `${R}/check-runs`, { name: "ext", head_sha: sha, details_url: "https://ci.example/run/1" }); |
| @@ -2091,3 +2103,84 @@ | ||
| 2091 | 2103 | // Names people would trust aren't for the taking. |
| 2092 | 2104 | for (const login of ["ghost", "github-actions", "noreply", "support", "abuse"]) await status(422, "POST", "/user/orgs", { login }); |
| 2093 | 2105 | }); |
| 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 @@ | ||
| 93 | 93 | }); |
| 94 | 94 | |
| 95 | 95 | 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 () => { | |
| 97 | 98 | const { app } = await import("../../src/app.js"); |
| 98 | 99 | const env = { DB: {}, PUBLIC_URL: "https://d.example", RL_ANON: limiter(1) } as unknown as Env; |
| 99 | 100 | const ctx = { waitUntil: () => {}, passThroughOnException: () => {}, props: {} } as unknown as ExecutionContext; |