| 1 | --- |
| 2 | title: Site administration |
| 3 | description: Site administrators manage people, handle abuse and security reports and contact messages, suspend and delete accounts, stop Actions, post incidents, oversee imports, and check audit and email logs. |
| 4 | --- |
| 5 | |
| 6 | Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, handle private security reports, post incidents on the status page, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**. |
| 7 | |
| 8 | ## Site administrators |
| 9 | |
| 10 | Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with these pages: |
| 11 | |
| 12 | | Page | Path | What it is for | |
| 13 | | --- | --- | --- | |
| 14 | | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links | |
| 15 | | **Early access** | `/settings/admin/early-access` | Requests from people without an invitation, to invite, decline or delete | |
| 16 | | **Contact messages** | `/settings/admin/contact` | Messages from the contact form, to read, answer, mark handled or spam, and delete | |
| 17 | | **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve | |
| 18 | | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email | |
| 19 | | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance | |
| 20 | | **Security reports** | `/settings/admin/security-reports` | Private vulnerability reports from the report form, to read, triage and resolve | |
| 21 | | **Status** | `/settings/admin/status` | Incidents on the public status page, to open, update and resolve | |
| 22 | | **Search engines** | `/settings/admin/search-engines` | What Diffinery told search engines through IndexNow about new, changed and removed public pages | |
| 23 | | **Audit log** | `/settings/audit` | The most recent security-relevant events | |
| 24 | |
| 25 | Their **Settings → Profile** page shows **Site admin** as yes, and they have an **admin** badge in the users list. |
| 26 | |
| 27 | ### Who becomes a site administrator |
| 28 | |
| 29 | The instance setting `BOOTSTRAP_ADMINS` is a comma-separated list of email addresses, set on deploy from the `BOOTSTRAP_ADMINS` environment variable (a deploy without it keeps the list the instance has; see [BOOTSTRAP_ADMINS](/docs/self-hosting/configuration#bootstrap_admins)). An account becomes a site administrator when: |
| 30 | |
| 31 | - it is created with an email address on the list (from an invite, after opening the link emailed to that address, or by verifying the email address through email recovery at `/auth/access`), or |
| 32 | - it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`. |
| 33 | |
| 34 | Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active; use **Make admin** instead. See [Configuration](/docs/self-hosting/configuration) for the setting. |
| 35 | |
| 36 | ### Granting and removing site administration |
| 37 | |
| 38 | In the **Users** table on **Settings → People and invitations**, the **Site admin** column has a **Make admin** button for each active person who isn't a site administrator, and a **Remove admin** button for each one who is. Both ask you to confirm, and both need a sign-in from the last 2 hours: if yours is older, Diffinery asks you to sign in again and brings you back. You see "diffy is now a site administrator." or "diffy is no longer a site administrator." |
| 39 | |
| 40 | - Only active accounts can be made site administrators. Enable a pending or disabled account first. |
| 41 | - The last site administrator can never be removed: "Diffinery needs at least one site administrator: make someone else a site administrator first". You can remove yourself while there is another one; you then lose access to these pages. |
| 42 | - The person gets a security email saying who made the change. |
| 43 | - Each change is recorded in the [audit log](/docs/security/audit-log) as `site_admin.grant` or `site_admin.revoke`, with who made it and the person it was about. |
| 44 | |
| 45 | ### First sign-in for administrators |
| 46 | |
| 47 | On a new instance nobody has a passkey yet. A bootstrap administrator signs in through email recovery: |
| 48 | |
| 49 | 1. Open `https://diffinery.com/auth/access`. |
| 50 | 2. Confirm your email address with the one-time PIN that Cloudflare Access emails you. |
| 51 | 3. On the page that follows (**Settings → Passkeys and sessions**), click **Add a passkey**. |
| 52 | |
| 53 | From then on you sign in with the passkey. Email recovery needs Cloudflare Access to be set up for `/auth/access`; see [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). |
| 54 | |
| 55 | ### Tokens and site administration |
| 56 | |
| 57 | A site administrator's tokens are capped by their scope like anyone's: to act as admin on repositories through Git or the API, use an `admin`-scoped token. See [Personal access tokens](/docs/accounts/tokens). |
| 58 | |
| 59 | ## People |
| 60 | |
| 61 | **Settings → People and invitations** (`/settings/admin`, titled **Site administration**) has three sections: **Pending access requests**, **Invite someone to this instance** and **Users**. Other people get `403 site administrators only`. |
| 62 | |
| 63 | ### Pending access requests |
| 64 | |
| 65 | 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. |
| 66 | |
| 67 | - **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. |
| 68 | - **Deny** disables the account. The person gets a "Your account was disabled" email. |
| 69 | |
| 70 | See [Invitations](/docs/accounts/invitations#access-requests) for the requester's side. |
| 71 | |
| 72 | ### Users |
| 73 | |
| 74 | The **Users** table lists every person (not organizations, agents or deleted accounts) with their picture and username, email address, when they joined, their status and whether they are a site administrator. Each row has **Make admin** or **Remove admin** (see [Granting and removing site administration](#granting-and-removing-site-administration)), **Sign-in link** (see [Sign-in links](#sign-in-links)) and **Delete** (see [Deleting an account](#deleting-an-account)). For each user except yourself you can pick a status in the menu and click **Save**. You see "User updated." |
| 75 | |
| 76 | | Status in the menu | Value | Meaning | |
| 77 | | --- | --- | --- | |
| 78 | | **Active** | `active` | Can sign in and use everything their roles allow | |
| 79 | | **Pending** | `pending` | Waiting for an invite: can sign in but only sees the **Waiting for an invite** page | |
| 80 | | **Disabled** | `disabled` | Can't use Diffinery | |
| 81 | |
| 82 | A suspended person has a red **suspended** badge, and their status is `disabled` until you unsuspend them (see [Suspend an account](#suspend-an-account)). Each row also has **Suspend** (with a reason), or **Unsuspend** for someone suspended. |
| 83 | |
| 84 | You can't change your own status ("you cannot change your own status"). |
| 85 | |
| 86 | 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. |
| 87 | |
| 88 | ### Disable an account |
| 89 | |
| 90 | Set the user's status to **Disabled** and click **Save**. Immediately: |
| 91 | |
| 92 | - every browser session they have ends, so they are signed out everywhere, and passkey sign-in is refused with "Your account is disabled.", |
| 93 | - every access token the person has is revoked, |
| 94 | - agents they own stop working, because an agent can never do more than its owner, |
| 95 | - they get a "Your account was disabled" email. |
| 96 | |
| 97 | Their repositories, organizations and memberships stay as they are, but they can't be added to organizations or repositories while disabled ("diffy's account is disabled"). Set the status back to **Active** to enable the account; the person gets "Your account was enabled" and signs in with their passkey again. Their old tokens stay revoked, so they need to create new ones. |
| 98 | |
| 99 | Every status change is recorded in the audit log as `user.active`, `user.pending` or `user.disabled`. |
| 100 | |
| 101 | ### Deleting an account |
| 102 | |
| 103 | Click **Delete** on the person's row in the **Users** table. A page shows what will go: their repositories (with how many), container images, tokens, passkeys, agents, settings and memberships. Type their username and click **Delete this account**. It needs a sign-in from the last 2 hours, like granting site administration. You see "Deleted the account diffy." and the person gets an email saying a site administrator deleted their account. |
| 104 | |
| 105 | The page says why, and the button is off, when the person is the only owner of an organization (it names each one), the only site administrator, or has a GitHub import running. Make someone else an owner, or delete the organization, first. |
| 106 | |
| 107 | Deleting an account is the same as the person deleting it themselves: everything they own goes, what they wrote in other people's repositories is shown as written by **ghost**, and their username can't be used for 90 days. See [Exporting and deleting your account](/docs/accounts/account-data#deleting-your-account). It can't be undone; to keep someone out while you decide, disable the account instead. Each deletion is recorded in the audit log as `account.delete`, with `"via":"admin"`. |
| 108 | |
| 109 | ### Suspend an account |
| 110 | |
| 111 | Suspending is for abuse: like disabling, it stops the person at once, but it also hides what they made, and unsuspending puts everything back as it was, tokens included. It works as on GitHub Enterprise. |
| 112 | |
| 113 | In the **Users** table, click **Suspend** next to the person, enter a **Reason** (at most 500 characters; it is emailed to them and kept in the audit log) and click **Suspend diffy**. You can also suspend from a report about the person. Immediately: |
| 114 | |
| 115 | - every browser session they have ends, and passkey sign-in is refused with "Your account is disabled.", |
| 116 | - their access tokens, their agents' tokens and their runners are refused (`401`), but not revoked, |
| 117 | - running workflow runs and agent sandboxes in their repositories stop, and no new ones start, as with [Suspend Actions for an account](#kill-switches), |
| 118 | - their profile, repositories (on the web, through Git, the API and the container registry) and packages answer `404` to everyone else, |
| 119 | - their issues and pull requests answer `404` and are left out of lists and search, and their comments and reviews are left out of every conversation, |
| 120 | - they get a "Your account was suspended" email with the reason. |
| 121 | |
| 122 | Site administrators still see all of it, with a **suspended** badge on the profile; `GET /api/v3/users/{username}` shows them `suspended_at` and `suspension_reason`. Site administrators can't be suspended, and you can't suspend yourself. |
| 123 | |
| 124 | **Unsuspend** makes the account active again. Their tokens, agents and runners work again, their content is shown again and they get a "Your account is no longer suspended" email. Their browser sessions stay ended, so they sign in again with their passkey. Workflow runs that were stopped don't restart. |
| 125 | |
| 126 | Suspensions are audited as `user.suspend` (with the reason and what was stopped) and `user.unsuspend`. From the API, with an `admin`-scoped token that isn't limited to repositories (GitHub Enterprise's endpoints): |
| 127 | |
| 128 | ```sh |
| 129 | echo '{"reason": "spam in issues, see report 4f2c"}' | diffinery api PUT /users/diffy/suspended --input - |
| 130 | diffinery api DELETE /users/diffy/suspended |
| 131 | ``` |
| 132 | |
| 133 | Both answer `204`. Anyone else gets `403 site administrators only`. |
| 134 | |
| 135 | ## Early access |
| 136 | |
| 137 | While the instance is invite-only, people without an invitation can ask for one at `/early-access`, and confirm their address with an emailed link. **Settings → Early access** (`/settings/admin/early-access`) lists the requests: **Confirmed** ones wait for you, with their count next to **Early access** in the sidebar and a reminder on **People and invitations**. **Invite** sends the usual instance invitation (see [Invites](#invites)), **Decline** emails nobody, and **Delete request** removes everything stored about a request. Each decision is in the audit log. See [Early access](/docs/accounts/early-access#for-site-administrators) for the whole page, the API and how the form is protected. |
| 138 | |
| 139 | ## Contact messages |
| 140 | |
| 141 | Anyone can write to the site administrators at `/contact`, linked from every page's footer. People who aren't signed in pass Turnstile and confirm their address with an emailed link first. **Settings → Contact messages** (`/settings/admin/contact`) lists the messages: **New** ones wait for you, with their count next to **Contact messages** in the sidebar, and each one emails every site administrator with the sender as Reply-To. **Reply by email** opens your mail app, **Mark handled** and **Spam** sort them, a **Note** tells the other site administrators what happened, and **Delete message** removes everything stored about one. Each change is in the audit log. See [Contact form](/docs/admin/contact-form#for-site-administrators) for the whole page, the API and how the form is protected. |
| 142 | |
| 143 | ## Reports |
| 144 | |
| 145 | Anyone signed in can report a person, a repository, an issue, a pull request or a comment as abusive, from the **…** menu on it (or on a profile) and **Report**. They pick a reason (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else) and can add a note of up to 2,000 characters; "Something else" needs the note. The person reported isn't told. One person files at most 20 reports a day, and one open report per thing. |
| 146 | |
| 147 | **Settings → Reports** (`/settings/admin/reports`) lists the open reports, newest first, with what was reported, the reason, who reported it and when; **Resolved** lists the rest. Open one to see: |
| 148 | |
| 149 | - a link to what was reported, and the account that wrote it, |
| 150 | - the reporter's note, |
| 151 | - what was reported as it was then (an issue's title and text, a comment's text), so editing or deleting it afterwards hides nothing, |
| 152 | - **Resolve with a note**: say what you did (hid the comment, suspended the account, nothing to do) and click **Resolve**. A resolved report can be reopened with **Reopen**. |
| 153 | - **Suspend** the account that wrote it, with a reason. |
| 154 | |
| 155 | Filing, resolving and reopening are audited as `report.create`, `report.resolve` and `report.reopen`. |
| 156 | |
| 157 | From the API, people file reports with `POST /api/v3/diffinery/reports`; site administrators read the queue with `GET /api/v3/admin/reports` (`?state=open`, `resolved` or `all`) and `GET /api/v3/admin/reports/{id}`, and resolve with `PATCH /api/v3/admin/reports/{id}` and `{"state": "resolved", "resolution": "..."}` (an `admin`-scoped token). See [REST API](/docs/reference/rest-api#moderation). |
| 158 | |
| 159 | Other tools for abuse: [blocking](/docs/accounts/blocking-and-reporting) (anyone, for themselves or their organization), [hiding comments](/docs/collaboration/issues#hide-a-comment) (anyone with write access to the repository; site administrators everywhere) and the [kill switches](#kill-switches). |
| 160 | |
| 161 | ## Invites |
| 162 | |
| 163 | Under **Invite someone to this instance**, enter an **Email address** and click **Send invitation** (the hint says "We email them a link. Opening it lets them pick a username and create a passkey."). The invite isn't tied to an organization or repository: it only lets the person create an account. Diffinery emails the link and also shows it to you under "Invite created for friend@example.com" with a copy button, with the note that it expires in 14 days and works once. Instance invitations can't be revoked. |
| 164 | |
| 165 | The same works from the CLI (`diffinery invite friend@example.com`, without `--org`) and the API (`POST /api/v3/invitations` with `{"email": "friend@example.com"}`); both are limited to site administrators. Site administrators can also add people to any organization with **Add people** on its **People** tab, and revoke its pending invitations there. See [Invitations](/docs/accounts/invitations) for all kinds of invites. |
| 166 | |
| 167 | ## Sign-in links |
| 168 | |
| 169 | When someone loses their passkeys, a site administrator can give them a one-time link that adds a new passkey to their existing account. |
| 170 | |
| 171 | 1. In the **Users** table, click **Sign-in link** next to the person. |
| 172 | 2. Confirm "Create a one-time link that adds a passkey to diffy? Only send it to them." |
| 173 | 3. The page shows **Sign-in link for diffy** with the link and a copy button. Diffinery has already emailed it to the person ("Your one-time sign-in link"); you can also send it yourself through a channel you trust. |
| 174 | |
| 175 | The person opens the link, creates a passkey and is signed in. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in#sign-in-link-from-an-administrator) for their side. |
| 176 | |
| 177 | | Rule | Value | |
| 178 | | --- | --- | |
| 179 | | Lifetime | 24 hours | |
| 180 | | Uses | Once | |
| 181 | | Disabled accounts | Refused with "enable this account first"; enable the account, then create the link | |
| 182 | | Link format | `https://<host>/signin#<token>`. The token is in the URL fragment, which browsers never send to the server, so it stays out of request logs and `Referer` headers. The page removes it from the address bar. Older `/signin/<token>` links still work. | |
| 183 | | Effect | Adds a passkey and signs that browser in; the person's other passkeys stay | |
| 184 | | Audit | `signin_link.create` when you create it, `passkey.add` when it is used | |
| 185 | |
| 186 | Anyone with the link can add a passkey to the account, so send it only to that person. The link also works for a first sign-in, for an account that exists but has no passkey yet. |
| 187 | |
| 188 | ## Kill switches |
| 189 | |
| 190 | Kill switches stop Actions and agent sandboxes quickly, for abuse, a runaway workflow or a cost problem. There are three, from the narrowest: |
| 191 | |
| 192 | | Switch | What stops | What keeps working | |
| 193 | | --- | --- | --- | |
| 194 | | **Suspend Actions for a repository** | Every workflow run of the repository, on hosted and self-hosted runners, and its agent sandboxes (including agent tasks' and reviews') | Everything else in the repository: Git, issues, pull requests | |
| 195 | | **Suspend Actions for an account** | The same, for every repository a person or an organization owns, including repositories created or transferred in later | The account's other repositories' Git and collaboration | |
| 196 | | **Pause hosted compute** | Hosted Actions jobs and agent sandboxes in every repository on the instance | Self-hosted runners, which cost the instance nothing, and workflow runs themselves | |
| 197 | |
| 198 | Nobody is exempt, not even site administrators' own accounts and organizations (unlike [account quotas](/docs/reference/limits#account-quotas)). Every change is in the audit log: `actions.suspend` and `actions.unsuspend` with the repository (`owner/name`) or the account's login as the target, `compute.pause` and `compute.unpause`, each with the reason and what was stopped. |
| 199 | |
| 200 | ### While a switch is on |
| 201 | |
| 202 | While a repository or its owner is suspended: |
| 203 | |
| 204 | - no workflow run starts: pushes, pull requests, schedules and other events start nothing, and no run is recorded; |
| 205 | - **Run workflow** (`workflow_dispatch`), `repository_dispatch` and **Re-run** are refused with `403 Actions are suspended for this repository by a site administrator: <reason>` (or `... for every repository of <account> ...`); |
| 206 | - a job that would start fails instead, with "The job was not started: Actions are suspended ...", and a self-hosted runner isn't given a job that was already waiting; |
| 207 | - a new sandbox is refused with the same `403`, so agent tasks and reviews fail when they try to start theirs, with that message. |
| 208 | - [web terminals](/docs/agents/terminal) can't be opened or attached to, and open ones end within a minute ("A site administrator suspended Actions and sandboxes here"). |
| 209 | |
| 210 | While hosted compute is paused: |
| 211 | |
| 212 | - a job on a hosted runner fails without starting: "The job was not started: Hosted runners are paused on this instance by a site administrator: <reason>. Self-hosted runners still work." Jobs of the same run on self-hosted runners run as usual; |
| 213 | - a new sandbox is refused with `403 Agent sandboxes are paused on this instance by a site administrator: <reason>` (agent tasks and reviews fail to start theirs), and a sandbox that went to sleep doesn't wake up. |
| 214 | - web terminals can't be opened or attached to, and open ones end within a minute. |
| 215 | |
| 216 | The repository's **Actions** page, its run pages and its **Settings** pages show a red banner with the reason, so the people affected know why. While hosted compute is paused, every **Site administration** page shows "Hosted compute is paused on this instance" at the top, so nobody forgets to turn it back on. |
| 217 | |
| 218 | ### Turning a switch on stops what is running |
| 219 | |
| 220 | - Suspending a repository or an account cancels each of its unfinished runs ("Cancelled: Actions are suspended ...", including jobs on self-hosted runners) and closes each of its open sandboxes. |
| 221 | - Pausing hosted compute fails each running hosted job ("The job was stopped: Hosted runners are paused ...") and closes the open sandboxes, in every repository with hosted compute running (Diffinery finds them from the hosted minutes it is counting). Jobs that wait in the queue fail when they would start. |
| 222 | |
| 223 | The answer says what was stopped: `stopped` with `repositories`, `runs`, `jobs`, `sandboxes`, and `failed` for repositories that didn't answer (their work still can't start). |
| 224 | |
| 225 | Turning a switch off only lets new work start again. Nothing that was stopped restarts: re-run the runs you want and start new sandboxes. |
| 226 | |
| 227 | ### From the web |
| 228 | |
| 229 | **Settings → Kill switches** (`/settings/admin/kill-switches`): |
| 230 | |
| 231 | - **Hosted compute** shows **running** or **paused**. Enter a **Reason** and click **Pause hosted compute** (you confirm first), or click **Resume hosted compute**. |
| 232 | - **Suspend Actions for an account**: the person's or organization's login and a **Reason**, then **Suspend**. |
| 233 | - **Suspend Actions for a repository**: `owner/name` and a **Reason**, then **Suspend**. |
| 234 | - **Suspended** lists every suspended account and repository with who suspended it, when and why, and a **Lift** button. |
| 235 | |
| 236 | A reason is required (at most 500 characters): it is shown to the people affected and kept in the audit log. |
| 237 | |
| 238 | ### From the API |
| 239 | |
| 240 | Site administrators only (`403 only site administrators can ...` for anyone else). Reading needs any token that isn't limited to repositories; changing needs an `admin`-scoped token that isn't limited to repositories, or a browser session. |
| 241 | |
| 242 | | Method and path | Does | |
| 243 | | --- | --- | |
| 244 | | `GET /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | `suspended`, `reason`, `suspended_by`, `suspended_at`, and `owner_suspended` when the owner's Actions are suspended | |
| 245 | | `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Suspend, with `{"reason": "..."}` | |
| 246 | | `DELETE /api/v3/admin/repos/{owner}/{repo}/actions-suspension` | Lift | |
| 247 | | `GET`, `PUT`, `DELETE /api/v3/admin/accounts/{login}/actions-suspension` | The same for a person or an organization | |
| 248 | | `GET`, `PUT`, `DELETE /api/v3/admin/hosted-compute-pause` | `paused`, `reason`, `paused_by` and `paused_at`; pause with `{"reason": "..."}`, resume with `DELETE` | |
| 249 | |
| 250 | ```sh |
| 251 | echo '{"reason": "crypto mining, see ticket 123"}' | diffinery api PUT /admin/repos/acme/website/actions-suspension --input - |
| 252 | diffinery api DELETE /admin/repos/acme/website/actions-suspension |
| 253 | echo '{"reason": "runner image incident"}' | diffinery api PUT /admin/hosted-compute-pause --input - |
| 254 | ``` |
| 255 | |
| 256 | ## GitHub imports and linked accounts |
| 257 | |
| 258 | Site administrators have extra reach over [imports from GitHub](/docs/migrate-from-github): |
| 259 | |
| 260 | - **Import into any account.** A site administrator can start an import into any user's account or any organization, not only their own (agent accounts can't own imports). |
| 261 | - **See and cancel any import.** Open `https://diffinery.com/imports/<id>`, or call `GET /api/v3/diffinery/imports/{id}`, for any import, and stop one with **Cancel import** or `POST /api/v3/diffinery/imports/{id}/cancel` (audited as `import.cancel`, with you in `by`). The **Imports** page and `GET /api/v3/diffinery/imports` still list only the imports you started yourself. |
| 262 | - **Retrying stays with the starter, except for agents' imports.** A person's import can be retried only by that person, even by a site administrator. An import an agent started can also be retried by any person who may import into the target, which includes site administrators. |
| 263 | |
| 264 | ### Clear a GitHub link |
| 265 | |
| 266 | An import attributes a GitHub user's issues, pull requests, comments and reviews to the Diffinery account linked to that GitHub user. If a link is wrong, for example after a Diffinery account changed hands, a site administrator can remove it. There is no page for this; use the API with an `admin`-scoped token that isn't limited to repositories (or a browser session): |
| 267 | |
| 268 | ```sh |
| 269 | diffinery api DELETE /diffinery/admin/linked-accounts/<link id> |
| 270 | ``` |
| 271 | |
| 272 | The link id comes from `GET /api/v3/diffinery/user/linked-accounts`, called by the account that owns the link; Diffinery has no endpoint that lists other people's links. The request answers `204`, or `404 link not found`, and is audited as `account.link_cleared` with the link (`github.com/diffy`) as the target and the Diffinery account's id in the detail. Imports that already ran keep their attribution; later imports treat that GitHub user as a mannequin until someone links the account again. People unlink their own accounts on **Settings → Linked accounts** (`account.unlink_github`). |
| 273 | |
| 274 | ## Account quotas |
| 275 | |
| 276 | Every person and organization has [quotas](/docs/reference/limits#account-quotas) on hosted minutes per month, hosted jobs running at once, agent sandboxes open at once, repositories and storage. Accounts run by a site administrator have none. The defaults come from the `QUOTA_*` variables in [Configuration](/docs/self-hosting/configuration). There is no page for quotas; use the API: |
| 277 | |
| 278 | ```sh |
| 279 | diffinery api GET /admin/accounts/acme/quotas |
| 280 | echo '{"hosted_minutes": 6000, "repositories": -1, "storage_bytes": 53687091200}' | diffinery api PATCH /admin/accounts/acme/quotas --input - |
| 281 | ``` |
| 282 | |
| 283 | | Request | What it does | Who | |
| 284 | | --- | --- | --- | |
| 285 | | `GET /api/v3/admin/accounts/{login}/quotas` | The account's quotas and what it uses: `hosted_minutes` (`used`, `limit`), `hosted_jobs` (`running`, `limit`), `sandboxes` (`open`, `limit`), `repositories` (`count`, `limit`), `storage_bytes` (`used`, `limit`, in bytes), with `exempt` and `period_start` (the start of the month, UTC) | site administrator, any token scope | |
| 286 | | `PATCH /api/v3/admin/accounts/{login}/quotas` | Changes only the quotas you send: a whole number (`storage_bytes` in bytes, up to 1 PiB), `-1` for unlimited, or `null` to go back to the default. Answers like `GET`. Audited as `account.quotas.set`. | site administrator, with an `admin` token not limited to repositories, or a browser session | |
| 287 | |
| 288 | `{login}` is a person or an organization; an agent gets `404 no person or organization with that name`, because agents use their owner's quotas. Anyone else gets `403 only site administrators can see and change quotas`. People see their own usage with `GET /api/v3/user/usage`, and organization owners and admins see their organization's with `GET /api/v3/orgs/{org}/usage`. |
| 289 | |
| 290 | Storage is counted per repository as it changes: each repository's Durable Object keeps a running total of its git objects, Git LFS objects and release assets and reports it to D1 (`repo_storage`), where the owner's repositories and packages are added up. A repository that hasn't reported yet, such as one made before storage was counted, is measured the first time its account's storage is checked (up to 25 per check): its git objects are added up once from its own database, and its Git LFS objects are listed from R2 once. Purging unreachable objects counts the repository again. |
| 291 | |
| 292 | ## Audit log |
| 293 | |
| 294 | **Settings → Audit log** (`/settings/audit`) shows the 200 most recent security-relevant events: sign-ins, passkeys, tokens, invites, account status changes, repository and access changes, pushes, merges and push protection. Repository and organization settings changed through the REST API or the CLI are recorded too: repository settings, archiving, collaborators, branch protection, Actions secrets and variables (repository, environment and organization), webhooks, a repository's workflow token settings, and deleted Actions artifacts and caches. Imports from GitHub (`import.create`, `import.push`, `import.finish` and the rest) and linked GitHub accounts are recorded too. Settings changes made through the API have `via: api` in their **Detail**. Each row has **When**, **Actor** (and who an agent acted for), **Action**, **Target** and **Detail**. See [Audit log](/docs/security/audit-log) for every action and how to use it. |
| 295 | |
| 296 | ## Email |
| 297 | |
| 298 | **Settings → Email** (`/settings/admin/email`) shows whether the instance can send email and lets you check every template. |
| 299 | |
| 300 | | Item | What it shows | |
| 301 | | --- | --- | |
| 302 | | **Sending** | "Cloudflare Email Sending is bound", or "not configured (no EMAIL binding): emails are logged, not sent" | |
| 303 | | **Queue** | `diffinery-mail`, or "not bound: emails are dropped" | |
| 304 | | **From** | The address emails are sent from: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty (see [Configuration](/docs/self-hosting/configuration)) | |
| 305 | | **Bounces and complaints** | Where they come from: the `diffinery-mail-events` queue, and whether the signed endpoint is on (`MAIL_EVENTS_SECRET` set) | |
| 306 | | **Last 24 hours** | How many emails were sent, failed and skipped | |
| 307 | |
| 308 | Buttons: |
| 309 | |
| 310 | - **Send me a test email** sends one test email to you right away and shows the result. |
| 311 | - **Send every template to me** queues one sample of every template to your address, ignoring your notification settings. They show up under **Recent emails** as they are sent. |
| 312 | |
| 313 | Under **Templates**, pick a template from the menu to preview it in the page. **Open HTML** opens the HTML version in a new tab; **Plain text** shows the text version with its headers (`From`, `Subject`, `X-Diffinery-Reason`, `List-Unsubscribe` and the rest). The previews use sample data such as the repository `acme/website`. |
| 314 | |
| 315 | **Suppressed addresses** lists every address Diffinery sends nothing to, newest first, with why (`hard-bounce`, `complaint` or `manual`), where that came from and since when. **Remove** sends email to the address again; **Suppress an address** adds one by hand, with an optional note. Both are recorded in the audit log. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). |
| 316 | |
| 317 | **Recent emails** lists the last 100 emails sent to anyone, with the time, recipient address, subject, template kind, Cloudflare's message ID, the status (`sent`, `failed` or `skipped`) and the reason when it wasn't sent. The log keeps 90 days. |
| 318 | |
| 319 | See [Notifications](/docs/accounts/notifications#every-email-diffinery-sends) for when each email is sent. |
| 320 | |
| 321 | ### Email API for administrators |
| 322 | |
| 323 | | Method and path | Description | Needs | |
| 324 | | --- | --- | --- | |
| 325 | | `GET /api/v3/diffinery/admin/email` | `sending` and `queue` (whether they are configured), `from` (the sender address: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty), `last_24h` counts (`sent`, `failed`, `skipped`) and the `last` email logged | site administrator, any token scope | |
| 326 | | `POST /api/v3/diffinery/admin/emails/samples` | Send every template to yourself now, or only some with `{"only": ["signin", "invite-org"]}`. Returns `to`, `sent`, `total` and each result. | site administrator, with an `admin` token for all repositories or a browser session | |
| 327 | | `GET /api/v3/diffinery/admin/email/suppressions` | The suppression list, newest first: `email`, `reason`, `detail`, `source` (`cloudflare`, `webhook`, `send` or `admin`), `created_by`, `created_at` and `owner_can_lift` | site administrator, any token scope | |
| 328 | | `PUT /api/v3/diffinery/admin/email/suppressions/{email}` | Suppress an address by hand, with an optional `{"note": "..."}`. Audited as `email.suppression.add`. | site administrator, with an `admin` token or a browser session | |
| 329 | | `DELETE /api/v3/diffinery/admin/email/suppressions/{email}` | Remove an address from the list, whatever put it there; `204`, or `404` when it isn't there. Audited as `email.suppression.remove`. | site administrator, with an `admin` token or a browser session | |
| 330 | | `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` | |
| 331 | |
| 332 | The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `account-suspended`, `account-unsuspended`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `email-verify`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`. |
| 333 | |
| 334 | ### Bounces, complaints and the suppression list |
| 335 | |
| 336 | Diffinery sends no email at all to an address on the suppression list: not notifications, not security alerts, not sign-in links or account recovery. Such an email shows in **Recent emails** as `skipped` with "address is suppressed: ..." as the reason. An address gets on the list three ways: |
| 337 | |
| 338 | | Reason | How | |
| 339 | | --- | --- | |
| 340 | | `hard-bounce` | Email to it bounced permanently (the mailbox or domain doesn't exist, or the receiving server refuses it for good), or Email Sending refused to send to it for good (`E_RECIPIENT_SUPPRESSED`, source `send`). Soft bounces, delays and temporary send errors (rate and daily limits, delivery errors) change nothing. | |
| 341 | | `complaint` | Its owner marked an email from Diffinery as spam. | |
| 342 | | `manual` | A site administrator added it, under **Suppress an address** or with the API. | |
| 343 | |
| 344 | A newer event replaces an older one for the same address. Every addition is in the audit log as `email.suppression.add`. |
| 345 | |
| 346 | **Lifting a suppression.** A site administrator can remove any address (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`). People can lift a bounce or complaint on their own address themselves: **Settings → Email** shows that the address is suppressed and why, and **Send a verification email** sends one email to it, past the list. Following its link while signed in to the same account turns email back on (`email.suppression.lift` in the audit log). The link works for 24 hours, and at most 3 can be asked for in an hour. A `manual` suppression can only be removed by an administrator. See [Notifications](/docs/accounts/notifications#when-diffinery-stops-emailing-you). |
| 347 | |
| 348 | Cloudflare Email Sending keeps a suppression list of its own, for bounces and complaints it sees. When it refuses to send to an address on that list (`E_RECIPIENT_SUPPRESSED`, shown as `failed` in **Recent emails**), Diffinery adds the address to its own list as a `hard-bounce` with source `send`, so later emails are skipped. If you remove such an address from Diffinery's list, remove it in the Cloudflare dashboard too (**Email Service → Email Sending → Suppressions**), or the next email to it puts it back. |
| 349 | |
| 350 | #### Where bounces and complaints come from |
| 351 | |
| 352 | Cloudflare Email Sending publishes delivery events (`message.delivered`, `message.bounced`, `message.complained` and others) through [event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) to a queue. Diffinery consumes the `diffinery-mail-events` queue, and **the deploy sets it all up**: `scripts/bootstrap.sh` creates the queue and, when it is missing, an event subscription named `diffinery-mail-events <domain>` with source **Email Sending**, your sending domain (`MAIL_DOMAIN`, or `DIFFINERY_DOMAIN`), and the **bounced** and **complained** events. A subscription you made yourself for that domain and queue is kept; any of those two events it lacks are added. Other events are ignored. |
| 353 | |
| 354 | The deploy's API token needs **Account → Queues → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/queues/subresources/subscriptions/methods/create/) calls it "Queues Write"), and **Zone → Zone → Read** on the zone of your sending domain to look up its zone ID; the domain must be onboarded to Email Sending. Without them the deploy goes on and prints a warning such as `Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for example.com. The token needs Account → Queues → Edit ...`, followed by Cloudflare's error. Then either add the permission to the token and deploy again, or subscribe the queue by hand once: |
| 355 | |
| 356 | 1. In the Cloudflare dashboard, open **Storage & databases → Queues** and select `diffinery-mail-events`. |
| 357 | 2. On the **Subscriptions** tab, click **Subscribe to events**. |
| 358 | 3. Name the subscription (for example `diffinery-bounces`), pick **Email Sending** as the source and your sending domain (the domain of `MAIL_FROM`), and select at least the **bounced** and **complained** events. Others are ignored. |
| 359 | 4. Click **Subscribe**. |
| 360 | |
| 361 | With Wrangler, the same is `wrangler queues subscription create diffinery-mail-events --source email.sending --zone-id <zone id> --domain <sending domain> --events message.bounced,message.complained`. |
| 362 | |
| 363 | Only Cloudflare can publish to the queue, so its messages need no signature. Until the subscription exists, bounces and complaints reach the list only when Email Sending refuses a send (see above); administrators can still add and remove addresses by hand. |
| 364 | |
| 365 | **The signed endpoint.** Anything else that can relay bounces and complaints, such as another provider's webhook through a small relay, can POST them to `https://<your host>/-/email/events`. It is off (`404`) until you set the `MAIL_EVENTS_SECRET` Worker secret, and it refuses (`401`) any request without a good signature: |
| 366 | |
| 367 | - Header `X-Diffinery-Signature: t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256, keyed with `MAIL_EVENTS_SECRET`, of `<t>.<the raw request body>`. Requests more than 5 minutes old (or ahead) are refused, so a captured request can't be replayed later. |
| 368 | - The body is JSON: one event, an array of events, or `{"events": [...]}`, at most 1,000 events and 1 MB. Each event has Cloudflare's shape: a `type` ending in `message.bounced` or `message.complained`, and a `payload` with `recipient` and, for bounces, `bounce.type` (`hard` or `soft`) and `bounce.reason`. |
| 369 | - The answer is `{"suppressed": <n>, "ignored": <n>}`. Events of any other type, and soft bounces, are ignored. |
| 370 | |
| 371 | ```sh |
| 372 | secret=... # the value of MAIL_EVENTS_SECRET |
| 373 | body='{"type":"message.bounced","payload":{"recipient":"someone@example.com","bounce":{"type":"hard","reason":"550 5.1.1 User unknown"}}}' |
| 374 | t=$(date +%s) |
| 375 | sig=$(printf '%s' "$t.$body" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //') |
| 376 | curl -sS https://diffinery.example/-/email/events -H 'content-type: application/json' \ |
| 377 | -H "X-Diffinery-Signature: t=$t,v1=$sig" --data "$body" |
| 378 | ``` |
| 379 | |
| 380 | Set the secret with `wrangler secret put MAIL_EVENTS_SECRET -c wrangler.deploy.jsonc` (see [Configuration](/docs/self-hosting/configuration#worker-secrets)). |
| 381 | |
| 382 | ## Security reports |
| 383 | |
| 384 | `/.well-known/security.txt` names the instance's private report form, `/security/report`, as its first contact (see [Configuration](/docs/self-hosting/configuration#security-contact)). Anyone can send a report there: signed in, or without an account after Cloudflare Turnstile, which the deploy sets up when its API token has **Account → Turnstile → Edit** (see [Turnstile](/docs/self-hosting/configuration#turnstile); without it, only people who are signed in can report). A report has a one-line summary (up to 200 characters), details (plain text, up to 20,000 characters), optionally the affected URL or area, and a reply-to address: required without an account, optional when signed in, when the account's own address is used. Limits: 5 reports a day from one IP address for people who aren't signed in, 50 a day from all of them together, 10 a day per account, and the sign-in rate limit of 20 requests a minute per IP address. |
| 385 | |
| 386 | Only site administrators can see reports. To anyone else the pages and the API answer `404`, as if they didn't exist. |
| 387 | |
| 388 | Each new report emails every active site administrator (`security_report`, an email that always sends). The email has the summary and a link to the report, never the details: read those in Diffinery. |
| 389 | |
| 390 | **Settings → Security reports** (`/settings/admin/security-reports`) lists reports newest first, with when they arrived, the summary, who sent it (a username, or "not signed in") and the state: **New**, **Triaged** or **Resolved**. The **Show** menu filters by state. Click a summary to open the report: its state, when it arrived, the reporter, the reply-to address, the affected URL or area, its reference (the id the reporter was shown) and the details as plain text. Under **Triage**, pick a state, write a note for the other administrators and click **Save**; you see "Report updated." Each change is in the audit log (`security_report.triage`, `security_report.resolve`, or `security_report.reopen` when set back to **New**), with the note. Reply to the reporter from your own mailbox: Diffinery doesn't email them. |
| 391 | |
| 392 | | Method and path | Description | Needs | |
| 393 | | --- | --- | --- | |
| 394 | | `GET /api/v3/diffinery/admin/security-reports` | Reports, newest first: `id`, `state`, `summary`, `details`, `area`, `reply_to`, `reporter` (a login, or `null`), `note`, `updated_by`, `updated_at`, `created_at` and `html_url`. Takes `state` (`new`, `triaged` or `resolved`) and `per_page` (default 100, up to 500). | site administrator, a `read` token not limited to repositories, or a browser session | |
| 395 | | `GET /api/v3/diffinery/admin/security-reports/{id}` | One report, as in the list | site administrator, a `read` token not limited to repositories, or a browser session | |
| 396 | | `PATCH /api/v3/diffinery/admin/security-reports/{id}` | `{"state": "triaged", "note": "..."}`: sets the state (`triaged`, `resolved`, or `new` to reopen) and the note (up to 2,000 characters; leaving it out clears it). Answers with the report. Audited like **Save**, with `via: api`. | site administrator, with an `admin` token not limited to repositories, or a browser session | |
| 397 | |
| 398 | ```sh |
| 399 | diffinery api GET '/diffinery/admin/security-reports?state=new' |
| 400 | echo '{"state": "resolved", "note": "Fixed in 2026.10.15; reporter credited."}' | diffinery api PATCH /diffinery/admin/security-reports/<id> --input - |
| 401 | ``` |
| 402 | |
| 403 | ## Status |
| 404 | |
| 405 | Every instance has a public [status page](/docs/self-hosting/status-page) at `/status`, with JSON at `GET /api/v3/diffinery/status`: each component's status, incidents in progress and those resolved in the last 14 days. Anyone can read it without signing in. |
| 406 | |
| 407 | **Settings → Status** (`/settings/admin/status`) shows what the page says now, a form to open an incident (a title, the impact, the affected components, a state and the first update), each open incident with a form to post an update or resolve it, and the recent incidents. While an incident isn't resolved, its impact sets the status of each component it names; pausing hosted compute (see [Kill switches](#kill-switches)) puts **Actions** and **Agents and sandboxes** under maintenance without one. Each change is in the audit log (`status_incident.create`, `status_incident.update` or `status_incident.resolve`). Incidents and their updates are public, so keep internal details out of them. |
| 408 | |
| 409 | | Method and path | Description | Needs | |
| 410 | | --- | --- | --- | |
| 411 | | `GET /api/v3/diffinery/admin/status/incidents` | Incidents, newest first, with their updates | site administrator, a `read` token not limited to repositories, or a browser session | |
| 412 | | `POST /api/v3/diffinery/admin/status/incidents` | `{"title": "...", "impact": "major", "components": ["git"], "state": "investigating", "body": "..."}`: opens an incident | site administrator, with an `admin` token not limited to repositories, or a browser session | |
| 413 | | `POST /api/v3/diffinery/admin/status/incidents/{id}/updates` | `{"state": "resolved", "body": "..."}`: posts an update, which resolves the incident when its state is `resolved` | site administrator, with an `admin` token not limited to repositories, or a browser session | |
| 414 | |
| 415 | To anyone else these endpoints answer `404`. The status page runs in the instance's own Worker, so it is down whenever the whole Worker is: watch `/healthz` with an external uptime monitor too (see [Monitoring from outside](/docs/self-hosting/status-page#monitoring-from-outside)). |
| 416 | |
| 417 | ## Search engines |
| 418 | |
| 419 | Diffinery tells search engines about public pages that are added, change or go away through [IndexNow](https://www.indexnow.org), so Bing, Yandex, Naver, Seznam, Yep and the other engines that share IndexNow submissions recrawl them within minutes instead of whenever they next visit. Nobody needs to do anything: it runs by itself after every deploy. |
| 420 | |
| 421 | What it covers: |
| 422 | |
| 423 | - **After each deploy**, the pages the deploy added, changed or removed: the home page, the product, comparison and company pages (`/features`, `/compare/github`, ...), every docs page, each of those pages' Markdown version (`/index.md`, `/compare/github.md`, `/docs/quickstart.md`, ...), `/llms.txt`, `/llms-full.txt`, `/contact`, and `/early-access` while the instance is invite-only. These are the pages in `/sitemap.xml` plus their Markdown versions. A deploy that changes no page's content submits nothing. |
| 424 | - **Every hour**, public repositories pushed since they were last submitted (their page, `/<owner>/<repo>`, as the sitemap lists it). |
| 425 | - **Never** a private repository, a repository whose owner is suspended, or any page behind sign-in (settings, notifications, the API, sign-in pages). A repository is only submitted in the same check that found it public, so one made private in the meantime isn't sent, and it is forgotten. Removed site and docs pages are submitted once more, so search engines learn they are gone. |
| 426 | |
| 427 | How it works: the Worker's five-minute cron notices a new deploy, hashes each page's content and compares the hashes with the ones it last submitted, then sends only the differences in one request (up to 10,000 URLs; more wait for the next run) to `api.indexnow.org`. Engines share what any of them receives, so when `api.indexnow.org` answers `429` or `5xx` or can't be reached, the same request goes to Bing's own endpoint (`www.bing.com/indexnow`), then Yandex's (`yandex.com/indexnow`): IndexNow limits requests per IP address, and a Worker sends from addresses it shares with other Workers. A run on an older version during a deploy's rollout is ignored. Submitting happens after the deploy, in the background, so it can't fail or slow one. If every endpoint answers `429 Too Many Requests`, the pages stay queued for an hour; after another refusal (`400`, `403`, `422`) for 6 hours; if it can't be reached or fails (`5xx`, a timeout), for 15 minutes. IndexNow's `200 OK` and `202 Accepted` (key validation pending) count as submitted. |
| 428 | |
| 429 | The key that proves submissions come from this site is served at the site root, `/<key>.txt`. It is 32 hex characters derived from the instance's `MASTER_KEY` and `PUBLIC_URL`, so each instance has its own and nothing needs configuring. [Rotating MASTER_KEY](/docs/self-hosting/configuration#rotating-master_key) gives the instance a new key, which the next submission uses. |
| 430 | |
| 431 | **Settings → Search engines** (`/settings/admin/search-engines`) shows whether IndexNow is on and its key file, how many pages and public repositories it tracks and how many are waiting, and the newest submissions: when, how many URLs, which endpoint answered, its answer and how long it took, any error, and the pages sent. The newest 200 submissions are kept. The same is available over the API: |
| 432 | |
| 433 | ```bash |
| 434 | diffinery api GET "/diffinery/admin/indexnow?per_page=10" |
| 435 | ``` |
| 436 | |
| 437 | Anyone can see whether it is on and how the last submission went, without the pages, in `GET /api/v3/meta` (`indexnow`). Each submission is also logged in the Worker's logs as `indexnow: <n> URLs, <status> in <ms> ms`. |
| 438 | |
| 439 | IndexNow is off when `INDEXNOW` is `off`, when `PUBLIC_URL` isn't a public `https` origin (`localhost`, an IP address or `http`, as in local development), or when `MASTER_KEY` isn't set. See [Configuration](/docs/self-hosting/configuration#worker-variables). |
| 440 | |
| 441 | ## Workflow scheduler |
| 442 | |
| 443 | Scheduled workflows are started by Diffinery's own timer, every 5 minutes, which also catches up checks that came late (see [Scheduled workflows](/docs/actions/events#schedule)). When a scheduled run doesn't start, read the scheduler's state to tell a timer that stopped from a run that failed to start: |
| 444 | |
| 445 | ```bash |
| 446 | diffinery api GET /diffinery/admin/scheduler |
| 447 | ``` |
| 448 | |
| 449 | ```json |
| 450 | { "tick_minutes": 5, "last_tick_at": "2026-10-13T17:30:01.204Z", "covered_until": "2026-10-13T17:30:00.000Z", "next_tick_at": "2026-10-13T17:35:00.000Z" } |
| 451 | ``` |
| 452 | |
| 453 | | Field | Meaning | |
| 454 | | --- | --- | |
| 455 | | `tick_minutes` | Minutes between checks (`SCHEDULE_TICK_MINUTES`, default 5) | |
| 456 | | `last_tick_at` | When the last check finished, or `null` before the first | |
| 457 | | `covered_until` | The last minute whose schedules have been started | |
| 458 | | `next_tick_at` | When the next check is due, or `null` if the timer isn't set (the cron trigger sets it again within 5 minutes) | |
| 459 | |
| 460 | A `last_tick_at` far behind the time means checks are failing; look in the Worker's logs for `schedule tick failed`. Site administrators only, with any token scope. How it works is in [Architecture](/docs/self-hosting/architecture#workflow-schedules). |
| 461 | |
| 462 | ## Server errors |
| 463 | |
| 464 | When Diffinery answers a request with a 5xx status, it keeps a record of the error. The answer's JSON includes a `request_id`, which also appears in the `x-request-id` header, and an error page shows it as **Request ID**. Look the request ID up under **Settings → Server errors**, or over the API: |
| 465 | |
| 466 | ```bash |
| 467 | diffinery api GET /diffinery/admin/errors/<request id> |
| 468 | diffinery api GET "/diffinery/admin/errors?per_page=20" |
| 469 | ``` |
| 470 | |
| 471 | Each record has the request's method, `route` (the matched route pattern), `path`, `status`, `message` and `stack`. Only the newest 500 errors are kept. Records never include request bodies, query strings or headers. Tokens that appear in a message are masked, and a path that carries a secret, such as a sign-in link, is stored as its route pattern instead. On Cloudflare, the request ID is the request's Ray ID, so the same request can also be found in Cloudflare's logs. |
| 472 | |
| 473 | ## Administration from the CLI |
| 474 | |
| 475 | The CLI covers invites and, through `diffinery api`, any API endpoint: |
| 476 | |
| 477 | ```sh |
| 478 | curl -fsSL https://diffinery.com/install | bash |
| 479 | diffinery login |
| 480 | |
| 481 | # Invite someone to the instance (site administrators only) |
| 482 | diffinery invite friend@example.com |
| 483 | |
| 484 | # Invite someone to an organization as an admin |
| 485 | diffinery invite friend@example.com --org acme --role admin |
| 486 | |
| 487 | # Email delivery status |
| 488 | diffinery api GET /diffinery/admin/email |
| 489 | |
| 490 | # Send two sample emails to yourself (needs an admin-scoped token) |
| 491 | echo '{"only": ["signin", "test"]}' | diffinery api POST /diffinery/admin/emails/samples --input - |
| 492 | |
| 493 | # Everyone's recent emails, with their status |
| 494 | diffinery api GET /diffinery/emails -f all=1 -f per_page=100 |
| 495 | ``` |
| 496 | |
| 497 | `diffinery api METHOD PATH` sends an authenticated request; `PATH` is relative to `/api/v3`. `-f key=value` adds a field (a query parameter for `GET`), and `--input FILE` (or `-` for standard input) sends a JSON body. The CLI also manages tokens, agents and organization secrets; see [CLI reference](/docs/reference/cli). |
| 498 | |
| 499 | Approving access requests, changing account status and creating sign-in links are only available on the **People and invitations** page. |
| 500 | |
| 501 | ## Troubleshooting |
| 502 | |
| 503 | | Message | Cause and fix | |
| 504 | | --- | --- | |
| 505 | | `403 site administrators only` | Your account isn't a site administrator. | |
| 506 | | `403 site admin only` from `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | Your account isn't a site administrator. | |
| 507 | | `404 link not found` | No GitHub link has that id. Ask the account's owner for the id from `GET /api/v3/diffinery/user/linked-accounts`. | |
| 508 | | "you cannot change your own status" | Ask another site administrator. | |
| 509 | | `422 ... site administrators can't be suspended` | Site administrators can't be suspended. | |
| 510 | | `422 give a reason for the suspension` | Suspending needs a reason. | |
| 511 | | `403 Actions are suspended ... by a site administrator` | A kill switch is on for the repository or its owner. Lift it under **Settings → Kill switches**. | |
| 512 | | "Hosted runners are paused on this instance" | Hosted compute is paused. Resume it under **Settings → Kill switches**, or run the job on a self-hosted runner. | |
| 513 | | `422 give a reason` | Kill switches need a reason. | |
| 514 | | "enable this account first" | Sign-in links can't be created for disabled accounts. Set the status to `active`, then create the link. | |
| 515 | | "enter a valid email address" | The invite form needs an address like `friend@example.com`. | |
| 516 | | `403 only site admins can invite people without an organization` | Instance invites from the CLI or API need a site administrator; others must pass `--org`. | |
| 517 | | **Sending** says "not configured" | The Worker has no `send_email` binding, so emails are logged as skipped. See [Configuration](/docs/self-hosting/configuration). | |
| 518 | | Emails show as `failed` | The **Recent emails** detail has Cloudflare's error. Check the sending domain's DNS records and the queue. | |
| 519 | | Emails show as `skipped` with "address is suppressed" | The address bounced, reported spam or was suppressed by hand. See [Bounces, complaints and the suppression list](#bounces-complaints-and-the-suppression-list). | |
| 520 | | `E_RECIPIENT_SUPPRESSED` | The address is on Cloudflare's own suppression list. Remove it in the Cloudflare dashboard. | |
| 521 | | `401 missing or bad signature` from `/-/email/events` | The `X-Diffinery-Signature` header is missing, signed with another secret, or more than 5 minutes old. | |