Diffinery

codician/diffinerypublic

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

521 lines · 53.1 KBRaw History
1---
2title: Site administration
3description: 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
6Site 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
10Site 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
25Their **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
29The 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
34Bootstrap 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
38In 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
47On a new instance nobody has a passkey yet. A bootstrap administrator signs in through email recovery:
48
491. Open `https://diffinery.com/auth/access`.
502. Confirm your email address with the one-time PIN that Cloudflare Access emails you.
513. On the page that follows (**Settings → Passkeys and sessions**), click **Add a passkey**.
52
53From 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
57A 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
65People 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
70See [Invitations](/docs/accounts/invitations#access-requests) for the requester's side.
71
72### Users
73
74The **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
82A 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
84You can't change your own status ("you cannot change your own status").
85
86Pending 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
90Set 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
97Their 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
99Every status change is recorded in the audit log as `user.active`, `user.pending` or `user.disabled`.
100
101### Deleting an account
102
103Click **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
105The 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
107Deleting 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
111Suspending 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
113In 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
122Site 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
126Suspensions 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
129echo '{"reason": "spam in issues, see report 4f2c"}' | diffinery api PUT /users/diffy/suspended --input -
130diffinery api DELETE /users/diffy/suspended
131```
132
133Both answer `204`. Anyone else gets `403 site administrators only`.
134
135## Early access
136
137While 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
141Anyone 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
145Anyone 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
155Filing, resolving and reopening are audited as `report.create`, `report.resolve` and `report.reopen`.
156
157From 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
159Other 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
163Under **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
165The 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
169When someone loses their passkeys, a site administrator can give them a one-time link that adds a new passkey to their existing account.
170
1711. In the **Users** table, click **Sign-in link** next to the person.
1722. Confirm "Create a one-time link that adds a passkey to diffy? Only send it to them."
1733. 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
175The 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
186Anyone 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
190Kill 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
198Nobody 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
202While 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
210While 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
216The 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
223The 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
225Turning 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
236A 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
240Site 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
251echo '{"reason": "crypto mining, see ticket 123"}' | diffinery api PUT /admin/repos/acme/website/actions-suspension --input -
252diffinery api DELETE /admin/repos/acme/website/actions-suspension
253echo '{"reason": "runner image incident"}' | diffinery api PUT /admin/hosted-compute-pause --input -
254```
255
256## GitHub imports and linked accounts
257
258Site 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
266An 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
269diffinery api DELETE /diffinery/admin/linked-accounts/<link id>
270```
271
272The 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
276Every 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
279diffinery api GET /admin/accounts/acme/quotas
280echo '{"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
290Storage 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
308Buttons:
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
313Under **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
319See [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
332The 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
336Diffinery 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
344A 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
348Cloudflare 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
352Cloudflare 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
354The 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
3561. In the Cloudflare dashboard, open **Storage & databases → Queues** and select `diffinery-mail-events`.
3572. On the **Subscriptions** tab, click **Subscribe to events**.
3583. 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.
3594. Click **Subscribe**.
360
361With 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
363Only 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
372secret=... # the value of MAIL_EVENTS_SECRET
373body='{"type":"message.bounced","payload":{"recipient":"someone@example.com","bounce":{"type":"hard","reason":"550 5.1.1 User unknown"}}}'
374t=$(date +%s)
375sig=$(printf '%s' "$t.$body" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //')
376curl -sS https://diffinery.example/-/email/events -H 'content-type: application/json' \
377 -H "X-Diffinery-Signature: t=$t,v1=$sig" --data "$body"
378```
379
380Set 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
386Only site administrators can see reports. To anyone else the pages and the API answer `404`, as if they didn't exist.
387
388Each 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
399diffinery api GET '/diffinery/admin/security-reports?state=new'
400echo '{"state": "resolved", "note": "Fixed in 2026.10.15; reporter credited."}' | diffinery api PATCH /diffinery/admin/security-reports/<id> --input -
401```
402
403## Status
404
405Every 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
415To 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
419Diffinery 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
421What 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
427How 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
429The 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
434diffinery api GET "/diffinery/admin/indexnow?per_page=10"
435```
436
437Anyone 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
439IndexNow 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
443Scheduled 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
446diffinery 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
460A `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
464When 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
467diffinery api GET /diffinery/admin/errors/<request id>
468diffinery api GET "/diffinery/admin/errors?per_page=20"
469```
470
471Each 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
475The CLI covers invites and, through `diffinery api`, any API endpoint:
476
477```sh
478curl -fsSL https://diffinery.com/install | bash
479diffinery login
480
481# Invite someone to the instance (site administrators only)
482diffinery invite friend@example.com
483
484# Invite someone to an organization as an admin
485diffinery invite friend@example.com --org acme --role admin
486
487# Email delivery status
488diffinery api GET /diffinery/admin/email
489
490# Send two sample emails to yourself (needs an admin-scoped token)
491echo '{"only": ["signin", "test"]}' | diffinery api POST /diffinery/admin/emails/samples --input -
492
493# Everyone's recent emails, with their status
494diffinery 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
499Approving 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. |