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.

Contact page: docs, links, privacy, FAQ, unit tests

Claude authored signed1 parent 7c9e755commit b1af272edbd24f99acb4101dbecb28bb5308eff1Browse files
18 files changed+482−16
Files (18)
modifiedapps/diffinery/docs/accounts/notifications.md+6−1View file
@@ -52,7 +52,7 @@
5252 | Watching | Everything else in repositories you watch with **All activity** | Yes | On |
5353 | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only |
5454 | Repositories | Repositories created or deleted in your organizations (for owners and admins), your own new repositories, and repository access granted, changed or removed | Yes | On |
55-| Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests (for site administrators) | Yes | On |
55+| Organizations | Your organization role added, changed or removed (with who did it), invitations you sent being accepted, and access requests and contact messages (for site administrators) | Yes | On |
5656 | Morning digest | [Today](/docs/agents/today) by email, once a day at the hour you choose | Yes | Off |
5757
5858 ## Notification settings
@@ -229,6 +229,8 @@
229229 | `admin` | You are an administrator (of the organization, repository or instance). |
230230 | `account` | You have a Diffinery account (account and membership changes). |
231231 | `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). |
232+| `early_access` | Someone asked for [early access](/docs/accounts/early-access) with this address. |
233+| `contact` | Someone sent a message through the [contact form](/docs/admin/contact-form) with this address (the confirmation link). |
232234 | `digest` | You turned on the morning digest in your notification settings. |
233235
234236 ## When Diffinery stops emailing you
@@ -268,6 +270,8 @@
268270 | `early_access_confirm` | `[Diffinery] Confirm your early access request` | Account | `early_access` | Someone asked for [early access](/docs/accounts/early-access) with this address: the link that confirms the request. Sent to the address typed, not to an account. |
269271 | `early_access_known` | `[Diffinery] We already have your early access request`, `You're already invited` or `You already have a Diffinery account` | Account | `early_access` | Someone asked for early access again with an address whose request is already confirmed, declined or invited, or that belongs to an active account |
270272 | `early_access_remove` | `[Diffinery] Remove your early access request` | Account | `early_access` | Someone asked to remove the early access request for this address: the link that deletes it |
273+| `contact_confirm` | `[Diffinery] Confirm your message to the Diffinery team` | Account | `contact` | Someone who isn't signed in sent a message through the [contact form](/docs/admin/contact-form) with this address: the link that confirms it. Sent to the address typed. It never repeats what was typed. |
274+| `contact_received` | `[Diffinery] We got your message` | Account | `account` | You sent a message through the contact form while signed in: a copy, with its reference |
271275 | `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) |
272276 | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up |
273277 | `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. |
@@ -279,6 +283,7 @@
279283 | `digest` | `[Diffinery] Today: 2 need you, 3 merged` | Morning digest | `digest` | At the hour you chose, when something needs you or happened since the last one; or when you click **Send me my digest now** |
280284 | `invite_accepted` | `[Diffinery] @diffy accepted your invitation` | Organizations | `account` | Someone accepted an invite you created |
281285 | `access_request` | `[Diffinery] newcomer@example.com asked for access` | Organizations | `admin` | A person waiting for an invite requested access (sent to site administrators) |
286+| `contact_message` | `[Diffinery] Contact: Press and media from Rivera Kim` | Organizations | `admin` | A [contact message](/docs/admin/contact-form) arrived, confirmed or from someone signed in (sent to every active site administrator). It holds the whole message, and its Reply-To is the sender, so replying answers them. |
282287 | `org_membership` | `[Diffinery] You're now a member of acme` | Organizations | `account` | Your organization role was added, changed or removed by an owner or admin. The email names who added you ("Added by") or who changed your role ("Changed by"). |
283288 | `repo_access` | `[acme/website] You now have write access to acme/website` | Repositories | `account` | Your collaborator role on a repository was added, changed or removed |
284289 | `repo_created` | `[acme/website] New private repository` | Repositories | `your_activity` or `admin` | You created a repository in the web UI (not when a token or script creates it), or someone created one in an organization where you are an owner or admin |
addedapps/diffinery/docs/admin/contact-form.md+124−0View file
@@ -1,0 +1,124 @@
1+---
2+title: Contact form
3+description: Write to the people who run a Diffinery instance at /contact, what happens to your message and data, how the form is protected, and how site administrators read, answer, mark and delete messages.
4+---
5+
6+Every Diffinery instance has a contact form at `/contact`, linked from the footer of every page. Anyone can use it, signed in or not, to write to the instance's site administrators: questions, help with an account or invitation, running your own Diffinery, feedback, partnerships, press, and privacy requests. A person reads every message and replies by email.
7+
8+On diffinery.com, the project's own instance, this is how to reach the Diffinery team: [diffinery.com/contact](https://diffinery.com/contact).
9+
10+Two things have their own, better places:
11+
12+- **A security problem:** use the private [security report form](/docs/security/disclosure) at `/security/report`.
13+- **A bug or an idea for Diffinery itself:** open an issue on [the Diffinery repository](https://diffinery.com/codician/diffinery) or its [GitHub mirror](https://github.com/codician-team/diffinery).
14+
15+## Send a message
16+
17+1. Open `/contact`, or click **Contact** in the footer.
18+2. Fill in the form. Every field is required:
19+
20+ | Field | Limit | What it's for |
21+ | --- | --- | --- |
22+ | **Name** | up to 100 characters | How the team addresses you |
23+ | **Email** | a valid address, up to 254 characters | Where the confirmation link and the reply go. Not shown when you're signed in: your account's address is used. |
24+ | **Topic** | one of A question about Diffinery, My account or invitation, Running my own Diffinery, Feedback or an idea, Partnerships and integrations, Press and media, Privacy and my data, Something else | Gets your message to the right person |
25+ | **Message** | 10 to 5,000 characters and at most 5 links, plain text | What you want to tell us |
26+
27+ A link can choose the topic for you: `/contact?topic=press` opens the form with **Press and media** selected. The values are `question`, `account`, `self-hosting`, `feedback`, `partnership`, `press`, `privacy` and `other`.
28+3. If you aren't signed in, complete the "are you a person" check if one shows.
29+4. Click **Send message**.
30+
31+**If you aren't signed in**, you see **Check your inbox**. Open the email titled "Confirm your message to the Diffinery team" and click **Confirm my message**, then press **Confirm my message** on the page that opens. You see **Your message is with the team**. The link works once, for 48 hours. A message that is never confirmed is never read, and is deleted after 7 days. The confirmation email never repeats what you typed, so nobody can use the form to send someone else words through Diffinery.
32+
33+**If you're signed in**, there's nothing to confirm: you see **Message sent**, and you get a copy of your message by email ("We got your message"), with its reference.
34+
35+The team replies from their own mailbox to the address you gave, or your account's.
36+
37+## What is stored
38+
39+| What | Kept |
40+| --- | --- |
41+| Your name, address, topic and message, and the message's state | Until a site administrator deletes it; unconfirmed messages 7 days, messages marked spam 30 days after that, handled messages 365 days after they were handled |
42+| Your account, when you were signed in | With the message |
43+| A SHA-256 hash of the confirmation link's code | Until the link is used |
44+| One row per submission from someone not signed in, with SHA-256 hashes of the IP address and of the address typed | One day, for the daily limits only |
45+| A site administrator's note on the message | With the message |
46+
47+Your IP address and the address you typed are never stored in the clear for the limits, and nothing you type is written to logs. Only site administrators can read messages. To have a message deleted sooner, ask through the form (topic **Privacy and my data**). In the [data model](/docs/self-hosting/architecture), these are the `contact_messages` and `contact_attempts` tables.
48+
49+## How the form is protected
50+
51+| Protection | What it does |
52+| --- | --- |
53+| Cloudflare Turnstile | The "are you a person" check for people who aren't signed in, the same one as the [early access](/docs/accounts/early-access) and security report forms. A deployed instance without Turnstile keys (`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`) takes messages only from people who are signed in; on your own machine the check is skipped. |
54+| Honeypot and timing | A field people never see, and a signed time stamp: a form that fills the hidden field, comes back within 3 seconds, or carries a missing or forged stamp gets the usual **Check your inbox** page, and nothing is stored or sent. A form left open more than 24 hours asks you to send it again. |
55+| Confirmation by email | A message from someone who isn't signed in reaches the team only after its address follows the emailed link, so nobody can write in someone else's name. |
56+| Per-minute limit | At most 20 sign-in attempts, security reports, early access requests and contact messages a minute from one network (`RL_AUTH`, see [Rate limits](/docs/reference/rest-api#rate-limits)). |
57+| Daily limits | Without signing in: at most 10 submissions from one IP address, 3 emails to one address, and 300 emails from the form in total, in 24 hours. Past the network limit you see "Too many messages from your network today"; past the other two the page looks the same and nothing is stored or sent. Signed in: 20 messages a day per person. |
58+| Spam | While a site administrator has a message from an address marked as spam, new messages from that address are dropped without an email; the page looks the same. |
59+| Strict input | Lengths are capped (longer is refused, never cut), the email address is checked, the topic must be one of the list, links are limited to 5, and control and text-direction characters are removed. Messages are shown as plain text, never as HTML or Markdown. |
60+
61+## For site administrators
62+
63+**Settings → Contact messages** (`/settings/admin/contact`) is in the **Site administration** group. The sidebar shows how many new messages are waiting. Anyone who isn't a site administrator gets `404`.
64+
65+When a message arrives (confirmed, or from someone signed in), each active site administrator gets:
66+
67+- an email, "Contact: *topic* from *name*", with the whole message. Its **Reply-To** is the sender, so replying in your mail app answers them directly. It belongs to the **Organizations** group of your [notification settings](/docs/accounts/notifications), so you can turn it off there;
68+- one in-app notification, "New contact messages are waiting for you", which stays until it is read however many messages arrive.
69+
70+The tabs filter by state, with counts:
71+
72+| Tab | State | Meaning |
73+| --- | --- | --- |
74+| **New** (the default) | `new` | Waiting for someone to answer |
75+| **Handled** | `handled` | Answered or dealt with, by whom and when |
76+| **Spam** | `spam` | Marked as spam; new messages from the same address are dropped |
77+| **Unconfirmed** | `unconfirmed` | The address hasn't followed the link yet; you don't need to act on these |
78+| **All** | | Every message |
79+
80+The search box finds messages whose address, name or text contains what you type. Each message shows its sender's name and address, its state, the topic, when it was sent, the start of the text, and an amber badge when the sender was signed in or an active account uses the address. Click a name to read the whole message.
81+
82+- **Reply by email** opens your mail app with a reply to the sender. Diffinery doesn't send replies itself, so the conversation stays in your mailbox.
83+- **Mark handled** marks the message **Handled**. **Spam** marks it **Spam**. **Reopen** puts a handled or spam message back to **New** (or **Unconfirmed**, if its address never confirmed it). Nobody is emailed.
84+- **Note**, on a message's page, keeps a note for the other site administrators, such as who replied and what was agreed, up to 2,000 characters. The sender never sees it. Save an empty note to clear it.
85+- **Delete message** removes it for good, for someone who asks for their data to be removed.
86+
87+Every change is in the [audit log](/docs/security/audit-log): `contact.create` and `contact.confirm` when a message is sent and confirmed, `contact.handle`, `contact.spam`, `contact.reopen`, `contact.note` and `contact.delete`. Entries name the message by its id; none holds what it said, and `contact.delete` doesn't hold the address.
88+
89+### From the API
90+
91+With a site administrator's token (`read` to list, `admin` to change):
92+
93+| Endpoint | What it does |
94+| --- | --- |
95+| `GET /api/v3/diffinery/admin/contact` | List messages, newest first. `state`, `q`, `per_page` (up to 500) and `page` filter and page them. |
96+| `GET /api/v3/diffinery/admin/contact/{id}` | One message |
97+| `PATCH /api/v3/diffinery/admin/contact/{id}` | `{"state": "handled"}`, `{"state": "spam"}` or `{"state": "new"}` (reopen), and or `{"note": "..."}` |
98+| `DELETE /api/v3/diffinery/admin/contact/{id}` | Delete it (`204`) |
99+
100+```bash
101+# New messages
102+curl -H "Authorization: Bearer $DIFFINERY_TOKEN" \
103+ "https://diffinery.com/api/v3/diffinery/admin/contact?state=new"
104+
105+# Mark one handled, with a note
106+curl -X PATCH -H "Authorization: Bearer $DIFFINERY_TOKEN" \
107+ -d '{"state": "handled", "note": "Replied with the self-hosting guide."}' \
108+ https://diffinery.com/api/v3/diffinery/admin/contact/<id>
109+```
110+
111+Each message has `id`, `state`, `email`, `name`, `topic`, `message`, `sender` (the login of a signed-in sender, or null), `confirmed_at`, `note`, `updated_by`, `handled_at`, `created_at`, `updated_at` and `html_url`. Everyone else gets `404`. See the [API reference](/docs/api) for every field.
112+
113+## Troubleshooting
114+
115+| What you see | Why, and what to do |
116+| --- | --- |
117+| No email after **Check your inbox** | Check the spam folder. The address may have had 3 emails from the form today; try again tomorrow, or sign in and send it from your account. |
118+| "This link doesn't work any more" | The link was already used or is older than 48 hours. If you confirmed already, your message is with the team. Otherwise write it again. |
119+| "This link is incomplete" | The part after `#` was cut off. Open the link from the email in full, or copy all of it. |
120+| "We couldn't confirm that you're a person" | The Turnstile check failed or expired. Reload the page and try again. |
121+| "This form was open for a long time" | The page was open more than 24 hours. Check the fields and send it again. |
122+| "Too many messages from your network today" | 10 messages came from your network in 24 hours. Try again tomorrow. |
123+| "Keep your message to 5 links or fewer" | Messages with many links look like spam. Leave some out; you can send them once we reply. |
124+| The form says it takes messages only from people who are signed in | The instance has no Turnstile keys. Sign in, or ask its administrators to add them (see [Turnstile](/docs/self-hosting/configuration#turnstile)). |
modifiedapps/diffinery/docs/admin/site-administration.md+7−2View file
@@ -1,6 +1,6 @@
11 ---
22 title: Site administration
3-description: Site administrators manage people and admins, handle abuse and security reports, suspend and delete accounts, stop Actions, post status incidents, oversee imports, and check the audit and email logs.
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.
44 ---
55
66 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**.
@@ -13,6 +13,7 @@
1313 | --- | --- | --- |
1414 | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links |
1515 | **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 |
1617 | **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve |
1718 | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email |
1819 | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance |
@@ -135,6 +136,10 @@
135136
136137 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.
137138
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+
138143 ## Reports
139144
140145 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.
@@ -415,7 +420,7 @@
415420
416421 What it covers:
417422
418-- **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`, 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.
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.
419424 - **Every hour**, public repositories pushed since they were last submitted (their page, `/<owner>/<repo>`, as the sitemap lists it).
420425 - **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.
421426
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -127,6 +127,7 @@
127127 "pages": [
128128 "self-hosting",
129129 "admin/site-administration",
130+ "admin/contact-form",
130131 "self-hosting/deploy",
131132 "self-hosting/configuration",
132133 "self-hosting/architecture",
modifiedapps/diffinery/docs/reference/limits.md+2−0View file
@@ -29,6 +29,8 @@
2929 | Early access requests | 10 per IP address, 3 emails per address and 500 emails in total, in 24 hours | The [early access](/docs/accounts/early-access) form and its removal form together. The 11th from one network answers `Too many requests from your network today`; past the other two the page looks the same and no email is sent. |
3030 | Early access links | 48 hours to confirm, 24 hours to remove, single use | Only the newest link sent to an address works. Unconfirmed requests are deleted 7 days after they were last sent. |
3131 | Early access fields | name 100, company or team 120, use 1,000 characters | Longer is refused, not cut. |
32+| Contact messages | Without signing in: 10 per IP address, 3 emails per address and 300 emails in total, in 24 hours. Signed in: 20 per person in 24 hours | The [contact form](/docs/admin/contact-form). The 11th from one network answers `Too many messages from your network today`; past the other two the page looks the same and nothing is stored or sent. |
33+| Contact message fields | name 100, message 10 to 5,000 characters with at most 5 links, note 2,000 characters | Longer is refused, not cut. Confirmation links work once, for 48 hours. Unconfirmed messages are deleted after 7 days, spam 30 days and handled messages 365 days after they were marked. |
3234 | Note on an access request | 500 characters | The note someone without an invite sends to site administrators. Up to 20 site administrators are emailed about each request. |
3335
3436 ## Tokens
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -409,7 +409,7 @@
409409
410410 | What | Limit | Counted by |
411411 | --- | --- | --- |
412-| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`), early access requests, confirmations and removals (`POST /early-access`, `/early-access/confirm`, `/early-access/remove`, `/early-access/remove/confirm`) | 20 a minute | IP address |
412+| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`), early access requests, confirmations and removals (`POST /early-access`, `/early-access/confirm`, `/early-access/remove`, `/early-access/remove/confirm`), contact messages and their confirmations (`POST /contact`, `/contact/confirm`) | 20 a minute | IP address |
413413 | Anything else without signing in (web pages, API, git) | 300 a minute | IP address |
414414 | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account |
415415 | Creating repositories, organizations, forks, issues, pull requests, releases, comments, reviews and reports, and MCP tools that change something | 60 a minute | Account |
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
@@ -137,6 +137,9 @@
137137 | `early_access.confirm` | An address confirms its [early access](/docs/accounts/early-access) request with the emailed link (the actor is `system`) | the request's id | none |
138138 | `early_access.invite` | A site administrator invites an early access request (**Invite** on **Settings → Early access**, or `POST /api/v3/diffinery/admin/early-access/{id}/invite`) | the request's id | `invite` (the invitation id), `from` (the state it was in), `existing_invitation` when a pending invitation was kept, `via` (`web` or `api`) |
139139 | `early_access.decline`, `early_access.restore` | A site administrator declines a request, or puts a declined one back (**Decline** or **Put back**, or `PATCH /api/v3/diffinery/admin/early-access/{id}`) | the request's id | `from` or `to` (the states), `via` |
140+| `contact.create`, `contact.confirm` | Someone sends a message through the [contact form](/docs/admin/contact-form) (the actor is the sender when signed in, otherwise `system`), or confirms one with the emailed link (`system`) | the message's id | `signed_in` on `contact.create`; none on `contact.confirm` |
141+| `contact.handle`, `contact.spam`, `contact.reopen` | A site administrator marks a contact message handled or spam, or reopens it (**Mark handled**, **Spam** or **Reopen** on **Settings → Contact messages**, or `PATCH /api/v3/diffinery/admin/contact/{id}`) | the message's id | `from` and `to` (the states), `via` (`web` or `api`) |
142+| `contact.note`, `contact.delete` | A site administrator changes a message's note, or deletes it (**Delete message**, or `DELETE /api/v3/diffinery/admin/contact/{id}`). Neither the note, the address nor the message is recorded. | the message's id | `via`; `state` on `contact.delete` |
140143 | `early_access.delete` | A site administrator deletes a request (**Delete request**, or `DELETE /api/v3/diffinery/admin/early-access/{id}`), or someone removes their own with an emailed link (the actor is `system`). Neither the address nor the request's content is recorded. | the request's id | `state`, `via` (`web`, `api` or `self`) |
141144 | `status_incident.create` | A site administrator opens an incident on the [status page](/docs/self-hosting/status-page) (**Open the incident** on **Settings → Status**, or `POST /api/v3/diffinery/admin/status/incidents`) | the incident's id | `title`, `impact`, `components`, `state`, `via` (`web` or `api`); not the update's text |
142145 | `status_incident.update`, `status_incident.resolve` | A site administrator posts an update to an incident (**Post the update**, or `POST /api/v3/diffinery/admin/status/incidents/{id}/updates`); `resolve` when it resolves the incident | the incident's id | `from` and `to` (the states), `via` |
modifiedapps/diffinery/docs/self-hosting.md+1−1View file
@@ -55,7 +55,7 @@
5555
5656 - **Who can join.** An instance is invite-only by default. Set `SIGNUP=open` to let anyone create an account; see [SIGNUP](/docs/self-hosting/configuration#signup) and [Invitations](/docs/accounts/invitations).
5757 - **Limits.** Change the per-account quotas and the instance's hosted job limit with the `QUOTA_*` and `HOSTED_JOBS_LIMIT` variables; see [Configuration](/docs/self-hosting/configuration#worker-variables).
58-- **Running it.** [Site administration](/docs/admin/site-administration) covers accounts, early access requests, email and audits, and the [status page](/docs/self-hosting/status-page) shows whether each part of your instance works.
58+- **Running it.** [Site administration](/docs/admin/site-administration) covers accounts, early access requests, contact messages, email and audits, and the [status page](/docs/self-hosting/status-page) shows whether each part of your instance works.
5959 - **Your team's tools.** The CLI, the Claude Code and Codex plugins, the MCP server and the editor extensions all work with your instance. People install the CLI from it and run `diffinery setup` with its URL, which signs them in and points git, gh, the Claude Code plugins and Codex at it: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. AI apps connect to `https://git.example.com/mcp`. See [Claude Code](/docs/connect/claude-code#self-hosted-diffinery), [Codex](/docs/connect/codex#self-hosted-diffinery), [Clone in your editor](/docs/repositories/clone-in-your-editor) and [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
6060 - **How it fits together.** [Architecture](/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it.
6161
modifiedapps/diffinery/docs/self-hosting/architecture.md+1−0View file
@@ -275,6 +275,7 @@
275275 | `invites` | Invitations to the site, an organization or a repository, by token hash, with expiry and acceptance. |
276276 | `access_requests` | Requests from people waiting for an invite. |
277277 | `early_access`, `early_access_attempts` | [Early access](/docs/accounts/early-access) requests from `/early-access`: the address, name, company, use and hosting given, the state (`unconfirmed`, `confirmed`, `invited`, `declined`), hashes of the confirmation and removal links' codes, and who decided; and one row per form submission with hashes of the IP address and the address typed, kept a day for the daily limits. |
278+| `contact_messages`, `contact_attempts` | Messages from the [contact form](/docs/admin/contact-form) at `/contact`: the name, address, topic and message, the sender's account when signed in, the state (`unconfirmed`, `new`, `handled`, `spam`), a hash of the confirmation link's code, and the site administrators' note and who last changed it; and one row per submission from someone not signed in with hashes of the IP address and the address typed, kept a day for the daily limits. |
278279 | `security_reports` | Private vulnerability reports from `/security/report`: summary, details, affected area, reply-to address, the reporter when signed in, a hash of the sender's IP address (for the daily limits), and the state (`new`, `triaged`, `resolved`) and note a site administrator set. |
279280 | `passkeys` | WebAuthn credentials: public key, counter, transports, device type, backup state, name. |
280281 | `sessions` | Browser sessions by hash of the cookie value, with the sign-in method and passkey, user agent and expiry. |
modifiedapps/diffinery/docs/self-hosting/configuration.md+4−4View file
@@ -29,7 +29,7 @@
2929 | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](/docs/reference/limits#caches-and-artifacts). |
3030 | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). |
3131 | `SIGNUP` | Not set | No | `open` lets anyone create an account at `/signup`. See [SIGNUP](#signup). Any other value, or none, keeps the instance invite-only. |
32-| `TURNSTILE_SITE_KEY` | Set by the deploy | With `SIGNUP=open`; for anonymous security reports and [early access requests](/docs/accounts/early-access) | The site key of the Cloudflare Turnstile widget on the sign-up page, the [security report form](#security-contact) and the early access form. The deploy creates the widget and sets this; see [Turnstile](#turnstile). Setting it doesn't open sign-up. |
32+| `TURNSTILE_SITE_KEY` | Set by the deploy | With `SIGNUP=open`; for anonymous security reports, [early access requests](/docs/accounts/early-access) and [contact messages](/docs/admin/contact-form) | The site key of the Cloudflare Turnstile widget on the sign-up page, the [security report form](#security-contact), the early access form and the contact form. The deploy creates the widget and sets this; see [Turnstile](#turnstile). Setting it doesn't open sign-up. |
3333 | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). |
3434 | `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES`, `QUOTA_STORAGE_BYTES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`20`), agent sandboxes open at once (`5`), repositories (`500`) and storage in bytes (`10737418240`, 10 GB). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](/docs/reference/limits#account-quotas). |
3535 | `HOSTED_JOBS_LIMIT` | Not set | No | Hosted Actions jobs the whole instance runs at once (default `80`); one account runs at most half of them, accounts run by a site administrator included. Keep it under the runner container classes' `max_instances`. Past it, hosted jobs wait as queued until one finishes. See [Limits](/docs/reference/limits#actions). |
@@ -150,13 +150,13 @@
150150
151151 ### Turnstile
152152
153-The security report form, and open sign-up when it's on, check with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) that a visitor without an account is a person. Every deploy sets it up (`scripts/turnstile.sh`, run by `scripts/bootstrap.sh`):
153+The security report, early access and contact forms, and open sign-up when it's on, check with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) that a visitor without an account is a person. Every deploy sets it up (`scripts/turnstile.sh`, run by `scripts/bootstrap.sh`):
154154
155155 1. It looks for a Turnstile widget named `diffinery <DIFFINERY_DOMAIN>` in the account, and creates one when there is none: managed mode, for your hostname only.
156156 2. The widget's site key becomes the `TURNSTILE_SITE_KEY` variable of the deployed Worker.
157157 3. Its secret key becomes the `TURNSTILE_SECRET` secret: `scripts/ensure-secrets.sh` sets it when it's missing, and bootstrap replaces it before the deploy when it goes with another widget (the deployed `TURNSTILE_SITE_KEY` is not this widget's, for example because the widget was deleted and created again). The key goes from the Cloudflare API straight to `wrangler secret put` and is never printed.
158158
159-The deploy's API token needs **Account → Turnstile → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/) calls it "Turnstile Sites Write"). Without it the deploy warns, naming the permission, and goes on; people who aren't signed in then can't send security reports until you add it and deploy again.
159+The deploy's API token needs **Account → Turnstile → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/) calls it "Turnstile Sites Write"). Without it the deploy warns, naming the permission, and goes on; people who aren't signed in then can't send security reports, early access requests or contact messages until you add it and deploy again.
160160
161161 To use a widget of your own instead, add `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc` and store its secret key with `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`. The deploy then creates no widget and never replaces your secret; it only fills in a missing `TURNSTILE_SECRET` when your widget is in the same Cloudflare account. If you rotate the secret key of the deploy's widget in the dashboard, run `pnpm exec wrangler secret delete TURNSTILE_SECRET -c wrangler.deploy.jsonc` and deploy again, so the deploy stores the new one.
162162
@@ -177,7 +177,7 @@
177177 | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. |
178178 | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. |
179179 | `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. |
180-| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | The deploy, from its widget (see [Turnstile](#turnstile)) | With `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code (with `SIGNUP=open`), or sends a security report without signing in, passed the Turnstile widget. See [SIGNUP](#signup) and [Security contact](#security-contact). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured", and the security report form takes reports only from people who are signed in (local development skips the check). The next deploy sets it when the token has **Account → Turnstile → Edit**. |
180+| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | The deploy, from its widget (see [Turnstile](#turnstile)) | With `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code (with `SIGNUP=open`), or sends a security report, an early access request or a contact message without signing in, passed the Turnstile widget. See [SIGNUP](#signup) and [Security contact](#security-contact). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured", the security report and contact forms take reports and messages only from people who are signed in, and the early access form is closed (local development skips the check). The next deploy sets it when the token has **Account → Turnstile → Edit**. |
181181
182182 None of these secrets is rotated automatically, and `ensure-secrets.sh` never replaces one that exists. Keep a copy of `MASTER_KEY` somewhere safe: replacing it without keeping the old one as `MASTER_KEY_PREV` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out. To rotate it, follow [Rotating MASTER_KEY](#rotating-master_key). To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`.
183183
modifiedapps/diffinery/site/about.md+2−1View file
@@ -2,7 +2,7 @@
22 title: About Diffinery
33 seo_title: "About Diffinery: the git forge for people and AI agents"
44 description: Why Diffinery exists, why it's open source, how it's built, how we check the claims on this site, and how to reach us.
5-updated: 2026-10-25
5+updated: 2026-10-26
66 ---
77
88 Diffinery is a git forge and CI platform for teams where AI agents write a growing share of the code. Agents do the work through Git, pull requests and CI like everyone else; people see what the agents did, approve what needs them and follow the whole process. Diffinery runs entirely on Cloudflare, and it's open source under the Apache License 2.0: you deploy it to your own Cloudflare account and run it yourself.
@@ -40,6 +40,7 @@
4040
4141 ## Contact
4242
43+- **Questions, partnerships, press and privacy requests:** the [contact form](https://diffinery.com/contact). A person reads every message and replies by email.
4344 - **Running your own instance:** [Deploy your own Diffinery](/docs/self-hosting/deploy).
4445 - **Bugs, ideas and contributions:** issues and pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery), or on [the Diffinery repository](https://diffinery.com/codician/diffinery) if you have an account there.
4546 - **Security problems:** the private [report form](/security/report), as listed in [`/.well-known/security.txt`](/.well-known/security.txt).
modifiedapps/diffinery/site/faq.md+5−1View file
@@ -2,7 +2,7 @@
22 title: Diffinery FAQ
33 seo_title: "Diffinery FAQ: open source, self-hosting, GitHub Actions, AI agents"
44 description: Short answers about Diffinery: open source and self-hosting, cost, GitHub Actions compatibility, runners, Cloudflare, AI agents, security and migration.
5-updated: 2026-10-25
5+updated: 2026-10-26
66 ---
77
88 Short, direct answers to the questions people and AI assistants ask most about Diffinery. Each answer links to the page with the details. Facts about GitHub are from GitHub's own documentation, checked on October 25, 2026.
@@ -137,6 +137,10 @@
137137
138138 diffinery.com is the instance Diffinery itself is developed on, and accounts there are by invitation for the people working on it. To use Diffinery, deploy your own instance. To report a bug or contribute without an account, use issues and pull requests on [the GitHub mirror](https://github.com/codician-team/diffinery).
139139
140+### How do I contact the Diffinery team?
141+
142+Use the [contact form](https://diffinery.com/contact) at diffinery.com/contact, for questions, partnerships, press or privacy requests; a person reads every message and replies by email. Report security problems privately through [the security report form](/security/report), and bugs or ideas as issues on [the Diffinery repository](https://diffinery.com/codician/diffinery). See [Contact form](/docs/admin/contact-form).
143+
140144 ### How do people join my instance?
141145
142146 Every instance is invite-only by default: organization owners and admins invite people by email, and each invitation link creates an account with a passkey. Administrators can open sign-up to everyone with `SIGNUP=open`. See [Invitations](/docs/accounts/invitations) and [Configuration](/docs/self-hosting/configuration#signup).
modifiedapps/diffinery/site/privacy.md+3−1View file
@@ -46,6 +46,7 @@
4646 ## Forms anyone can use
4747
4848 - **Early access requests** (`/early-access`): the email address you give and, if you fill them in, your name, company, what you'd use Diffinery for and where you host code today. Unconfirmed requests are deleted after 7 days; confirmed ones are kept until a site administrator deletes the request, and you can delete yours yourself at any time. To enforce daily limits, we keep a hashed form of your IP address and of the email address you typed for one day. Details: [Early access](/docs/accounts/early-access).
49+- **Contact messages** (`/contact`): your name, the email address you give (or your account's, if you're signed in), the topic and your message, and a note our site administrators may add about how they answered. Only diffinery.com's site administrators can read messages, and we use them only to reply. If you aren't signed in, your message waits until you confirm your address with the link we email you; unconfirmed messages are deleted after 7 days, unread. Messages marked as spam are deleted after 30 days, and answered ones 365 days after we mark them handled. For daily limits, we keep a hashed form of your IP address and of the email address you typed for one day. Details: [Contact form](/docs/admin/contact-form).
4950 - **Security reports** (`/security/report`): what you write, and the email address you give for a reply if you aren't signed in. Only diffinery.com's site administrators can read reports.
5051 - People who aren't signed in pass a **Cloudflare Turnstile** check on these forms, which Cloudflare runs using signals from your browser.
5152
@@ -79,6 +80,7 @@
7980 | Job logs | 90 days |
8081 | Workflow artifacts | 1 to 90 days, as the workflow sets (90 by default) |
8182 | Account data exports | 7 days |
83+| Contact messages | 7 days unconfirmed; 30 days once marked spam; 365 days after they are answered |
8284 | Audit log | 2 years |
8385 | Database backups | 30 days of point-in-time recovery |
8486 | Username of a deleted account | Not reusable for 90 days |
@@ -105,4 +107,4 @@
105107
106108 ## Contact
107109
108-Codician, **hi@codician.com**. Security reports: [diffinery.com/security/report](/security/report).
110+Codician, **hi@codician.com**, or the [contact form](/contact). Security reports: [diffinery.com/security/report](/security/report).
modifiedapps/diffinery/src/web/indexnow.ts+3−1View file
@@ -28,7 +28,7 @@
2828 import { hmacSha256 } from "../lib/crypto.js";
2929 import type { Directory } from "../lib/directory.js";
3030 import { sha256 } from "../lib/util.js";
31-import { homeContent, homeMarkdown, sitePageMarkdown, sitePages } from "./site.js";
31+import { CONTACT, homeContent, homeMarkdown, sitePageMarkdown, sitePages } from "./site.js";
3232
3333 /**
3434 * Where submissions go, in order. IndexNow engines share what each one receives, so one accepted
@@ -104,6 +104,8 @@
104104 pages.set(pageUrl(p.slug), `${p.section}\n${md}`);
105105 pages.set(markdownUrl(p.slug), md);
106106 }
107+ // The contact form: its content is the code, so its last-changed date stands in for it.
108+ pages.set(CONTACT.path, `contact form, updated ${CONTACT.updated}`);
107109 pages.set("/llms.txt", llmsTxt(o));
108110 pages.set("/llms-full.txt", llmsFullTxt(o));
109111 pages.set("/llms-small.txt", llmsSmallTxt(o));
addedapps/diffinery/test/unit/contact.test.ts+316−0View file
@@ -1,0 +1,316 @@
1+import { describe, expect, it } from "vitest";
2+import type { Env } from "../../src/env.js";
3+import type { Account } from "../../src/lib/directory.js";
4+import {
5+ ContactMessages,
6+ EMAILS_PER_ADDRESS_PER_DAY,
7+ EMAILS_PER_DAY,
8+ HANDLED_DAYS,
9+ MAX_LINKS,
10+ MAX_MESSAGE,
11+ MAX_NAME,
12+ MAX_NOTE,
13+ MESSAGES_PER_ACCOUNT_PER_DAY,
14+ SPAM_DAYS,
15+ SUBMISSIONS_PER_IP_PER_DAY,
16+ UNCONFIRMED_DAYS,
17+ changeMessage,
18+ confirmMessage,
19+ deleteMessage,
20+ messageJson,
21+ submitMessage,
22+ validateMessage,
23+ type MessageInput,
24+} from "../../src/lib/contact.js";
25+import { pruneExpired } from "../../src/lib/retention.js";
26+import { HttpError } from "../../src/lib/util.js";
27+import type { MailJob } from "../../src/mail/types.js";
28+import { testD1 } from "./stubs/d1.js";
29+
30+const at = "2026-10-01T00:00:00.000Z";
31+const PUB = "https://diffinery.example";
32+
33+/** A D1 with two site administrators (one disabled), a person, and a mail queue that records what it gets. */
34+function setup() {
35+ const DB = testD1();
36+ const add = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)");
37+ add.run("u-root", "root", "Root", "root@example.com", "active", 1, at);
38+ add.run("u-old", "old", "Old", "old@example.com", "disabled", 1, at);
39+ add.run("u-ann", "ann", "Ann", "ann@example.com", "active", 0, at);
40+ const queued: MailJob[] = [];
41+ const MAIL = { sendBatch: async (b: Array<{ body: MailJob }>) => void queued.push(...b.map((m) => m.body)) };
42+ const env = { DB, MAIL, PUBLIC_URL: PUB } as unknown as Env;
43+ const audits: Array<{ action: string; target?: string; actorLogin?: string; detail?: unknown }> = [];
44+ const audit = async (e: (typeof audits)[number]) => void audits.push(e);
45+ const account = (id: string) => DB.sqlite.prepare("SELECT * FROM accounts WHERE id = ?").get(id) as unknown as Account;
46+ return { env, DB, queued, audits, audit, root: account("u-root"), ann: account("u-ann") };
47+}
48+
49+const refused = (f: () => unknown, status: number, msg: RegExp) => {
50+ try {
51+ f();
52+ } catch (e) {
53+ expect(e).toBeInstanceOf(HttpError);
54+ expect((e as HttpError).status).toBe(status);
55+ expect((e as HttpError).detail).toMatch(msg);
56+ return;
57+ }
58+ throw new Error("expected it to be refused");
59+};
60+
61+const rejects = async (p: Promise<unknown>, status: number, msg: RegExp) => {
62+ const e = await p.then(
63+ () => null,
64+ (x: unknown) => x,
65+ );
66+ expect(e).toBeInstanceOf(HttpError);
67+ expect((e as HttpError).status).toBe(status);
68+ expect((e as HttpError).detail).toMatch(msg);
69+};
70+
71+const input = (email: string | null, over: Partial<MessageInput> = {}): MessageInput => ({ email, name: "Ada", topic: "question", message: "Does Diffinery run my workflows as they are?", ...over });
72+const tokenOf = (job: MailJob | undefined) => /#([A-Za-z0-9_-]+)$/.exec((job as { data: { url: string } }).data.url)![1]!;
73+const rows = (DB: ReturnType<typeof testD1>) => DB.sqlite.prepare("SELECT * FROM contact_messages ORDER BY created_at, id").all() as Array<Record<string, unknown>>;
74+
75+describe("message validation", () => {
76+ it("needs a name, a valid address, a topic from the list and a message, and tidies them", () => {
77+ expect(validateMessage({ email: " Ada@Example.COM ", name: " Ada \n Lovelace ", topic: "press", message: "\r\nLine one\r\n\r\n\r\n\r\nLine two " }, false)).toEqual({ email: "ada@example.com", name: "Ada Lovelace", topic: "press", message: "Line one\n\nLine two" });
78+ });
79+
80+ it("takes no address from a signed-in person: their account's is used", () => {
81+ expect(validateMessage({ email: "someone-else@example.com", name: "Ann", topic: "other", message: "Hello there, team!" }, true)).toEqual({ email: null, name: "Ann", topic: "other", message: "Hello there, team!" });
82+ });
83+
84+ it("strips control and text-direction characters that could disguise text", () => {
85+ expect(validateMessage({ email: "a@b.co", name: "Ad‮a\u0000⁦x", topic: "question", message: "okay then\u0007‏, thanks" }, false)).toMatchObject({ name: "Adax", message: "okay then, thanks" });
86+ });
87+
88+ it("refuses what's missing, too short, too long (never cut), off the list or full of links", () => {
89+ const ok = { email: "a@b.co", name: "Ada", topic: "question", message: "A real question here." };
90+ refused(() => validateMessage({ ...ok, email: " " }, false), 422, /Enter your email address/);
91+ refused(() => validateMessage({ ...ok, email: "<script>@x.y" }, false), 422, /valid email address/);
92+ refused(() => validateMessage({ ...ok, name: " " }, false), 422, /Enter your name/);
93+ refused(() => validateMessage({ ...ok, name: "x".repeat(MAX_NAME + 1) }, false), 422, /100 characters/);
94+ refused(() => validateMessage({ ...ok, topic: "" }, false), 422, /Choose what your message is about/);
95+ refused(() => validateMessage({ ...ok, topic: "sales" }, false), 422, /from the list/);
96+ refused(() => validateMessage({ ...ok, message: "" }, false), 422, /Write your message/);
97+ refused(() => validateMessage({ ...ok, message: "hi" }, false), 422, /at least 10 characters/);
98+ refused(() => validateMessage({ ...ok, message: "x".repeat(MAX_MESSAGE + 1) }, false), 422, /5,000 characters/);
99+ expect(validateMessage({ ...ok, message: "x".repeat(MAX_MESSAGE) }, false).message).toHaveLength(MAX_MESSAGE);
100+ const links = (n: number) => Array.from({ length: n }, (_, i) => `https://spam${i}.example`).join(" ");
101+ expect(validateMessage({ ...ok, message: links(MAX_LINKS) }, false).message).toContain("spam4");
102+ refused(() => validateMessage({ ...ok, message: `${links(MAX_LINKS)} www.more.example` }, false), 422, /5 links or fewer/);
103+ refused(() => validateMessage({ ...ok, message: ["a list, not text"] }, false), 422, /Write your message/);
104+ });
105+});
106+
107+describe("sending without signing in", () => {
108+ it("stores the message unconfirmed and emails only a confirmation link, with nothing typed in it", async () => {
109+ const { env, DB, queued, audits, audit } = setup();
110+ expect(await submitMessage(env, audit, input("new@example.net", { name: "<b>Buy now</b>", message: "Visit https://spam.example for deals!" }), null, "203.0.113.9")).toBe("confirm");
111+ const [m] = rows(DB);
112+ expect(m).toMatchObject({ email: "new@example.net", name: "<b>Buy now</b>", state: "unconfirmed", account_id: null, confirmed_at: null });
113+ expect(queued).toHaveLength(1);
114+ expect(queued[0]).toMatchObject({ kind: "contact_confirm", to: { email: "new@example.net" }, reason: "contact", data: { topic: "A question about Diffinery" } });
115+ expect(JSON.stringify(queued[0])).not.toMatch(/Buy now|spam\.example/);
116+ // Only a hash of the link's code is kept.
117+ expect(m!.confirm_hash).toMatch(/^[0-9a-f]{64}$/);
118+ expect(JSON.stringify(rows(DB))).not.toContain(tokenOf(queued[0]));
119+ expect(audits).toEqual([{ action: "contact.create", target: m!.id, detail: { signed_in: false } }]);
120+ // The site administrators hear nothing until it is confirmed.
121+ expect(DB.sqlite.prepare("SELECT count(*) AS n FROM notifications").get()).toEqual({ n: 0 });
122+ });
123+
124+ it("confirms with the link once: the message becomes new and every active site administrator hears about it", async () => {
125+ const { env, DB, queued, audits, audit } = setup();
126+ await submitMessage(env, audit, input("new@example.net"), null, "203.0.113.9");
127+ const token = tokenOf(queued[0]);
128+ const m = (await confirmMessage(env, audit, token))!;
129+ expect(m).toMatchObject({ state: "new", confirm_hash: null });
130+ expect(rows(DB)[0]).toMatchObject({ state: "new", confirm_hash: null, confirm_expires_at: null });
131+ expect(audits.at(-1)).toEqual({ action: "contact.confirm", target: m.id });
132+ const mails = queued.slice(1);
133+ expect(mails).toEqual([{ kind: "contact_message", data: { id: m.id, name: "Ada", email: "new@example.net", login: null, topic: "A question about Diffinery", message: "Does Diffinery run my workflows as they are?" }, to: { accountId: "u-root" }, reason: "admin", at: m.created_at }]);
134+ expect(DB.sqlite.prepare("SELECT account_id, kind, url FROM notifications").all()).toEqual([{ account_id: "u-root", kind: "contact", url: "/settings/admin/contact" }]);
135+ // The link works once.
136+ expect(await confirmMessage(env, audit, token)).toBeNull();
137+ // A second message adds no second unread notification, but does email again.
138+ await submitMessage(env, audit, input("other@example.net"), null, "203.0.113.10");
139+ await confirmMessage(env, audit, tokenOf(queued.at(-1)));
140+ expect(DB.sqlite.prepare("SELECT count(*) AS n FROM notifications").get()).toEqual({ n: 1 });
141+ expect(queued.filter((j) => j.kind === "contact_message")).toHaveLength(2);
142+ });
143+
144+ it("refuses unknown, malformed and expired links", async () => {
145+ const { env, DB, queued, audit } = setup();
146+ expect(await confirmMessage(env, audit, "nope")).toBeNull();
147+ expect(await confirmMessage(env, audit, "a".repeat(32))).toBeNull();
148+ await submitMessage(env, audit, input("late@example.net"), null, "203.0.113.9");
149+ DB.sqlite.prepare("UPDATE contact_messages SET confirm_expires_at = ?").run(at);
150+ expect(await confirmMessage(env, audit, tokenOf(queued[0]))).toBeNull();
151+ expect(rows(DB)[0]).toMatchObject({ state: "unconfirmed" });
152+ });
153+
154+ it("keeps each message separate, so a second one from the same address needs its own confirmation", async () => {
155+ const { env, DB, queued, audit } = setup();
156+ await submitMessage(env, audit, input("ada@example.net", { message: "First message, about one thing." }), null, "203.0.113.9");
157+ await submitMessage(env, audit, input("ada@example.net", { message: "Second message, about another." }), null, "203.0.113.9");
158+ expect(rows(DB)).toHaveLength(2);
159+ await confirmMessage(env, audit, tokenOf(queued[1]));
160+ expect(rows(DB).map((r) => [r.message, r.state])).toEqual([
161+ ["First message, about one thing.", "unconfirmed"],
162+ ["Second message, about another.", "new"],
163+ ]);
164+ });
165+
166+ it("limits one network, one address and the whole form per day, quietly past the last two", async () => {
167+ const { env, DB, queued, audit } = setup();
168+ for (let i = 0; i < EMAILS_PER_ADDRESS_PER_DAY; i++) expect(await submitMessage(env, audit, input("same@example.net"), null, `198.51.100.${i}`)).toBe("confirm");
169+ expect(await submitMessage(env, audit, input("same@example.net"), null, "198.51.100.99")).toBe("limited");
170+ expect(queued).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY);
171+ expect(rows(DB)).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY);
172+
173+ for (let i = 0; i < SUBMISSIONS_PER_IP_PER_DAY; i++) await submitMessage(env, audit, input(`n${i}@example.net`), null, "192.0.2.1");
174+ await rejects(submitMessage(env, audit, input("one-more@example.net"), null, "192.0.2.1"), 429, /Too many messages from your network today/);
175+
176+ DB.sqlite.prepare("DELETE FROM contact_attempts").run();
177+ const many = DB.sqlite.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, 'x', ?, 1, ?)");
178+ for (let i = 0; i < EMAILS_PER_DAY; i++) many.run(`a${i}`, `e${i}`, new Date().toISOString());
179+ const before = queued.length;
180+ expect(await submitMessage(env, audit, input("fresh@example.net"), null, "203.0.113.50")).toBe("limited");
181+ expect(queued).toHaveLength(before);
182+ });
183+
184+ it("keeps only hashes of the IP address and the address typed for the limits", async () => {
185+ const { env, DB, audit } = setup();
186+ await submitMessage(env, audit, input("hash@example.net"), null, "203.0.113.77");
187+ const dump = JSON.stringify(DB.sqlite.prepare("SELECT * FROM contact_attempts").all());
188+ expect(dump).not.toContain("203.0.113.77");
189+ expect(dump).not.toContain("hash@example.net");
190+ });
191+
192+ it("drops messages from an address while one from it is marked spam, until it is reopened", async () => {
193+ const { env, DB, queued, audit, root } = setup();
194+ await submitMessage(env, audit, input("spammer@example.net"), null, "203.0.113.9");
195+ const [first] = rows(DB);
196+ await changeMessage(env.DB, audit, root, first!.id as string, { state: "spam" }, "web");
197+ const sent = queued.length;
198+ expect(await submitMessage(env, audit, input("SPAMMER@example.net"), null, "203.0.113.10")).toBe("blocked");
199+ expect(queued).toHaveLength(sent);
200+ expect(rows(DB)).toHaveLength(1);
201+ await changeMessage(env.DB, audit, root, first!.id as string, { state: "new" }, "web");
202+ expect(await submitMessage(env, audit, input("spammer@example.net"), null, "203.0.113.10")).toBe("confirm");
203+ });
204+});
205+
206+describe("sending signed in", () => {
207+ it("arrives new from the account's address, with a copy to the sender and a notice to the site administrators", async () => {
208+ const { env, DB, queued, audits, audit, ann } = setup();
209+ expect(await submitMessage(env, audit, input(null, { name: "Ann A." }), ann, "203.0.113.9")).toBe("sent");
210+ const [m] = rows(DB);
211+ expect(m).toMatchObject({ email: "ann@example.com", name: "Ann A.", account_id: "u-ann", sender_login: "ann", state: "new", confirm_hash: null });
212+ expect(m!.confirmed_at).toBe(m!.created_at);
213+ expect(queued.map((j) => [j.kind, j.to])).toEqual([
214+ ["contact_received", { accountId: "u-ann" }],
215+ ["contact_message", { accountId: "u-root" }],
216+ ]);
217+ expect(queued[1]).toMatchObject({ data: { login: "ann", email: "ann@example.com" } });
218+ expect(audits).toEqual([{ actorId: "u-ann", actorLogin: "ann", action: "contact.create", target: m!.id, detail: { signed_in: true } }]);
219+ // No daily-limit rows: a signed-in person is counted by account.
220+ expect(DB.sqlite.prepare("SELECT count(*) AS n FROM contact_attempts").get()).toEqual({ n: 0 });
221+ });
222+
223+ it("limits one person per day", async () => {
224+ const { env, DB, audit, ann } = setup();
225+ const add = DB.sqlite.prepare("INSERT INTO contact_messages (id, email, name, topic, message, account_id, state, created_at, updated_at) VALUES (?, 'ann@example.com', 'Ann', 'other', 'm', 'u-ann', 'new', ?, ?)");
226+ for (let i = 0; i < MESSAGES_PER_ACCOUNT_PER_DAY; i++) add.run(`m${i}`, new Date().toISOString(), new Date().toISOString());
227+ await rejects(submitMessage(env, audit, input(null), ann, null), 429, /20 messages today/);
228+ });
229+});
230+
231+describe("site administrators", () => {
232+ async function confirmed() {
233+ const s = setup();
234+ await submitMessage(s.env, s.audit, input("ada@example.net"), null, "203.0.113.9");
235+ await confirmMessage(s.env, s.audit, tokenOf(s.queued[0]));
236+ return { ...s, id: rows(s.DB)[0]!.id as string };
237+ }
238+
239+ it("mark handled and spam, reopen, and keep a note, auditing each change without the message", async () => {
240+ const { env, audits, audit, root, id } = await confirmed();
241+ const handled = await changeMessage(env.DB, audit, root, id, { state: "handled", note: " Replied with the guide. " }, "api");
242+ expect(handled).toMatchObject({ state: "handled", note: "Replied with the guide.", updated_by: "root" });
243+ expect(handled.handled_at).toBeTruthy();
244+ expect(audits.slice(-2)).toEqual([
245+ { actorId: "u-root", actorLogin: "root", action: "contact.handle", target: id, detail: { from: "new", to: "handled", via: "api" } },
246+ { actorId: "u-root", actorLogin: "root", action: "contact.note", target: id, detail: { via: "api" } },
247+ ]);
248+ expect(JSON.stringify(audits)).not.toContain("Replied with");
249+ // A note alone keeps the state; an empty note clears it.
250+ expect(await changeMessage(env.DB, audit, root, id, { note: "" }, "web")).toMatchObject({ state: "handled", note: null });
251+ expect(await changeMessage(env.DB, audit, root, id, { state: "spam" }, "web")).toMatchObject({ state: "spam" });
252+ const reopened = await changeMessage(env.DB, audit, root, id, { state: "new" }, "web");
253+ expect(reopened).toMatchObject({ state: "new", handled_at: null });
254+ expect(audits.at(-1)).toMatchObject({ action: "contact.reopen", detail: { from: "spam", to: "new" } });
255+ await rejects(changeMessage(env.DB, audit, root, id, { state: "deleted" }, "api"), 422, /state must be one of/);
256+ await rejects(changeMessage(env.DB, audit, root, id, {}, "api"), 422, /a state, a note, or both/);
257+ await rejects(changeMessage(env.DB, audit, root, id, { note: "x".repeat(MAX_NOTE + 1) }, "api"), 422, /2,000 characters/);
258+ await rejects(changeMessage(env.DB, audit, root, "0".repeat(32), { state: "handled" }, "api"), 404, /no contact message/);
259+ });
260+
261+ it("reopen an unconfirmed message as unconfirmed", async () => {
262+ const { env, DB, audit, root } = setup();
263+ await submitMessage(env, audit, input("ada@example.net"), null, "203.0.113.9");
264+ const id = rows(DB)[0]!.id as string;
265+ await changeMessage(env.DB, audit, root, id, { state: "spam" }, "web");
266+ expect(await changeMessage(env.DB, audit, root, id, { state: "new" }, "web")).toMatchObject({ state: "unconfirmed" });
267+ });
268+
269+ it("delete a message, auditing that it happened but not what it said or who sent it", async () => {
270+ const { env, DB, audits, audit, root, id } = await confirmed();
271+ await deleteMessage(env.DB, audit, root, id, "web");
272+ expect(rows(DB)).toHaveLength(0);
273+ expect(audits.at(-1)).toEqual({ actorId: "u-root", actorLogin: "root", action: "contact.delete", target: id, detail: { state: "new", via: "web" } });
274+ expect(JSON.stringify(audits)).not.toContain("ada@example.net");
275+ await rejects(deleteMessage(env.DB, audit, root, id, "web"), 404, /no contact message/);
276+ });
277+
278+ it("list, filter, search, count and show messages as the API does", async () => {
279+ const { env, audit, root, id } = await confirmed();
280+ await submitMessage(env, audit, input("bob@example.net", { name: "Bob", message: "Partnership idea with 100% more agents" }), null, "203.0.113.20");
281+ const store = new ContactMessages(env.DB);
282+ expect(await store.counts()).toEqual({ new: 1, handled: 0, spam: 0, unconfirmed: 1 });
283+ expect(await store.waiting()).toBe(1);
284+ expect((await store.list({ state: "new" })).items.map((m) => m.id)).toEqual([id]);
285+ expect((await store.list({ q: "100%" })).items.map((m) => m.name)).toEqual(["Bob"]);
286+ expect((await store.list({ q: "%" })).items.map((m) => m.name)).toEqual(["Bob"]);
287+ expect((await store.list({ limit: 1 })).more).toBe(true);
288+ expect(await store.get("not-an-id")).toBeNull();
289+ const json = messageJson(env, (await changeMessage(env.DB, audit, root, id, { state: "handled" }, "api")) as never);
290+ expect(Object.keys(json)).toEqual(["id", "state", "email", "name", "topic", "message", "sender", "confirmed_at", "note", "updated_by", "handled_at", "created_at", "updated_at", "html_url"]);
291+ expect(json).toMatchObject({ state: "handled", topic: "question", html_url: `${PUB}/settings/admin/contact/${id}` });
292+ expect(JSON.stringify(json)).not.toContain("confirm_hash");
293+ });
294+});
295+
296+describe("retention", () => {
297+ it("deletes unconfirmed messages after a week, spam after a month, handled after a year, and limit rows after a day", async () => {
298+ const { DB } = setup();
299+ const now = new Date("2026-10-20T00:00:00.000Z");
300+ const ago = (d: number) => new Date(now.getTime() - d * 86400_000).toISOString();
301+ const add = DB.sqlite.prepare("INSERT INTO contact_messages (id, email, name, topic, message, state, handled_at, created_at, updated_at) VALUES (?, 'a@b.co', 'A', 'other', 'm', ?, ?, ?, ?)");
302+ add.run("old-unconfirmed", "unconfirmed", null, ago(UNCONFIRMED_DAYS + 1), ago(UNCONFIRMED_DAYS + 1));
303+ add.run("new-unconfirmed", "unconfirmed", null, ago(1), ago(1));
304+ add.run("old-spam", "spam", ago(SPAM_DAYS + 1), ago(400), ago(SPAM_DAYS + 1));
305+ add.run("new-spam", "spam", ago(SPAM_DAYS - 1), ago(400), ago(SPAM_DAYS - 1));
306+ add.run("old-handled", "handled", ago(HANDLED_DAYS + 1), ago(400), ago(HANDLED_DAYS + 1));
307+ add.run("new-handled", "handled", ago(HANDLED_DAYS - 1), ago(400), ago(HANDLED_DAYS - 1));
308+ add.run("waiting", "new", null, ago(800), ago(800));
309+ const attempt = DB.sqlite.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, 'i', 'e', 1, ?)");
310+ attempt.run("old", ago(2));
311+ attempt.run("new", ago(0.5));
312+ const r = await pruneExpired(DB, now);
313+ expect(r).toMatchObject({ contact_messages: 3, contact_attempts: 1 });
314+ expect((DB.sqlite.prepare("SELECT id FROM contact_messages ORDER BY id").all() as Array<{ id: string }>).map((x) => x.id)).toEqual(["new-handled", "new-spam", "new-unconfirmed", "waiting"]);
315+ });
316+});
modifiedapps/diffinery/test/unit/docs.test.ts+1−1View file
@@ -43,7 +43,7 @@
4343 }
4444
4545 /** Paths outside /docs that pages may link to: real routes of the app. */
46-const SITE_PATHS = [/^\/$/, /^\/llms(-full|-small)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/early-access(\/remove)?$/, /^\/status$/, /^\/healthz$/];
46+const SITE_PATHS = [/^\/$/, /^\/llms(-full|-small)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/early-access(\/remove)?$/, /^\/contact$/, /^\/status$/, /^\/healthz$/];
4747
4848 describe("docs pages", () => {
4949 it("has every page in the navigation, in order", () => {
modifiedapps/diffinery/test/unit/retention.test.ts+1−1View file
@@ -19,7 +19,7 @@
1919 usage.run("old-job", ago(COMPUTE_RETENTION_DAYS + 2), ago(COMPUTE_RETENTION_DAYS + 1));
2020 usage.run("recent-job", ago(30), ago(30));
2121 usage.run("running", ago(0), null);
22- expect(await pruneExpired(DB, at)).toEqual({ sessions: 1, notifications: 1, compute_usage: 1, early_access: 0, early_access_attempts: 0 });
22+ expect(await pruneExpired(DB, at)).toEqual({ sessions: 1, notifications: 1, compute_usage: 1, early_access: 0, early_access_attempts: 0, contact_messages: 0, contact_attempts: 0 });
2323 const ids = (t: string) => (DB.sqlite.prepare(`SELECT id FROM ${t} ORDER BY id`).all() as Array<{ id: string }>).map((r) => r.id);
2424 expect(ids("sessions")).toEqual(["just-expired", "live"]);
2525 expect(ids("notifications")).toEqual(["recent"]);
modifiedapps/diffinery/test/unit/site.test.ts+1−1View file
@@ -39,7 +39,7 @@
3939 const prose = (md: string) => md.replace(/^```[\s\S]*?^```/gm, "").replace(/`[^`\n]*`/g, "");
4040
4141 /** Paths outside /docs and the site pages that the site may link to. */
42-const OTHER_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/early-access$/, /^\/status$/, /^\/security\/report$/, /^\/\.well-known\/security\.txt$/, /^\/docs\/api\/openapi\.json$/];
42+const OTHER_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/early-access$/, /^\/contact$/, /^\/status$/, /^\/security\/report$/, /^\/\.well-known\/security\.txt$/, /^\/docs\/api\/openapi\.json$/];
4343
4444 function linkProblems(md: string): string[] {
4545 const problems: string[] = [];