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: a public contact form with Turnstile, email confirmation and admin review #329

Mergedtest-botagenton behalf of cnbrkdmrcimerged into main from claude/contact-page
41 files changed+2112−41base 2bf0f45 → head 0b3a209
Files (41)
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
addedapps/diffinery/migrations/0043_contact.sql+41−0View file
@@ -1,0 +1,41 @@
1+-- Contact messages (lib/contact.ts): what people send through /contact to the instance's site
2+-- administrators. A message from someone who isn't signed in is unconfirmed until its address follows
3+-- the emailed link; a signed-in person's arrives confirmed. Confirmed messages are new until a site
4+-- administrator marks them handled or spam. Only site administrators can read them; the audit log keeps
5+-- every change. Unconfirmed messages are deleted after 7 days, spam after 30, handled after 365.
6+CREATE TABLE IF NOT EXISTS contact_messages (
7+ id TEXT PRIMARY KEY,
8+ email TEXT NOT NULL COLLATE NOCASE, -- where to reply: the address typed, or a signed-in sender's own
9+ name TEXT NOT NULL,
10+ topic TEXT NOT NULL, -- one of a fixed list
11+ message TEXT NOT NULL,
12+ account_id TEXT, -- the sender, when signed in
13+ sender_login TEXT,
14+ state TEXT NOT NULL DEFAULT 'unconfirmed' CHECK (state IN ('unconfirmed', 'new', 'handled', 'spam')),
15+ confirm_hash TEXT, -- SHA-256 of the emailed confirmation token, until it is used
16+ confirm_expires_at TEXT,
17+ confirmed_at TEXT,
18+ note TEXT, -- the site administrators' latest note
19+ updated_by TEXT, -- the site administrator's login who last changed it
20+ handled_at TEXT, -- when it was marked handled or spam
21+ created_at TEXT NOT NULL,
22+ updated_at TEXT NOT NULL
23+);
24+CREATE INDEX IF NOT EXISTS contact_messages_state ON contact_messages(state, created_at);
25+CREATE INDEX IF NOT EXISTS contact_messages_email ON contact_messages(email, state);
26+CREATE INDEX IF NOT EXISTS contact_messages_account ON contact_messages(account_id, created_at);
27+CREATE UNIQUE INDEX IF NOT EXISTS contact_messages_confirm ON contact_messages(confirm_hash);
28+
29+-- One row per form submission from someone who isn't signed in, for the daily limits only: hashes of
30+-- the sender's IP address and of the address typed, never either in the clear. Rows older than a day
31+-- are deleted.
32+CREATE TABLE IF NOT EXISTS contact_attempts (
33+ id TEXT PRIMARY KEY,
34+ ip_hash TEXT NOT NULL,
35+ email_hash TEXT NOT NULL,
36+ emailed INTEGER NOT NULL DEFAULT 0, -- 1 when this submission sent an email
37+ created_at TEXT NOT NULL
38+);
39+CREATE INDEX IF NOT EXISTS contact_attempts_ip ON contact_attempts(ip_hash, created_at);
40+CREATE INDEX IF NOT EXISTS contact_attempts_email ON contact_attempts(email_hash, created_at);
41+CREATE INDEX IF NOT EXISTS contact_attempts_at ON contact_attempts(created_at);
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/app.ts+2−0View file
@@ -38,6 +38,7 @@
3838 import { securityTxtRoutes } from "./http/security-txt.js";
3939 import { mcpRegistryAuthRoutes } from "./http/mcp-registry-auth.js";
4040 import { securityReportsApi } from "./http/security-reports.js";
41+import { contactApi } from "./http/contact.js";
4142 import { earlyAccessApi } from "./http/early-access.js";
4243 import { statusApi } from "./http/status.js";
4344 import { onReviewEvent } from "./agentic/reviews.js";
@@ -130,6 +131,7 @@
130131 app.route("/", mailApi);
131132 app.route("/", securityReportsApi);
132133 app.route("/", earlyAccessApi);
134+app.route("/", contactApi);
133135 app.route("/", statusApi);
134136 app.route("/", mailEvents);
135137 app.route("/", registry);
modifiedapps/diffinery/src/docs/api-meta.ts+25−0View file
@@ -121,6 +121,10 @@
121121 "POST /diffinery/admin/early-access/{id}/invite": { scope: "admin" },
122122 "PATCH /diffinery/admin/early-access/{id}": { scope: "admin" },
123123 "DELETE /diffinery/admin/early-access/{id}": { scope: "admin" },
124+ "GET /diffinery/admin/contact": { scope: "read" },
125+ "GET /diffinery/admin/contact/{id}": { scope: "read" },
126+ "PATCH /diffinery/admin/contact/{id}": { scope: "admin" },
127+ "DELETE /diffinery/admin/contact/{id}": { scope: "admin" },
124128 "GET /diffinery/admin/status/incidents": { scope: "read" },
125129 "POST /diffinery/admin/status/incidents": { scope: "admin" },
126130 "POST /diffinery/admin/status/incidents/{id}/updates": { scope: "admin" },
@@ -1629,6 +1633,27 @@
16291633 access: "site-admin",
16301634 status: 204,
16311635 },
1636+ "GET /diffinery/admin/contact": {
1637+ summary: "List contact messages",
1638+ description:
1639+ "Messages sent through `/contact`, newest first: `id`, `state` (`unconfirmed` until the address follows the emailed link, then `new`, `handled` or `spam`), `email`, `name`, `topic` (`question`, `account`, `self-hosting`, `feedback`, `partnership`, `press`, `privacy` or `other`), `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`. 404 for anyone but a site administrator. See [Contact form](/docs/admin/contact-form).",
1640+ access: "site-admin",
1641+ query: { state: ["string", "Only messages in this state: `new`, `handled`, `spam` or `unconfirmed`."], q: ["string", "Only messages whose address, name or text contains this."], per_page: ["integer", "At most this many (default 100, up to 500)."], page: ["integer", "Page number, from 1."] },
1642+ },
1643+ "GET /diffinery/admin/contact/{id}": { summary: "Get a contact message", description: "One message, as in the list. 404 when there is none, or for anyone but a site administrator.", access: "site-admin" },
1644+ "PATCH /diffinery/admin/contact/{id}": {
1645+ summary: "Mark a contact message handled or spam, or note it",
1646+ description:
1647+ "`handled` or `spam` marks it; `new` reopens it (as `unconfirmed` if its address never confirmed it). `note` sets the note for the other site administrators (an empty one clears it; leaving it out keeps it). Nobody is emailed. Recorded in the audit log as `contact.handle`, `contact.spam`, `contact.reopen` or `contact.note`. While a message from an address is `spam`, new messages from that address are dropped without an email. 404 for anyone but a site administrator.",
1648+ access: "site-admin",
1649+ body: { state: ["string", "`handled`, `spam`, or `new` to reopen it."], note: ["string", "For the other site administrators, up to 2,000 characters."] },
1650+ },
1651+ "DELETE /diffinery/admin/contact/{id}": {
1652+ summary: "Delete a contact message",
1653+ description: "Removes the message, for someone who asks for their data to be removed. Recorded in the audit log as `contact.delete`, without the message or its address. 404 for anyone but a site administrator.",
1654+ access: "site-admin",
1655+ status: 204,
1656+ },
16321657 "GET /diffinery/status": {
16331658 summary: "Get the instance's status",
16341659 description:
addedapps/diffinery/src/http/contact.ts+59−0View file
@@ -1,0 +1,59 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js";
4+import type { Account } from "../lib/directory.js";
5+import { ContactMessages, MESSAGE_STATES, changeMessage, deleteMessage, messageJson, type MessageState } from "../lib/contact.js";
6+import { HttpError } from "../lib/util.js";
7+
8+/**
9+ * Contact messages over the REST API (lib/contact.ts), for site administrators only.
10+ * To anyone else these endpoints don't exist (404), like the messages themselves.
11+ */
12+export const contactApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
13+
14+const siteAdmin = (c: AppContext, scope: "read" | "admin"): Account => {
15+ const a = requireAccount(c);
16+ if (!a.site_admin || a.kind !== "user") throw new HttpError(404, "not found");
17+ requireAccountScope(c, scope);
18+ return a;
19+};
20+
21+const audit = (c: AppContext) => (e: Parameters<AppContext["var"]["dir"]["audit"]>[0]) => c.get("dir").audit(e);
22+
23+contactApi.get("/diffinery/admin/contact", async (c) => {
24+ siteAdmin(c, "read");
25+ const s = c.req.query("state");
26+ if (s && !MESSAGE_STATES.includes(s as MessageState)) throw new HttpError(422, `state must be one of ${MESSAGE_STATES.join(", ")}`);
27+ const n = Number(c.req.query("per_page") ?? 100);
28+ const page = Math.max(1, Math.min(1000, Number(c.req.query("page") ?? 1) || 1));
29+ const limit = Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), 1), 500) : 100;
30+ const { items } = await new ContactMessages(c.env.DB).list({ state: (s as MessageState) || undefined, q: c.req.query("q") ?? "", limit, offset: (page - 1) * limit });
31+ return c.json(items.map((m) => messageJson(c.env, m)));
32+});
33+
34+contactApi.get("/diffinery/admin/contact/:id", async (c) => {
35+ siteAdmin(c, "read");
36+ const m = await new ContactMessages(c.env.DB).get(c.req.param("id"));
37+ if (!m) throw new HttpError(404, "no contact message with that id");
38+ return c.json(messageJson(c.env, m));
39+});
40+
41+/** Mark a message handled or spam, reopen it (`{"state": "new"}`), or set its note. Audited; nobody is emailed. */
42+contactApi.patch("/diffinery/admin/contact/:id", async (c) => {
43+ const me = siteAdmin(c, "admin");
44+ let b: { state?: unknown; note?: unknown };
45+ try {
46+ b = (await c.req.json()) as typeof b;
47+ } catch {
48+ throw new HttpError(400, "request body must be JSON");
49+ }
50+ if (!b || typeof b !== "object") throw new HttpError(422, 'send {"state": "handled"}, "spam" or "new", a "note", or both');
51+ return c.json(messageJson(c.env, await changeMessage(c.env.DB, audit(c), me, c.req.param("id"), { state: b.state, note: b.note }, "api")));
52+});
53+
54+/** Delete a message (data removal). Audited without its content. */
55+contactApi.delete("/diffinery/admin/contact/:id", async (c) => {
56+ const me = siteAdmin(c, "admin");
57+ await deleteMessage(c.env.DB, audit(c), me, c.req.param("id"), "api");
58+ return c.body(null, 204);
59+});
addedapps/diffinery/src/lib/contact.ts+372−0View file
@@ -1,0 +1,372 @@
1+import type { Env } from "../env.js";
2+import type { Account } from "./directory.js";
3+import { normalEmail } from "../auth/signup.js";
4+import { enqueueMail } from "../mail/send.js";
5+import type { MailJob } from "../mail/types.js";
6+import { HttpError, newId, now, randomToken, sha256 } from "./util.js";
7+
8+/**
9+ * Contact messages: anyone can write to an instance's site administrators at /contact
10+ * (web/contact.tsx), signed in or not. Someone who isn't signed in passes Cloudflare Turnstile, a
11+ * hidden honeypot field and a signed timing stamp (the early access form's protections), and their
12+ * message stays unconfirmed until its address follows the emailed link: so nobody can put words in
13+ * someone else's name, and replies only ever go to an inbox that asked for them. A signed-in person's
14+ * message arrives confirmed, from their account's address.
15+ *
16+ * A confirmed message is new until a site administrator marks it handled or spam, on Site
17+ * administration → Contact messages. Each one emails the site administrators (from the sender's
18+ * address as Reply-To, so they can answer from their mailbox) and raises one in-app notification.
19+ *
20+ * The confirmation email never repeats anything typed into the form, so the form can't be used to
21+ * send someone else a message through Diffinery. An address with a message marked spam gets nothing
22+ * through until that message is put back or deleted.
23+ */
24+
25+export type MessageState = "unconfirmed" | "new" | "handled" | "spam";
26+export const MESSAGE_STATES: readonly MessageState[] = ["new", "handled", "spam", "unconfirmed"];
27+
28+/** Longest name, message and note, in characters, and the shortest message. Longer is refused, never cut. */
29+export const MAX_NAME = 100;
30+export const MAX_MESSAGE = 5_000;
31+export const MIN_MESSAGE = 10;
32+export const MAX_NOTE = 2_000;
33+/** Links in one message: spam carries many, people rarely need more. */
34+export const MAX_LINKS = 5;
35+
36+/** What a message is about: the only values the topic takes. */
37+export const TOPICS = [
38+ { value: "question", label: "A question about Diffinery" },
39+ { value: "account", label: "My account or invitation" },
40+ { value: "self-hosting", label: "Running my own Diffinery" },
41+ { value: "feedback", label: "Feedback or an idea" },
42+ { value: "partnership", label: "Partnerships and integrations" },
43+ { value: "press", label: "Press and media" },
44+ { value: "privacy", label: "Privacy and my data" },
45+ { value: "other", label: "Something else" },
46+] as const;
47+export type Topic = (typeof TOPICS)[number]["value"];
48+export const topicLabel = (v: string): string => TOPICS.find((t) => t.value === v)?.label ?? v;
49+export const isTopic = (v: string): v is Topic => TOPICS.some((t) => t.value === v);
50+
51+/** How long a confirmation link works, and how long unconfirmed, spam and handled messages are kept. */
52+export const CONFIRM_HOURS = 48;
53+export const UNCONFIRMED_DAYS = 7;
54+export const SPAM_DAYS = 30;
55+export const HANDLED_DAYS = 365;
56+
57+/** In 24 hours: submissions from one IP address, emails to one address, and emails from the form in total (people not signed in). */
58+export const SUBMISSIONS_PER_IP_PER_DAY = 10;
59+export const EMAILS_PER_ADDRESS_PER_DAY = 3;
60+export const EMAILS_PER_DAY = 300;
61+/** In 24 hours: messages from one signed-in person. */
62+export const MESSAGES_PER_ACCOUNT_PER_DAY = 20;
63+
64+export interface ContactMessage {
65+ id: string;
66+ email: string;
67+ name: string;
68+ topic: string;
69+ message: string;
70+ account_id: string | null;
71+ sender_login: string | null;
72+ state: MessageState;
73+ confirm_hash: string | null;
74+ confirm_expires_at: string | null;
75+ confirmed_at: string | null;
76+ note: string | null;
77+ updated_by: string | null;
78+ handled_at: string | null;
79+ created_at: string;
80+ updated_at: string;
81+}
82+
83+export interface MessageInput {
84+ /** Null for a signed-in sender, whose account's address is used. */
85+ email: string | null;
86+ name: string;
87+ topic: Topic;
88+ message: string;
89+}
90+
91+/** Control characters (except tab and newline) and bidirectional overrides, which could disguise text on the admin page. */
92+// eslint-disable-next-line no-control-regex
93+const INVISIBLE = /[\u0000-\u0008\u000B-\u001F\u007F‎‏‪-‮⁦-⁩]/g;
94+
95+const LINK = /\bhttps?:\/\/|\bwww\./gi;
96+
97+/**
98+ * Check what someone typed: their name, an email address (only when not signed in), a topic from the
99+ * list, and the message. One-line fields lose extra spaces; the message keeps its lines. Too long is
100+ * refused with the limit, never cut.
101+ */
102+export function validateMessage(raw: { email?: unknown; name?: unknown; topic?: unknown; message?: unknown }, signedIn: boolean): MessageInput {
103+ const str = (v: unknown) => (typeof v === "string" ? v.replace(INVISIBLE, "") : "");
104+ const line = (v: unknown) => str(v).replace(/\s+/g, " ").trim();
105+ const name = line(raw.name);
106+ const topic = line(raw.topic);
107+ const message = str(raw.message)
108+ .replace(/\r\n?/g, "\n")
109+ .replace(/\t/g, " ")
110+ .replace(/\n{3,}/g, "\n\n")
111+ .trim();
112+ let email: string | null = null;
113+ if (!signedIn) {
114+ const typed = line(raw.email);
115+ if (!typed) throw new HttpError(422, "Enter your email address, so we can reply.");
116+ email = normalEmail(typed);
117+ if (!email) throw new HttpError(422, "Enter a valid email address, like you@example.com.");
118+ }
119+ if (!name) throw new HttpError(422, "Enter your name.");
120+ if (name.length > MAX_NAME) throw new HttpError(422, `Keep your name to ${MAX_NAME} characters.`);
121+ if (!topic) throw new HttpError(422, "Choose what your message is about.");
122+ if (!isTopic(topic)) throw new HttpError(422, "Choose what your message is about from the list.");
123+ if (!message) throw new HttpError(422, "Write your message.");
124+ if (message.length < MIN_MESSAGE) throw new HttpError(422, `Write a little more: at least ${MIN_MESSAGE} characters.`);
125+ if (message.length > MAX_MESSAGE) throw new HttpError(422, `Keep your message to ${MAX_MESSAGE.toLocaleString("en")} characters. Offer to send more once we reply.`);
126+ if ((message.match(LINK) ?? []).length > MAX_LINKS) throw new HttpError(422, `Keep your message to ${MAX_LINKS} links or fewer.`);
127+ return { email, name, topic, message };
128+}
129+
130+// ---------------------------------------------------------------- storage
131+
132+/** Only hashes of the sender's IP address and of the typed address are kept for the daily limits. */
133+export const ipHash = (ip: string | null | undefined): Promise<string> => sha256(`diffinery-contact-ip:${ip || "unknown"}`);
134+export const emailHash = (email: string): Promise<string> => sha256(`diffinery-contact-email:${email.toLowerCase()}`);
135+
136+const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
137+
138+/** Contact messages in D1. */
139+export class ContactMessages {
140+ constructor(private db: D1Database) {}
141+
142+ async get(id: string): Promise<ContactMessage | null> {
143+ if (!/^[0-9a-f]{32}$/.test(id)) return null;
144+ return this.db.prepare("SELECT * FROM contact_messages WHERE id = ?").bind(id).first<ContactMessage>();
145+ }
146+
147+ /**
148+ * Newest first, optionally in one state and matching `q` (in the address, name or message).
149+ * One more than `limit` is read, so callers know whether there is a next page.
150+ */
151+ async list(o: { state?: MessageState; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: ContactMessage[]; more: boolean }> {
152+ const limit = Math.min(Math.max(o.limit ?? 50, 1), 500);
153+ const offset = Math.min(Math.max(o.offset ?? 0, 0), 100_000);
154+ const where: string[] = [];
155+ const binds: unknown[] = [];
156+ if (o.state) {
157+ where.push("state = ?");
158+ binds.push(o.state);
159+ }
160+ const q = (o.q ?? "").trim().slice(0, 100);
161+ if (q) {
162+ const like = `%${likeEscape(q)}%`;
163+ where.push("(email LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\')");
164+ binds.push(like, like, like);
165+ }
166+ const rows = (
167+ await this.db
168+ .prepare(`SELECT * FROM contact_messages${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`)
169+ .bind(...binds, limit + 1, offset)
170+ .all<ContactMessage>()
171+ ).results;
172+ return { items: rows.slice(0, limit), more: rows.length > limit };
173+ }
174+
175+ async counts(): Promise<Record<MessageState, number>> {
176+ const rows = (await this.db.prepare("SELECT state, count(*) AS n FROM contact_messages GROUP BY state").all<{ state: MessageState; n: number }>()).results;
177+ const out: Record<MessageState, number> = { unconfirmed: 0, new: 0, handled: 0, spam: 0 };
178+ for (const r of rows) out[r.state] = r.n;
179+ return out;
180+ }
181+
182+ /** New messages waiting for a site administrator: the count on the Site administration menu. */
183+ async waiting(): Promise<number> {
184+ return (await this.db.prepare("SELECT count(*) AS n FROM contact_messages WHERE state = 'new'").first<{ n: number }>())?.n ?? 0;
185+ }
186+
187+ /** Whether a site administrator marked a message from this address as spam (and it's still kept). */
188+ async spammer(email: string): Promise<boolean> {
189+ return !!(await this.db.prepare("SELECT 1 AS x FROM contact_messages WHERE email = ? AND state = 'spam' LIMIT 1").bind(email).first());
190+ }
191+
192+ /** Submissions from this IP address, emails to this address, and emails in total, in the last 24 hours. */
193+ async sentToday(ip: string, email: string): Promise<{ ip: number; email: number; all: number }> {
194+ const since = new Date(Date.now() - 86400_000).toISOString();
195+ const r = await this.db
196+ .prepare(
197+ "SELECT (SELECT count(*) FROM contact_attempts WHERE ip_hash = ? AND created_at > ?) AS ip, (SELECT count(*) FROM contact_attempts WHERE email_hash = ? AND emailed = 1 AND created_at > ?) AS email, (SELECT count(*) FROM contact_attempts WHERE emailed = 1 AND created_at > ?) AS a",
198+ )
199+ .bind(ip, since, email, since, since)
200+ .first<{ ip: number; email: number; a: number }>();
201+ return { ip: r?.ip ?? 0, email: r?.email ?? 0, all: r?.a ?? 0 };
202+ }
203+
204+ /** Messages from this signed-in person in the last 24 hours. */
205+ async fromAccountToday(accountId: string): Promise<number> {
206+ const since = new Date(Date.now() - 86400_000).toISOString();
207+ return (await this.db.prepare("SELECT count(*) AS n FROM contact_messages WHERE account_id = ? AND created_at > ?").bind(accountId, since).first<{ n: number }>())?.n ?? 0;
208+ }
209+
210+ async attempt(ip: string, email: string, emailed: boolean): Promise<void> {
211+ await this.db.prepare("INSERT INTO contact_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), ip, email, emailed ? 1 : 0, now()).run();
212+ }
213+}
214+
215+// ---------------------------------------------------------------- sending and confirming
216+
217+type Audit = (e: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
218+
219+/** What a submission did, for tests and logs; the visitor sees the same page whatever it is. */
220+export type Outcome = "confirm" | "sent" | "blocked" | "limited";
221+
222+/**
223+ * Take a validated message. `sender` is the signed-in person (their message arrives new, they get a
224+ * receipt and the site administrators hear about it now), or null: then the message is stored
225+ * unconfirmed and the typed address gets a link to confirm it.
226+ *
227+ * Refuses (429) when this network or this person sent too many today. Otherwise the visitor sees the
228+ * same page whatever happens; no email is sent and nothing is stored once the address had
229+ * EMAILS_PER_ADDRESS_PER_DAY today or the form EMAILS_PER_DAY in total, or when a message from the
230+ * address is marked spam.
231+ */
232+export async function submitMessage(env: Env, audit: Audit, input: MessageInput, sender: Account | null, ip: string | null): Promise<Outcome> {
233+ const store = new ContactMessages(env.DB);
234+ const at = now();
235+ if (sender) {
236+ if ((await store.fromAccountToday(sender.id)) >= MESSAGES_PER_ACCOUNT_PER_DAY) throw new HttpError(429, `You have sent ${MESSAGES_PER_ACCOUNT_PER_DAY} messages today. We'll reply to those first; try again tomorrow.`);
237+ const email = sender.email ?? "";
238+ if (!email) throw new HttpError(422, "Your account has no email address to reply to. Add one in Settings → Email first.");
239+ if (await store.spammer(email)) return "blocked";
240+ const id = newId();
241+ await env.DB.prepare("INSERT INTO contact_messages (id, email, name, topic, message, account_id, sender_login, state, confirmed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)")
242+ .bind(id, email, input.name, input.topic, input.message, sender.id, sender.login, at, at, at)
243+ .run();
244+ await audit({ actorId: sender.id, actorLogin: sender.login, action: "contact.create", target: id, detail: { signed_in: true } });
245+ await enqueueMail(env, [{ kind: "contact_received", data: { id, topic: topicLabel(input.topic), message: input.message }, to: { accountId: sender.id }, reason: "account", at }]);
246+ await notifyAdmins(env, { id, email, name: input.name, topic: input.topic, message: input.message, sender_login: sender.login, created_at: at });
247+ return "sent";
248+ }
249+
250+ const email = input.email!;
251+ const [ipH, emailH] = await Promise.all([ipHash(ip), emailHash(email)]);
252+ const sent = await store.sentToday(ipH, emailH);
253+ if (sent.ip >= SUBMISSIONS_PER_IP_PER_DAY) throw new HttpError(429, "Too many messages from your network today. Please try again tomorrow.");
254+ if (sent.email >= EMAILS_PER_ADDRESS_PER_DAY || sent.all >= EMAILS_PER_DAY) {
255+ await store.attempt(ipH, emailH, false);
256+ return "limited";
257+ }
258+ if (await store.spammer(email)) {
259+ await store.attempt(ipH, emailH, false);
260+ return "blocked";
261+ }
262+ const id = newId();
263+ const token = randomToken(24);
264+ const expiresAt = new Date(Date.now() + CONFIRM_HOURS * 3600_000).toISOString();
265+ await env.DB.prepare("INSERT INTO contact_messages (id, email, name, topic, message, state, confirm_hash, confirm_expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'unconfirmed', ?, ?, ?, ?)")
266+ .bind(id, email, input.name, input.topic, input.message, await sha256(token), expiresAt, at, at)
267+ .run();
268+ await store.attempt(ipH, emailH, true);
269+ await audit({ action: "contact.create", target: id, detail: { signed_in: false } });
270+ // The token rides in the fragment, which browsers never send, so request logs never hold it.
271+ await enqueueMail(env, [{ kind: "contact_confirm", data: { url: `${env.PUBLIC_URL}/contact/confirm#${token}`, expiresAt, topic: topicLabel(input.topic) }, to: { email }, reason: "contact", at }]);
272+ return "confirm";
273+}
274+
275+/**
276+ * Confirm the message a link's token belongs to: it becomes new, and the site administrators hear
277+ * about it. Null when the token is unknown, already used or expired.
278+ */
279+export async function confirmMessage(env: Env, audit: Audit, token: string): Promise<ContactMessage | null> {
280+ if (!/^[A-Za-z0-9_-]{20,100}$/.test(token)) return null;
281+ const hash = await sha256(token);
282+ const at = now();
283+ const row = await env.DB.prepare("SELECT * FROM contact_messages WHERE confirm_hash = ? AND state = 'unconfirmed' AND confirm_expires_at > ?").bind(hash, at).first<ContactMessage>();
284+ if (!row) return null;
285+ const done = await env.DB.prepare("UPDATE contact_messages SET state = 'new', confirmed_at = ?, confirm_hash = NULL, confirm_expires_at = NULL, updated_at = ? WHERE id = ? AND confirm_hash = ? AND state = 'unconfirmed'").bind(at, at, row.id, hash).run();
286+ if (!done.meta.changes) return null;
287+ await audit({ action: "contact.confirm", target: row.id });
288+ await notifyAdmins(env, row);
289+ return { ...row, state: "new", confirmed_at: at, confirm_hash: null, confirm_expires_at: null, updated_at: at };
290+}
291+
292+/**
293+ * Tell every active site administrator about a new message: an email each (the "Organizations" group
294+ * of their notification settings, with the sender as Reply-To), and one in-app notification, which
295+ * stays one however many messages arrive until it is read.
296+ */
297+export async function notifyAdmins(env: Env, m: Pick<ContactMessage, "id" | "email" | "name" | "topic" | "message" | "sender_login" | "created_at">): Promise<void> {
298+ const db = env.DB;
299+ const admins = (await db.prepare("SELECT id FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND suspended_at IS NULL LIMIT 50").all<{ id: string }>()).results;
300+ if (!admins.length) return;
301+ const at = now();
302+ await db.batch(
303+ admins.map((a) =>
304+ db
305+ .prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) SELECT ?, ?, NULL, 'contact', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM notifications WHERE account_id = ? AND kind = 'contact' AND read_at IS NULL)")
306+ .bind(newId(), a.id, "New contact messages are waiting for you", "/settings/admin/contact", at, a.id),
307+ ),
308+ );
309+ await enqueueMail(
310+ env,
311+ admins.map((a): MailJob => ({ kind: "contact_message", data: { id: m.id, name: m.name, email: m.email, login: m.sender_login, topic: topicLabel(m.topic), message: m.message }, to: { accountId: a.id }, reason: "admin", at: m.created_at })),
312+ );
313+}
314+
315+// ---------------------------------------------------------------- site administrators
316+
317+/** A message that exists, or 404. */
318+async function existing(db: D1Database, id: string): Promise<ContactMessage> {
319+ const m = await new ContactMessages(db).get(id);
320+ if (!m) throw new HttpError(404, "no contact message with that id");
321+ return m;
322+}
323+
324+const ACTION: Record<"new" | "handled" | "spam", string> = { new: "contact.reopen", handled: "contact.handle", spam: "contact.spam" };
325+
326+/**
327+ * Mark a message handled or spam, or put it back (`new`: new again, or unconfirmed if its address
328+ * never confirmed it), and keep a note for the other site administrators when one is given (an empty
329+ * note clears it; leaving it out keeps it). Nobody is emailed. Audited, without the message.
330+ */
331+export async function changeMessage(db: D1Database, audit: Audit, me: Account, id: string, change: { state?: unknown; note?: unknown }, via: "web" | "api"): Promise<ContactMessage> {
332+ const { state, note } = change;
333+ if (state !== undefined && state !== "new" && state !== "handled" && state !== "spam") throw new HttpError(422, "state must be one of new, handled, spam");
334+ if (note !== undefined && note !== null && typeof note !== "string") throw new HttpError(422, "note must be a string");
335+ if (state === undefined && note === undefined) throw new HttpError(422, "send a state, a note, or both");
336+ const text = typeof note === "string" ? note.replace(/\r\n?/g, "\n").trim() : "";
337+ if (text.length > MAX_NOTE) throw new HttpError(422, `Keep the note to ${MAX_NOTE.toLocaleString("en")} characters.`);
338+ const m = await existing(db, id);
339+ const at = now();
340+ const next: MessageState = state === undefined ? m.state : state === "new" ? (m.confirmed_at ? "new" : "unconfirmed") : state;
341+ const newNote = note === undefined ? m.note : text || null;
342+ const handledAt = next === "handled" || next === "spam" ? (next === m.state ? m.handled_at : at) : null;
343+ await db.prepare("UPDATE contact_messages SET state = ?, note = ?, updated_by = ?, handled_at = ?, updated_at = ? WHERE id = ?").bind(next, newNote, me.login, handledAt, at, m.id).run();
344+ if (next !== m.state) await audit({ actorId: me.id, actorLogin: me.login, action: ACTION[state as "new" | "handled" | "spam"], target: m.id, detail: { from: m.state, to: next, via } });
345+ if (newNote !== m.note) await audit({ actorId: me.id, actorLogin: me.login, action: "contact.note", target: m.id, detail: { via } });
346+ return { ...m, state: next, note: newNote, updated_by: me.login, handled_at: handledAt, updated_at: at };
347+}
348+
349+/** Delete a message, for someone who asks for their data to be removed. The audit log records that it happened, never what it said or its address. */
350+export async function deleteMessage(db: D1Database, audit: Audit, me: Account, id: string, via: "web" | "api"): Promise<void> {
351+ const m = await existing(db, id);
352+ await db.prepare("DELETE FROM contact_messages WHERE id = ?").bind(m.id).run();
353+ await audit({ actorId: me.id, actorLogin: me.login, action: "contact.delete", target: m.id, detail: { state: m.state, via } });
354+}
355+
356+/** The API's view of a message. */
357+export const messageJson = (env: Pick<Env, "PUBLIC_URL">, m: ContactMessage) => ({
358+ id: m.id,
359+ state: m.state,
360+ email: m.email,
361+ name: m.name,
362+ topic: m.topic,
363+ message: m.message,
364+ sender: m.sender_login,
365+ confirmed_at: m.confirmed_at,
366+ note: m.note,
367+ updated_by: m.updated_by,
368+ handled_at: m.handled_at,
369+ created_at: m.created_at,
370+ updated_at: m.updated_at,
371+ html_url: `${env.PUBLIC_URL}/settings/admin/contact/${m.id}`,
372+});
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
@@ -10,7 +10,8 @@
1010 *
1111 * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, private
1212 * security reports (/security/report), early access requests and their confirmations
13- * (/early-access and its confirm and remove pages), CLI sign-ins (starting one, typing or approving a
13+ * (/early-access and its confirm and remove pages), contact messages and their confirmations
14+ * (/contact and /contact/confirm), CLI sign-ins (starting one, typing or approving a
1415 * code), and MCP apps registering themselves for OAuth (/oauth/register), per IP address.
1516 * - RL_ANON: everything else from someone who isn't signed in, per IP address.
1617 * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account; from an
@@ -35,7 +36,7 @@
3536
3637 /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
3738 const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
38-const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|-\/cli\/device$|oauth\/register$)/;
39+const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|contact(?:\/confirm)?$|-\/cli\/device$|oauth\/register$)/;
3940 const CREATE: Array<[method: string, path: RegExp]> = [
4041 ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
4142 ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
@@ -97,7 +98,7 @@
9798 }
9899
99100 const WHAT: Record<Bucket, string> = {
100- RL_AUTH: "sign-in attempts, security reports and early access requests from your network",
101+ RL_AUTH: "sign-in attempts, security reports, early access requests and contact messages from your network",
101102 RL_ANON: "requests from your network without signing in",
102103 RL_ACCOUNT: "requests from your account",
103104 RL_CREATE: "new repositories, issues, pull requests and comments from your account",
modifiedapps/diffinery/src/lib/retention.ts+13−3View file
@@ -1,3 +1,4 @@
1+import { HANDLED_DAYS, SPAM_DAYS, UNCONFIRMED_DAYS as CONTACT_UNCONFIRMED_DAYS } from "./contact.js";
12 import { UNCONFIRMED_DAYS } from "./early-access.js";
23
34 /**
@@ -10,21 +11,28 @@
1011 * - hosted compute records (lib/quotas.ts) ended more than COMPUTE_RETENTION_DAYS ago, past any month
1112 * the usage pages show;
1213 * - early access requests (lib/early-access.ts) never confirmed within UNCONFIRMED_DAYS of their last
13- * submission, and the form's daily-limit rows once they are a day old.
14+ * submission, and the form's daily-limit rows once they are a day old;
15+ * - contact messages (lib/contact.ts) never confirmed within CONTACT_UNCONFIRMED_DAYS, marked spam more
16+ * than SPAM_DAYS ago or handled more than HANDLED_DAYS ago, and the form's daily-limit rows once they
17+ * are a day old.
1418 */
1519 export const NOTIFICATION_RETENTION_DAYS = 180;
1620 export const COMPUTE_RETENTION_DAYS = 400;
1721 /** Rows deleted per table per run: an hourly run keeps up with 120,000 new rows a day per table. */
1822 export const RETENTION_BATCH = 5000;
1923
20-export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage" | "early_access" | "early_access_attempts", number>> {
24+export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage" | "early_access" | "early_access_attempts" | "contact_messages" | "contact_attempts", number>> {
2125 const ago = (days: number) => new Date(at.getTime() - days * 86400_000).toISOString();
22- const [sessions, notifications, compute, early, attempts] = await db.batch([
26+ const [sessions, notifications, compute, early, attempts, contact, contactAttempts] = await db.batch([
2327 db.prepare("DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE expires_at < ? LIMIT ?)").bind(ago(1), batch),
2428 db.prepare("DELETE FROM notifications WHERE id IN (SELECT id FROM notifications WHERE created_at < ? LIMIT ?)").bind(ago(NOTIFICATION_RETENTION_DAYS), batch),
2529 db.prepare("DELETE FROM compute_usage WHERE id IN (SELECT id FROM compute_usage WHERE ended_at < ? LIMIT ?)").bind(ago(COMPUTE_RETENTION_DAYS), batch),
2630 db.prepare("DELETE FROM early_access WHERE id IN (SELECT id FROM early_access WHERE state = 'unconfirmed' AND updated_at < ? LIMIT ?)").bind(ago(UNCONFIRMED_DAYS), batch),
2731 db.prepare("DELETE FROM early_access_attempts WHERE id IN (SELECT id FROM early_access_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch),
32+ db
33+ .prepare("DELETE FROM contact_messages WHERE id IN (SELECT id FROM contact_messages WHERE (state = 'unconfirmed' AND created_at < ?) OR (state = 'spam' AND handled_at < ?) OR (state = 'handled' AND handled_at < ?) LIMIT ?)")
34+ .bind(ago(CONTACT_UNCONFIRMED_DAYS), ago(SPAM_DAYS), ago(HANDLED_DAYS), batch),
35+ db.prepare("DELETE FROM contact_attempts WHERE id IN (SELECT id FROM contact_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch),
2836 ]);
2937 return {
3038 sessions: sessions!.meta.changes ?? 0,
@@ -32,5 +40,7 @@
3240 compute_usage: compute!.meta.changes ?? 0,
3341 early_access: early!.meta.changes ?? 0,
3442 early_access_attempts: attempts!.meta.changes ?? 0,
43+ contact_messages: contact!.meta.changes ?? 0,
44+ contact_attempts: contactAttempts!.meta.changes ?? 0,
3545 };
3646 }
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -111,7 +111,7 @@
111111 }
112112
113113 /** Top-level paths the app serves; no account may use them as its login. */
114-export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
114+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "contact", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
115115
116116 /**
117117 * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
modifiedapps/diffinery/src/mail/prefs.ts+1−1View file
@@ -12,7 +12,7 @@
1212 actions: { web: boolean; email: boolean; failuresOnly: boolean };
1313 /** Repositories created, deleted, or access granted to you. */
1414 repos: { email: boolean };
15- /** Organization membership, accepted invitations and (for admins) access requests. */
15+ /** Organization membership, accepted invitations and (for admins) access requests and contact messages. */
1616 orgs: { email: boolean };
1717 /** Also email me about my own comments and changes (GitHub's "Include your own updates"). */
1818 ownActivity: boolean;
modifiedapps/diffinery/src/mail/samples.ts+7−0View file
@@ -94,6 +94,13 @@
9494 item("early-access-waiting", "Early access: already on the list", { kind: "early_access_known", reason: "early_access", data: { variant: "waiting", since: at } }),
9595 item("early-access-invited", "Early access: already invited", { kind: "early_access_known", reason: "early_access", data: { variant: "invited", since: at } }),
9696 item("early-access-account", "Early access: already has an account", { kind: "early_access_known", reason: "early_access", data: { variant: "account", since: at } }),
97+ item("contact-confirm", "Contact: confirm the message", { kind: "contact_confirm", reason: "contact", data: { url: `${pub}/contact/confirm#sample`, expiresAt: exp, topic: "A question about Diffinery" } }),
98+ item("contact-received", "Contact: copy for a signed-in sender", { kind: "contact_received", reason: "account", data: { id: "0123456789abcdef0123456789abcdef", topic: "Running my own Diffinery", message: "Hi! We'd like to run Diffinery for our 40 repositories.\n\nDoes it work with a custom domain on a Cloudflare zone we already have?" } }),
99+ item("contact-message", "Contact: new message for site administrators", {
100+ kind: "contact_message",
101+ reason: "admin",
102+ data: { id: "0123456789abcdef0123456789abcdef", name: "Rivera Kim", email: "rivera@example.com", login: null, topic: "Partnerships and integrations", message: "Hello,\n\nWe build a code review tool and would like to list Diffinery as a supported forge. Who should we talk to?\n\nThanks, Rivera" },
103+ }),
97104 item("security-report", "Security report", { kind: "security_report", reason: "admin", data: { id: "0123456789abcdef0123456789abcdef", summary: "Stored XSS in release notes" } }),
98105 item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }),
99106 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
modifiedapps/diffinery/src/mail/send.ts+2−0View file
@@ -91,6 +91,7 @@
9191 html: string;
9292 text: string;
9393 headers: Record<string, string>;
94+ replyTo?: { name: string; email: string } | string;
9495 }
9596
9697 /** Render a job for one recipient: the message plus the headers mail clients use for threading and unsubscribing. */
@@ -143,6 +144,7 @@
143144 html: toHtml(email, ctx),
144145 text: toText(email, ctx),
145146 headers,
147+ ...(email.replyTo ? { replyTo: email.replyTo.name ? { name: clean(email.replyTo.name), email: email.replyTo.email } : email.replyTo.email } : {}),
146148 };
147149 }
148150
modifiedapps/diffinery/src/mail/templates.ts+57−1View file
@@ -17,7 +17,9 @@
1717 | { callout: Inline[]; tone: "info" | "warn" | "danger" }
1818 | { code: string }
1919 | { items: Array<{ mark: "ok" | "fail" | "skip" | "dot"; text: Inline[]; url?: string }> }
20- | { quote: Inline[] };
20+ | { quote: Inline[] }
21+ /** Plain text exactly as someone typed it, lines kept, never read as Markdown or HTML. */
22+ | { plain: string };
2123
2224 export interface Email {
2325 subject: string;
@@ -35,6 +37,8 @@
3537 thread?: string;
3638 /** Main link (also "View it on Diffinery" in the footer). */
3739 url?: string;
40+ /** Where the recipient's reply goes, when it isn't to us (a contact message's confirmed sender). */
41+ replyTo?: { email: string; name?: string };
3842 }
3943
4044 export interface RenderCtx {
@@ -129,6 +133,8 @@
129133 if ("code" in b)
130134 return `<pre class="md" style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${C.codeBg};font-family:${MONO};font-size:12.5px;line-height:1.55;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.code)}</pre>`;
131135 if ("quote" in b) return `<div class="muted" style="margin:0 0 16px;padding:2px 0 2px 14px;border-left:3px solid ${C.border};color:${C.fg2};font-size:14px;line-height:1.6">${inlineHtml(b.quote)}</div>`;
136+ if ("plain" in b)
137+ return `<div class="md box" style="margin:0 0 16px;padding:14px 16px;border:1px solid ${C.border};border-radius:8px;font-size:14px;line-height:1.6;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.plain)}</div>`;
132138 const marks = { ok: ["✓", C.tones.green![1]], fail: ["✕", C.tones.red![1]], skip: ["–", C.fg3], dot: ["•", C.fg3] } as const;
133139 return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.items
134140 .map(
@@ -146,6 +152,7 @@
146152 if ("callout" in b) return `! ${inlineText(b.callout)}`;
147153 if ("code" in b) return b.code.split("\n").map((l) => ` ${l}`).join("\n");
148154 if ("quote" in b) return `> ${inlineText(b.quote)}`;
155+ if ("plain" in b) return b.plain.split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
149156 const marks = { ok: "[ok]", fail: "[failed]", skip: "[skipped]", dot: "-" };
150157 return b.items.map((it) => `${marks[it.mark]} ${inlineText(it.text)}${it.url ? ` <${it.url}>` : ""}`).join("\n");
151158 }
@@ -167,6 +174,7 @@
167174 account: "You are receiving this because you have a Diffinery account.",
168175 signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.",
169176 early_access: "You are receiving this because someone asked for early access to Diffinery with this email address. If it wasn't you, you can ignore this email.",
177+ contact: "You are receiving this because someone sent a message through Diffinery's contact form with this email address. If it wasn't you, you can ignore this email.",
170178 digest: "You are receiving this because you turned on the morning digest in your notification settings.",
171179 };
172180
@@ -840,6 +848,54 @@
840848 ],
841849 url: `${pub}/settings/admin`,
842850 };
851+ case "contact_confirm": {
852+ // Nothing typed into the form: the address isn't confirmed yet, so this must not carry anyone's words.
853+ const d = job.data;
854+ return {
855+ subject: "[Diffinery] Confirm your message to the Diffinery team",
856+ preheader: `Confirm before ${day(d.expiresAt)} and it goes to the team. Nothing is sent without it.`,
857+ eyebrow: { text: "Contact", tone: "blue" },
858+ title: ["Confirm your message"],
859+ blocks: [
860+ { p: ["Thanks for writing to us. We received a message sent with this email address through the contact form, under the topic ", { b: d.topic }, ". Confirm that it's yours and it goes to the team, who reply to this address."] },
861+ { button: { label: "Confirm my message", url: d.url } },
862+ { p: ["The link works once and expires ", at(d.expiresAt), ". If you didn't write to us, ignore this email: the message is never read and is deleted after a week. Please don't reply to this email; replies aren't read."], muted: true },
863+ ],
864+ url: d.url,
865+ };
866+ }
867+ case "contact_received": {
868+ const d = job.data;
869+ return {
870+ subject: "[Diffinery] We got your message",
871+ preheader: `Topic: ${d.topic}. The team replies to this address.`,
872+ eyebrow: { text: "Contact", tone: "green" },
873+ title: ["We got your message"],
874+ blocks: [
875+ { p: ["Thanks for writing to us. The team reads every message and replies to this address. Here is a copy of what you sent, under the topic ", { b: d.topic }, ":"] },
876+ { plain: d.message },
877+ { p: ["Reference ", { code: d.id }, ". Please don't reply to this email; replies aren't read. To add something, send another message."], muted: true },
878+ ],
879+ };
880+ }
881+ case "contact_message": {
882+ const d = job.data;
883+ const url = `${pub}/settings/admin/contact/${d.id}`;
884+ return {
885+ subject: `[Diffinery] Contact: ${d.topic} from ${d.name}`,
886+ preheader: d.message.replace(/\s+/g, " ").slice(0, 140),
887+ eyebrow: { text: "Contact message", tone: "blue" },
888+ title: ["New message from ", { b: d.name }],
889+ blocks: [
890+ { kv: [["From", d.login ? [d.name, " (", { b: user(d.login) }, ")"] : d.name], ["Email", d.email], ["Topic", d.topic], ["When", at(job.at)]] },
891+ { plain: d.message },
892+ { button: { label: "Open in Diffinery", url } },
893+ { p: [`Reply to this email to answer ${d.email} from your own mailbox, then mark the message handled in Site administration → Contact messages. The address ${d.login ? "belongs to the sender's account" : "was confirmed through an emailed link"}.`], muted: true },
894+ ],
895+ url,
896+ replyTo: { email: d.email, name: d.name },
897+ };
898+ }
843899 case "security_report": {
844900 // Only the summary: the details stay in Diffinery, behind a site administrator's sign-in.
845901 const url = `${pub}/settings/admin/security-reports/${job.data.id}`;
modifiedapps/diffinery/src/mail/types.ts+14−0View file
@@ -22,6 +22,7 @@
2222 | "account"
2323 | "signup"
2424 | "early_access"
25+ | "contact"
2526 | "digest";
2627
2728 /**
@@ -139,6 +140,15 @@
139140 | { kind: "early_access_remove"; data: { url: string; expiresAt: string } }
140141 | { kind: "early_access_known"; data: { variant: "waiting" | "invited" | "account"; since: string } }
141142 | { kind: "security_report"; data: { id: string; summary: string } }
143+ /**
144+ * The link that confirms a message sent at /contact by someone who isn't signed in (lib/contact.ts). Never carries
145+ * what they typed. `topic` is the topic's label, from the form's fixed list.
146+ */
147+ | { kind: "contact_confirm"; data: { url: string; expiresAt: string; topic: string } }
148+ /** A signed-in person's copy of the message they sent at /contact. */
149+ | { kind: "contact_received"; data: { id: string; topic: string; message: string } }
150+ /** A confirmed contact message, for each site administrator, with the sender as Reply-To. */
151+ | { kind: "contact_message"; data: { id: string; name: string; email: string; login: string | null; topic: string; message: string } }
142152 | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
143153 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
144154 | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } }
@@ -189,12 +199,16 @@
189199 case "early_access_confirm":
190200 case "early_access_known":
191201 case "early_access_remove":
202+ // The sender's own confirmation link or copy of their message.
203+ case "contact_confirm":
204+ case "contact_received":
192205 // It is about the payer's own model spend, so it always sends.
193206 case "review_limit_reached":
194207 case "test":
195208 return "account";
196209 case "invite_accepted":
197210 case "access_request":
211+ case "contact_message":
198212 case "org_membership":
199213 return "orgs";
200214 case "repo_access":
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
@@ -179,7 +179,7 @@
179179 }
180180 />
181181 <PrefRow title="Repositories" hint="Repositories created or deleted in your organizations, and access granted to you." email={{ name: "repos.email", on: p.repos.email }} />
182- <PrefRow title="Organizations" hint="Your organization role, accepted invitations and access requests (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} />
182+ <PrefRow title="Organizations" hint="Your organization role, accepted invitations, and access requests and contact messages (for administrators)." email={{ name: "orgs.email", on: p.orgs.email }} />
183183 <PrefRow title="Security and account" hint="New sign-ins, passkeys, tokens, agents, push protection, invitations and sign-in links." email={{ name: "security", on: true, locked: true }} />
184184 </tbody>
185185 </table>
modifiedapps/diffinery/src/web/account.tsx+21−4View file
@@ -23,6 +23,7 @@
2323 import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
2424 import { MAX_AVATAR_BYTES } from "../lib/image.js";
2525 import { gatherToday } from "../agentic/today.js";
26+import { ContactMessages } from "../lib/contact.js";
2627 import { EarlyAccess } from "../lib/early-access.js";
2728 import { COLLAPSED_COOKIE, Rows, Section, TodayView, collapsedSections } from "../agentic/web.js";
2829 import { landingPage } from "./site.js";
@@ -158,7 +159,7 @@
158159 if (kind.endsWith("comment")) return ICONS.comment;
159160 if (kind === "org_membership") return ICONS.org;
160161 if (kind === "repo_access") return ICONS.repo;
161- if (kind === "invitation" || kind === "early_access") return ICONS.envelope;
162+ if (kind === "invitation" || kind === "early_access" || kind === "contact") return ICONS.envelope;
162163 return ICONS.bell;
163164 }
164165
@@ -634,9 +635,9 @@
634635 { value: "rebase", label: "Rebase and merge" },
635636 ];
636637
637-export type SettingsTab = "profile" | "account" | "appearance" | "pull-requests" | "security" | "tokens" | "apps" | "installations" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access" | "search-engines";
638+export type SettingsTab = "profile" | "account" | "appearance" | "pull-requests" | "security" | "tokens" | "apps" | "installations" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access" | "contact" | "search-engines";
638639 /** The site administration pages: they show when hosted compute is paused. */
639-export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status", "search-engines"];
640+export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "contact", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status", "search-engines"];
640641
641642 /** The number of confirmed early access requests waiting, next to Early access in the sidebar (lib/early-access.ts). */
642643 const EarlyAccessWaiting = async ({ db }: { db?: D1Database }) => {
@@ -650,6 +651,18 @@
650651 );
651652 };
652653
654+/** The number of new contact messages, next to Contact messages in the sidebar (lib/contact.ts). */
655+const ContactWaiting = async ({ db }: { db?: D1Database }) => {
656+ const n = db ? await new ContactMessages(db).waiting().catch(() => 0) : 0;
657+ return n ? (
658+ <span class="badge blue" style="margin-left:6px" title={`${n} new message${n === 1 ? "" : "s"}`}>
659+ {n}
660+ </span>
661+ ) : (
662+ <></>
663+ );
664+};
665+
653666 /** On People and invitations: a pointer to the early access requests waiting, when there are any. */
654667 const EarlyAccessCallout = async ({ db }: { db: D1Database }) => {
655668 const n = await new EarlyAccess(db).waiting().catch(() => 0);
@@ -667,7 +680,7 @@
667680 );
668681 };
669682
670-/** The Settings sidebar. `db` lets it show how many early access requests wait (site administrators only). */
683+/** The Settings sidebar. `db` lets it show how many early access requests and contact messages wait (site administrators only). */
671684 export const SettingsNav: FC<{ me: Account; tab: SettingsTab; db?: D1Database }> = ({ me, tab, db }) => (
672685 <aside class="side vt-side">
673686 <section>
@@ -738,6 +751,10 @@
738751 Early access
739752 <EarlyAccessWaiting db={db} />
740753 </a>
754+ <a href="/settings/admin/contact" class={tab === "contact" ? "on" : ""}>
755+ Contact messages
756+ <ContactWaiting db={db} />
757+ </a>
741758 <a href="/settings/admin/reports" class={tab === "reports" ? "on" : ""}>
742759 Reports
743760 </a>
modifiedapps/diffinery/src/web/assets.ts+2−0View file
@@ -328,6 +328,8 @@
328328 .ea-av.lg{width:48px;height:48px;font-size:20px}
329329 .ea-use{margin-top:6px;color:var(--fg);font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
330330 .ea-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
331+/* The contact form (web/contact.tsx): its card is taller than the heading, so the heading's row keeps its own height. */
332+@media (min-width:901px){.ea.ct{grid-template-rows:auto 1fr}}
331333 @media (max-width:900px){.ea{grid-template-columns:1fr;grid-template-areas:"head" "card" "more";row-gap:24px;margin-top:24px}.ea h1{font-size:28px}.ea-list>li{flex-wrap:wrap}.ea-actions{width:100%;justify-content:flex-start;padding-left:42px}}
332334 .stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px}
333335 .stats b{color:var(--fg)}
addedapps/diffinery/src/web/contact.tsx+595−0View file
@@ -1,0 +1,595 @@
1+/** @jsxImportSource hono/jsx */
2+import { raw } from "hono/html";
3+import type { Child, FC } from "hono/jsx";
4+import { requireAccount, type AppContext } from "../auth/principal.js";
5+import { turnstileMode } from "../auth/signup.js";
6+import { formStamp } from "../lib/early-access.js";
7+import type { Account, Directory } from "../lib/directory.js";
8+import {
9+ CONFIRM_HOURS,
10+ ContactMessages,
11+ MAX_MESSAGE,
12+ MAX_NAME,
13+ MAX_NOTE,
14+ MESSAGE_STATES,
15+ TOPICS,
16+ UNCONFIRMED_DAYS,
17+ changeMessage,
18+ confirmMessage,
19+ deleteMessage,
20+ isTopic,
21+ submitMessage,
22+ topicLabel,
23+ validateMessage,
24+ type ContactMessage,
25+ type MessageState,
26+} from "../lib/contact.js";
27+import { HttpError } from "../lib/util.js";
28+import { ICONS } from "./assets.js";
29+import { SettingsNav } from "./account.js";
30+import { csrfToken } from "./csrf.js";
31+import { CONFIRM_SCRIPT, Protections, guard } from "./early-access.js";
32+import { ComputePausedBanner } from "./kill-switches.js";
33+import { KSelect } from "./kumo.js";
34+import { Form, Pager, Time, act, flash, pageNum, post, principalAccount, prm, qs, render, statusOf, type WebApp } from "./ui.js";
35+
36+/**
37+ * The contact form in the web UI (lib/contact.ts): /contact for anyone, signed in or not, the page
38+ * its confirmation email links to, and the **Contact messages** page of site administration, where
39+ * messages are read, marked handled or spam, noted and deleted.
40+ */
41+
42+/** Largest message form, in bytes: the message's cap with room for percent-encoded UTF-8. */
43+const MAX_FORM_BYTES = 64 * 1024;
44+const BASE = "/settings/admin/contact";
45+
46+const TURNSTILE_MISSING = "This instance has no Turnstile configured (TURNSTILE_SITE_KEY and TURNSTILE_SECRET), so its contact form takes messages only from people who are signed in.";
47+
48+export const STATE_TEXT: Record<MessageState, string> = { new: "New", handled: "Handled", spam: "Spam", unconfirmed: "Unconfirmed" };
49+const STATE_BADGE: Record<MessageState, string> = { new: "badge blue", handled: "badge green", spam: "badge red", unconfirmed: "badge" };
50+const StateBadge: FC<{ state: MessageState }> = ({ state }) => <span class={STATE_BADGE[state]}>{STATE_TEXT[state]}</span>;
51+
52+/** Who writes as themselves: an active person signed in. Anyone else goes through Turnstile and confirms by email. */
53+const senderOf = (c: AppContext): Account | null => {
54+ const a = principalAccount(c);
55+ return a && a.kind === "user" && a.status === "active" ? a : null;
56+};
57+
58+interface FormValues {
59+ email?: string;
60+ name?: string;
61+ topic?: string;
62+ message?: string;
63+}
64+
65+// ---------------------------------------------------------------- the form
66+
67+const Way: FC<{ icon: string; title: string; children: Child }> = ({ icon, title, children }) => (
68+ <li>
69+ <span class="ic">{raw(icon)}</span>
70+ <span>
71+ <b>{title}</b>
72+ <span class="muted">{children}</span>
73+ </span>
74+ </li>
75+);
76+
77+async function contactPage(c: AppContext, o: { values?: FormValues; error?: string; status?: number } = {}): Promise<Response> {
78+ const me = senderOf(c);
79+ const mode = turnstileMode(c.env);
80+ const siteKey = !me && mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
81+ const closed = !me && mode === "missing";
82+ const v = o.values ?? {};
83+ const stamp = me || closed ? "" : await formStamp(c.env);
84+ const topic = v.topic ?? (isTopic(c.req.query("topic") ?? "") ? c.req.query("topic") : "");
85+ const origin = c.env.PUBLIC_URL.replace(/\/$/, "");
86+ return render(
87+ c,
88+ {
89+ title: "Contact us",
90+ seoTitle: "Contact the Diffinery team",
91+ status: o.status,
92+ turnstile: !!siteKey,
93+ description: "Write to the people who run this Diffinery instance: questions, help with your account, running your own Diffinery, partnerships, press and privacy requests.",
94+ jsonLd: [{ "@type": "ContactPage", "@id": `${origin}/contact#webpage`, url: `${origin}/contact`, name: "Contact the Diffinery team", isPartOf: { "@id": `${origin}/#website` }, about: { "@id": `${origin}/#organization` }, inLanguage: "en" }],
95+ },
96+ <div class="ea ct">
97+ <section class="ea-head">
98+ <span class="ea-eyebrow">{raw(ICONS.envelope)} Contact</span>
99+ <h1>Contact us</h1>
100+ <p class="ea-lede">Questions, help with your account, running your own Diffinery, partnerships or press: write to the people who run this instance. A person reads every message and replies by email.</p>
101+ </section>
102+ <section class="ea-more">
103+ <ul class="ea-points">
104+ <Way icon={ICONS.shield} title="Found a security problem?">
105+ Report it privately with the <a href="/security/report">security report form</a> instead, so only the site administrators see it.
106+ </Way>
107+ <Way icon={ICONS.zap} title="Is something down?">
108+ The <a href="/status">status page</a> shows incidents as they happen.
109+ </Way>
110+ <Way icon={ICONS.code} title="Want to know how something works?">
111+ The <a href="/docs">docs</a> answer most questions, and the <a href="/faq">FAQ</a> the common ones.
112+ </Way>
113+ </ul>
114+ {me ? null : (
115+ <ol class="ea-steps">
116+ <li>Send this form.</li>
117+ <li>Confirm your email address with the link we send you.</li>
118+ <li>Your message goes to the team, who reply to that address.</li>
119+ </ol>
120+ )}
121+ </section>
122+ <div class="box ea-card">
123+ <div class="body">
124+ {closed ? (
125+ <p class="m0">
126+ {TURNSTILE_MISSING} <a href="/login?next=%2Fcontact">Sign in</a> to write to them.
127+ </p>
128+ ) : (
129+ <Form action="/contact" t={csrfToken(c)}>
130+ <h2>Send us a message</h2>
131+ <p class="muted small">{me ? `Signed in as ${me.login}. We'll reply to ${me.email ?? "your account's email address"}.` : "Every field is required."}</p>
132+ {o.error ? (
133+ <div class="flash error" role="alert">
134+ {o.error}
135+ </div>
136+ ) : null}
137+ <div class="field">
138+ <label for="ct-name">Name</label>
139+ <input id="ct-name" name="name" type="text" required maxlength={MAX_NAME} value={v.name ?? (me ? me.name || "" : undefined)} autocomplete="name" />
140+ </div>
141+ {me ? null : (
142+ <div class="field">
143+ <label for="ct-email">Email</label>
144+ <input id="ct-email" name="email" type="email" required maxlength={254} value={v.email} autocomplete="email" placeholder="you@example.com" />
145+ </div>
146+ )}
147+ <div class="field">
148+ <label>Topic</label>
149+ <KSelect name="topic" label="Topic" block value={topic || undefined} placeholder="Choose a topic" options={TOPICS.map((t) => ({ value: t.value, label: t.label }))} />
150+ </div>
151+ <div class="field">
152+ <label for="ct-message">Message</label>
153+ <textarea id="ct-message" name="message" required maxlength={MAX_MESSAGE} style="min-height:160px">
154+ {v.message ?? ""}
155+ </textarea>
156+ </div>
157+ {me ? null : <Protections stamp={stamp} siteKey={siteKey} />}
158+ <button class="btn primary block">Send message</button>
159+ <p class="ea-privacy">
160+ Only the site administrators can read your message. We use it and your address only to reply, and delete it within a year of handling it. See <a href="/privacy">Privacy</a>.
161+ </p>
162+ </Form>
163+ )}
164+ </div>
165+ </div>
166+ </div>,
167+ );
168+}
169+
170+/** What someone who isn't signed in sees after sending, whatever happened to the message. */
171+function checkInboxPage(c: AppContext, email: string): Response | Promise<Response> {
172+ return render(
173+ c,
174+ { title: "Check your inbox" },
175+ <div class="ea-done">
176+ <span class="ic">{raw(ICONS.envelope)}</span>
177+ <h1>Check your inbox</h1>
178+ <p>
179+ We've sent <b>{email}</b> a link to confirm your message. Open it within {CONFIRM_HOURS} hours and your message goes to the team.
180+ </p>
181+ <p class="muted">Nothing there? Check your spam folder. Messages that are never confirmed are deleted after {UNCONFIRMED_DAYS} days, unread.</p>
182+ <p class="row" style="justify-content:center">
183+ <a class="btn" href="/">
184+ Back to the home page
185+ </a>
186+ <a class="btn" href="/docs">
187+ Read the docs meanwhile
188+ </a>
189+ </p>
190+ </div>,
191+ );
192+}
193+
194+/** What a signed-in person sees after sending. */
195+function sentPage(c: AppContext, email: string): Response | Promise<Response> {
196+ return render(
197+ c,
198+ { title: "Message sent" },
199+ <div class="ea-done">
200+ <span class="ic">{raw(ICONS.check)}</span>
201+ <h1>Message sent</h1>
202+ <p>
203+ Thanks for writing to us. The team reads every message and replies to <b>{email}</b>. We've emailed you a copy.
204+ </p>
205+ <p class="row" style="justify-content:center">
206+ <a class="btn" href="/">
207+ Back to your dashboard
208+ </a>
209+ </p>
210+ </div>,
211+ );
212+}
213+
214+/** Runs on the confirmation page: the early access form's, for this form. */
215+const SCRIPT = CONFIRM_SCRIPT.replace('"ea-confirm"', '"ct-confirm"').replace('"ea-confirm-status"', '"ct-confirm-status"');
216+
217+function confirmPage(c: AppContext): Response | Promise<Response> {
218+ return render(
219+ c,
220+ { title: "Confirm your message", script: SCRIPT },
221+ <div class="ea-done">
222+ <span class="ic">{raw(ICONS.envelope)}</span>
223+ <h1>Confirm your message</h1>
224+ <p class="muted" id="ct-confirm-status" aria-live="polite">
225+ Checking your link…
226+ </p>
227+ <noscript>
228+ <p class="muted">Turn on JavaScript for this page to confirm your message: the link's code stays in your browser until you press the button.</p>
229+ </noscript>
230+ <Form action="/contact/confirm" t={csrfToken(c)} id="ct-confirm" style="display:none">
231+ <input type="hidden" name="token" value="" />
232+ <p>Press the button to confirm that you wrote this message from this email address. It then goes to the team.</p>
233+ <button class="btn primary">Confirm my message</button>
234+ </Form>
235+ </div>,
236+ );
237+}
238+
239+function confirmedPage(c: AppContext, m: ContactMessage | null): Response | Promise<Response> {
240+ if (!m)
241+ return render(
242+ c,
243+ { title: "This link doesn't work", status: 410 },
244+ <div class="ea-done">
245+ <span class="ic">{raw(ICONS.x)}</span>
246+ <h1>This link doesn't work any more</h1>
247+ <p class="muted">Confirmation links work once, for {CONFIRM_HOURS} hours. If you already confirmed, your message is with the team and there's nothing else to do.</p>
248+ <p>
249+ <a class="btn primary" href="/contact">
250+ Write a new message
251+ </a>
252+ </p>
253+ </div>,
254+ );
255+ return render(
256+ c,
257+ { title: "Message sent" },
258+ <div class="ea-done">
259+ <span class="ic">{raw(ICONS.check)}</span>
260+ <h1>Your message is with the team</h1>
261+ <p>
262+ Thanks, <b>{m.name}</b>. A person reads every message and replies to <b>{m.email}</b>.
263+ </p>
264+ <p class="row" style="justify-content:center">
265+ <a class="btn" href="/">
266+ Back to the home page
267+ </a>
268+ <a class="btn" href="/docs">
269+ Read the docs meanwhile
270+ </a>
271+ </p>
272+ </div>,
273+ );
274+}
275+
276+// ---------------------------------------------------------------- site administration
277+
278+/** The initial on a message's avatar: its sender's name's first letter. */
279+const initial = (m: Pick<ContactMessage, "name" | "email">) => (m.name || m.email).trim().charAt(0).toUpperCase() || "?";
280+
281+const Actions: FC<{ m: ContactMessage; t: string; back: string }> = ({ m, t, back }) => {
282+ const button = (state: "new" | "handled" | "spam", label: Child, cls = "btn sm") => (
283+ <Form action={`${BASE}/${m.id}/state`} t={t} class="inline">
284+ <input type="hidden" name="state" value={state} />
285+ <input type="hidden" name="back" value={back} />
286+ <button class={cls}>{label}</button>
287+ </Form>
288+ );
289+ return (
290+ <>
291+ {m.state === "new" || m.state === "unconfirmed" ? button("handled", <>{raw(ICONS.check)} Mark handled</>, "btn sm primary") : null}{" "}
292+ {m.state !== "spam" ? button("spam", "Spam") : null} {m.state === "handled" || m.state === "spam" ? button("new", "Reopen") : null}
293+ </>
294+ );
295+};
296+
297+type Row = ContactMessage & { account_login: string | null };
298+
299+/** Messages with the login of an active account that uses the address, if any (a signed-in sender's own). */
300+async function withAccounts(db: D1Database, rows: ContactMessage[]): Promise<Row[]> {
301+ if (!rows.length) return [];
302+ const emails = [...new Set(rows.map((r) => r.email.toLowerCase()))];
303+ const found = (
304+ await db
305+ .prepare(`SELECT lower(email) AS email, login FROM accounts WHERE kind = 'user' AND status = 'active' AND lower(email) IN (${emails.map(() => "?").join(", ")})`)
306+ .bind(...emails)
307+ .all<{ email: string; login: string }>()
308+ ).results;
309+ const by = new Map(found.map((f) => [f.email, f.login]));
310+ return rows.map((r) => ({ ...r, account_login: r.sender_login ?? by.get(r.email.toLowerCase()) ?? null }));
311+}
312+
313+/** A reply in the site administrator's own mail app, to the sender, quoting the topic. */
314+const mailto = (m: ContactMessage) => `mailto:${encodeURIComponent(m.email).replace(/%40/g, "@")}?subject=${encodeURIComponent(`Re: ${topicLabel(m.topic)}`)}`;
315+
316+const backTo = (v: string): string => (v.startsWith(BASE) && !v.startsWith("//") && !/[\r\n]/.test(v) ? v : BASE);
317+
318+export function registerContact(web: WebApp): void {
319+ // ------------------------------------------------------------ the form
320+
321+ web.get("/contact", (c) => contactPage(c));
322+ web.get("/contact/", (c) => c.redirect("/contact", 301));
323+
324+ post(
325+ web,
326+ "/contact",
327+ async (c, f) => {
328+ const values: FormValues = { email: f.s("email").slice(0, 254), name: f.s("name").slice(0, MAX_NAME * 2), topic: f.s("topic").slice(0, 40), message: f.s("message").slice(0, MAX_MESSAGE * 2) };
329+ try {
330+ const me = senderOf(c);
331+ // A filled honeypot or a form sent faster than a person types: answer as if it worked, keep nothing.
332+ if (!me && (await guard(c, f, TURNSTILE_MISSING)) === "silent") return checkInboxPage(c, values.email?.trim() || "your address");
333+ const input = validateMessage(values, !!me);
334+ await submitMessage(c.env, (e) => c.get("dir").audit(e), input, me, c.req.header("cf-connecting-ip") ?? null);
335+ return me ? sentPage(c, me.email ?? "your address") : checkInboxPage(c, input.email!);
336+ } catch (e) {
337+ const s = statusOf(e);
338+ if (s.status >= 500 && s.status !== 503) throw e;
339+ return contactPage(c, { values, error: s.message, status: s.status });
340+ }
341+ },
342+ { maxBytes: MAX_FORM_BYTES },
343+ );
344+
345+ web.get("/contact/confirm", (c) => confirmPage(c));
346+
347+ post(
348+ web,
349+ "/contact/confirm",
350+ async (c, f) => confirmedPage(c, await confirmMessage(c.env, (e) => c.get("dir").audit(e), f.s("token").slice(0, 200))),
351+ { maxBytes: 4096 },
352+ );
353+
354+ // ------------------------------------------------------------ site administration: Contact messages
355+
356+ /** Messages don't exist for anyone but site administrators: 404, not 403. */
357+ const requireSiteAdmin = (c: AppContext): Account => {
358+ const me = requireAccount(c);
359+ if (!me.site_admin || me.kind !== "user") throw new HttpError(404, "not found");
360+ return me;
361+ };
362+
363+ const page = (c: AppContext, me: Account, title: string, body: Child) =>
364+ render(
365+ c,
366+ { title },
367+ <div class="grid left">
368+ <SettingsNav me={me} tab="contact" db={c.env.DB} />
369+ <div>
370+ <ComputePausedBanner env={c.env} />
371+ {body}
372+ </div>
373+ </div>,
374+ );
375+
376+ const PER_PAGE = 50;
377+
378+ web.get(BASE, async (c) => {
379+ const me = requireSiteAdmin(c);
380+ const qState = c.req.query("state");
381+ const state: MessageState | "all" = qState === "all" ? "all" : MESSAGE_STATES.includes(qState as MessageState) ? (qState as MessageState) : "new";
382+ const q = (c.req.query("q") ?? "").trim().slice(0, 100);
383+ const pg = pageNum(c);
384+ const store = new ContactMessages(c.env.DB);
385+ const [list, counts] = await Promise.all([store.list({ state: state === "all" ? undefined : state, q, limit: PER_PAGE, offset: (pg - 1) * PER_PAGE }), store.counts()]);
386+ const rows = await withAccounts(c.env.DB, list.items);
387+ const total = counts.new + counts.handled + counts.spam + counts.unconfirmed;
388+ const here = `${BASE}${qs({ state: state === "new" ? undefined : state, q, page: pg > 1 ? pg : undefined })}`;
389+ const t = csrfToken(c);
390+ const tab = (s: MessageState | "all", label: string, n: number) => (
391+ <a href={`${BASE}${qs({ state: s === "new" ? undefined : s, q })}`} class={state === s ? "on" : ""}>
392+ {label} <span class="n">{n}</span>
393+ </a>
394+ );
395+ return page(
396+ c,
397+ me,
398+ "Contact messages",
399+ <>
400+ <h1>Contact messages</h1>
401+ <p class="muted">
402+ Messages from <a href="/contact">/contact</a>. A message from someone who isn't signed in arrives once its address follows the emailed link. Reply from your own mailbox (the notification email's Reply-To is the sender), then mark it handled. Every change is in the <a href="/settings/audit">audit log</a>.
403+ </p>
404+ <nav class="tabs mb" aria-label="Messages by state">
405+ {tab("new", "New", counts.new)}
406+ {tab("handled", "Handled", counts.handled)}
407+ {tab("spam", "Spam", counts.spam)}
408+ {tab("unconfirmed", "Unconfirmed", counts.unconfirmed)}
409+ {tab("all", "All", total)}
410+ </nav>
411+ <form method="get" action={BASE} class="row mb" role="search">
412+ {state === "new" ? null : <input type="hidden" name="state" value={state} />}
413+ <input type="search" name="q" value={q} maxlength={100} placeholder="Search by email, name or message" aria-label="Search messages" class="grow" style="max-width:420px" />
414+ <button class="btn">{raw(ICONS.search)} Search</button>
415+ {q ? (
416+ <a class="btn" href={`${BASE}${qs({ state: state === "new" ? undefined : state })}`}>
417+ Clear
418+ </a>
419+ ) : null}
420+ </form>
421+ <div class="box">
422+ {rows.length ? (
423+ <ul class="list ea-list">
424+ {rows.map((m) => (
425+ <li>
426+ <span class="ea-av" aria-hidden="true">
427+ {initial(m)}
428+ </span>
429+ <div class="grow" style="min-width:0">
430+ <div class="row" style="gap:6px;flex-wrap:wrap">
431+ <a class="title" href={`${BASE}/${m.id}`}>
432+ {m.name}
433+ </a>
434+ <span class="muted small">{m.email}</span>
435+ <StateBadge state={m.state} />
436+ {m.account_login ? <span class="badge amber">{m.sender_login ? "signed in" : "has an account"}: {m.account_login}</span> : null}
437+ </div>
438+ <div class="meta">
439+ {topicLabel(m.topic)} · sent <Time at={m.created_at} />
440+ {m.handled_at && m.updated_by ? (
441+ <>
442+ {" "}
443+ · {m.state === "spam" ? "marked spam" : "handled"} by {m.updated_by} <Time at={m.handled_at} />
444+ </>
445+ ) : null}
446+ </div>
447+ <div class="ea-use">{m.message.replace(/\s+/g, " ")}</div>
448+ </div>
449+ <div class="ea-actions nowrap">
450+ <Actions m={m} t={t} back={here} />
451+ </div>
452+ </li>
453+ ))}
454+ </ul>
455+ ) : (
456+ <div class="empty">{q ? "No messages match your search." : state === "new" ? "No new messages. Messages show up here once their address confirms." : state === "all" ? "No contact messages yet." : `No ${STATE_TEXT[state].toLowerCase()} messages.`}</div>
457+ )}
458+ </div>
459+ <Pager page={pg} hasNext={list.more} href={(p) => `${BASE}${qs({ state: state === "new" ? undefined : state, q, page: p > 1 ? p : undefined })}`} />
460+ </>,
461+ );
462+ });
463+
464+ web.get(`${BASE}/:id`, async (c) => {
465+ const me = requireSiteAdmin(c);
466+ const found = await new ContactMessages(c.env.DB).get(prm(c, "id"));
467+ if (!found) throw new HttpError(404, "no contact message with that id");
468+ const [m] = (await withAccounts(c.env.DB, [found])) as [Row];
469+ const t = csrfToken(c);
470+ const here = `${BASE}/${m.id}`;
471+ return page(
472+ c,
473+ me,
474+ `Contact: ${m.name}`,
475+ <>
476+ <p class="small">
477+ <a href={BASE}>← Contact messages</a>
478+ </p>
479+ <div class="row mb" style="gap:12px;align-items:center;flex-wrap:wrap">
480+ <span class="ea-av lg" aria-hidden="true">
481+ {initial(m)}
482+ </span>
483+ <div class="grow" style="min-width:0">
484+ <h1 class="m0" style="overflow-wrap:anywhere">{m.name}</h1>
485+ <div class="row" style="gap:6px;margin-top:4px;flex-wrap:wrap">
486+ <StateBadge state={m.state} />
487+ <span class="badge">{topicLabel(m.topic)}</span>
488+ </div>
489+ </div>
490+ <div class="ea-actions">
491+ <a class="btn sm" href={mailto(m)}>
492+ {raw(ICONS.envelope)} Reply by email
493+ </a>
494+ <Actions m={m} t={t} back={here} />
495+ </div>
496+ </div>
497+ <h2>Message</h2>
498+ <div class="box">
499+ {/* Plain text exactly as sent: never rendered as Markdown or HTML. */}
500+ <pre class="body m0" style="white-space:pre-wrap;overflow-wrap:anywhere;font-family:var(--sans);font-size:14px">
501+ {m.message}
502+ </pre>
503+ </div>
504+ <div class="box">
505+ <div class="body">
506+ <dl class="kv m0">
507+ <dt>From</dt>
508+ <dd style="overflow-wrap:anywhere">
509+ {m.name} &lt;<a href={mailto(m)}>{m.email}</a>&gt;
510+ </dd>
511+ <dt>Account</dt>
512+ <dd>
513+ {m.account_login ? (
514+ <>
515+ <a href={`/${m.account_login}`}>{m.account_login}</a> <span class="muted small">{m.sender_login ? "sent it signed in" : "uses this address"}</span>
516+ </>
517+ ) : (
518+ <span class="muted">none uses this address</span>
519+ )}
520+ </dd>
521+ <dt>Topic</dt>
522+ <dd>{topicLabel(m.topic)}</dd>
523+ <dt>Sent</dt>
524+ <dd>
525+ <Time at={m.created_at} /> <span class="muted small">{m.created_at}</span>
526+ </dd>
527+ <dt>Confirmed</dt>
528+ <dd>{m.confirmed_at ? m.sender_login ? <span class="muted">signed in, so no confirmation was needed</span> : <Time at={m.confirmed_at} /> : <span class="muted">not yet: the address hasn't followed the emailed link</span>}</dd>
529+ {m.handled_at && m.updated_by ? (
530+ <>
531+ <dt>{m.state === "spam" ? "Marked spam" : "Handled"}</dt>
532+ <dd>
533+ by {m.updated_by} <Time at={m.handled_at} />
534+ </dd>
535+ </>
536+ ) : null}
537+ <dt>Reference</dt>
538+ <dd class="mono">{m.id}</dd>
539+ </dl>
540+ </div>
541+ </div>
542+ <h2>Note</h2>
543+ <Form action={`${BASE}/${m.id}/note`} t={t} class="box">
544+ <div class="body">
545+ <div class="field">
546+ <label for="ct-note">For the other site administrators: who replied, what was agreed. The sender never sees it.</label>
547+ <textarea id="ct-note" name="note" maxlength={MAX_NOTE} style="min-height:80px;width:100%">
548+ {m.note ?? ""}
549+ </textarea>
550+ </div>
551+ <button class="btn">Save note</button>
552+ </div>
553+ </Form>
554+ <h2>Delete</h2>
555+ <Form action={`${BASE}/${m.id}/delete`} t={t} class="box" confirm={`Delete the message from ${m.email} and everything stored about it? This can't be undone.`}>
556+ <div class="body row between" style="gap:12px;flex-wrap:wrap">
557+ <span class="muted">For someone who asks for their data to be removed. The audit log keeps that a message was deleted, not what it said.</span>
558+ <button class="btn danger">{raw(ICONS.trash)} Delete message</button>
559+ </div>
560+ </Form>
561+ </>,
562+ );
563+ });
564+
565+ const audit = (c: AppContext) => (e: Parameters<Directory["audit"]>[0]) => c.get("dir").audit(e);
566+ const DONE: Record<MessageState, string> = { new: "Reopened the message.", unconfirmed: "Reopened the message. Its address still has to confirm it.", handled: "Marked the message handled.", spam: "Marked the message as spam. Nothing more from its address gets through while it stays spam." };
567+
568+ post(web, `${BASE}/:id/state`, async (c, f) => {
569+ const me = requireSiteAdmin(c);
570+ return act(c, backTo(f.s("back")), async () => {
571+ const m = await changeMessage(c.env.DB, audit(c), me, prm(c, "id"), { state: f.s("state") }, "web");
572+ return void flash(c, "notice", DONE[m.state]);
573+ });
574+ });
575+ post(
576+ web,
577+ `${BASE}/:id/note`,
578+ async (c, f) => {
579+ const me = requireSiteAdmin(c);
580+ return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => {
581+ const m = await changeMessage(c.env.DB, audit(c), me, prm(c, "id"), { note: f.s("note") }, "web");
582+ return void flash(c, "notice", m.note ? "Note saved." : "Note cleared.");
583+ });
584+ },
585+ { maxBytes: 16 * 1024 },
586+ );
587+ post(web, `${BASE}/:id/delete`, async (c) => {
588+ const me = requireSiteAdmin(c);
589+ return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => {
590+ await deleteMessage(c.env.DB, audit(c), me, prm(c, "id"), "web");
591+ flash(c, "notice", "Message deleted.");
592+ return BASE;
593+ });
594+ });
595+}
modifiedapps/diffinery/src/web/early-access.tsx+8−7View file
@@ -204,7 +204,7 @@
204204 }
205205
206206 /** Runs on the confirmation page: takes the token from the fragment (never sent to the server in a request line) into the form. */
207-const CONFIRM_SCRIPT = `(function(){var f=document.getElementById("ea-confirm"),s=document.getElementById("ea-confirm-status");if(!f||!s)return;function go(){var t=location.hash.slice(1);if(!t){if(!f.elements.token.value){s.textContent="This link is incomplete. Open it again from the email, in full.";}return;}if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);f.elements.token.value=t;s.textContent="";f.style.display="";}go();addEventListener("hashchange",go);})();`;
207+export const CONFIRM_SCRIPT = `(function(){var f=document.getElementById("ea-confirm"),s=document.getElementById("ea-confirm-status");if(!f||!s)return;function go(){var t=location.hash.slice(1);if(!t){if(!f.elements.token.value){s.textContent="This link is incomplete. Open it again from the email, in full.";}return;}if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);f.elements.token.value=t;s.textContent="";f.style.display="";}go();addEventListener("hashchange",go);})();`;
208208
209209 function confirmPage(c: AppContext): Response | Promise<Response> {
210210 return render(
@@ -309,13 +309,14 @@
309309 }
310310
311311 /**
312- * The checks every public form here makes before it does anything: Turnstile is configured, the
313- * honeypot is empty, the timing stamp is genuine and the form wasn't sent faster than a person types
314- * ("silent": answer as if it worked and keep nothing), and the Turnstile token is good.
312+ * The checks every public form here (and the contact form, web/contact.tsx) makes before it does
313+ * anything: Turnstile is configured (else 503 with `missing`), the honeypot is empty, the timing stamp
314+ * is genuine and the form wasn't sent faster than a person types ("silent": answer as if it worked and
315+ * keep nothing), and the Turnstile token is good.
315316 */
316-async function guard(c: AppContext, f: FormData): Promise<"ok" | "silent"> {
317+export async function guard(c: AppContext, f: FormData, missing = TURNSTILE_MISSING): Promise<"ok" | "silent"> {
317318 const mode = turnstileMode(c.env);
318- if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
319+ if (mode === "missing") throw new HttpError(503, missing);
319320 const stamp = await checkStamp(c.env, f.s("stamp"));
320321 if (f.s("website") !== "" || stamp === "fast" || stamp === "bad") return "silent";
321322 if (stamp === "stale") throw new HttpError(422, "This form was open for a long time. Please check it and send it again.");
@@ -324,7 +325,7 @@
324325 }
325326
326327 /** The honeypot, timing stamp and Turnstile widget every public form here carries. */
327-const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => (
328+export const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => (
328329 <>
329330 {/* A field people never see or fill in: a script filling every field gives itself away. */}
330331 <div class="ea-hp" aria-hidden="true">
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));
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
@@ -11,6 +11,7 @@
1111 import { registerTerminals } from "../agentic/terminal-web.js";
1212 import { registerMailWeb } from "../mail/web.js";
1313 import { registerSecurityReports } from "./security-reports.js";
14+import { registerContact } from "./contact.js";
1415 import { registerEarlyAccess } from "./early-access.js";
1516 import { registerStatus } from "./status.js";
1617 import { registerAuth } from "./auth.js";
@@ -80,6 +81,7 @@
8081 registerMailWeb(web);
8182 registerSecurityReports(web);
8283 registerEarlyAccess(web);
84+registerContact(web);
8385 // /status: before the /:owner catch-all.
8486 registerStatus(web);
8587 // Before registerAccount: its /:owner catch-all would otherwise swallow /imports, /new/import and /settings/linked-accounts.
modifiedapps/diffinery/src/web/seo.tsx+5−3View file
@@ -12,7 +12,7 @@
1212 import type { WebApp } from "./ui.js";
1313 import { ICON_FILES } from "./icons.generated.js";
1414 import { docsSitemapEntries } from "../docs/web.js";
15-import { siteSitemapEntries } from "./site.js";
15+import { CONTACT, siteSitemapEntries } from "./site.js";
1616 import { indexNowKey } from "./indexnow.js";
1717
1818 export const SITE = {
@@ -44,7 +44,7 @@
4444 const CACHE = "public, max-age=604800";
4545
4646 /** Paths that are never useful in search results. */
47-const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|-)(\/|$)/;
47+const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|contact\/confirm|-)(\/|$)/;
4848
4949 export function registerSeo(web: WebApp): void {
5050 for (const name of Object.keys(ICON_FILES)) {
@@ -80,7 +80,7 @@
8080 "User-agent: *",
8181 "Content-Signal: search=yes, ai-input=yes, ai-train=yes",
8282 "Allow: /",
83- ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`),
83+ ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/early-access/confirm", "/early-access/remove", "/contact/confirm", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`),
8484 "",
8585 `Sitemap: ${origin}/sitemap.xml`,
8686 "",
@@ -105,6 +105,8 @@
105105 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
106106 // The home page, then the product, comparison and company pages (web/site.tsx).
107107 ...siteSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)),
108+ // The contact form (web/contact.tsx), for people and AI assistants looking for how to reach the team.
109+ url(`${origin}${CONTACT.path}`, CONTACT.updated),
108110 ...docsSitemapEntries().map((e) => url(`${origin}${e.path}`, e.updated)),
109111 ...repos.map((r) => url(`${origin}/${encodeURIComponent(r.owner_login)}/${encodeURIComponent(r.name)}`, r.pushed_at ?? r.updated_at)),
110112 "</urlset>",
modifiedapps/diffinery/src/web/site.tsx+4−0View file
@@ -267,6 +267,9 @@
267267 /** Where to ask to try Diffinery on the project's own instance, whatever instance serves this page (web/early-access.tsx). */
268268 export const EARLY_ACCESS = "https://diffinery.com/early-access";
269269
270+/** The contact form (web/contact.tsx): on every instance, it reaches that instance's site administrators. `updated` is for the sitemap. */
271+export const CONTACT = { path: "/contact", updated: "2026-10-26" };
272+
270273 /** schema.org ids for the site's entities, shared by every page's graph. */
271274 const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` });
272275
@@ -676,6 +679,7 @@
676679 `- [Home page](${o}/index.md): ${HOME.description}`,
677680 `- Open source under the Apache License 2.0 (SPDX: ${SOURCE.spdx}). Source code: ${SOURCE.repo}, mirrored to ${SOURCE.github}. Free, with no paid edition and nothing sold.`,
678681 `- How to use it: deploy your own instance to your own Cloudflare account and domain (Workers Paid plan); you pay Cloudflare for what it uses. Guide: ${o}/docs/self-hosting/deploy.md. Overview: ${o}/docs/self-hosting.md. There's no hosted service for teams to sign up for: diffinery.com is the project's home and its own instance, with accounts by invitation. To try Diffinery before deploying your own, request early access at ${EARLY_ACCESS} (how it works: ${o}/docs/accounts/early-access.md).`,
682+ `- Contact the Diffinery team: ${o}${CONTACT.path} (a form; a person replies by email). Private security reports: ${o}/security/report. Bugs and ideas: issues on ${SOURCE.repo}.`,
679683 `- Runs entirely on Cloudflare (Workers, Durable Objects, R2, D1, Queues, Workflows, Containers): code, pull requests, CI runners and the agent features alike. It is ${CLOUDFLARE_CLAIM} See ${o}/built-on-cloudflare.md, which compares the other Git and CI projects on Cloudflare.`,
680684 "- Hosted CI runners: Ubuntu 24.04 x64 containers, 4 vCPU and 12 GiB of memory by default, with smaller sizes chosen per job from its measured runs. No hosted macOS, Windows or Arm runners; self-hosted runners cover them.",
681685 "- Runs GitHub Actions workflow files from `.github/workflows/` as they are; differences are listed at " + `${o}/docs/actions/compatibility.md`,
modifiedapps/diffinery/src/web/ui.tsx+2−1View file
@@ -476,7 +476,7 @@
476476 { title: "Product", links: [["/features", "Features"], ["/agents", "For AI agents"], ["/performance", "Performance"], ["/security", "Security"], ["/built-on-cloudflare", "Built on Cloudflare"], ["/open-source", "Open source"], ["/docs/migrate-from-github", "Migrate from GitHub"]] },
477477 { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] },
478478 { title: "Resources", links: [["/docs", "Docs"], ["/docs/self-hosting", "Self-hosting"], ["/docs/guides", "Guides"], ["/docs/connect", "Connect AI apps"], ["/llms.txt", "llms.txt"], ["/status", "Status"], ["/docs/security/disclosure", "Report a vulnerability"]] },
479- { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/privacy", "Privacy"]] },
479+ { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], [GITHUB_REPO, "GitHub mirror"], ["/contact", "Contact"], ["/privacy", "Privacy"]] },
480480 ].map((col) => (
481481 <nav aria-label={col.title}>
482482 <h2>{col.title}</h2>
@@ -619,6 +619,7 @@
619619 <a href="/docs">Docs</a>
620620 <a href="/docs/security/disclosure">Security</a>
621621 <a href="/status">Status</a>
622+ <a href="/contact">Contact</a>
622623 </nav>
623624 ) : (
624625 <a href="/built-on-cloudflare">Runs entirely on Cloudflare</a>
addedapps/diffinery/test/e2e/contact.test.mjs+392−0View file
@@ -1,0 +1,392 @@
1+// End-to-end contact form (lib/contact.ts, web/contact.tsx) on a real `wrangler dev` Worker: the
2+// footer, sitemap, robots.txt and llms.txt lead to /contact; a visitor's message is confirmed with the
3+// link from its email and only then reaches the site administrators, by email (Reply-To the sender) and
4+// in Diffinery; a signed-in person's message arrives at once with a copy for them; bots, bursts,
5+// oversized and hostile input get nowhere; only site administrators see messages, on the web and
6+// through the API; marking handled, spam, reopening, noting and deleting work and are audited without
7+// the message's content, and an address with a message marked spam gets nothing more through.
8+//
9+// The Worker keeps env.dev's DEV_AUTH so it keeps whole copies of the emails it sends (for reading the
10+// links). Visitors who aren't signed in are requests with another Host header: local development's fixed
11+// identity only applies to requests addressed to this machine.
12+//
13+// pnpm --filter @diffinery/app exec node --test test/e2e/contact.test.mjs
14+import "./fresh-connections.mjs";
15+import { spawn, execFileSync } from "node:child_process";
16+import { createHash, randomBytes } from "node:crypto";
17+import { mkdtempSync, rmSync } from "node:fs";
18+import { tmpdir } from "node:os";
19+import { join, dirname, resolve } from "node:path";
20+import { fileURLToPath } from "node:url";
21+import { request as httpRequest } from "node:http";
22+import { test, after } from "node:test";
23+import assert from "node:assert/strict";
24+import { migratedState } from "./d1-state.mjs";
25+import { freePort } from "./ports.mjs";
26+
27+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
28+const work = mkdtempSync(join(tmpdir(), "diffinery-contact-e2e-"));
29+const running = [];
30+
31+const hash = (s) => createHash("sha256").update(s).digest("hex");
32+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33+
34+/**
35+ * A fetch-like request over node:http, which (unlike fetch) sends the Host header it is given: the
36+ * Worker sees a visitor's requests addressed to VISITOR. Redirects are never followed.
37+ */
38+function send(url, { method = "GET", headers = {}, body } = {}) {
39+ const data = body === undefined ? undefined : Buffer.from(typeof body === "string" ? body : body.toString());
40+ return new Promise((res, rej) => {
41+ const r = httpRequest(url, { method, headers: { ...headers, ...(data ? { "content-length": String(data.length) } : {}) } }, (resp) => {
42+ const chunks = [];
43+ resp.on("data", (c) => chunks.push(c));
44+ resp.on("end", () => {
45+ const text = Buffer.concat(chunks).toString("utf8");
46+ const h = resp.headers;
47+ res({
48+ status: resp.statusCode,
49+ headers: { get: (k) => (Array.isArray(h[k.toLowerCase()]) ? h[k.toLowerCase()].join(", ") : (h[k.toLowerCase()] ?? null)) },
50+ text: async () => text,
51+ json: async () => JSON.parse(text),
52+ });
53+ });
54+ });
55+ r.on("error", rej);
56+ if (data) r.write(data);
57+ r.end();
58+ });
59+}
60+
61+function stopWorker(dev) {
62+ try {
63+ process.kill(-dev.pid, "SIGTERM");
64+ } catch {}
65+}
66+
67+after(() => {
68+ for (const dev of running) stopWorker(dev);
69+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
70+});
71+
72+const T = { root: `dfy_root_${randomBytes(12).toString("hex")}`, ann: `dfy_ann_${randomBytes(12).toString("hex")}` };
73+const S = { root: `root-session-${randomBytes(12).toString("hex")}`, ann: `ann-session-${randomBytes(12).toString("hex")}` };
74+/** The Host a visitor who isn't signed in uses (see the header comment). */
75+const VISITOR = "visitor.example";
76+
77+let w;
78+async function worker() {
79+ if (w) return w;
80+ const port = await freePort();
81+ const base = `http://127.0.0.1:${port}`;
82+ const persist = join(work, "state");
83+ const at = new Date().toISOString();
84+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
85+ const person = (id, login, email, admin) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${email}', 'active', ${admin}, '${at}')`;
86+ const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`;
87+ const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`;
88+ const seed = [person("u-root", "root", "root@example.com", 1), person("u-ann", "ann", "ann@example.com", 0), token("t-root", "u-root", T.root), token("t-ann", "u-ann", T.ann), session("u-root", S.root), session("u-ann", S.ann)];
89+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
90+ migratedState(APP, persist);
91+ wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; "));
92+ const args = ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"];
93+ const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true });
94+ let log = "";
95+ dev.stdout.on("data", (d) => (log += d));
96+ dev.stderr.on("data", (d) => (log += d));
97+ running.push(dev);
98+ const end = Date.now() + 120_000;
99+ while (Date.now() < end) {
100+ try {
101+ if ((await fetch(`${base}/healthz`)).ok) break;
102+ } catch {}
103+ await sleep(500);
104+ }
105+ assert.ok((await fetch(`${base}/healthz`).catch(() => ({ ok: false }))).ok, `wrangler dev did not start\n${log.slice(-4000)}`);
106+
107+ /** Headers for a visitor (no `who`), or a signed-in person's browser. */
108+ const headers = (who, extra = {}) => ({ ...(who ? { cookie: `__Host-dfy_session=${S[who]}` } : { host: VISITOR }), ...extra });
109+ const page = (path, who) => send(`${base}${path}`, { headers: headers(who) });
110+ const api = async (method, path, body, tok) => {
111+ const r = await send(`${base}/api/v3${path}`, { method, headers: { "content-type": "application/json", ...(tok ? { authorization: `Bearer ${tok}` } : { host: VISITOR }) }, body: body === undefined ? undefined : JSON.stringify(body) });
112+ const text = await r.text();
113+ let json = null;
114+ try {
115+ json = text ? JSON.parse(text) : null;
116+ } catch {
117+ json = text;
118+ }
119+ return { status: r.status, json };
120+ };
121+ /** A form's CSRF pair and timing stamp, read from `path` the way a browser does. */
122+ const formOf = async (path, who) => {
123+ const res = await page(path, who);
124+ assert.equal(res.status, 200, `GET ${path} -> ${res.status}`);
125+ const html = await res.text();
126+ const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1];
127+ const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
128+ assert.ok(csrf && pair, `no CSRF pair on ${path}`);
129+ return { csrf, pair, stamp: /name="stamp" value="([^"]*)"/.exec(html)?.[1] ?? "", html };
130+ };
131+ /** POST a form; `form` is from formOf. `ip` sets the address the Worker sees. */
132+ const post = async (action, form, fields, who, ip) => {
133+ const body = new URLSearchParams({ _csrf: form.csrf, ...(form.stamp ? { stamp: form.stamp } : {}), ...fields });
134+ const cookie = [who ? `__Host-dfy_session=${S[who]}` : "", `dfy_csrf=${form.pair}`].filter(Boolean).join("; ");
135+ const r = await send(`${base}${action}`, { method: "POST", body, headers: { ...headers(who), cookie, "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...(ip ? { "cf-connecting-ip": ip } : {}) } });
136+ // Apostrophes come back as &#39;; nothing else is unescaped, so the escaping checks still hold.
137+ return { status: r.status, location: r.headers.get("location"), html: (await r.text()).replace(/&#39;/g, "'") };
138+ };
139+ /** Every email the Worker sent, newest first, with the whole message (DEV_AUTH keeps it). */
140+ const emails = async () => {
141+ const r = await api("GET", "/diffinery/emails?all=1&per_page=200", undefined, T.root);
142+ assert.equal(r.status, 200, JSON.stringify(r.json));
143+ return r.json;
144+ };
145+ const email = async (what, pred) => {
146+ const end = Date.now() + 60_000;
147+ while (Date.now() < end) {
148+ const m = (await emails()).find(pred);
149+ if (m) return m;
150+ await sleep(500);
151+ }
152+ assert.fail(`timed out waiting for the email: ${what}\n${log.slice(-3000)}`);
153+ };
154+ const linkIn = (m, path) => {
155+ const text = m.dev_copy?.text ?? "";
156+ const found = new RegExp(`${base.replace(/[.]/g, "\\.")}${path}#([A-Za-z0-9_-]{20,100})`).exec(text);
157+ assert.ok(found, `no ${path} link in:\n${text}`);
158+ return found[1];
159+ };
160+ /** A form whose stamp is old enough to count as a person's (3 seconds). */
161+ const ready = async (path = "/contact", who) => {
162+ const f = await formOf(path, who);
163+ await sleep(3200);
164+ return f;
165+ };
166+ w = { base, page, api, formOf, post, emails, email, linkIn, ready, log: () => log };
167+ return w;
168+}
169+
170+const message = (email, over = {}) => ({ email, name: "Ada", topic: "question", message: "Does Diffinery run my workflows as they are?", website: "", ...over });
171+const byEmail = async (api, address) => (await api("GET", `/diffinery/admin/contact?q=${encodeURIComponent(address)}`, undefined, T.root)).json;
172+let nextIp = 1;
173+/** A fresh address for the Worker to see each request from, so the daily limit per network doesn't mix tests. */
174+const ip = () => `198.51.100.${nextIp++}`;
175+
176+test("contact: the footer, sitemap, robots.txt and llms.txt lead to the form", async () => {
177+ const { page } = await worker();
178+ assert.match(await (await page("/")).text(), /href="\/contact">Contact</, "the footer links it");
179+ assert.match(await (await page("/about")).text(), /href="https:\/\/diffinery\.com\/contact"/, "the About page links it");
180+ const form = await page("/contact");
181+ assert.equal(form.status, 200);
182+ const html = await form.text();
183+ assert.match(html, /<h1>Contact us<\/h1>/);
184+ for (const name of ["name", "email", "message", "website", "stamp"]) assert.match(html, new RegExp(`name="${name}"`), name);
185+ assert.match(html, /data-k="select" data-p="[^"]*&quot;name&quot;:&quot;topic&quot;/, "the topic is a Kumo select");
186+ assert.doesNotMatch(html, /<select|type="checkbox"/, "Kumo controls only");
187+ assert.match(html, /"@type":"ContactPage"/, "structured data for search engines");
188+ assert.match(html, /href="\/security\/report"/, "security problems go to the private form");
189+ assert.match(await (await page("/contact?topic=press")).text(), /name="topic" value="press"/, "a link can choose the topic");
190+ assert.doesNotMatch(html, /name="topic" value=/, "no topic until one is chosen");
191+ // Signed in: no email field, the account's address is used.
192+ const ann = (await (await page("/contact", "ann")).text()).replace(/&#39;/g, "'");
193+ assert.doesNotMatch(ann, /name="email"/);
194+ assert.match(ann, /Signed in as ann\. We'll reply to ann@example\.com\./);
195+ assert.match(ann, /href="\/contact">Contact</, "the signed-in footer links it too");
196+ // Search engines find the form but not the confirmation page.
197+ const robots = await (await page("/robots.txt")).text();
198+ assert.match(robots, /Disallow: \/contact\/confirm/);
199+ assert.doesNotMatch(robots, /Disallow: \/contact\n/);
200+ assert.match(await (await page("/sitemap.xml")).text(), /:\d+\/contact<\/loc>/);
201+ assert.match(await (await page("/llms.txt")).text(), /Contact the Diffinery team: http:\/\/[^ ]+\/contact/);
202+ assert.match(await (await page("/contact/confirm")).text(), /name="robots" content="noindex/);
203+});
204+
205+test("contact: a visitor's message is confirmed with the emailed link, then reaches the site administrators", async () => {
206+ const { page, api, post, email, linkIn, ready } = await worker();
207+ const f = await ready();
208+ const hostile = { name: "Ada <script>alert(1)</script>", topic: "partnership", message: "Hello team,\n<img src=x onerror=alert(1)>\nLet's talk." };
209+ const sent = await post("/contact", f, message("Ada@Example.net", hostile), undefined, ip());
210+ assert.equal(sent.status, 200, sent.html.slice(0, 600));
211+ assert.match(sent.html, /Check your inbox/);
212+ assert.match(sent.html, /ada@example\.net/);
213+ const m = await email("the confirmation", (e) => e.kind === "contact_confirm" && e.to_addr === "ada@example.net");
214+ assert.equal(m.subject, "[Diffinery] Confirm your message to the Diffinery team");
215+ assert.match(m.dev_copy.text, /Partnerships and integrations/);
216+ assert.doesNotMatch(JSON.stringify(m.dev_copy), /Ada|onerror|Let's talk/, "nothing typed in the form goes into the email");
217+ const token = linkIn(m, "/contact/confirm");
218+
219+ // Unconfirmed: nobody is told yet.
220+ const [before] = await byEmail(api, "ada@example.net");
221+ assert.equal(before.state, "unconfirmed");
222+ assert.doesNotMatch(await (await page("/settings/admin/contact", "root")).text(), /ada@example\.net/);
223+
224+ const cf = { ...(await w.formOf("/contact/confirm")), stamp: "" };
225+ const wrong = await post("/contact/confirm", cf, { token: "x".repeat(40) });
226+ assert.equal(wrong.status, 410);
227+ assert.match(wrong.html, /This link doesn't work any more/);
228+ const ok = await post("/contact/confirm", cf, { token });
229+ assert.equal(ok.status, 200, ok.html.slice(0, 600));
230+ assert.match(ok.html, /Your message is with the team/);
231+ assert.ok(ok.html.includes("Ada &lt;script&gt;alert(1)&lt;/script&gt;"), "the name is escaped");
232+ assert.equal((await post("/contact/confirm", cf, { token })).status, 410, "a link works once");
233+
234+ const [after] = await byEmail(api, "ada@example.net");
235+ assert.equal(after.state, "new");
236+ assert.deepEqual({ name: after.name, topic: after.topic, message: after.message, sender: after.sender }, { name: hostile.name, topic: "partnership", message: hostile.message, sender: null });
237+ assert.ok(!("confirm_hash" in after));
238+ // The site administrators get an email to answer from their mailbox, and a notification.
239+ const note = await email("the site administrators' email", (e) => e.kind === "contact_message" && e.to_addr === "root@example.com");
240+ assert.equal(note.subject, "[Diffinery] Contact: Partnerships and integrations from Ada <script>alert(1)</script>");
241+ assert.deepEqual(note.dev_copy.replyTo, { name: hostile.name, email: "ada@example.net" });
242+ assert.match(note.dev_copy.text, /> Hello team,\n> <img src=x onerror=alert\(1\)>\n> Let's talk\./);
243+ assert.ok(!note.dev_copy.html.includes("<img src=x"), "the message is escaped in the HTML email");
244+ assert.match(await (await page("/notifications", "root")).text(), /New contact messages are waiting for you/);
245+ const admin = await (await page("/settings/admin/contact", "root")).text();
246+ assert.match(admin, /ada@example\.net/);
247+ assert.ok(!admin.includes("<script>alert(1)") && !admin.includes("<img src=x"), "everything is escaped");
248+ assert.match(admin, /Contact messages\s*<span class="badge blue"[^>]*>\s*1\s*</, "the sidebar counts what waits");
249+ const detail = (await (await page(`/settings/admin/contact/${after.id}`, "root")).text()).replace(/&#39;/g, "'");
250+ assert.ok(detail.includes("Hello team,\n&lt;img src=x onerror=alert(1)&gt;\nLet's talk."), "the message is plain text");
251+ assert.match(detail, /href="mailto:ada@example\.net\?subject=Re%3A%20Partnerships%20and%20integrations"/);
252+});
253+
254+test("contact: a signed-in person's message arrives at once, with a copy for them", async () => {
255+ const { api, post, email, formOf } = await worker();
256+ const f = await formOf("/contact", "ann");
257+ assert.equal(f.stamp, "", "no timing stamp for a signed-in person");
258+ const sent = await post("/contact", f, { name: "Ann", topic: "account", message: "My invitation link expired, can you resend?", email: "someone-else@example.net" }, "ann");
259+ assert.equal(sent.status, 200, sent.html.slice(0, 600));
260+ assert.match(sent.html, /Message sent/);
261+ const [m] = await byEmail(api, "ann@example.com");
262+ assert.equal(m.state, "new");
263+ assert.equal(m.sender, "ann");
264+ assert.ok(m.confirmed_at);
265+ assert.deepEqual(await byEmail(api, "someone-else@example.net"), [], "the address typed is ignored");
266+ const copy = await email("the copy", (e) => e.kind === "contact_received" && e.to_addr === "ann@example.com");
267+ assert.equal(copy.subject, "[Diffinery] We got your message");
268+ assert.match(copy.dev_copy.text, /My invitation link expired/);
269+ const note = await email("the site administrators' email", (e) => e.kind === "contact_message" && e.dev_copy?.replyTo?.email === "ann@example.com");
270+ assert.match(note.dev_copy.text, /From: Ann \(@ann\)/);
271+});
272+
273+test("contact: bots, bad input and big bodies get nowhere", async () => {
274+ const { api, post, emails, formOf, ready } = await worker();
275+ const fresh = await formOf("/contact");
276+ // Too fast after the page was shown, a filled honeypot, or a forged or missing stamp: the usual page, nothing kept or sent.
277+ const fast = await post("/contact", fresh, message("bot1@example.net"), undefined, ip());
278+ assert.equal(fast.status, 200);
279+ assert.match(fast.html, /Check your inbox/);
280+ const f = await ready();
281+ const honey = await post("/contact", f, message("bot2@example.net", { website: "https://spam.example" }), undefined, ip());
282+ assert.match(honey.html, /Check your inbox/);
283+ const forged = await post("/contact", { ...f, stamp: `${Date.now() - 10_000}.${"A".repeat(43)}` }, message("bot3@example.net"), undefined, ip());
284+ assert.match(forged.html, /Check your inbox/);
285+ const missing = await post("/contact", { ...f, stamp: "" }, message("bot4@example.net"), undefined, ip());
286+ assert.match(missing.html, /Check your inbox/);
287+ for (const a of ["bot1", "bot2", "bot3", "bot4"]) assert.deepEqual(await byEmail(api, `${a}@example.net`), [], a);
288+
289+ // Bad input is refused with the reason, keeping what was typed.
290+ const long = await post("/contact", f, message("long@example.net", { message: "x".repeat(5001), name: "Keep me" }), undefined, ip());
291+ assert.equal(long.status, 422);
292+ assert.match(long.html, /Keep your message to 5,000 characters/);
293+ assert.match(long.html, /value="Keep me"/);
294+ const links = await post("/contact", f, message("links@example.net", { message: Array.from({ length: 6 }, (_, i) => `https://s${i}.example`).join(" ") }), undefined, ip());
295+ assert.equal(links.status, 422);
296+ assert.match(links.html, /5 links or fewer/);
297+ const bad = await post("/contact", f, message("not-an-address"), undefined, ip());
298+ assert.equal(bad.status, 422);
299+ assert.match(bad.html, /Enter a valid email address/);
300+ const topic = await post("/contact", f, message("t@example.net", { topic: "<script>" }), undefined, ip());
301+ assert.equal(topic.status, 422);
302+ assert.ok(!topic.html.includes('value="<script>"'));
303+ const huge = await post("/contact", f, message("huge@example.net", { message: "y".repeat(80_000) }), undefined, ip());
304+ assert.equal(huge.status, 413);
305+ await sleep(1500);
306+ const tried = new Set(["bot1", "bot2", "bot3", "bot4", "long", "links", "t", "huge"].map((n) => `${n}@example.net`));
307+ assert.deepEqual((await emails()).filter((e) => tried.has(e.to_addr)), [], "nothing was emailed");
308+});
309+
310+test("contact: only site administrators see messages (404 for anyone else)", async () => {
311+ const { page, api, post, formOf } = await worker();
312+ const [ada] = await byEmail(api, "ada@example.net");
313+ assert.ok(ada, "the message from the tests above");
314+ for (const path of ["/settings/admin/contact", `/settings/admin/contact/${ada.id}`]) {
315+ assert.equal((await page(path, "ann")).status, 404, `${path} for a person who isn't a site administrator`);
316+ const anon = await page(path);
317+ assert.equal(anon.status, 302, `${path} sends a visitor to sign in`);
318+ assert.match(anon.headers.get("location") ?? "", /^\/login\?next=/);
319+ }
320+ const annForm = await formOf("/settings", "ann");
321+ for (const action of ["state", "note", "delete"]) assert.equal((await post(`/settings/admin/contact/${ada.id}/${action}`, annForm, { state: "spam", note: "x" }, "ann")).status, 404, action);
322+ assert.equal((await api("GET", "/diffinery/admin/contact", undefined, T.ann)).status, 404);
323+ assert.equal((await api("GET", `/diffinery/admin/contact/${ada.id}`, undefined, T.ann)).status, 404);
324+ assert.equal((await api("PATCH", `/diffinery/admin/contact/${ada.id}`, { state: "spam" }, T.ann)).status, 404);
325+ assert.equal((await api("DELETE", `/diffinery/admin/contact/${ada.id}`, undefined, T.ann)).status, 404);
326+ assert.equal((await api("GET", "/diffinery/admin/contact")).status, 401);
327+ assert.doesNotMatch(await (await page("/settings", "ann")).text(), /href="\/settings\/admin\/contact"/);
328+ assert.equal((await byEmail(api, "ada@example.net"))[0].state, "new", "nothing changed");
329+});
330+
331+test("contact: handle, note, spam, reopen and delete, on the web and through the API, audited", async () => {
332+ const { page, api, post, formOf, email, emails, linkIn, ready } = await worker();
333+ const [ada] = await byEmail(api, "ada@example.net");
334+ // Mark handled from the web, with a note.
335+ const handled = await post(`/settings/admin/contact/${ada.id}/state`, await formOf("/settings/admin/contact", "root"), { state: "handled", back: "/settings/admin/contact" }, "root");
336+ assert.equal(handled.status, 303);
337+ assert.equal(handled.location, "/settings/admin/contact");
338+ const noted = await post(`/settings/admin/contact/${ada.id}/note`, await formOf(`/settings/admin/contact/${ada.id}`, "root"), { note: "Replied with the partner guide." }, "root");
339+ assert.equal(noted.status, 303);
340+ const [now] = await byEmail(api, "ada@example.net");
341+ assert.deepEqual({ state: now.state, note: now.note, updated_by: now.updated_by }, { state: "handled", note: "Replied with the partner guide.", updated_by: "root" });
342+ assert.match(await (await page("/settings/admin/contact?state=handled", "root")).text(), /handled by root/);
343+ assert.match(await (await page(`/settings/admin/contact/${ada.id}`, "root")).text(), />Replied with the partner guide\.</);
344+
345+ // A second message: spam through the API, after which its address gets nothing through; reopen; then delete on the web.
346+ const f = await ready();
347+ await post("/contact", f, message("bob@example.net", { message: "Buy cheap watches, secret plans." }), undefined, ip());
348+ const token = linkIn(await email("bob's confirmation", (e) => e.kind === "contact_confirm" && e.to_addr === "bob@example.net"), "/contact/confirm");
349+ await post("/contact/confirm", { ...(await formOf("/contact/confirm")), stamp: "" }, { token });
350+ const [bob] = await byEmail(api, "bob@example.net");
351+ assert.equal(bob.state, "new");
352+ const spam = await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "spam" }, T.root);
353+ assert.equal(spam.status, 200, JSON.stringify(spam.json));
354+ assert.equal(spam.json.state, "spam");
355+ assert.equal((await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "deleted" }, T.root)).status, 422);
356+ const toBob = async () => (await emails()).filter((e) => e.to_addr === "bob@example.net").length;
357+ const sent = await toBob();
358+ const again = await post("/contact", f, message("bob@example.net"), undefined, ip());
359+ assert.match(again.html, /Check your inbox/, "the same page");
360+ await sleep(1500);
361+ assert.equal(await toBob(), sent, "nothing is emailed to an address marked spam");
362+ assert.equal((await byEmail(api, "bob@example.net")).length, 1, "and nothing is stored");
363+ assert.equal((await api("PATCH", `/diffinery/admin/contact/${bob.id}`, { state: "new", note: "Not spam after all" }, T.root)).json.state, "new");
364+ const del = await post(`/settings/admin/contact/${bob.id}/delete`, await formOf(`/settings/admin/contact/${bob.id}`, "root"), {}, "root");
365+ assert.equal(del.status, 303);
366+ assert.equal(del.location, "/settings/admin/contact");
367+ assert.equal((await api("GET", `/diffinery/admin/contact/${bob.id}`, undefined, T.root)).status, 404);
368+
369+ const audit = await (await page("/settings/audit", "root")).text();
370+ for (const action of ["contact.create", "contact.confirm", "contact.handle", "contact.note", "contact.spam", "contact.reopen", "contact.delete"]) assert.ok(audit.includes(action), `${action} is audited`);
371+ assert.ok(!audit.includes("secret plans") && !audit.includes("partner guide") && !audit.includes("onerror"), "the audit log holds no message content");
372+});
373+
374+test("contact: daily limits per network and per address", async () => {
375+ const { api, post, emails, ready } = await worker();
376+ const f = await ready();
377+ // One address: three emails a day, then the page stays the same and nothing more is sent.
378+ const flood = "flood@example.net";
379+ for (let i = 0; i < 4; i++) assert.equal((await post("/contact", f, message(flood), undefined, ip())).status, 200);
380+ await sleep(1500);
381+ assert.equal((await emails()).filter((e) => e.to_addr === flood).length, 3);
382+ assert.equal((await byEmail(api, flood)).length, 3);
383+ // One network: ten submissions a day, then a visible refusal.
384+ const net = "203.0.113.77";
385+ for (let i = 0; i < 10; i++) assert.equal((await post("/contact", f, message(`n${i}@example.net`), undefined, net)).status, 200, `submission ${i + 1}`);
386+ const over = await post("/contact", f, message("n10@example.net"), undefined, net);
387+ assert.equal(over.status, 429);
388+ assert.match(over.html, /Too many messages from your network today/);
389+ assert.deepEqual(await byEmail(api, "n10@example.net"), []);
390+ // Another network still gets through.
391+ assert.equal((await post("/contact", f, message("other@example.net"), undefined, ip())).status, 200);
392+});
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[] = [];