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.

Request early access: a protected form for visitors, double opt-in, and an Early access page for site administrators (WIP)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jAs6Yp1agcb2w5FdELuEd
Claude authored signed1 parent 7ceb513commit afe26442d6e4f00fb7aa7593258c6d12141320bfBrowse files
38 files changed+2026−37
Files (38)
addedapps/diffinery/docs/accounts/early-access.md+133−0View file
@@ -1,0 +1,133 @@
1+---
2+title: Early access
3+description: Ask for an invitation to an invite-only Diffinery instance, what happens to your request and data, how the form is protected, and how site administrators invite, decline or delete requests.
4+---
5+
6+While a Diffinery instance is invite-only, people without an invitation can ask for one at `/early-access`. On diffinery.com that's [diffinery.com/early-access](https://diffinery.com/early-access). The home page, the sign-in page and the sign-up address all lead there. A request never grants access by itself: a site administrator reads it and decides whether to send an [invitation](/docs/accounts/invitations).
7+
8+The form is only there while sign-up is closed. On an instance with [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up) (`SIGNUP=open`), `/early-access` leads to the sign-up page instead.
9+
10+## Request early access
11+
12+1. Open `/early-access`. If you're already signed in, you go to your dashboard instead.
13+2. Fill in the form. Only **Work email** is required:
14+
15+ | Field | Limit | What it's for |
16+ | --- | --- | --- |
17+ | **Work email** | a valid address, up to 254 characters | Where we send the confirmation link and, later, the invitation |
18+ | **Name** | up to 100 characters | How the site administrators address you |
19+ | **Company or team** | up to 120 characters | Who you'd use Diffinery with |
20+ | **What would you use Diffinery for?** | up to 1,000 characters, plain text | What you'd move or build, so the team can prioritize |
21+ | **Where do you host your code today?** | one of GitHub, GitLab, Bitbucket, Azure DevOps, Self-hosted, Somewhere else, Nowhere yet | Helps plan your move |
22+
23+3. Complete the "are you a person" check if one shows, and click **Request early access**.
24+4. You see **Check your inbox**. Open the email titled "Confirm your early access request" and click **Confirm my request**, then press **Confirm my request** on the page that opens. The link works once, for 48 hours; asking again sends a new link, and only the newest one works.
25+5. You see **You're on the list**. Your request now waits for a site administrator.
26+
27+When a site administrator invites you, you get the usual invitation email ("invited you to Diffinery"). Its link lets you pick a username and create a passkey; see [How sign-up works](/docs/accounts/invitations#how-sign-up-works). Nobody emails you if your request is declined.
28+
29+### What the email says
30+
31+The form always shows **Check your inbox**, whatever the address. What arrives depends on the address:
32+
33+| The address | The email |
34+| --- | --- |
35+| New, or asked before but never confirmed | "Confirm your early access request", with the link. The new details replace the unconfirmed ones. |
36+| Already confirmed or declined | "We already have your early access request": nothing else to do |
37+| Already invited | "You're already invited", with the date the invitation was sent |
38+| Belongs to an active Diffinery account | "You already have a Diffinery account", with a link to sign in |
39+| Belongs to a disabled or suspended account | Nothing |
40+
41+So only the person who reads that inbox learns whether the address has an account or asked before; the form itself never tells anyone. These emails never contain anything typed into the form.
42+
43+### Removing your request
44+
45+You can delete your request yourself at any time, whatever its state:
46+
47+1. Open `/early-access/remove` (the form's privacy note links to it).
48+2. Enter the address of the request, complete the check if one shows, and click **Email me a removal link**.
49+3. Open the email titled "Remove your early access request" and click **Remove my request**, then press **Remove my request** on the page that opens. You see **Your request is removed**.
50+
51+The link works once, for 24 hours. The page answers **Check your inbox** whether or not the address has a request, and only an address with one gets an email, so nobody can remove or look up someone else's request. Removal links count towards the same daily limits as requests. A site administrator can also delete a request for you.
52+
53+Unconfirmed requests are deleted automatically 7 days after they were last sent.
54+
55+## What is stored
56+
57+| What | Kept |
58+| --- | --- |
59+| The fields you filled in and the request's state | Until a site administrator deletes the request; unconfirmed requests for 7 days |
60+| A SHA-256 hash of the confirmation link's code | Until the link is used or replaced |
61+| A SHA-256 hash of a removal link's code, when you ask for one | Until the request is removed |
62+| One row per form submission with SHA-256 hashes of your IP address and of the address typed | One day, for the daily limits only, even after the request is deleted |
63+
64+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 requests. In the [data model](/docs/self-hosting/architecture), these are the `early_access` and `early_access_attempts` tables.
65+
66+## How the form is protected
67+
68+| Protection | What it does |
69+| --- | --- |
70+| Cloudflare Turnstile | The "are you a person" check, the same one the [security report form](/docs/security/disclosure) uses. A deployed instance without Turnstile keys (`TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`) doesn't show the form; on your own machine the check is skipped. |
71+| 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. |
72+| Per-minute limit | At most 20 sign-in attempts, security reports and early access requests a minute from one network (`RL_AUTH`, see [Rate limits](/docs/reference/rest-api#rate-limits)). |
73+| Daily limits | At most 10 submissions from one IP address, 3 emails to one address, and 500 emails from the form in total, in 24 hours. Past the network limit you see "Too many requests from your network today"; past the other two the page looks the same and no email is sent. |
74+| Double opt-in | A request counts only after its address follows the emailed link, so nobody can sign up someone else. |
75+| Strict input | Lengths are capped (longer is refused, never cut), the email address is checked, the hosting answer must be one of the list, and control and text-direction characters are removed. Everything is shown as plain text, never as HTML or Markdown. |
76+
77+## For site administrators
78+
79+**Settings → Early access** (`/settings/admin/early-access`) is in the **Site administration** group. The sidebar shows how many confirmed requests are waiting, **People and invitations** has a reminder while any are, and each site administrator gets one in-app notification, "New early access requests are waiting for you", which stays until it is read however many requests arrive. There are no per-request emails. Anyone who isn't a site administrator gets `404`.
80+
81+The tabs filter by state, with counts:
82+
83+| Tab | State | Meaning |
84+| --- | --- | --- |
85+| **Confirmed** (the default) | `confirmed` | The address confirmed; waiting for you to invite or decline |
86+| **Unconfirmed** | `unconfirmed` | The address hasn't followed the link yet |
87+| **Invited** | `invited` | Invited, by whom and when |
88+| **Declined** | `declined` | Declined, by whom and when |
89+| **All** | | Every request |
90+
91+The search box finds requests whose address, name, company or use contains what you type. Each request shows its name and address, company, where they host code, when they asked, the start of what they'd use Diffinery for, and an amber **has an account** badge when an active account already uses the address. Click a name to see everything, including the full text and its reference.
92+
93+- **Invite** asks you to confirm, then sends the address the instance's invitation email, exactly like **Invite someone to this instance** on [People and invitations](/docs/admin/site-administration#invites): it works once for 14 days. The request becomes **Invited**. If the address already has a pending instance invitation, that one is kept and no second email is sent. An address that already has an account has no **Invite** button. You can invite an unconfirmed request too: the invitation itself only works for someone who reads that inbox.
94+- **Decline** marks the request **Declined**. Nobody is emailed. **Put back** on a declined request returns it to **Confirmed** (or **Unconfirmed**, if it never was).
95+- **Delete request**, on a request's page, removes it for good, for someone who asks for their data to be removed. An invitation already sent stays; revoke it on **People and invitations**.
96+
97+Every decision is in the [audit log](/docs/security/audit-log): `early_access.invite` (with `invite.create` when a new invitation is sent), `early_access.decline`, `early_access.restore` and `early_access.delete` (also when someone removes their own request), and `early_access.confirm` when an address confirms. Entries name the request by its id; none holds what the request said, and `early_access.delete` doesn't hold the address.
98+
99+### From the API
100+
101+With a site administrator's token (`read` to list, `admin` to change):
102+
103+| Endpoint | What it does |
104+| --- | --- |
105+| `GET /api/v3/diffinery/admin/early-access` | List requests, newest first. `state`, `q`, `per_page` (up to 500) and `page` filter and page them. |
106+| `GET /api/v3/diffinery/admin/early-access/{id}` | One request |
107+| `POST /api/v3/diffinery/admin/early-access/{id}/invite` | Invite it, as **Invite** does |
108+| `PATCH /api/v3/diffinery/admin/early-access/{id}` | `{"state": "declined"}` declines it; `{"state": "confirmed"}` puts a declined request back |
109+| `DELETE /api/v3/diffinery/admin/early-access/{id}` | Delete it (`204`) |
110+
111+```bash
112+# Confirmed requests waiting for a decision
113+curl -H "Authorization: Bearer $DIFFINERY_TOKEN" \
114+ "https://diffinery.com/api/v3/diffinery/admin/early-access?state=confirmed"
115+
116+# Invite one
117+curl -X POST -H "Authorization: Bearer $DIFFINERY_TOKEN" \
118+ https://diffinery.com/api/v3/diffinery/admin/early-access/<id>/invite
119+```
120+
121+Each request has `id`, `state`, `email`, `name`, `company`, `use_case`, `hosting`, `confirmed_at`, `invite_id`, `decided_by`, `decided_at`, `created_at`, `updated_at` and `html_url`. Everyone else gets `404`. See the [API reference](/docs/api) for every field.
122+
123+## Troubleshooting
124+
125+| What you see | Why, and what to do |
126+| --- | --- |
127+| No email after **Check your inbox** | Check the spam folder. The address may have had 3 emails from the form today, or the address belongs to a disabled account; try again tomorrow, or ask someone who uses the instance. |
128+| "This link doesn't work any more" | The link was already used, is older than 48 hours, or a newer one was sent. If you confirmed already, you're on the list. Otherwise request again. |
129+| "This link is incomplete" | The part after `#` was cut off. Open the link from the email in full, or copy all of it. |
130+| "This link doesn't work any more" after **Remove my request** | The removal link was already used, is older than 24 hours, or a newer one was sent. If you used it, the request is gone. |
131+| "We couldn't confirm that you're a person" | The Turnstile check failed or expired. Reload the page and try again. |
132+| "This form was open for a long time" | The page was open more than 24 hours. Check the fields and send it again. |
133+| "Too many requests from your network today" | 10 submissions came from your network in 24 hours. Try again tomorrow. |
modifiedapps/diffinery/docs/accounts/invitations.md+2−0View file
@@ -5,6 +5,8 @@
55
66 Diffinery is invite-only by default: nobody can sign up from the sign-in page. (An operator can turn on [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up) with `SIGNUP=open`, where people create an account by confirming their email with a code; invitations work the same either way.) A person joins by opening an invite link sent to their email address, picking a username and creating a passkey. Organizations and repositories have an **Add people** form that takes a username or an email address. Either way the person gets an invitation, as on GitHub: someone added by username accepts it under **Settings → Invitations**, and an email address gets an invite link that also creates the account if there is none.
77
8+Without an invitation, someone can [request early access](/docs/accounts/early-access) at `/early-access`, which the home page and the sign-in page link to. A site administrator reads the request and decides whether to invite them.
9+
810 ## How sign-up works
911
1012 1. Someone with permission creates an invite for an email address. The invite can be for the whole instance, for an organization (with an organization role) or for a repository (with a repository role).
modifiedapps/diffinery/docs/accounts/notifications.md+3−0View file
@@ -265,6 +265,9 @@
265265 | `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection, or committed a flagged file through the commits API with `allow_secrets`; sent to that admin and the repository's admins |
266266 | `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again |
267267 | `security_report` | `[Diffinery] New security report` | Security | `admin` | Someone sent a private report through `/security/report` (sent to every active site administrator). It holds only the report's summary and a link to it in [Site administration](/docs/admin/site-administration#security-reports). |
268+| `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. |
269+| `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 |
270+| `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 |
268271 | `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**) |
269272 | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up |
270273 | `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. |
modifiedapps/diffinery/docs/admin/site-administration.md+5−0View file
@@ -12,6 +12,7 @@
1212 | Page | Path | What it is for |
1313 | --- | --- | --- |
1414 | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links |
15+| **Early access** | `/settings/admin/early-access` | Requests from people without an invitation, to invite, decline or delete |
1516 | **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve |
1617 | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email |
1718 | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance |
@@ -129,6 +130,10 @@
129130
130131 Both answer `204`. Anyone else gets `403 site administrators only`.
131132
133+## Early access
134+
135+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.
136+
132137 ## Reports
133138
134139 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.
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -24,6 +24,7 @@
2424 "pages": [
2525 "accounts/passkeys-and-sign-in",
2626 "accounts/invitations",
27+ "accounts/early-access",
2728 "accounts/organizations",
2829 "accounts/permissions",
2930 "accounts/tokens",
modifiedapps/diffinery/docs/reference/limits.md+3−0View file
@@ -26,6 +26,9 @@
2626 | Display name when joining | 100 characters | Longer names are cut off. |
2727 | Passkey name | 100 characters | Longer names are cut off. |
2828 | Repository, organization and agent names | 1 to 100 characters | Start with a letter or digit; then letters, digits, `.`, `_` and `-`. Repository names can't end in `.git`. Organization and agent names can't contain `.` and can't be a reserved name. |
29+| 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. |
30+| 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. |
31+| Early access fields | name 100, company or team 120, use 1,000 characters | Longer is refused, not cut. |
2932 | 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. |
3033
3134 ## Tokens
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -405,7 +405,7 @@
405405
406406 | What | Limit | Counted by |
407407 | --- | --- | --- |
408-| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`) | 20 a minute | IP address |
408+| 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 |
409409 | Anything else without signing in (web pages, API, git) | 300 a minute | IP address |
410410 | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account |
411411 | 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+4−0View file
@@ -132,6 +132,10 @@
132132 | `email.suppression.lift` | Someone lifts a bounce or complaint on their own address by following the verification link from **Settings → Email** | the email address | `reason` of the entry lifted |
133133 | `security_report.create` | Someone sends a private [security report](/docs/admin/site-administration#security-reports) through `/security/report`. The actor is the reporter when signed in, otherwise `system`. Nothing of the report's content is recorded. | the report's id | `signed_in` |
134134 | `security_report.triage`, `security_report.resolve`, `security_report.reopen` | A site administrator marks a security report triaged or resolved, or new again (**Save** on the report's page, or `PATCH /api/v3/diffinery/admin/security-reports/{id}`) | the report's id | `from` and `to` (the states), `note`, `via` (`web` or `api`) |
135+| `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 |
136+| `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`) |
137+| `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` |
138+| `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`) |
135139 | `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 |
136140 | `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` |
137141
modifiedapps/diffinery/docs/self-hosting/architecture.md+1−0View file
@@ -272,6 +272,7 @@
272272 | `tokens` | Personal and agent tokens: SHA-256 hash, prefix, scopes, repository restriction, expiry, last use, revocation, expiry reminder, and the import a GitHub import's push token belongs to. |
273273 | `invites` | Invitations to the site, an organization or a repository, by token hash, with expiry and acceptance. |
274274 | `access_requests` | Requests from people waiting for an invite. |
275+| `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. |
275276 | `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. |
276277 | `passkeys` | WebAuthn credentials: public key, counter, transports, device type, backup state, name. |
277278 | `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+1−1View 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 | The site key of the Cloudflare Turnstile widget on the sign-up page and the [security report form](#security-contact). 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 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. |
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). |
addedapps/diffinery/migrations/0037_early_access.sql+40−0View file
@@ -1,0 +1,40 @@
1+-- Early access requests (lib/early-access.ts): what people send through /early-access while the
2+-- instance is invite-only. A request is unconfirmed until its email link is followed, then waits as
3+-- confirmed until a site administrator invites or declines it. Only site administrators can read
4+-- them; the audit log keeps every decision. Unconfirmed requests are deleted after 7 days, and anyone
5+-- can delete their own request through an emailed link (/early-access/remove).
6+CREATE TABLE IF NOT EXISTS early_access (
7+ id TEXT PRIMARY KEY,
8+ email TEXT NOT NULL COLLATE NOCASE UNIQUE,
9+ name TEXT,
10+ company TEXT,
11+ use_case TEXT,
12+ hosting TEXT, -- where they host code today, one of a fixed list
13+ state TEXT NOT NULL DEFAULT 'unconfirmed' CHECK (state IN ('unconfirmed', 'confirmed', 'invited', 'declined')),
14+ confirm_hash TEXT, -- SHA-256 of the emailed confirmation token, until it is used
15+ confirm_expires_at TEXT,
16+ confirmed_at TEXT,
17+ remove_hash TEXT, -- SHA-256 of an emailed removal token, when the address asked to be removed
18+ remove_expires_at TEXT,
19+ invite_id TEXT, -- the site invitation sent from it
20+ decided_by TEXT, -- the site administrator's login
21+ decided_at TEXT,
22+ created_at TEXT NOT NULL,
23+ updated_at TEXT NOT NULL
24+);
25+CREATE INDEX IF NOT EXISTS early_access_state ON early_access(state, created_at);
26+CREATE UNIQUE INDEX IF NOT EXISTS early_access_confirm ON early_access(confirm_hash);
27+CREATE UNIQUE INDEX IF NOT EXISTS early_access_remove ON early_access(remove_hash);
28+
29+-- One row per form submission, for the daily limits only: hashes of the sender's IP address and of
30+-- the address typed, never either in the clear. Rows older than a day are deleted.
31+CREATE TABLE IF NOT EXISTS early_access_attempts (
32+ id TEXT PRIMARY KEY,
33+ ip_hash TEXT NOT NULL,
34+ email_hash TEXT NOT NULL,
35+ emailed INTEGER NOT NULL DEFAULT 0, -- 1 when this submission sent an email
36+ created_at TEXT NOT NULL
37+);
38+CREATE INDEX IF NOT EXISTS early_access_attempts_ip ON early_access_attempts(ip_hash, created_at);
39+CREATE INDEX IF NOT EXISTS early_access_attempts_email ON early_access_attempts(email_hash, created_at);
40+CREATE INDEX IF NOT EXISTS early_access_attempts_at ON early_access_attempts(created_at);
modifiedapps/diffinery/src/agentic/tasks-web.tsx+1−1View file
@@ -139,7 +139,7 @@
139139 c,
140140 { title: "Model access" },
141141 <div class="grid left">
142- <SettingsNav me={me} tab="model-access" />
142+ <SettingsNav me={me} tab="model-access" db={c.env.DB} />
143143 <div>
144144 <h1>Model access</h1>
145145 <p class="muted">
modifiedapps/diffinery/src/app.ts+2−0View file
@@ -34,6 +34,7 @@
3434 import { mailEvents, mailEventsQueue } from "./mail/events.js";
3535 import { securityTxtRoutes } from "./http/security-txt.js";
3636 import { securityReportsApi } from "./http/security-reports.js";
37+import { earlyAccessApi } from "./http/early-access.js";
3738 import { statusApi } from "./http/status.js";
3839 import { onReviewEvent } from "./agentic/reviews.js";
3940 import { onVerifyEvent } from "./agentic/tasks.js";
@@ -115,6 +116,7 @@
115116 app.route("/", runner);
116117 app.route("/", mailApi);
117118 app.route("/", securityReportsApi);
119+app.route("/", earlyAccessApi);
118120 app.route("/", statusApi);
119121 app.route("/", mailEvents);
120122 app.route("/", registry);
modifiedapps/diffinery/src/docs/api-meta.ts+31−0View file
@@ -113,6 +113,11 @@
113113 "GET /diffinery/admin/security-reports": { scope: "read" },
114114 "GET /diffinery/admin/security-reports/{id}": { scope: "read" },
115115 "PATCH /diffinery/admin/security-reports/{id}": { scope: "admin" },
116+ "GET /diffinery/admin/early-access": { scope: "read" },
117+ "GET /diffinery/admin/early-access/{id}": { scope: "read" },
118+ "POST /diffinery/admin/early-access/{id}/invite": { scope: "admin" },
119+ "PATCH /diffinery/admin/early-access/{id}": { scope: "admin" },
120+ "DELETE /diffinery/admin/early-access/{id}": { scope: "admin" },
116121 "GET /diffinery/admin/status/incidents": { scope: "read" },
117122 "POST /diffinery/admin/status/incidents": { scope: "admin" },
118123 "POST /diffinery/admin/status/incidents/{id}/updates": { scope: "admin" },
@@ -1583,6 +1588,32 @@
15831588 access: "site-admin",
15841589 body: { state: ["string", "`triaged`, `resolved`, or `new` to reopen it.", true], note: ["string", "For the other site administrators, up to 2,000 characters."] },
15851590 },
1591+ "GET /diffinery/admin/early-access": {
1592+ summary: "List early access requests",
1593+ description:
1594+ "Requests sent through `/early-access`, newest first: `id`, `state` (`unconfirmed` until the address follows the emailed link, then `confirmed`, `invited` or `declined`), `email`, `name`, `company`, `use_case`, `hosting` (`github`, `gitlab`, `bitbucket`, `azure`, `self-hosted`, `other`, `none` or null), `confirmed_at`, `invite_id`, `decided_by`, `decided_at`, `created_at`, `updated_at` and `html_url`. 404 for anyone but a site administrator. See [Early access](/docs/accounts/early-access).",
1595+ access: "site-admin",
1596+ query: { state: ["string", "Only requests in this state: `confirmed`, `unconfirmed`, `invited` or `declined`."], q: ["string", "Only requests whose address, name, company or use contains this text."], per_page: ["integer", "At most this many (default 100, up to 500)."], page: ["integer", "Page number, from 1."] },
1597+ },
1598+ "GET /diffinery/admin/early-access/{id}": { summary: "Get an early access request", description: "One request, as in the list. 404 when there is none, or for anyone but a site administrator.", access: "site-admin" },
1599+ "POST /diffinery/admin/early-access/{id}/invite": {
1600+ summary: "Invite an early access request",
1601+ description:
1602+ "Sends the request's address the instance's usual invitation email (it works once for 14 days) and marks the request `invited`. An address that already has a pending instance invitation keeps it; one that already belongs to an active account is refused (422). Recorded in the audit log as `early_access.invite`, and `invite.create` when a new invitation is sent. 404 for anyone but a site administrator.",
1603+ access: "site-admin",
1604+ },
1605+ "PATCH /diffinery/admin/early-access/{id}": {
1606+ summary: "Decline an early access request, or put it back",
1607+ description: "`declined` declines it; `confirmed` puts a declined request back (as `unconfirmed` if its address never confirmed). Nobody is emailed. Recorded in the audit log as `early_access.decline` or `early_access.restore`. 404 for anyone but a site administrator.",
1608+ access: "site-admin",
1609+ body: { state: ["string", "`declined`, or `confirmed` to put a declined request back.", true] },
1610+ },
1611+ "DELETE /diffinery/admin/early-access/{id}": {
1612+ summary: "Delete an early access request",
1613+ description: "Removes the request, for someone who asks for their data to be removed. An invitation already sent from it stays; revoke it separately. Recorded in the audit log as `early_access.delete`, without the request's content or address. 404 for anyone but a site administrator.",
1614+ access: "site-admin",
1615+ status: 204,
1616+ },
15861617 "GET /diffinery/status": {
15871618 summary: "Get the instance's status",
15881619 description:
addedapps/diffinery/src/http/early-access.ts+65−0View file
@@ -1,0 +1,65 @@
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 { EarlyAccess, REQUEST_STATES, declineRequest, deleteRequest, inviteRequest, requestJson, type RequestState } from "../lib/early-access.js";
6+import { HttpError } from "../lib/util.js";
7+
8+/**
9+ * Early access requests over the REST API (lib/early-access.ts), for site administrators only.
10+ * To anyone else these endpoints don't exist (404), like the requests themselves.
11+ */
12+export const earlyAccessApi = 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+earlyAccessApi.get("/diffinery/admin/early-access", async (c) => {
24+ siteAdmin(c, "read");
25+ const s = c.req.query("state");
26+ if (s && !REQUEST_STATES.includes(s as RequestState)) throw new HttpError(422, `state must be one of ${REQUEST_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 EarlyAccess(c.env.DB).list({ state: (s as RequestState) || undefined, q: c.req.query("q") ?? "", limit, offset: (page - 1) * limit });
31+ return c.json(items.map((r) => requestJson(c.env, r)));
32+});
33+
34+earlyAccessApi.get("/diffinery/admin/early-access/:id", async (c) => {
35+ siteAdmin(c, "read");
36+ const r = await new EarlyAccess(c.env.DB).get(c.req.param("id"));
37+ if (!r) throw new HttpError(404, "no early access request with that id");
38+ return c.json(requestJson(c.env, r));
39+});
40+
41+/** Send the request's address the instance's invitation email. Audited. */
42+earlyAccessApi.post("/diffinery/admin/early-access/:id/invite", async (c) => {
43+ const me = siteAdmin(c, "admin");
44+ return c.json(requestJson(c.env, await inviteRequest(c.env, c.get("dir"), audit(c), me, c.req.param("id"), "api")));
45+});
46+
47+/** Decline a request (`{"state": "declined"}`) or put a declined one back (`{"state": "confirmed"}`). Audited; nobody is emailed. */
48+earlyAccessApi.patch("/diffinery/admin/early-access/:id", async (c) => {
49+ const me = siteAdmin(c, "admin");
50+ let b: { state?: unknown };
51+ try {
52+ b = (await c.req.json()) as typeof b;
53+ } catch {
54+ throw new HttpError(400, "request body must be JSON");
55+ }
56+ if (!b || typeof b !== "object" || (b.state !== "declined" && b.state !== "confirmed")) throw new HttpError(422, 'send {"state": "declined"} to decline, or {"state": "confirmed"} to put a declined request back');
57+ return c.json(requestJson(c.env, await declineRequest(c.env.DB, audit(c), me, c.req.param("id"), b.state === "confirmed", "api")));
58+});
59+
60+/** Delete a request and its daily-limit rows (data removal). Audited without its content. */
61+earlyAccessApi.delete("/diffinery/admin/early-access/:id", async (c) => {
62+ const me = siteAdmin(c, "admin");
63+ await deleteRequest(c.env.DB, audit(c), me, c.req.param("id"), "api");
64+ return c.body(null, 204);
65+});
addedapps/diffinery/src/lib/early-access.ts+445−0View file
@@ -1,0 +1,445 @@
1+import type { Env } from "../env.js";
2+import type { Account, Directory } from "./directory.js";
3+import { loopback } from "../auth/dev.js";
4+import { normalEmail } from "../auth/signup.js";
5+import { hmacSha256, matchesAnyKey, signingSecrets } from "./crypto.js";
6+import { enqueueMail } from "../mail/send.js";
7+import type { MailJob } from "../mail/types.js";
8+import { HttpError, b64url, newId, now, randomToken, sha256 } from "./util.js";
9+
10+/**
11+ * Early access requests: while an instance is invite-only, anyone can ask for an invitation at
12+ * /early-access (web/early-access.tsx). A request is stored unconfirmed and its address gets a link;
13+ * following it confirms the request (double opt-in), and only then do site administrators see it as
14+ * waiting, on Site administration → Early access. A request never grants access by itself: a site
15+ * administrator invites it, with the instance's usual invitation email, or declines it.
16+ *
17+ * The form answers the same whatever the address: new, already asked, already invited or already an
18+ * account. Only the address's inbox learns which (a confirmation link, or a short note), so the form
19+ * can't be used to find out who has an account or asked before. People who aren't signed in pass
20+ * Cloudflare Turnstile, a hidden honeypot field and a signed timing stamp, and the daily limits below
21+ * bound what one network, one address, and everyone together can send.
22+ */
23+
24+export type RequestState = "unconfirmed" | "confirmed" | "invited" | "declined";
25+export const REQUEST_STATES: readonly RequestState[] = ["confirmed", "unconfirmed", "invited", "declined"];
26+
27+/** Longest name, company or team, and use, in characters. Longer is refused, never cut. */
28+export const MAX_NAME = 100;
29+export const MAX_COMPANY = 120;
30+export const MAX_USE_CASE = 1_000;
31+
32+/** Where someone hosts their code today: the only values the optional "hosting" field takes. */
33+export const HOSTING = [
34+ { value: "github", label: "GitHub" },
35+ { value: "gitlab", label: "GitLab" },
36+ { value: "bitbucket", label: "Bitbucket" },
37+ { value: "azure", label: "Azure DevOps" },
38+ { value: "self-hosted", label: "Self-hosted (Gitea, Forgejo, GitLab and others)" },
39+ { value: "other", label: "Somewhere else" },
40+ { value: "none", label: "Nowhere yet" },
41+] as const;
42+export type Hosting = (typeof HOSTING)[number]["value"];
43+export const hostingLabel = (v: string | null): string | null => HOSTING.find((h) => h.value === v)?.label ?? null;
44+
45+/** How long a confirmation link works, how long a removal link works, and how long an unconfirmed request is kept. */
46+export const CONFIRM_HOURS = 48;
47+export const REMOVE_HOURS = 24;
48+export const UNCONFIRMED_DAYS = 7;
49+
50+/** In 24 hours: form submissions from one IP address, emails to one address, and emails from the form in total. */
51+export const SUBMISSIONS_PER_IP_PER_DAY = 10;
52+export const EMAILS_PER_ADDRESS_PER_DAY = 3;
53+export const EMAILS_PER_DAY = 500;
54+
55+/** A form sent sooner than this after it was shown is a script's; one older than the maximum is shown again. */
56+export const MIN_FILL_MS = 3_000;
57+export const MAX_FORM_AGE_MS = 24 * 3600_000;
58+
59+export interface EarlyAccessRequest {
60+ id: string;
61+ email: string;
62+ name: string | null;
63+ company: string | null;
64+ use_case: string | null;
65+ hosting: string | null;
66+ state: RequestState;
67+ confirm_hash: string | null;
68+ confirm_expires_at: string | null;
69+ confirmed_at: string | null;
70+ remove_hash: string | null;
71+ remove_expires_at: string | null;
72+ invite_id: string | null;
73+ decided_by: string | null;
74+ decided_at: string | null;
75+ created_at: string;
76+ updated_at: string;
77+}
78+
79+export interface RequestInput {
80+ email: string;
81+ name: string | null;
82+ company: string | null;
83+ useCase: string | null;
84+ hosting: Hosting | null;
85+}
86+
87+/** Control characters (except tab and newline) and bidirectional overrides, which could disguise text on the admin page. */
88+// eslint-disable-next-line no-control-regex
89+const INVISIBLE = /[\u0000-\u0008\u000B-\u001F\u007F‎‏‪-‮⁦-⁩]/g;
90+
91+/**
92+ * Check what someone typed: an email address (required), and optionally a name, a company or team,
93+ * what they would use Diffinery for, and where they host code today. One-line fields lose extra
94+ * spaces; the use keeps its lines. Too long is refused with the limit, never cut.
95+ */
96+export function validateRequest(raw: { email?: unknown; name?: unknown; company?: unknown; useCase?: unknown; hosting?: unknown }): RequestInput {
97+ const str = (v: unknown) => (typeof v === "string" ? v.replace(INVISIBLE, "") : "");
98+ const line = (v: unknown) => str(v).replace(/\s+/g, " ").trim();
99+ const typed = line(raw.email);
100+ const name = line(raw.name);
101+ const company = line(raw.company);
102+ const useCase = str(raw.useCase)
103+ .replace(/\r\n?/g, "\n")
104+ .replace(/\t/g, " ")
105+ .replace(/\n{3,}/g, "\n\n")
106+ .trim();
107+ const hosting = line(raw.hosting);
108+ if (!typed) throw new HttpError(422, "Enter your email address.");
109+ const email = normalEmail(typed);
110+ if (!email) throw new HttpError(422, "Enter a valid email address, like you@example.com.");
111+ if (name.length > MAX_NAME) throw new HttpError(422, `Keep your name to ${MAX_NAME} characters.`);
112+ if (company.length > MAX_COMPANY) throw new HttpError(422, `Keep the company or team to ${MAX_COMPANY} characters.`);
113+ if (useCase.length > MAX_USE_CASE) throw new HttpError(422, `Keep what you'd use Diffinery for to ${MAX_USE_CASE.toLocaleString("en")} characters.`);
114+ if (hosting && !HOSTING.some((h) => h.value === hosting)) throw new HttpError(422, "Pick where you host your code today from the list, or leave it empty.");
115+ return { email, name: name || null, company: company || null, useCase: useCase || null, hosting: (hosting || null) as Hosting | null };
116+}
117+
118+// ---------------------------------------------------------------- the form's timing stamp
119+
120+type KeyEnv = Pick<Env, "MASTER_KEY" | "MASTER_KEY_PREV" | "PUBLIC_URL">;
121+
122+function stampSecrets(env: KeyEnv): string[] {
123+ return signingSecrets(env, loopback(env.PUBLIC_URL) ? "diffinery-dev-early-access" : null, "MASTER_KEY is not set, so the early access form can't be signed");
124+}
125+
126+const stampMac = async (secret: string, issued: number): Promise<string> => b64url(await hmacSha256(`diffinery-early-access-form-v1:${secret}`, String(issued)));
127+
128+/** A stamp for a form shown now: when it was shown, signed, so a script can't backdate it. */
129+export async function formStamp(env: KeyEnv, at = Date.now()): Promise<string> {
130+ return `${at}.${await stampMac(stampSecrets(env)[0]!, at)}`;
131+}
132+
133+/** Whether a form came back like a person's: "ok", too "fast" after it was shown, "stale" (shown too long ago), or "bad" (missing or forged). */
134+export async function checkStamp(env: KeyEnv, stamp: string, at = Date.now()): Promise<"ok" | "fast" | "stale" | "bad"> {
135+ const m = /^(\d{13})\.([A-Za-z0-9_-]{43})$/.exec(stamp);
136+ if (!m) return "bad";
137+ const issued = Number(m[1]);
138+ if (!(await matchesAnyKey(stampSecrets(env), m[2]!, (s) => stampMac(s, issued)))) return "bad";
139+ if (issued > at + 60_000) return "bad";
140+ if (at - issued < MIN_FILL_MS) return "fast";
141+ if (at - issued > MAX_FORM_AGE_MS) return "stale";
142+ return "ok";
143+}
144+
145+// ---------------------------------------------------------------- storage
146+
147+/** Only hashes of the sender's IP address and of the typed address are kept for the daily limits. */
148+export const ipHash = (ip: string | null | undefined): Promise<string> => sha256(`diffinery-early-access-ip:${ip || "unknown"}`);
149+export const emailHash = (email: string): Promise<string> => sha256(`diffinery-early-access-email:${email.toLowerCase()}`);
150+
151+const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
152+
153+/** Early access requests in D1. */
154+export class EarlyAccess {
155+ constructor(private db: D1Database) {}
156+
157+ async get(id: string): Promise<EarlyAccessRequest | null> {
158+ if (!/^[0-9a-f]{32}$/.test(id)) return null;
159+ return this.db.prepare("SELECT * FROM early_access WHERE id = ?").bind(id).first<EarlyAccessRequest>();
160+ }
161+
162+ async byEmail(email: string): Promise<EarlyAccessRequest | null> {
163+ return this.db.prepare("SELECT * FROM early_access WHERE email = ?").bind(email).first<EarlyAccessRequest>();
164+ }
165+
166+ /**
167+ * Newest first, optionally in one state and matching `q` (in the address, name, company or use).
168+ * One more than `limit` is read, so callers know whether there is a next page.
169+ */
170+ async list(o: { state?: RequestState; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: EarlyAccessRequest[]; more: boolean }> {
171+ const limit = Math.min(Math.max(o.limit ?? 50, 1), 500);
172+ const offset = Math.min(Math.max(o.offset ?? 0, 0), 100_000);
173+ const where: string[] = [];
174+ const binds: unknown[] = [];
175+ if (o.state) {
176+ where.push("state = ?");
177+ binds.push(o.state);
178+ }
179+ const q = (o.q ?? "").trim().slice(0, 100);
180+ if (q) {
181+ const like = `%${likeEscape(q)}%`;
182+ where.push("(email LIKE ? ESCAPE '\\' OR name LIKE ? ESCAPE '\\' OR company LIKE ? ESCAPE '\\' OR use_case LIKE ? ESCAPE '\\')");
183+ binds.push(like, like, like, like);
184+ }
185+ const rows = (
186+ await this.db
187+ .prepare(`SELECT * FROM early_access${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`)
188+ .bind(...binds, limit + 1, offset)
189+ .all<EarlyAccessRequest>()
190+ ).results;
191+ return { items: rows.slice(0, limit), more: rows.length > limit };
192+ }
193+
194+ async counts(): Promise<Record<RequestState, number>> {
195+ const rows = (await this.db.prepare("SELECT state, count(*) AS n FROM early_access GROUP BY state").all<{ state: RequestState; n: number }>()).results;
196+ const out: Record<RequestState, number> = { unconfirmed: 0, confirmed: 0, invited: 0, declined: 0 };
197+ for (const r of rows) out[r.state] = r.n;
198+ return out;
199+ }
200+
201+ /** Confirmed requests waiting for a decision: the count on the Site administration menu. */
202+ async waiting(): Promise<number> {
203+ return (await this.db.prepare("SELECT count(*) AS n FROM early_access WHERE state = 'confirmed'").first<{ n: number }>())?.n ?? 0;
204+ }
205+
206+ /** Submissions from this IP address, emails to this address, and emails in total, in the last 24 hours. */
207+ async sentToday(ip: string, email: string): Promise<{ ip: number; email: number; all: number }> {
208+ const since = new Date(Date.now() - 86400_000).toISOString();
209+ const r = await this.db
210+ .prepare(
211+ "SELECT (SELECT count(*) FROM early_access_attempts WHERE ip_hash = ? AND created_at > ?) AS ip, (SELECT count(*) FROM early_access_attempts WHERE email_hash = ? AND emailed = 1 AND created_at > ?) AS email, (SELECT count(*) FROM early_access_attempts WHERE emailed = 1 AND created_at > ?) AS a",
212+ )
213+ .bind(ip, since, email, since, since)
214+ .first<{ ip: number; email: number; a: number }>();
215+ return { ip: r?.ip ?? 0, email: r?.email ?? 0, all: r?.a ?? 0 };
216+ }
217+
218+ async attempt(ip: string, email: string, emailed: boolean): Promise<void> {
219+ await this.db.prepare("INSERT INTO early_access_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), ip, email, emailed ? 1 : 0, now()).run();
220+ }
221+}
222+
223+// ---------------------------------------------------------------- requesting and confirming
224+
225+type Audit = (e: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
226+
227+/** What a submission did, for tests and logs; the visitor sees the same page whatever it is. */
228+export type Outcome = "confirm" | "known" | "invited" | "account" | "quiet" | "limited";
229+
230+/**
231+ * Take a validated request from `ip`. Refuses (429) only when this network sent too many today;
232+ * everything else answers the same to the visitor, and the address's inbox gets:
233+ *
234+ * - a new or still unconfirmed request: a confirmation link (the request's details are replaced);
235+ * - a confirmed or declined request: a note that we already have it;
236+ * - an invited request: a note pointing to the invitation;
237+ * - an address with an active account: a note to sign in instead;
238+ * - a disabled or suspended account's address: nothing.
239+ *
240+ * No email at all once the address had EMAILS_PER_ADDRESS_PER_DAY today, or the form EMAILS_PER_DAY
241+ * in total; nothing is stored then either.
242+ */
243+export async function submitRequest(env: Env, input: RequestInput, ip: string | null): Promise<Outcome> {
244+ const store = new EarlyAccess(env.DB);
245+ const [ipH, emailH] = await Promise.all([ipHash(ip), emailHash(input.email)]);
246+ const sent = await store.sentToday(ipH, emailH);
247+ if (sent.ip >= SUBMISSIONS_PER_IP_PER_DAY) throw new HttpError(429, "Too many requests from your network today. Please try again tomorrow.");
248+ if (sent.email >= EMAILS_PER_ADDRESS_PER_DAY || sent.all >= EMAILS_PER_DAY) {
249+ await store.attempt(ipH, emailH, false);
250+ return "limited";
251+ }
252+ const at = now();
253+ const account = await env.DB.prepare("SELECT * FROM accounts WHERE kind = 'user' AND email = ? LIMIT 1").bind(input.email).first<Account>();
254+ let outcome: Outcome;
255+ let mail: MailJob | null = null;
256+ if (account && (account.status === "disabled" || account.suspended_at)) outcome = "quiet";
257+ else if (account && account.status === "active") {
258+ outcome = "account";
259+ mail = { kind: "early_access_known", data: { variant: "account", since: account.created_at }, to: { accountId: account.id }, reason: "early_access", at };
260+ } else {
261+ const existing = await store.byEmail(input.email);
262+ if (existing && existing.state !== "unconfirmed") {
263+ outcome = existing.state === "invited" ? "invited" : "known";
264+ mail = { kind: "early_access_known", data: { variant: existing.state === "invited" ? "invited" : "waiting", since: existing.decided_at && existing.state === "invited" ? existing.decided_at : (existing.confirmed_at ?? existing.created_at) }, to: { email: input.email }, reason: "early_access", at };
265+ } else {
266+ outcome = "confirm";
267+ const token = randomToken(24);
268+ const expiresAt = new Date(Date.now() + CONFIRM_HOURS * 3600_000).toISOString();
269+ const hash = await sha256(token);
270+ if (existing) {
271+ await env.DB.prepare("UPDATE early_access SET name = ?, company = ?, use_case = ?, hosting = ?, confirm_hash = ?, confirm_expires_at = ?, updated_at = ? WHERE id = ? AND state = 'unconfirmed'")
272+ .bind(input.name, input.company, input.useCase, input.hosting, hash, expiresAt, at, existing.id)
273+ .run();
274+ } else {
275+ // Two submissions racing for one new address: the second updates the first's row instead.
276+ await env.DB.prepare(
277+ "INSERT INTO early_access (id, email, name, company, use_case, hosting, state, confirm_hash, confirm_expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'unconfirmed', ?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET name = excluded.name, company = excluded.company, use_case = excluded.use_case, hosting = excluded.hosting, confirm_hash = excluded.confirm_hash, confirm_expires_at = excluded.confirm_expires_at, updated_at = excluded.updated_at WHERE early_access.state = 'unconfirmed'",
278+ )
279+ .bind(newId(), input.email, input.name, input.company, input.useCase, input.hosting, hash, expiresAt, at, at)
280+ .run();
281+ }
282+ // The token rides in the fragment, which browsers never send, so request logs never hold it.
283+ mail = { kind: "early_access_confirm", data: { url: `${env.PUBLIC_URL}/early-access/confirm#${token}`, expiresAt }, to: { email: input.email }, reason: "early_access", at };
284+ }
285+ }
286+ await store.attempt(ipH, emailH, !!mail);
287+ if (mail) await enqueueMail(env, [mail]);
288+ return outcome;
289+}
290+
291+/**
292+ * Confirm the request a link's token belongs to: it then waits for a site administrator, who hear
293+ * about it in Diffinery (one unread notification at a time, however many arrive). Null when the
294+ * token is unknown, already used or expired.
295+ */
296+export async function confirmRequest(env: Env, audit: Audit, token: string): Promise<EarlyAccessRequest | null> {
297+ if (!/^[A-Za-z0-9_-]{20,100}$/.test(token)) return null;
298+ const hash = await sha256(token);
299+ const at = now();
300+ const row = await env.DB.prepare("SELECT * FROM early_access WHERE confirm_hash = ? AND state = 'unconfirmed' AND confirm_expires_at > ?").bind(hash, at).first<EarlyAccessRequest>();
301+ if (!row) return null;
302+ const done = await env.DB.prepare("UPDATE early_access SET state = 'confirmed', 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();
303+ if (!done.meta.changes) return null;
304+ await audit({ action: "early_access.confirm", target: row.id });
305+ await notifyAdmins(env.DB);
306+ return { ...row, state: "confirmed", confirmed_at: at, confirm_hash: null, confirm_expires_at: null, updated_at: at };
307+}
308+
309+/**
310+ * Someone asks to remove their own request: the address gets a one-time link that deletes it, when
311+ * there is a request for it (nothing otherwise, and the visitor sees the same page either way). The
312+ * same daily limits as requests apply, and count together with them.
313+ */
314+export async function requestRemoval(env: Env, email: string, ip: string | null): Promise<Outcome> {
315+ const store = new EarlyAccess(env.DB);
316+ const [ipH, emailH] = await Promise.all([ipHash(ip), emailHash(email)]);
317+ const sent = await store.sentToday(ipH, emailH);
318+ if (sent.ip >= SUBMISSIONS_PER_IP_PER_DAY) throw new HttpError(429, "Too many requests from your network today. Please try again tomorrow.");
319+ if (sent.email >= EMAILS_PER_ADDRESS_PER_DAY || sent.all >= EMAILS_PER_DAY) {
320+ await store.attempt(ipH, emailH, false);
321+ return "limited";
322+ }
323+ const row = await store.byEmail(email);
324+ if (!row) {
325+ await store.attempt(ipH, emailH, false);
326+ return "quiet";
327+ }
328+ const token = randomToken(24);
329+ const expiresAt = new Date(Date.now() + REMOVE_HOURS * 3600_000).toISOString();
330+ await env.DB.prepare("UPDATE early_access SET remove_hash = ?, remove_expires_at = ? WHERE id = ?").bind(await sha256(token), expiresAt, row.id).run();
331+ await store.attempt(ipH, emailH, true);
332+ await enqueueMail(env, [{ kind: "early_access_remove", data: { url: `${env.PUBLIC_URL}/early-access/remove/confirm#${token}`, expiresAt }, to: { email: row.email }, reason: "early_access", at: now() }]);
333+ return "confirm";
334+}
335+
336+/**
337+ * Delete the request a removal link's token belongs to. False when the token is unknown, used or expired.
338+ * The daily-limit rows stay until they are a day old: they are hashes, and deleting them would let
339+ * whoever keeps asking for this address start over.
340+ */
341+export async function removeByToken(env: Env, audit: Audit, token: string): Promise<boolean> {
342+ if (!/^[A-Za-z0-9_-]{20,100}$/.test(token)) return false;
343+ const hash = await sha256(token);
344+ const row = await env.DB.prepare("SELECT * FROM early_access WHERE remove_hash = ? AND remove_expires_at > ?").bind(hash, now()).first<EarlyAccessRequest>();
345+ if (!row) return false;
346+ const done = await env.DB.prepare("DELETE FROM early_access WHERE id = ? AND remove_hash = ?").bind(row.id, hash).run();
347+ if (!done.meta.changes) return false;
348+ await audit({ action: "early_access.delete", target: row.id, detail: { state: row.state, via: "self" } });
349+ return true;
350+}
351+
352+/** One "new early access requests" notification per active site administrator, unless an unread one is already waiting. */
353+export async function notifyAdmins(db: D1Database): Promise<void> {
354+ 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;
355+ if (!admins.length) return;
356+ const at = now();
357+ await db.batch(
358+ admins.map((a) =>
359+ db
360+ .prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) SELECT ?, ?, NULL, 'early_access', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM notifications WHERE account_id = ? AND kind = 'early_access' AND read_at IS NULL)")
361+ .bind(newId(), a.id, "New early access requests are waiting for you", "/settings/admin/early-access", at, a.id),
362+ ),
363+ );
364+}
365+
366+// ---------------------------------------------------------------- site administrators' decisions
367+
368+/** A request that exists, or 404. */
369+async function existing(db: D1Database, id: string): Promise<EarlyAccessRequest> {
370+ const r = await new EarlyAccess(db).get(id);
371+ if (!r) throw new HttpError(404, "no early access request with that id");
372+ return r;
373+}
374+
375+/**
376+ * Invite a request's address to the instance with the usual invitation email (Directory.createInvite).
377+ * An address that already has a pending site invitation keeps it, and one that already belongs to an
378+ * active account is refused: there is nothing to invite it to.
379+ */
380+export async function inviteRequest(env: Env, dir: Directory, audit: Audit, me: Account, id: string, via: "web" | "api"): Promise<EarlyAccessRequest> {
381+ const r = await existing(env.DB, id);
382+ if (r.state === "invited") throw new HttpError(422, `${r.email} is already invited.`);
383+ const account = await dir.accountByEmail(r.email);
384+ if (account && account.status === "active") throw new HttpError(422, `${r.email} already has an account (${account.login}), so there is nothing to invite.`);
385+ const pending = await env.DB.prepare("SELECT id FROM invites WHERE email = ? AND account_id IS NULL AND org_id IS NULL AND repo_id IS NULL AND accepted_at IS NULL AND expires_at > ?").bind(r.email, now()).first<{ id: string }>();
386+ const inviteId = pending ? pending.id : (await dir.createInvite(me, { email: r.email, role: "read" })).id;
387+ if (!pending) await audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: r.email });
388+ const at = now();
389+ await env.DB.prepare("UPDATE early_access SET state = 'invited', invite_id = ?, decided_by = ?, decided_at = ?, confirm_hash = NULL, confirm_expires_at = NULL, updated_at = ? WHERE id = ?").bind(inviteId, me.login, at, at, r.id).run();
390+ await audit({ actorId: me.id, actorLogin: me.login, action: "early_access.invite", target: r.id, detail: { invite: inviteId, from: r.state, ...(pending ? { existing_invitation: true } : {}), via } });
391+ return { ...r, state: "invited", invite_id: inviteId, decided_by: me.login, decided_at: at, confirm_hash: null, confirm_expires_at: null, updated_at: at };
392+}
393+
394+/**
395+ * Decline a request, or put a declined one back (`restore`) where it was: confirmed, or unconfirmed
396+ * if its address never confirmed it. Nobody is emailed either way.
397+ */
398+export async function declineRequest(db: D1Database, audit: Audit, me: Account, id: string, restore: boolean, via: "web" | "api"): Promise<EarlyAccessRequest> {
399+ const r = await existing(db, id);
400+ if (r.state === "invited") throw new HttpError(422, "This request was already invited. Revoke the invitation in People and invitations instead.");
401+ const at = now();
402+ if (restore) {
403+ if (r.state !== "declined") throw new HttpError(422, "Only a declined request can be put back.");
404+ const state: RequestState = r.confirmed_at ? "confirmed" : "unconfirmed";
405+ await db.prepare("UPDATE early_access SET state = ?, decided_by = NULL, decided_at = NULL, updated_at = ? WHERE id = ?").bind(state, at, r.id).run();
406+ await audit({ actorId: me.id, actorLogin: me.login, action: "early_access.restore", target: r.id, detail: { to: state, via } });
407+ return { ...r, state, decided_by: null, decided_at: null, updated_at: at };
408+ }
409+ if (r.state === "declined") throw new HttpError(422, "This request is already declined.");
410+ await db.prepare("UPDATE early_access SET state = 'declined', decided_by = ?, decided_at = ?, confirm_hash = NULL, confirm_expires_at = NULL, updated_at = ? WHERE id = ?").bind(me.login, at, at, r.id).run();
411+ await audit({ actorId: me.id, actorLogin: me.login, action: "early_access.decline", target: r.id, detail: { from: r.state, via } });
412+ return { ...r, state: "declined", decided_by: me.login, decided_at: at, confirm_hash: null, confirm_expires_at: null, updated_at: at };
413+}
414+
415+/**
416+ * Delete a request, for someone who asks for their data to be removed. The audit log records that it
417+ * happened, never what the request said or its address. An invitation already sent from it stays;
418+ * revoke it separately. The daily-limit rows (hashes) stay until they are a day old, as in removeByToken.
419+ */
420+export async function deleteRequest(db: D1Database, audit: Audit, me: Account, id: string, via: "web" | "api"): Promise<void> {
421+ const r = await existing(db, id);
422+ await db.prepare("DELETE FROM early_access WHERE id = ?").bind(r.id).run();
423+ await audit({ actorId: me.id, actorLogin: me.login, action: "early_access.delete", target: r.id, detail: { state: r.state, via } });
424+}
425+
426+/** The API's view of a request. */
427+export const requestJson = (env: Pick<Env, "PUBLIC_URL">, r: EarlyAccessRequest) => ({
428+ id: r.id,
429+ state: r.state,
430+ email: r.email,
431+ name: r.name,
432+ company: r.company,
433+ use_case: r.use_case,
434+ hosting: r.hosting,
435+ confirmed_at: r.confirmed_at,
436+ invite_id: r.invite_id,
437+ decided_by: r.decided_by,
438+ decided_at: r.decided_at,
439+ created_at: r.created_at,
440+ updated_at: r.updated_at,
441+ html_url: `${env.PUBLIC_URL}/settings/admin/early-access/${r.id}`,
442+});
443+
444+/** Whether the form takes requests here: only while sign-up is closed (open sign-up needs no request). */
445+export const earlyAccessOpen = (env: Pick<Env, "SIGNUP">): boolean => env.SIGNUP !== "open";
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
@@ -9,7 +9,8 @@
99 * configured (local development) or that fails lets the request through.
1010 *
1111 * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, private
12- * security reports (/security/report), CLI sign-ins (starting one, typing or approving a
12+ * 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
1314 * code), and MCP apps registering themselves for OAuth (/oauth/register), per IP address.
1415 * - RL_ANON: everything else from someone who isn't signed in, per IP address.
1516 * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account.
@@ -33,7 +34,7 @@
3334
3435 /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
3536 const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
36-const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|-\/cli\/device$|oauth\/register$)/;
37+const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|early-access(?:\/confirm|\/remove(?:\/confirm)?)?$|-\/cli\/device$|oauth\/register$)/;
3738 const CREATE: Array<[method: string, path: RegExp]> = [
3839 ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
3940 ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
@@ -95,7 +96,7 @@
9596 }
9697
9798 const WHAT: Record<Bucket, string> = {
98- RL_AUTH: "sign-in attempts and security reports from your network",
99+ RL_AUTH: "sign-in attempts, security reports and early access requests from your network",
99100 RL_ANON: "requests from your network without signing in",
100101 RL_ACCOUNT: "requests from your account",
101102 RL_CREATE: "new repositories, issues, pull requests and comments from your account",
modifiedapps/diffinery/src/lib/retention.ts+16−4View file
@@ -1,3 +1,5 @@
1+import { UNCONFIRMED_DAYS } from "./early-access.js";
2+
13 /**
24 * Rows D1 doesn't need to keep, deleted a bounded batch per table each hour so the tables stop
35 * growing with every request, notification and job (scalability evaluation, 2026-10-14):
@@ -6,19 +8,29 @@
68 * one visible on the sessions page meanwhile);
79 * - notifications older than NOTIFICATION_RETENTION_DAYS, as GitHub drops them after five months;
810 * - hosted compute records (lib/quotas.ts) ended more than COMPUTE_RETENTION_DAYS ago, past any month
9- * the usage pages show.
11+ * the usage pages show;
12+ * - 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.
1014 */
1115 export const NOTIFICATION_RETENTION_DAYS = 180;
1216 export const COMPUTE_RETENTION_DAYS = 400;
1317 /** Rows deleted per table per run: an hourly run keeps up with 120,000 new rows a day per table. */
1418 export const RETENTION_BATCH = 5000;
1519
16-export async function pruneExpired(db: D1Database, at = new Date(), batch = RETENTION_BATCH): Promise<Record<"sessions" | "notifications" | "compute_usage", number>> {
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>> {
1721 const ago = (days: number) => new Date(at.getTime() - days * 86400_000).toISOString();
18- const [sessions, notifications, compute] = await db.batch([
22+ const [sessions, notifications, compute, early, attempts] = await db.batch([
1923 db.prepare("DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE expires_at < ? LIMIT ?)").bind(ago(1), batch),
2024 db.prepare("DELETE FROM notifications WHERE id IN (SELECT id FROM notifications WHERE created_at < ? LIMIT ?)").bind(ago(NOTIFICATION_RETENTION_DAYS), batch),
2125 db.prepare("DELETE FROM compute_usage WHERE id IN (SELECT id FROM compute_usage WHERE ended_at < ? LIMIT ?)").bind(ago(COMPUTE_RETENTION_DAYS), batch),
26+ 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),
27+ db.prepare("DELETE FROM early_access_attempts WHERE id IN (SELECT id FROM early_access_attempts WHERE created_at < ? LIMIT ?)").bind(ago(1), batch),
2228 ]);
23- return { sessions: sessions!.meta.changes ?? 0, notifications: notifications!.meta.changes ?? 0, compute_usage: compute!.meta.changes ?? 0 };
29+ return {
30+ sessions: sessions!.meta.changes ?? 0,
31+ notifications: notifications!.meta.changes ?? 0,
32+ compute_usage: compute!.meta.changes ?? 0,
33+ early_access: early!.meta.changes ?? 0,
34+ early_access_attempts: attempts!.meta.changes ?? 0,
35+ };
2436 }
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -93,7 +93,7 @@
9393 }
9494
9595 /** Top-level paths the app serves; no account may use them as its login. */
96-export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
96+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
9797
9898 /**
9999 * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
modifiedapps/diffinery/src/mail/samples.ts+5−0View file
@@ -89,6 +89,11 @@
8989 item("email-verify", "Confirm an address after a bounce", { kind: "email_verify", reason: "account", data: { email: "diffy@example.com", url: `${pub}/settings/email/verify/sample`, expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
9090 item("signup-attempt", "Sign-up with an existing address", { kind: "signup_attempt", reason: "account", data: { email: "diffy@example.com" } }),
9191 item("access-request", "Access request", { kind: "access_request", reason: "admin", data: { email: "newcomer@example.com", login: "newcomer", note: "I'm joining the platform team next week." } }),
92+ item("early-access-confirm", "Early access: confirm the request", { kind: "early_access_confirm", reason: "early_access", data: { url: `${pub}/early-access/confirm#sample`, expiresAt: exp } }),
93+ item("early-access-remove", "Early access: remove the request", { kind: "early_access_remove", reason: "early_access", data: { url: `${pub}/early-access/remove/confirm#sample`, expiresAt: exp } }),
94+ item("early-access-waiting", "Early access: already on the list", { kind: "early_access_known", reason: "early_access", data: { variant: "waiting", since: at } }),
95+ item("early-access-invited", "Early access: already invited", { kind: "early_access_known", reason: "early_access", data: { variant: "invited", since: at } }),
96+ item("early-access-account", "Early access: already has an account", { kind: "early_access_known", reason: "early_access", data: { variant: "account", since: at } }),
9297 item("security-report", "Security report", { kind: "security_report", reason: "admin", data: { id: "0123456789abcdef0123456789abcdef", summary: "Stored XSS in release notes" } }),
9398 item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }),
9499 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
modifiedapps/diffinery/src/mail/templates.ts+66−0View file
@@ -166,6 +166,7 @@
166166 admin: "You are receiving this because you are an administrator.",
167167 account: "You are receiving this because you have a Diffinery account.",
168168 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.",
169+ 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.",
169170 digest: "You are receiving this because you turned on the morning digest in your notification settings.",
170171 };
171172
@@ -749,6 +750,71 @@
749750 ],
750751 url: `${pub}/login`,
751752 };
753+ case "early_access_confirm":
754+ return {
755+ subject: "[Diffinery] Confirm your early access request",
756+ preheader: `Confirm before ${day(job.data.expiresAt)} to join the list. Nothing happens without it.`,
757+ eyebrow: { text: "Early access", tone: "blue" },
758+ title: ["Confirm your early access request"],
759+ blocks: [
760+ { p: ["Thanks for asking for early access to Diffinery. Confirm that this is your email address and your request joins the list the team reviews."] },
761+ { button: { label: "Confirm my request", url: job.data.url } },
762+ { p: ["When there's a place for you, we'll email you an invitation to pick a username and create a passkey. There are no passwords."] },
763+ { p: ["The link works once and expires ", at(job.data.expiresAt), ". If you didn't ask for early access, ignore this email: nothing is kept unless the link is opened, and the unconfirmed request is deleted after a week."], muted: true },
764+ ],
765+ url: job.data.url,
766+ };
767+ case "early_access_remove":
768+ return {
769+ subject: "[Diffinery] Remove your early access request",
770+ preheader: `Remove it before ${day(job.data.expiresAt)}. Nothing changes unless you do.`,
771+ eyebrow: { text: "Early access", tone: "amber" },
772+ title: ["Remove your early access request"],
773+ blocks: [
774+ { p: ["Someone asked to remove the early access request for this email address. Open the link and press the button to delete it and everything stored about it."] },
775+ { button: { label: "Remove my request", url: job.data.url } },
776+ { p: ["The link works once and expires ", at(job.data.expiresAt), ". If you didn't ask for this, ignore this email: your request stays as it is."], muted: true },
777+ ],
778+ url: job.data.url,
779+ };
780+ case "early_access_known": {
781+ const d = job.data;
782+ if (d.variant === "account")
783+ return {
784+ subject: "[Diffinery] You already have a Diffinery account",
785+ preheader: "No request was needed. Sign in with your passkey instead.",
786+ eyebrow: { text: "Early access", tone: "blue" },
787+ title: ["You already have an account"],
788+ blocks: [
789+ { p: ["Someone asked for early access to Diffinery with this email address, which already belongs to your account", ...(login ? [" ", { b: user(login) }] : []), ". Nothing changed and no request was stored."] },
790+ { button: { label: "Sign in", url: `${pub}/login` } },
791+ { p: ["If you lost your passkey, the sign-in page shows how to recover your account. If it wasn't you, you can ignore this email."], muted: true },
792+ ],
793+ url: `${pub}/login`,
794+ };
795+ if (d.variant === "invited")
796+ return {
797+ subject: "[Diffinery] You're already invited",
798+ preheader: `We sent your invitation on ${day(d.since)}. Look for it in this inbox.`,
799+ eyebrow: { text: "Early access", tone: "green" },
800+ title: ["You're already invited"],
801+ blocks: [
802+ { p: ["You asked for early access again, but we already sent this address an invitation on ", { b: day(d.since) }, ". Look for an email titled \"invited you to Diffinery\", and check your spam folder too."] },
803+ { p: ["Opening its link lets you pick a username and create a passkey. Invitations expire after 14 days; if yours has, let the person named in it know and they can send a new one."], muted: true },
804+ ],
805+ };
806+ return {
807+ subject: "[Diffinery] We already have your early access request",
808+ preheader: "There's nothing else to do. We'll email you when there's a place.",
809+ eyebrow: { text: "Early access", tone: "blue" },
810+ title: ["We already have your request"],
811+ blocks: [
812+ { p: ["You asked for early access to Diffinery again. Your request from ", { b: day(d.since) }, " is already on the list, so there's nothing else to do."] },
813+ { p: ["When there's a place for you, we'll email you an invitation to pick a username and create a passkey."] },
814+ { p: ["If it wasn't you who asked again, you can ignore this email."], muted: true },
815+ ],
816+ };
817+ }
752818 case "access_request":
753819 return {
754820 subject: `[Diffinery] ${job.data.email} asked for access`,
modifiedapps/diffinery/src/mail/types.ts+11−0View file
@@ -21,6 +21,7 @@
2121 | "admin"
2222 | "account"
2323 | "signup"
24+ | "early_access"
2425 | "digest";
2526
2627 /**
@@ -131,6 +132,12 @@
131132 | { kind: "signup_attempt"; data: { email: string } }
132133 | { kind: "email_verify"; data: { email: string; url: string; expiresAt: string } }
133134 | { kind: "access_request"; data: { email: string; login: string; note: string } }
135+ /** The double opt-in link for a request at /early-access (lib/early-access.ts). */
136+ | { kind: "early_access_confirm"; data: { url: string; expiresAt: string } }
137+ /** Asked again: the request is already confirmed or declined ("waiting"), invited, or the address has an account. */
138+ /** A one-time link that deletes the address's own request. */
139+ | { kind: "early_access_remove"; data: { url: string; expiresAt: string } }
140+ | { kind: "early_access_known"; data: { variant: "waiting" | "invited" | "account"; since: string } }
134141 | { kind: "security_report"; data: { id: string; summary: string } }
135142 | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
136143 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
@@ -178,6 +185,10 @@
178185 case "signup_code":
179186 case "signup_attempt":
180187 case "email_verify":
188+ // Sent only to the address that was typed, about its own request.
189+ case "early_access_confirm":
190+ case "early_access_known":
191+ case "early_access_remove":
181192 // It is about the payer's own model spend, so it always sends.
182193 case "review_limit_reached":
183194 case "test":
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
@@ -40,7 +40,7 @@
4040 c,
4141 { title },
4242 <div class="grid left">
43- <SettingsNav me={me} tab={tab} />
43+ <SettingsNav me={me} tab={tab} db={c.env.DB} />
4444 <div>
4545 {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
4646 {body}
modifiedapps/diffinery/src/web/account-lifecycle.tsx+2−2View file
@@ -23,7 +23,7 @@
2323 c,
2424 { title },
2525 <div class="grid left">
26- <SettingsNav me={me} tab={tab} />
26+ <SettingsNav me={me} tab={tab} db={c.env.DB} />
2727 <div>
2828 {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
2929 {body}
@@ -125,7 +125,7 @@
125125 c,
126126 { title: "Account", refresh: building ? 10 : undefined },
127127 <div class="grid left">
128- <SettingsNav me={me} tab="account" />
128+ <SettingsNav me={me} tab="account" db={c.env.DB} />
129129 <div>
130130 <h1>Account</h1>
131131 <div class="box">
modifiedapps/diffinery/src/web/account.tsx+76−14View file
@@ -22,6 +22,8 @@
2222 import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
2323 import { MAX_AVATAR_BYTES } from "../lib/image.js";
2424 import { gatherToday } from "../agentic/today.js";
25+import { turnstileMode } from "../auth/signup.js";
26+import { EarlyAccess, earlyAccessOpen } from "../lib/early-access.js";
2527 import { TodayView } from "../agentic/web.js";
2628 import { serverErrors } from "../lib/server-errors.js";
2729 import { ComputePausedBanner, KillSwitchesPage } from "./kill-switches.js";
@@ -146,20 +148,45 @@
146148
147149 // ---------------------------------------------------------------- landing / dashboard
148150
149-const Landing: FC = () => (
151+/** The home page for visitors. `early` shows the early access request (lib/early-access.ts) while the instance is invite-only. */
152+const Landing: FC<{ early: boolean }> = ({ early }) => (
150153 <div>
151154 <section class="hero">
155+ {early ? (
156+ <a class="ea-eyebrow" href="/early-access">
157+ {raw(ICONS.rocket)} Invite-only for now: request early access
158+ </a>
159+ ) : null}
152160 <h1>Diffinery</h1>
153161 <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p>
154162 <p class="row" style="justify-content:center">
155- <a class="btn primary" href="/login">
156- Sign in with a passkey
157- </a>
158- <a class="btn" href="/docs">
159- Read the docs
160- </a>
163+ {early ? (
164+ <>
165+ <a class="btn primary" href="/early-access">
166+ Request early access
167+ </a>
168+ <a class="btn" href="/login">
169+ Sign in with a passkey
170+ </a>
171+ </>
172+ ) : (
173+ <>
174+ <a class="btn primary" href="/login">
175+ Sign in with a passkey
176+ </a>
177+ <a class="btn" href="/docs">
178+ Read the docs
179+ </a>
180+ </>
181+ )}
161182 </p>
162- <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p>
183+ {early ? (
184+ <p class="muted small">
185+ Diffinery is invite-only while we open it up team by team. Got an invitation? Open its link to create your passkey; there are no passwords. Or <a href="/docs">read the docs</a> first.
186+ </p>
187+ ) : (
188+ <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p>
189+ )}
163190 </section>
164191 <div class="features">
165192 <div class="box">
@@ -190,7 +217,7 @@
190217 if (kind.endsWith("comment")) return ICONS.comment;
191218 if (kind === "org_membership") return ICONS.org;
192219 if (kind === "repo_access") return ICONS.repo;
193- if (kind === "invitation") return ICONS.envelope;
220+ if (kind === "invitation" || kind === "early_access") return ICONS.envelope;
194221 return ICONS.bell;
195222 }
196223
@@ -229,7 +256,7 @@
229256 export function registerAccount(web: WebApp): void {
230257 web.get("/", async (c) => {
231258 const p = c.get("principal");
232- if (p.type !== "account") return render(c, { title: "", home: true }, <Landing />);
259+ if (p.type !== "account") return render(c, { title: "", home: true }, <Landing early={earlyAccessOpen(c.env) && turnstileMode(c.env) !== "missing"} />);
233260 const me = p.account;
234261 const t = csrfToken(c);
235262 if (me.status === "disabled") {
@@ -649,11 +676,41 @@
649676
650677 // ---------------------------------------------------------------- /settings
651678
652-export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status";
679+export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports" | "status" | "early-access";
653680 /** The site administration pages: they show when hosted compute is paused. */
654-export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status"];
681+export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "early-access", "reports", "email", "audit", "kill-switches", "errors", "security-reports", "status"];
655682
656-export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
683+/** The number of confirmed early access requests waiting, next to Early access in the sidebar (lib/early-access.ts). */
684+const EarlyAccessWaiting = async ({ db }: { db?: D1Database }) => {
685+ const n = db ? await new EarlyAccess(db).waiting().catch(() => 0) : 0;
686+ return n ? (
687+ <span class="badge blue" style="margin-left:6px" title={`${n} confirmed request${n === 1 ? "" : "s"} waiting`}>
688+ {n}
689+ </span>
690+ ) : (
691+ <></>
692+ );
693+};
694+
695+/** On People and invitations: a pointer to the early access requests waiting, when there are any. */
696+const EarlyAccessCallout = async ({ db }: { db: D1Database }) => {
697+ const n = await new EarlyAccess(db).waiting().catch(() => 0);
698+ return n ? (
699+ <div class="flash notice row between" style="gap:12px">
700+ <span>
701+ {n} confirmed early access request{n === 1 ? " is" : "s are"} waiting for you to invite or decline.
702+ </span>
703+ <a class="btn sm" href="/settings/admin/early-access">
704+ Review requests
705+ </a>
706+ </div>
707+ ) : (
708+ <></>
709+ );
710+};
711+
712+/** The Settings sidebar. `db` lets it show how many early access requests wait (site administrators only). */
713+export const SettingsNav: FC<{ me: Account; tab: SettingsTab; db?: D1Database }> = ({ me, tab, db }) => (
657714 <aside class="side vt-side">
658715 <section>
659716 <div class="row mb">
@@ -710,6 +767,10 @@
710767 <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
711768 People and invitations
712769 </a>
770+ <a href="/settings/admin/early-access" class={tab === "early-access" ? "on" : ""}>
771+ Early access
772+ <EarlyAccessWaiting db={db} />
773+ </a>
713774 <a href="/settings/admin/reports" class={tab === "reports" ? "on" : ""}>
714775 Reports
715776 </a>
@@ -766,7 +827,7 @@
766827 c,
767828 { title, status },
768829 <div class="grid left">
769- <SettingsNav me={me} tab={tab} />
830+ <SettingsNav me={me} tab={tab} db={c.env.DB} />
770831 <div>
771832 {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
772833 {body}
@@ -1505,6 +1566,7 @@
15051566 </div>
15061567 </div>
15071568 ) : null}
1569+ <EarlyAccessCallout db={c.env.DB} />
15081570 <div class="box">
15091571 <div class="head">
15101572 <h2>Pending access requests</h2>
modifiedapps/diffinery/src/web/assets.ts+29−0View file
@@ -262,6 +262,35 @@
262262 .hero p{font-size:18px;color:var(--fg2);max-width:640px;margin:0 auto 26px}
263263 .features{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0}
264264 .features .box{padding:18px}.features h3{margin:0 0 6px}
265+/* Early access (web/early-access.tsx): the request form, its result pages and the admin list. */
266+.ea-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--accent);background:var(--accent-soft);border-radius:999px;padding:3px 12px;margin:0 0 16px}
267+a.ea-eyebrow:hover{text-decoration:none;filter:brightness(1.08)}
268+.ea{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:56px;align-items:start;max-width:1040px;margin:48px auto 24px}
269+.ea h1{font-size:36px;letter-spacing:-.025em;margin:0 0 12px}
270+.ea-lede{font-size:16px;color:var(--fg2);margin:0 0 28px;max-width:520px}
271+.ea-points{list-style:none;padding:0;margin:0 0 28px;display:grid;gap:16px}
272+.ea-points li{display:flex;gap:12px;align-items:flex-start}
273+.ea-points li>span:last-child{display:flex;flex-direction:column;gap:2px}
274+.ea-points .ic{flex:none;display:grid;place-items:center;width:34px;height:34px;border-radius:9px;background:var(--bg);border:1px solid var(--border);color:var(--fg)}
275+.ea-steps{counter-reset:s;list-style:none;margin:0;display:grid;gap:10px;border-top:1px solid var(--border2);padding:20px 0 0}
276+.ea-steps li{counter-increment:s;display:flex;gap:10px;align-items:center;color:var(--fg2)}
277+.ea-steps li::before{content:counter(s);flex:none;display:grid;place-items:center;width:22px;height:22px;border-radius:999px;background:var(--bg3);color:var(--fg);font-size:12px;font-weight:600}
278+.ea-card{border:0;box-shadow:var(--shadow)}
279+.ea-card .body{padding:26px}
280+.ea-card h2{margin:0 0 2px}
281+.ea-card textarea,.ea-card input[type=email],.ea-card input[type=text]{width:100%}
282+.ea-hp{position:absolute!important;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden}
283+.ea-privacy{font-size:12px;color:var(--fg2);margin:14px 0 0}
284+.ea-done{max-width:580px;margin:64px auto;text-align:center}
285+.ea-done>p{font-size:15px}
286+.ea-done .ic{display:inline-grid;place-items:center;width:60px;height:60px;border-radius:999px;background:var(--accent-soft);color:var(--accent);margin:0 0 18px}
287+.ea-done .ic svg{width:28px;height:28px}
288+.ea-done form{margin-top:18px}
289+.ea-av{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:999px;background:var(--accent-soft);color:var(--accent);font-weight:600;font-size:14px}
290+.ea-av.lg{width:48px;height:48px;font-size:20px}
291+.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}
292+.ea-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
293+@media (max-width:900px){.ea{grid-template-columns:1fr;gap:28px;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}}
265294 .stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px}
266295 .stats b{color:var(--fg)}
267296 .tx{display:flex;flex-direction:column;gap:10px}.tx .markdown{margin:0}
modifiedapps/diffinery/src/web/auth.tsx+5−1View file
@@ -391,7 +391,9 @@
391391 New here? <a href="/signup">Create an account</a>.
392392 </p>
393393 ) : (
394- <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p>
394+ <p>
395+ New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey{turnstileMode(c.env) !== "missing" ? <>, or <a href="/early-access">request early access</a></> : null}.
396+ </p>
395397 )}
396398 {accessConfigured(c) ? (
397399 <p>
@@ -684,8 +686,10 @@
684686 // ------------------------------------------------------------ open sign-up
685687
686688 // Off, sign-up doesn't exist: its pages answer 404 like any unknown path, before any CSRF check.
689+ // Only the sign-up page itself leads to the early access request instead (web/early-access.tsx).
687690 for (const path of ["/signup", "/signup/*"]) {
688691 web.use(path, async (c, next) => {
692+ if (!signupOpen(c.env) && c.req.method === "GET" && c.req.path === "/signup" && turnstileMode(c.env) !== "missing") return c.redirect("/early-access", 302);
689693 if (!signupOpen(c.env)) throw new HttpError(404, "not found");
690694 await next();
691695 });
addedapps/diffinery/src/web/early-access.tsx+727−0View file
@@ -1,0 +1,727 @@
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, verifyTurnstile } from "../auth/signup.js";
6+import type { Account, Directory } from "../lib/directory.js";
7+import {
8+ CONFIRM_HOURS,
9+ EarlyAccess,
10+ REMOVE_HOURS,
11+ HOSTING,
12+ MAX_COMPANY,
13+ MAX_NAME,
14+ MAX_USE_CASE,
15+ REQUEST_STATES,
16+ UNCONFIRMED_DAYS,
17+ checkStamp,
18+ confirmRequest,
19+ declineRequest,
20+ deleteRequest,
21+ earlyAccessOpen,
22+ formStamp,
23+ hostingLabel,
24+ inviteRequest,
25+ removeByToken,
26+ requestRemoval,
27+ submitRequest,
28+ validateRequest,
29+ type EarlyAccessRequest,
30+ type RequestState,
31+} from "../lib/early-access.js";
32+import { normalEmail } from "../auth/signup.js";
33+import { HttpError } from "../lib/util.js";
34+import type { FormData } from "./csrf.js";
35+import { ICONS } from "./assets.js";
36+import { SettingsNav } from "./account.js";
37+import { csrfToken } from "./csrf.js";
38+import { ComputePausedBanner } from "./kill-switches.js";
39+import { KSelect } from "./kumo.js";
40+import { Form, Pager, Time, act, flash, pageNum, post, prm, qs, render, statusOf, type WebApp } from "./ui.js";
41+
42+/**
43+ * Early access in the web UI (lib/early-access.ts): the request form at /early-access that the home
44+ * page, the sign-in page and a closed /signup lead to, the confirmation page its email links to, and
45+ * the **Early access** page of site administration, where requests are invited, declined or deleted.
46+ */
47+
48+/** Largest request form, in bytes: the use's cap with room for percent-encoded UTF-8. */
49+const MAX_FORM_BYTES = 32 * 1024;
50+const BASE = "/settings/admin/early-access";
51+
52+const TURNSTILE_MISSING = "This instance has no Turnstile configured (TURNSTILE_SITE_KEY and TURNSTILE_SECRET), so it can't take early access requests from the web yet. Ask someone who uses it for an invitation.";
53+
54+export const STATE_TEXT: Record<RequestState, string> = { confirmed: "Confirmed", unconfirmed: "Unconfirmed", invited: "Invited", declined: "Declined" };
55+const STATE_BADGE: Record<RequestState, string> = { confirmed: "badge blue", unconfirmed: "badge", invited: "badge green", declined: "badge red" };
56+const StateBadge: FC<{ state: RequestState }> = ({ state }) => <span class={STATE_BADGE[state]}>{STATE_TEXT[state]}</span>;
57+
58+interface FormValues {
59+ email?: string;
60+ name?: string;
61+ company?: string;
62+ useCase?: string;
63+ hosting?: string;
64+}
65+
66+// ---------------------------------------------------------------- the request form
67+
68+const Point: FC<{ icon: string; title: string; children: Child }> = ({ icon, title, children }) => (
69+ <li>
70+ <span class="ic">{raw(icon)}</span>
71+ <span>
72+ <b>{title}</b>
73+ <span class="muted">{children}</span>
74+ </span>
75+ </li>
76+);
77+
78+async function requestPage(c: AppContext, o: { values?: FormValues; error?: string; status?: number } = {}): Promise<Response> {
79+ const mode = turnstileMode(c.env);
80+ const siteKey = mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
81+ const v = o.values ?? {};
82+ const stamp = mode === "missing" ? "" : await formStamp(c.env);
83+ return render(
84+ c,
85+ { title: "Request early access", status: o.status, turnstile: !!siteKey, description: "Diffinery is invite-only for now. Request early access to Git hosting, pull requests and GitHub Actions–compatible CI built for agents, running on Cloudflare." },
86+ <div class="ea">
87+ <section>
88+ <span class="ea-eyebrow">{raw(ICONS.rocket)} Early access</span>
89+ <h1>Request early access to Diffinery</h1>
90+ <p class="ea-lede">Diffinery is invite-only while we open it up team by team. Tell us a little about yourself and we'll email you an invitation when there's a place for you.</p>
91+ <ul class="ea-points">
92+ <Point icon={ICONS.repo} title="Git hosting and pull requests">
93+ Repositories, reviews with intent, issues, releases and a container registry.
94+ </Point>
95+ <Point icon={ICONS.play} title="Your GitHub Actions workflows, as they are">
96+ Bring your workflow YAML. Jobs start in seconds on Cloudflare, with live logs and caches.
97+ </Point>
98+ <Point icon={ICONS.bot} title="Built for agents, with a human in the loop">
99+ Claude Code, Codex and other agents work through their own accounts, with guardrails you set.
100+ </Point>
101+ </ul>
102+ <ol class="ea-steps">
103+ <li>Send this form.</li>
104+ <li>Confirm your email address with the link we send you.</li>
105+ <li>When there's a place, we email you an invitation to create your passkey.</li>
106+ </ol>
107+ </section>
108+ <div class="box ea-card">
109+ <div class="body">
110+ {mode === "missing" ? (
111+ <p class="m0">
112+ {TURNSTILE_MISSING} Already invited? <a href="/login">Sign in</a>.
113+ </p>
114+ ) : (
115+ <Form action="/early-access" t={csrfToken(c)}>
116+ <h2>Tell us about you</h2>
117+ <p class="muted small">Only your email address is required.</p>
118+ {o.error ? (
119+ <div class="flash error" role="alert">
120+ {o.error}
121+ </div>
122+ ) : null}
123+ <div class="field">
124+ <label for="ea-email">Work email</label>
125+ <input id="ea-email" name="email" type="email" required maxlength={254} value={v.email} autocomplete="email" placeholder="you@company.com" />
126+ </div>
127+ <div class="formrow" style="margin-bottom:14px">
128+ <div class="field grow">
129+ <label for="ea-name">Name (optional)</label>
130+ <input id="ea-name" name="name" type="text" maxlength={MAX_NAME} value={v.name} autocomplete="name" />
131+ </div>
132+ <div class="field grow">
133+ <label for="ea-company">Company or team (optional)</label>
134+ <input id="ea-company" name="company" type="text" maxlength={MAX_COMPANY} value={v.company} autocomplete="organization" />
135+ </div>
136+ </div>
137+ <div class="field">
138+ <label for="ea-use">What would you use Diffinery for? (optional)</label>
139+ <textarea id="ea-use" name="use_case" maxlength={MAX_USE_CASE} style="min-height:96px" placeholder="e.g. Moving 40 repositories and their CI off GitHub, and letting our agents open pull requests.">
140+ {v.useCase ?? ""}
141+ </textarea>
142+ </div>
143+ <div class="field">
144+ <label>Where do you host your code today? (optional)</label>
145+ <KSelect name="hosting" label="Where do you host your code today?" value={v.hosting ?? ""} options={[{ value: "", label: "Choose one" }, ...HOSTING.map((h) => ({ value: h.value, label: h.label }))]} />
146+ </div>
147+ <Protections stamp={stamp} siteKey={siteKey} />
148+ <button class="btn primary block">Request early access</button>
149+ <p class="ea-privacy">
150+ We keep what you send only to review your request and invite you, and delete it if you never confirm your address. Site administrators can see it; nobody else can. You can <a href="/early-access/remove">remove your request</a> at any time.
151+ </p>
152+ </Form>
153+ )}
154+ </div>
155+ </div>
156+ </div>,
157+ );
158+}
159+
160+/** What everyone sees after sending the form, whatever happened to it. */
161+function sentPage(c: AppContext, email: string): Response | Promise<Response> {
162+ return render(
163+ c,
164+ { title: "Check your inbox" },
165+ <div class="ea-done">
166+ <span class="ic">{raw(ICONS.envelope)}</span>
167+ <h1>Check your inbox</h1>
168+ <p>
169+ If <b>{email}</b> can get an invitation, we've sent it a link to confirm your request. Open it within {CONFIRM_HOURS} hours and your request joins the list.
170+ </p>
171+ <p class="muted">
172+ Nothing there? Check your spam folder. If this address already asked or already has an account, the email says so instead. Unconfirmed requests are deleted after {UNCONFIRMED_DAYS} days.
173+ </p>
174+ <p class="row" style="justify-content:center">
175+ <a class="btn" href="/">
176+ Back to the home page
177+ </a>
178+ <a class="btn" href="/docs">
179+ Read the docs meanwhile
180+ </a>
181+ </p>
182+ </div>,
183+ );
184+}
185+
186+/** What everyone sees after asking for a removal link, whether or not there is a request. */
187+function removalSentPage(c: AppContext, email: string): Response | Promise<Response> {
188+ return render(
189+ c,
190+ { title: "Check your inbox" },
191+ <div class="ea-done">
192+ <span class="ic">{raw(ICONS.envelope)}</span>
193+ <h1>Check your inbox</h1>
194+ <p>
195+ If there's an early access request for <b>{email}</b>, we've sent it a link to remove it. The link works once, for {REMOVE_HOURS} hours.
196+ </p>
197+ <p class="muted">Nothing is deleted until the link is opened.</p>
198+ </div>,
199+ );
200+}
201+
202+/** Runs on the confirmation page: takes the token from the fragment (never sent to the server in a request line) into the form. */
203+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);})();`;
204+
205+function confirmPage(c: AppContext): Response | Promise<Response> {
206+ return render(
207+ c,
208+ { title: "Confirm your request", script: CONFIRM_SCRIPT },
209+ <div class="ea-done">
210+ <span class="ic">{raw(ICONS.envelope)}</span>
211+ <h1>Confirm your early access request</h1>
212+ <p class="muted" id="ea-confirm-status" aria-live="polite">
213+ Checking your link…
214+ </p>
215+ <noscript>
216+ <p class="muted">Turn on JavaScript for this page to confirm your request: the link's code stays in your browser until you press the button.</p>
217+ </noscript>
218+ <Form action="/early-access/confirm" t={csrfToken(c)} id="ea-confirm" style="display:none">
219+ <input type="hidden" name="token" value="" />
220+ <p>Press the button to confirm that this is your email address. Your request then joins the list the team reviews.</p>
221+ <button class="btn primary">Confirm my request</button>
222+ </Form>
223+ </div>,
224+ );
225+}
226+
227+function confirmedPage(c: AppContext, r: EarlyAccessRequest | null): Response | Promise<Response> {
228+ if (!r)
229+ return render(
230+ c,
231+ { title: "This link doesn't work", status: 410 },
232+ <div class="ea-done">
233+ <span class="ic">{raw(ICONS.x)}</span>
234+ <h1>This link doesn't work any more</h1>
235+ <p class="muted">
236+ Confirmation links work once, for {CONFIRM_HOURS} hours, and only the newest one sent to an address works. If you already confirmed, you're on the list and there's nothing else to do.
237+ </p>
238+ <p>
239+ <a class="btn primary" href="/early-access">
240+ Request early access again
241+ </a>
242+ </p>
243+ </div>,
244+ );
245+ return render(
246+ c,
247+ { title: "You're on the list" },
248+ <div class="ea-done">
249+ <span class="ic">{raw(ICONS.check)}</span>
250+ <h1>You're on the list</h1>
251+ <p>
252+ Thanks for confirming, <b>{r.name || r.email}</b>. The team reviews requests in order and we'll email <b>{r.email}</b> an invitation when there's a place for you.
253+ </p>
254+ <p class="muted">The invitation lets you pick a username and sign in with a passkey: your fingerprint, face or screen lock, with no password to remember.</p>
255+ <p class="row" style="justify-content:center">
256+ <a class="btn" href="/docs">
257+ Read the docs meanwhile
258+ </a>
259+ </p>
260+ </div>,
261+ );
262+}
263+
264+// ---------------------------------------------------------------- site administration
265+
266+/** The initial on a request's avatar: its name's or its address's first letter. */
267+const initial = (r: Pick<EarlyAccessRequest, "name" | "email">) => (r.name || r.email).trim().charAt(0).toUpperCase() || "?";
268+
269+const Actions: FC<{ r: EarlyAccessRequest; t: string; back: string; hasAccount: boolean }> = ({ r, t, back, hasAccount }) => (
270+ <>
271+ {(r.state === "confirmed" || r.state === "unconfirmed") && !hasAccount ? (
272+ <Form action={`${BASE}/${r.id}/invite`} t={t} class="inline" confirm={`Send ${r.email} an invitation to Diffinery? It works once for 14 days.`}>
273+ <input type="hidden" name="back" value={back} />
274+ <button class="btn sm primary">{raw(ICONS.envelope)} Invite</button>
275+ </Form>
276+ ) : null}{" "}
277+ {r.state === "confirmed" || r.state === "unconfirmed" ? (
278+ <Form action={`${BASE}/${r.id}/decline`} t={t} class="inline">
279+ <input type="hidden" name="back" value={back} />
280+ <button class="btn sm">Decline</button>
281+ </Form>
282+ ) : null}
283+ {r.state === "declined" ? (
284+ <Form action={`${BASE}/${r.id}/restore`} t={t} class="inline">
285+ <input type="hidden" name="back" value={back} />
286+ <button class="btn sm">Put back</button>
287+ </Form>
288+ ) : null}
289+ </>
290+);
291+
292+type Row = EarlyAccessRequest & { account_login: string | null };
293+
294+/** Requests with the login of an active account that already uses the address, if any. */
295+async function withAccounts(db: D1Database, rows: EarlyAccessRequest[]): Promise<Row[]> {
296+ if (!rows.length) return [];
297+ const found = (
298+ await db
299+ .prepare(`SELECT lower(email) AS email, login FROM accounts WHERE kind = 'user' AND status = 'active' AND lower(email) IN (${rows.map(() => "?").join(", ")})`)
300+ .bind(...rows.map((r) => r.email.toLowerCase()))
301+ .all<{ email: string; login: string }>()
302+ ).results;
303+ const by = new Map(found.map((f) => [f.email, f.login]));
304+ return rows.map((r) => ({ ...r, account_login: by.get(r.email.toLowerCase()) ?? null }));
305+}
306+
307+/**
308+ * The checks every public form here makes before it does anything: Turnstile is configured, the
309+ * honeypot is empty, the timing stamp is genuine and the form wasn't sent faster than a person types
310+ * ("silent": answer as if it worked and keep nothing), and the Turnstile token is good.
311+ */
312+async function guard(c: AppContext, f: FormData): Promise<"ok" | "silent"> {
313+ const mode = turnstileMode(c.env);
314+ if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
315+ const stamp = await checkStamp(c.env, f.s("stamp"));
316+ if (f.s("website") !== "" || stamp === "fast" || stamp === "bad") return "silent";
317+ if (stamp === "stale") throw new HttpError(422, "This form was open for a long time. Please check it and send it again.");
318+ if (mode === "verify" && !(await verifyTurnstile(c.env.TURNSTILE_SECRET!, f.s("cf-turnstile-response"), c.req.header("cf-connecting-ip") ?? null))) throw new HttpError(403, "We couldn't confirm that you're a person. Please try again.");
319+ return "ok";
320+}
321+
322+/** The honeypot, timing stamp and Turnstile widget every public form here carries. */
323+const Protections: FC<{ stamp: string; siteKey?: string }> = ({ stamp, siteKey }) => (
324+ <>
325+ {/* A field people never see or fill in: a script filling every field gives itself away. */}
326+ <div class="ea-hp" aria-hidden="true">
327+ <label for="ea-website">Leave this empty</label>
328+ <input id="ea-website" name="website" type="text" tabindex={-1} autocomplete="off" />
329+ </div>
330+ <input type="hidden" name="stamp" value={stamp} />
331+ {siteKey ? <div class="cf-turnstile" data-sitekey={siteKey} data-theme="auto" style="margin-bottom:12px"></div> : null}
332+ </>
333+);
334+
335+// ---------------------------------------------------------------- removing your own request
336+
337+async function removePage(c: AppContext, o: { email?: string; error?: string; status?: number } = {}): Promise<Response> {
338+ const mode = turnstileMode(c.env);
339+ const siteKey = mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
340+ const stamp = mode === "missing" ? "" : await formStamp(c.env);
341+ return render(
342+ c,
343+ { title: "Remove your early access request", status: o.status, turnstile: !!siteKey },
344+ <div style="max-width:520px;margin:48px auto">
345+ <h1>Remove your early access request</h1>
346+ <p class="muted">We'll email the address a link that deletes its request and everything stored about it. Nothing is deleted until the link is opened, so nobody else can remove your request.</p>
347+ <div class="box ea-card">
348+ <div class="body">
349+ {mode === "missing" ? (
350+ <p class="m0">{TURNSTILE_MISSING}</p>
351+ ) : (
352+ <Form action="/early-access/remove" t={csrfToken(c)}>
353+ {o.error ? (
354+ <div class="flash error" role="alert">
355+ {o.error}
356+ </div>
357+ ) : null}
358+ <div class="field">
359+ <label for="ea-remove-email">Email address of the request</label>
360+ <input id="ea-remove-email" name="email" type="email" required maxlength={254} value={o.email} autocomplete="email" />
361+ </div>
362+ <Protections stamp={stamp} siteKey={siteKey} />
363+ <button class="btn block">Email me a removal link</button>
364+ </Form>
365+ )}
366+ </div>
367+ </div>
368+ </div>,
369+ );
370+}
371+
372+/** Runs on the removal page: the same as CONFIRM_SCRIPT, for the removal form. */
373+const REMOVE_SCRIPT = CONFIRM_SCRIPT.replace('"ea-confirm"', '"ea-remove"').replace('"ea-confirm-status"', '"ea-remove-status"');
374+
375+const backTo = (v: string): string => (v.startsWith(`${BASE}`) && !v.startsWith("//") && !/[\r\n]/.test(v) ? v : BASE);
376+
377+export function registerEarlyAccess(web: WebApp): void {
378+ // ------------------------------------------------------------ the form
379+
380+ web.get("/early-access", (c) => {
381+ if (!earlyAccessOpen(c.env)) return c.redirect("/signup", 302);
382+ if (c.get("principal").type === "account") return c.redirect("/", 302);
383+ return requestPage(c);
384+ });
385+
386+ post(
387+ web,
388+ "/early-access",
389+ async (c, f) => {
390+ if (!earlyAccessOpen(c.env)) return c.redirect("/signup", 303);
391+ const values: FormValues = { email: f.s("email").slice(0, 254), name: f.s("name").slice(0, MAX_NAME * 2), company: f.s("company").slice(0, MAX_COMPANY * 2), useCase: f.s("use_case").slice(0, MAX_USE_CASE * 2), hosting: f.s("hosting").slice(0, 40) };
392+ try {
393+ // A filled honeypot or a form sent faster than a person types: answer as if it worked, keep nothing.
394+ if ((await guard(c, f)) === "silent") return sentPage(c, values.email?.trim() || "your address");
395+ const ip = c.req.header("cf-connecting-ip") ?? null;
396+ const input = validateRequest({ email: values.email, name: values.name, company: values.company, useCase: values.useCase, hosting: values.hosting });
397+ await submitRequest(c.env, input, ip);
398+ return sentPage(c, input.email);
399+ } catch (e) {
400+ const s = statusOf(e);
401+ if (s.status >= 500 && s.status !== 503) throw e;
402+ return requestPage(c, { values, error: s.message, status: s.status });
403+ }
404+ },
405+ { maxBytes: MAX_FORM_BYTES },
406+ );
407+
408+ web.get("/early-access/confirm", (c) => confirmPage(c));
409+
410+ post(
411+ web,
412+ "/early-access/confirm",
413+ async (c, f) => {
414+ const r = await confirmRequest(c.env, (e) => c.get("dir").audit(e), f.s("token").slice(0, 200));
415+ return confirmedPage(c, r);
416+ },
417+ { maxBytes: 4096 },
418+ );
419+
420+ web.get("/early-access/remove", (c) => removePage(c));
421+
422+ post(
423+ web,
424+ "/early-access/remove",
425+ async (c, f) => {
426+ const typed = f.s("email").slice(0, 254);
427+ try {
428+ if ((await guard(c, f)) === "silent") return removalSentPage(c, typed.trim() || "your address");
429+ const email = normalEmail(typed);
430+ if (!email) throw new HttpError(422, "Enter a valid email address, like you@example.com.");
431+ await requestRemoval(c.env, email, c.req.header("cf-connecting-ip") ?? null);
432+ return removalSentPage(c, email);
433+ } catch (e) {
434+ const s = statusOf(e);
435+ if (s.status >= 500 && s.status !== 503) throw e;
436+ return removePage(c, { email: typed, error: s.message, status: s.status });
437+ }
438+ },
439+ { maxBytes: 8192 },
440+ );
441+
442+ web.get("/early-access/remove/confirm", (c) =>
443+ render(
444+ c,
445+ { title: "Remove your request", script: REMOVE_SCRIPT },
446+ <div class="ea-done">
447+ <span class="ic">{raw(ICONS.trash)}</span>
448+ <h1>Remove your early access request</h1>
449+ <p class="muted" id="ea-remove-status" aria-live="polite">
450+ Checking your link…
451+ </p>
452+ <noscript>
453+ <p class="muted">Turn on JavaScript for this page to remove your request: the link's code stays in your browser until you press the button.</p>
454+ </noscript>
455+ <Form action="/early-access/remove/confirm" t={csrfToken(c)} id="ea-remove" style="display:none">
456+ <input type="hidden" name="token" value="" />
457+ <p>Press the button to delete your request and everything stored about it. This can't be undone; you can always ask again later.</p>
458+ <button class="btn danger">Remove my request</button>
459+ </Form>
460+ </div>,
461+ ),
462+ );
463+
464+ post(
465+ web,
466+ "/early-access/remove/confirm",
467+ async (c, f) => {
468+ const ok = await removeByToken(c.env, (e) => c.get("dir").audit(e), f.s("token").slice(0, 200));
469+ return render(
470+ c,
471+ { title: ok ? "Request removed" : "This link doesn't work", status: ok ? 200 : 410 },
472+ <div class="ea-done">
473+ <span class="ic">{raw(ok ? ICONS.check : ICONS.x)}</span>
474+ <h1>{ok ? "Your request is removed" : "This link doesn't work any more"}</h1>
475+ <p class="muted">{ok ? "We deleted your early access request and everything stored about it. If you change your mind, you're welcome to ask again." : `Removal links work once, for ${REMOVE_HOURS} hours, and only the newest one sent to an address works. If you already used it, your request is gone.`}</p>
476+ <p>
477+ <a class="btn" href={ok ? "/" : "/early-access/remove"}>
478+ {ok ? "Back to the home page" : "Ask for a new link"}
479+ </a>
480+ </p>
481+ </div>,
482+ );
483+ },
484+ { maxBytes: 4096 },
485+ );
486+
487+ // ------------------------------------------------------------ site administration: Early access
488+
489+ /** Requests don't exist for anyone but site administrators: 404, not 403. */
490+ const requireSiteAdmin = (c: AppContext): Account => {
491+ const me = requireAccount(c);
492+ if (!me.site_admin || me.kind !== "user") throw new HttpError(404, "not found");
493+ return me;
494+ };
495+
496+ const page = (c: AppContext, me: Account, title: string, body: Child) =>
497+ render(
498+ c,
499+ { title },
500+ <div class="grid left">
501+ <SettingsNav me={me} tab="early-access" db={c.env.DB} />
502+ <div>
503+ <ComputePausedBanner env={c.env} />
504+ {body}
505+ </div>
506+ </div>,
507+ );
508+
509+ const PER_PAGE = 50;
510+
511+ web.get(BASE, async (c) => {
512+ const me = requireSiteAdmin(c);
513+ const qState = c.req.query("state");
514+ const state: RequestState | "all" = qState === "all" ? "all" : REQUEST_STATES.includes(qState as RequestState) ? (qState as RequestState) : "confirmed";
515+ const q = (c.req.query("q") ?? "").trim().slice(0, 100);
516+ const pg = pageNum(c);
517+ const store = new EarlyAccess(c.env.DB);
518+ const [list, counts] = await Promise.all([store.list({ state: state === "all" ? undefined : state, q, limit: PER_PAGE, offset: (pg - 1) * PER_PAGE }), store.counts()]);
519+ const rows = await withAccounts(c.env.DB, list.items);
520+ const total = counts.confirmed + counts.unconfirmed + counts.invited + counts.declined;
521+ const here = `${BASE}${qs({ state: state === "confirmed" ? undefined : state, q, page: pg > 1 ? pg : undefined })}`;
522+ const t = csrfToken(c);
523+ const tab = (s: RequestState | "all", label: string, n: number) => (
524+ <a href={`${BASE}${qs({ state: s === "confirmed" ? undefined : s, q })}`} class={state === s ? "on" : ""}>
525+ {label} <span class="n">{n}</span>
526+ </a>
527+ );
528+ return page(
529+ c,
530+ me,
531+ "Early access",
532+ <>
533+ <h1>Early access</h1>
534+ <p class="muted">
535+ Requests from <a href="/early-access">/early-access</a>, where people without an invitation ask for one. A request is confirmed once its address follows the emailed link. Inviting sends the usual <a href="/settings/admin">instance invitation</a>; declining emails nobody. Every decision is in the <a href="/settings/audit">audit log</a>.
536+ </p>
537+ <nav class="tabs mb" aria-label="Requests by state">
538+ {tab("confirmed", "Confirmed", counts.confirmed)}
539+ {tab("unconfirmed", "Unconfirmed", counts.unconfirmed)}
540+ {tab("invited", "Invited", counts.invited)}
541+ {tab("declined", "Declined", counts.declined)}
542+ {tab("all", "All", total)}
543+ </nav>
544+ <form method="get" action={BASE} class="row mb" role="search">
545+ {state === "confirmed" ? null : <input type="hidden" name="state" value={state} />}
546+ <input type="search" name="q" value={q} maxlength={100} placeholder="Search by email, name, company or use" aria-label="Search requests" class="grow" style="max-width:420px" />
547+ <button class="btn">{raw(ICONS.search)} Search</button>
548+ {q ? (
549+ <a class="btn" href={`${BASE}${qs({ state: state === "confirmed" ? undefined : state })}`}>
550+ Clear
551+ </a>
552+ ) : null}
553+ </form>
554+ <div class="box">
555+ {rows.length ? (
556+ <ul class="list ea-list">
557+ {rows.map((r) => (
558+ <li>
559+ <span class="ea-av" aria-hidden="true">
560+ {initial(r)}
561+ </span>
562+ <div class="grow" style="min-width:0">
563+ <div class="row" style="gap:6px;flex-wrap:wrap">
564+ <a class="title" href={`${BASE}/${r.id}`}>
565+ {r.name || r.email}
566+ </a>
567+ {r.name ? <span class="muted small">{r.email}</span> : null}
568+ <StateBadge state={r.state} />
569+ {r.account_login ? <span class="badge amber">has an account: {r.account_login}</span> : null}
570+ </div>
571+ <div class="meta">
572+ {[r.company, hostingLabel(r.hosting) ? `hosts on ${hostingLabel(r.hosting)}` : null].filter(Boolean).join(" · ")}
573+ {r.company || r.hosting ? " · " : ""}
574+ requested <Time at={r.created_at} />
575+ {r.decided_by ? (
576+ <>
577+ {" "}
578+ · {r.state === "invited" ? "invited" : "declined"} by {r.decided_by} <Time at={r.decided_at} />
579+ </>
580+ ) : null}
581+ </div>
582+ {r.use_case ? <div class="ea-use">{r.use_case}</div> : null}
583+ </div>
584+ <div class="ea-actions nowrap">
585+ <Actions r={r} t={t} back={here} hasAccount={!!r.account_login} />
586+ </div>
587+ </li>
588+ ))}
589+ </ul>
590+ ) : (
591+ <div class="empty">{q ? "No requests match your search." : state === "confirmed" ? "No confirmed requests are waiting. New ones show up here once their address confirms." : state === "all" ? "No early access requests yet." : `No ${STATE_TEXT[state].toLowerCase()} requests.`}</div>
592+ )}
593+ </div>
594+ <Pager page={pg} hasNext={list.more} href={(p) => `${BASE}${qs({ state: state === "confirmed" ? undefined : state, q, page: p > 1 ? p : undefined })}`} />
595+ </>,
596+ );
597+ });
598+
599+ web.get(`${BASE}/:id`, async (c) => {
600+ const me = requireSiteAdmin(c);
601+ const found = await new EarlyAccess(c.env.DB).get(prm(c, "id"));
602+ if (!found) throw new HttpError(404, "no early access request with that id");
603+ const [r] = (await withAccounts(c.env.DB, [found])) as [Row];
604+ const t = csrfToken(c);
605+ const here = `${BASE}/${r.id}`;
606+ return page(
607+ c,
608+ me,
609+ `Early access: ${r.name || r.email}`,
610+ <>
611+ <p class="small">
612+ <a href={BASE}>← Early access</a>
613+ </p>
614+ <div class="row mb" style="gap:12px;align-items:center">
615+ <span class="ea-av lg" aria-hidden="true">
616+ {initial(r)}
617+ </span>
618+ <div class="grow" style="min-width:0">
619+ <h1 class="m0" style="overflow-wrap:anywhere">{r.name || r.email}</h1>
620+ <div class="row" style="gap:6px;margin-top:4px">
621+ <StateBadge state={r.state} />
622+ {r.account_login ? <span class="badge amber">has an account: {r.account_login}</span> : null}
623+ </div>
624+ </div>
625+ <div class="nowrap">
626+ <Actions r={r} t={t} back={here} hasAccount={!!r.account_login} />
627+ </div>
628+ </div>
629+ <div class="box">
630+ <div class="body">
631+ <dl class="kv m0">
632+ <dt>Email</dt>
633+ <dd style="overflow-wrap:anywhere">
634+ <a href={`mailto:${r.email}`}>{r.email}</a>
635+ </dd>
636+ <dt>Name</dt>
637+ <dd>{r.name ?? <span class="muted">not given</span>}</dd>
638+ <dt>Company or team</dt>
639+ <dd>{r.company ?? <span class="muted">not given</span>}</dd>
640+ <dt>Hosts code on</dt>
641+ <dd>{hostingLabel(r.hosting) ?? <span class="muted">not given</span>}</dd>
642+ <dt>Requested</dt>
643+ <dd>
644+ <Time at={r.created_at} /> <span class="muted small">{r.created_at}</span>
645+ </dd>
646+ <dt>Confirmed</dt>
647+ <dd>{r.confirmed_at ? <Time at={r.confirmed_at} /> : <span class="muted">not yet: the address hasn't followed the emailed link</span>}</dd>
648+ {r.decided_by ? (
649+ <>
650+ <dt>{r.state === "invited" ? "Invited" : "Declined"}</dt>
651+ <dd>
652+ by {r.decided_by} <Time at={r.decided_at} />
653+ </dd>
654+ </>
655+ ) : null}
656+ {r.account_login ? (
657+ <>
658+ <dt>Account</dt>
659+ <dd>
660+ <a href={`/${r.account_login}`}>{r.account_login}</a> <span class="muted small">already uses this address</span>
661+ </dd>
662+ </>
663+ ) : null}
664+ <dt>Reference</dt>
665+ <dd class="mono">{r.id}</dd>
666+ </dl>
667+ </div>
668+ </div>
669+ <h2>What they'd use Diffinery for</h2>
670+ <div class="box">
671+ {/* Plain text exactly as sent: never rendered as Markdown or HTML. */}
672+ {r.use_case ? (
673+ <pre class="body m0" style="white-space:pre-wrap;overflow-wrap:anywhere;font-family:var(--sans);font-size:14px">
674+ {r.use_case}
675+ </pre>
676+ ) : (
677+ <div class="empty">Not given.</div>
678+ )}
679+ </div>
680+ <h2>Delete</h2>
681+ <Form action={`${BASE}/${r.id}/delete`} t={t} class="box" confirm={`Delete the request from ${r.email} and everything stored about it? This can't be undone.`}>
682+ <div class="body row between" style="gap:12px;flex-wrap:wrap">
683+ <span class="muted">
684+ For someone who asks for their data to be removed. The audit log keeps that a request was deleted, not what it said.
685+ {r.state === "invited" ? " The invitation already sent stays; revoke it in People and invitations." : ""}
686+ </span>
687+ <button class="btn danger">{raw(ICONS.trash)} Delete request</button>
688+ </div>
689+ </Form>
690+ </>,
691+ );
692+ });
693+
694+ const audit = (c: AppContext) => (e: Parameters<Directory["audit"]>[0]) => c.get("dir").audit(e);
695+
696+ post(web, `${BASE}/:id/invite`, async (c, f) => {
697+ const me = requireSiteAdmin(c);
698+ return act(c, backTo(f.s("back")), async () => {
699+ const r = await inviteRequest(c.env, c.get("dir"), audit(c), me, prm(c, "id"), "web");
700+ return void flashNotice(c, `Invited ${r.email}. They'll get the invitation email shortly.`);
701+ });
702+ });
703+ post(web, `${BASE}/:id/decline`, async (c, f) => {
704+ const me = requireSiteAdmin(c);
705+ return act(c, backTo(f.s("back")), async () => {
706+ const r = await declineRequest(c.env.DB, audit(c), me, prm(c, "id"), false, "web");
707+ return void flashNotice(c, `Declined the request from ${r.email}. Nobody was emailed.`);
708+ });
709+ });
710+ post(web, `${BASE}/:id/restore`, async (c, f) => {
711+ const me = requireSiteAdmin(c);
712+ return act(c, backTo(f.s("back")), async () => {
713+ const r = await declineRequest(c.env.DB, audit(c), me, prm(c, "id"), true, "web");
714+ return void flashNotice(c, `The request from ${r.email} is ${STATE_TEXT[r.state].toLowerCase()} again.`);
715+ });
716+ });
717+ post(web, `${BASE}/:id/delete`, async (c) => {
718+ const me = requireSiteAdmin(c);
719+ return act(c, `${BASE}/${encodeURIComponent(prm(c, "id"))}`, async () => {
720+ await deleteRequest(c.env.DB, audit(c), me, prm(c, "id"), "web");
721+ flashNotice(c, "Request deleted.");
722+ return BASE;
723+ });
724+ });
725+}
726+
727+const flashNotice = (c: AppContext, message: string) => flash(c, "notice", message);
modifiedapps/diffinery/src/web/imports.tsx+1−1View file
@@ -542,7 +542,7 @@
542542 c,
543543 { title: "Linked accounts" },
544544 <div class="grid left">
545- <SettingsNav me={me} tab="linked" />
545+ <SettingsNav me={me} tab="linked" db={c.env.DB} />
546546 <div style="max-width:640px">
547547 <h1>Linked GitHub accounts</h1>
548548 <p class="muted">When you import from GitHub, work by a linked GitHub account is attributed to your Diffinery account. Prove you control the account by putting a one-time code in a public gist; no token is needed.</p>
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 { registerEarlyAccess } from "./early-access.js";
1415 import { registerStatus } from "./status.js";
1516 import { registerAuth } from "./auth.js";
1617 import { registerCliLogin } from "./cli-login.js";
@@ -74,6 +75,7 @@
7475 registerPackages(web);
7576 registerMailWeb(web);
7677 registerSecurityReports(web);
78+registerEarlyAccess(web);
7779 // /status: before the /:owner catch-all.
7880 registerStatus(web);
7981 // Before registerAccount: its /:owner catch-all would otherwise swallow /imports, /new/import and /settings/linked-accounts.
modifiedapps/diffinery/src/web/security-reports.tsx+1−1View file
@@ -183,7 +183,7 @@
183183 c,
184184 { title },
185185 <div class="grid left">
186- <SettingsNav me={me} tab="security-reports" />
186+ <SettingsNav me={me} tab="security-reports" db={c.env.DB} />
187187 <div>
188188 <ComputePausedBanner env={c.env} />
189189 {body}
modifiedapps/diffinery/src/web/seo.tsx+5−2View file
@@ -7,6 +7,7 @@
77 * shows a site's favicon in results when it is crawlable, square and a multiple of
88 * 48px, so /favicon.ico (16–64px) and /favicon-48.png are linked from every page.
99 */
10+import { earlyAccessOpen } from "../lib/early-access.js";
1011 import { raw } from "hono/html";
1112 import type { AppContext } from "../auth/principal.js";
1213 import type { WebApp } from "./ui.js";
@@ -42,7 +43,7 @@
4243 const CACHE = "public, max-age=604800";
4344
4445 /** Paths that are never useful in search results. */
45-const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|-)(\/|$)/;
46+const PRIVATE = /^\/(login|logout|auth|signin|signup|invite|settings|notifications|new|admin|request-access|early-access\/(?:confirm|remove)|-)(\/|$)/;
4647
4748 export function registerSeo(web: WebApp): void {
4849 for (const name of Object.keys(ICON_FILES)) {
@@ -75,7 +76,7 @@
7576 const lines = [
7677 "User-agent: *",
7778 "Allow: /",
78- ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`),
79+ ...["/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}`),
7980 "",
8081 `Sitemap: ${origin}/sitemap.xml`,
8182 "",
@@ -91,6 +92,8 @@
9192 '<?xml version="1.0" encoding="UTF-8"?>',
9293 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
9394 url(`${origin}/`),
95+ // The early access form, while the instance is invite-only (lib/early-access.ts).
96+ ...(earlyAccessOpen(c.env) ? [url(`${origin}/early-access`)] : []),
9497 ...docsSitemapPaths().map((p) => url(`${origin}${p}`)),
9598 ...repos.map((r) => url(`${origin}/${encodeURIComponent(r.owner_login)}/${encodeURIComponent(r.name)}`, r.pushed_at ?? r.updated_at)),
9699 "</urlset>",
modifiedapps/diffinery/src/web/status.tsx+1−1View file
@@ -149,7 +149,7 @@
149149 c,
150150 { title: "Status" },
151151 <div class="grid left">
152- <SettingsNav me={me} tab="status" />
152+ <SettingsNav me={me} tab="status" db={c.env.DB} />
153153 <div>
154154 <ComputePausedBanner env={c.env} />
155155 <h1>Status</h1>
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+5−1View file
@@ -443,7 +443,11 @@
443443
444444 test("open sign-up: off by default; on, an emailed code, then a passkey", async () => {
445445 // ------------------------------------------------------------ off (this file's Worker has no SIGNUP)
446- assert.equal((await fetch(`${base}/signup`)).status, 404);
446+ // The sign-up page leads to the early access request instead; everything else under /signup is 404.
447+ const toEarly = await fetch(`${base}/signup`, { redirect: "manual" });
448+ assert.equal(toEarly.status, 302);
449+ assert.equal(toEarly.headers.get("location"), "/early-access");
450+ assert.equal((await fetch(`${base}/signup/verify`)).status, 404);
447451 for (const path of ["/signup", "/signup/verify"]) {
448452 const r = await fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "email=a%40example.com&code=123456" });
449453 assert.equal(r.status, 404, path);
modifiedapps/diffinery/test/unit/docs.test.ts+1−1View file
@@ -42,7 +42,7 @@
4242 }
4343
4444 /** Paths outside /docs that pages may link to: real routes of the app. */
45-const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/status$/, /^\/healthz$/];
45+const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/early-access(\/remove)?$/, /^\/status$/, /^\/healthz$/];
4646
4747 describe("docs pages", () => {
4848 it("has every page in the navigation, in order", () => {
addedapps/diffinery/test/unit/early-access.test.ts+326−0View file
@@ -1,0 +1,326 @@
1+import { describe, expect, it } from "vitest";
2+import type { Env } from "../../src/env.js";
3+import type { Account, Directory } from "../../src/lib/directory.js";
4+import {
5+ EMAILS_PER_ADDRESS_PER_DAY,
6+ EMAILS_PER_DAY,
7+ EarlyAccess,
8+ MAX_COMPANY,
9+ MAX_NAME,
10+ MAX_USE_CASE,
11+ MIN_FILL_MS,
12+ MAX_FORM_AGE_MS,
13+ SUBMISSIONS_PER_IP_PER_DAY,
14+ checkStamp,
15+ confirmRequest,
16+ declineRequest,
17+ deleteRequest,
18+ formStamp,
19+ inviteRequest,
20+ removeByToken,
21+ requestJson,
22+ requestRemoval,
23+ submitRequest,
24+ validateRequest,
25+ type RequestInput,
26+} from "../../src/lib/early-access.js";
27+import { pruneExpired } from "../../src/lib/retention.js";
28+import { HttpError } from "../../src/lib/util.js";
29+import type { MailJob } from "../../src/mail/types.js";
30+import { testD1 } from "./stubs/d1.js";
31+
32+const at = "2026-10-01T00:00:00.000Z";
33+const PUB = "https://diffinery.example";
34+const MASTER_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));
35+
36+/** A D1 with two site administrators (one disabled), a person, a disabled person, and a mail queue that records what it gets. */
37+function setup() {
38+ const DB = testD1();
39+ const add = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?)");
40+ add.run("u-root", "root", "root@example.com", "active", 1, at);
41+ add.run("u-old", "old", "old@example.com", "disabled", 1, at);
42+ add.run("u-ann", "ann", "ann@example.com", "active", 0, at);
43+ add.run("u-gone", "gone", "gone@example.com", "disabled", 0, at);
44+ const queued: MailJob[] = [];
45+ const MAIL = { sendBatch: async (b: Array<{ body: MailJob }>) => void queued.push(...b.map((m) => m.body)) };
46+ const env = { DB, MAIL, PUBLIC_URL: PUB, MASTER_KEY } as unknown as Env;
47+ const audits: Array<{ action: string; target?: string; actorLogin?: string; detail?: unknown }> = [];
48+ const audit = async (e: (typeof audits)[number]) => void audits.push(e);
49+ const root = DB.sqlite.prepare("SELECT * FROM accounts WHERE id = 'u-root'").get() as unknown as Account;
50+ const invites: Array<{ email: string; role: string }> = [];
51+ const dir = {
52+ accountByEmail: async (email: string) => (DB.sqlite.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").get(email) as unknown as Account) ?? null,
53+ createInvite: async (_inviter: Account, input: { email: string; role: string }) => {
54+ invites.push(input);
55+ const id = `inv-${invites.length}`;
56+ DB.sqlite.prepare("INSERT INTO invites (id, email, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(id, input.email, input.role, `h-${id}`, "u-root", at, "2999-01-01T00:00:00.000Z");
57+ return { id, url: `${PUB}/invite/x`, token: "x" };
58+ },
59+ } as unknown as Directory;
60+ return { env, DB, queued, audits, audit, root, dir, invites };
61+}
62+
63+const refused = (f: () => unknown, status: number, msg: RegExp) => {
64+ try {
65+ f();
66+ } catch (e) {
67+ expect(e).toBeInstanceOf(HttpError);
68+ expect((e as HttpError).status).toBe(status);
69+ expect((e as HttpError).detail).toMatch(msg);
70+ return;
71+ }
72+ throw new Error("expected it to be refused");
73+};
74+
75+const rejects = async (p: Promise<unknown>, status: number, msg: RegExp) => {
76+ const e = await p.then(
77+ () => null,
78+ (x: unknown) => x,
79+ );
80+ expect(e).toBeInstanceOf(HttpError);
81+ expect((e as HttpError).status).toBe(status);
82+ expect((e as HttpError).detail).toMatch(msg);
83+};
84+
85+const input = (email: string, over: Partial<RequestInput> = {}): RequestInput => ({ email, name: "Ada", company: "Acme", useCase: "Move our CI", hosting: "github", ...over });
86+const tokenOf = (job: MailJob | undefined) => /#([A-Za-z0-9_-]+)$/.exec((job as { data: { url: string } }).data.url)![1]!;
87+
88+describe("request validation", () => {
89+ it("needs only a valid email address, lower-cases it and tidies the other fields", () => {
90+ expect(validateRequest({ email: " Ada@Example.COM " })).toEqual({ email: "ada@example.com", name: null, company: null, useCase: null, hosting: null });
91+ expect(validateRequest({ email: "a@b.co", name: " Ada \n Lovelace ", company: " Acme\tLabs ", useCase: "\r\nLine one\r\n\r\n\r\n\r\nLine two ", hosting: "gitlab" })).toEqual({ email: "a@b.co", name: "Ada Lovelace", company: "Acme Labs", useCase: "Line one\n\nLine two", hosting: "gitlab" });
92+ });
93+
94+ it("strips control and text-direction characters that could disguise text", () => {
95+ expect(validateRequest({ email: "a@b.co", name: "Ad‮a\u0000⁦x", useCase: "ok\u0007‏" })).toMatchObject({ name: "Adax", useCase: "ok" });
96+ });
97+
98+ it("refuses a missing or bad address, too much text (never cut) and an unknown hosting answer", () => {
99+ refused(() => validateRequest({ email: " " }), 422, /Enter your email address/);
100+ refused(() => validateRequest({ email: "not-an-address" }), 422, /valid email address/);
101+ refused(() => validateRequest({ email: "<script>@x.y" }), 422, /valid email address/);
102+ refused(() => validateRequest({ email: `${"a".repeat(250)}@b.co` }), 422, /valid email address/);
103+ refused(() => validateRequest({ email: "a@b.co", name: "x".repeat(MAX_NAME + 1) }), 422, /100 characters/);
104+ refused(() => validateRequest({ email: "a@b.co", company: "x".repeat(MAX_COMPANY + 1) }), 422, /120 characters/);
105+ refused(() => validateRequest({ email: "a@b.co", useCase: "x".repeat(MAX_USE_CASE + 1) }), 422, /1,000 characters/);
106+ expect(validateRequest({ email: "a@b.co", useCase: "x".repeat(MAX_USE_CASE) }).useCase).toHaveLength(MAX_USE_CASE);
107+ refused(() => validateRequest({ email: "a@b.co", hosting: "sourceforge" }), 422, /from the list/);
108+ refused(() => validateRequest({ email: ["a@b.co"] }), 422, /Enter your email address/);
109+ });
110+});
111+
112+describe("the form's timing stamp", () => {
113+ const env = { MASTER_KEY, PUBLIC_URL: PUB } as unknown as Env;
114+
115+ it("takes a genuine stamp between 3 seconds and 24 hours old", async () => {
116+ const t = Date.now();
117+ const stamp = await formStamp(env, t);
118+ expect(await checkStamp(env, stamp, t + MIN_FILL_MS + 1)).toBe("ok");
119+ expect(await checkStamp(env, stamp, t + 500)).toBe("fast");
120+ expect(await checkStamp(env, stamp, t + MAX_FORM_AGE_MS + 1)).toBe("stale");
121+ });
122+
123+ it("refuses a missing, forged or future stamp, and one signed with another key", async () => {
124+ const t = Date.now();
125+ const stamp = await formStamp(env, t);
126+ expect(await checkStamp(env, "", t)).toBe("bad");
127+ expect(await checkStamp(env, `${t - 60_000}.${stamp.split(".")[1]}`, t)).toBe("bad");
128+ expect(await checkStamp(env, await formStamp(env, t + 3600_000), t)).toBe("bad");
129+ const other = { MASTER_KEY: btoa("x".repeat(32)), PUBLIC_URL: PUB } as unknown as Env;
130+ expect(await checkStamp(other, stamp, t + 5000)).toBe("bad");
131+ // A key rotation still takes stamps made with the previous key.
132+ expect(await checkStamp({ ...other, MASTER_KEY_PREV: MASTER_KEY } as Env, stamp, t + 5000)).toBe("ok");
133+ });
134+});
135+
136+describe("requesting and confirming", () => {
137+ it("stores a new request unconfirmed and emails only a confirmation link, with nothing typed in it", async () => {
138+ const { env, queued } = setup();
139+ expect(await submitRequest(env, input("new@example.net", { name: "<b>Ada</b>" }), "203.0.113.9")).toBe("confirm");
140+ const r = (await new EarlyAccess(env.DB).byEmail("new@example.net"))!;
141+ expect(r).toMatchObject({ state: "unconfirmed", name: "<b>Ada</b>", company: "Acme", use_case: "Move our CI", hosting: "github", confirmed_at: null });
142+ expect(r.confirm_hash).toMatch(/^[0-9a-f]{64}$/);
143+ expect(queued).toHaveLength(1);
144+ expect(queued[0]).toMatchObject({ kind: "early_access_confirm", to: { email: "new@example.net" }, reason: "early_access" });
145+ expect((queued[0] as { data: { url: string } }).data.url).toMatch(new RegExp(`^${PUB}/early-access/confirm#[A-Za-z0-9_-]{32}$`));
146+ expect(JSON.stringify(queued)).not.toMatch(/Ada|Acme|Move our CI/);
147+ // Only hashes of the IP address and the address are kept for the limits.
148+ const attempts = env.DB.sqlite.prepare("SELECT * FROM early_access_attempts").all() as Array<Record<string, string>>;
149+ expect(JSON.stringify(attempts)).not.toMatch(/203\.0\.113\.9|new@example/);
150+ });
151+
152+ it("confirms once with the newest link, and tells every active site administrator once", async () => {
153+ const { env, queued, audits, audit, DB } = setup();
154+ await submitRequest(env, input("new@example.net"), "203.0.113.9");
155+ const first = tokenOf(queued[0]);
156+ await submitRequest(env, input("new@example.net", { name: "Ada L." }), "203.0.113.9");
157+ const second = tokenOf(queued[1]);
158+ expect(await confirmRequest(env, audit, first)).toBeNull();
159+ const r = await confirmRequest(env, audit, second);
160+ expect(r).toMatchObject({ state: "confirmed", name: "Ada L.", confirm_hash: null });
161+ expect(await confirmRequest(env, audit, second)).toBeNull();
162+ expect(audits).toEqual([{ action: "early_access.confirm", target: r!.id }]);
163+ const notes = () => DB.sqlite.prepare("SELECT account_id, kind, url FROM notifications").all();
164+ expect(notes()).toEqual([{ account_id: "u-root", kind: "early_access", url: "/settings/admin/early-access" }]);
165+ // Another confirmation while that one is unread adds nothing.
166+ await submitRequest(env, input("two@example.net"), "203.0.113.10");
167+ await confirmRequest(env, audit, tokenOf(queued[2]));
168+ expect(notes()).toHaveLength(1);
169+ expect(await new EarlyAccess(env.DB).waiting()).toBe(2);
170+ });
171+
172+ it("refuses an expired, malformed or unknown token", async () => {
173+ const { env, queued, audit, DB } = setup();
174+ await submitRequest(env, input("new@example.net"), "203.0.113.9");
175+ DB.sqlite.prepare("UPDATE early_access SET confirm_expires_at = ?").run(at);
176+ expect(await confirmRequest(env, audit, tokenOf(queued[0]))).toBeNull();
177+ expect(await confirmRequest(env, audit, "short")).toBeNull();
178+ expect(await confirmRequest(env, audit, "x".repeat(40))).toBeNull();
179+ });
180+
181+ it("answers an address that already asked, was invited or has an account only in its own inbox, and changes nothing", async () => {
182+ const { env, queued, audit } = setup();
183+ await submitRequest(env, input("new@example.net"), "203.0.113.9");
184+ await confirmRequest(env, audit, tokenOf(queued[0]));
185+ expect(await submitRequest(env, input("new@example.net", { name: "Mallory" }), "198.51.100.1")).toBe("known");
186+ expect(queued[1]).toMatchObject({ kind: "early_access_known", data: { variant: "waiting" }, to: { email: "new@example.net" } });
187+ expect((await new EarlyAccess(env.DB).byEmail("new@example.net"))!.name).toBe("Ada");
188+ expect(await submitRequest(env, input("ANN@example.com"), "198.51.100.1")).toBe("account");
189+ expect(queued[2]).toMatchObject({ kind: "early_access_known", data: { variant: "account" }, to: { accountId: "u-ann" } });
190+ expect(await new EarlyAccess(env.DB).byEmail("ann@example.com")).toBeNull();
191+ expect(await submitRequest(env, input("gone@example.com"), "198.51.100.1")).toBe("quiet");
192+ expect(queued).toHaveLength(3);
193+ });
194+
195+ it("limits submissions per IP address (a visible 429) and emails per address and in total (silently)", async () => {
196+ const { env, queued, DB } = setup();
197+ for (let i = 0; i < EMAILS_PER_ADDRESS_PER_DAY; i++) expect(await submitRequest(env, input("flood@example.net"), `10.0.0.${i}`)).toBe("confirm");
198+ expect(await submitRequest(env, input("flood@example.net"), "10.0.1.1")).toBe("limited");
199+ expect(queued).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY);
200+ for (let i = 0; i < SUBMISSIONS_PER_IP_PER_DAY; i++) await submitRequest(env, input(`p${i}@example.net`), "192.0.2.1");
201+ await rejects(submitRequest(env, input("one-more@example.net"), "192.0.2.1"), 429, /Too many requests from your network today/);
202+ // Everyone together: the form stops emailing at EMAILS_PER_DAY.
203+ const add = DB.sqlite.prepare("INSERT INTO early_access_attempts (id, ip_hash, email_hash, emailed, created_at) VALUES (?, 'x', 'y', 1, ?)");
204+ for (let i = 0; i < EMAILS_PER_DAY; i++) add.run(`a${i}`, new Date().toISOString());
205+ const before = queued.length;
206+ expect(await submitRequest(env, input("late@example.net"), "192.0.2.200")).toBe("limited");
207+ expect(queued).toHaveLength(before);
208+ expect(await new EarlyAccess(env.DB).byEmail("late@example.net")).toBeNull();
209+ });
210+});
211+
212+describe("site administrators' decisions", () => {
213+ async function confirmed(s: ReturnType<typeof setup>, email: string) {
214+ await submitRequest(s.env, input(email), "203.0.113.9");
215+ return (await confirmRequest(s.env, s.audit, tokenOf(s.queued.at(-1))))!;
216+ }
217+
218+ it("invites with the instance invitation, audited, and won't invite twice or invite an account", async () => {
219+ const s = setup();
220+ const r = await confirmed(s, "new@example.net");
221+ const done = await inviteRequest(s.env, s.dir, s.audit, s.root, r.id, "web");
222+ expect(done).toMatchObject({ state: "invited", invite_id: "inv-1", decided_by: "root" });
223+ expect(s.invites).toEqual([{ email: "new@example.net", role: "read" }]);
224+ expect(s.audits.slice(-2)).toEqual([
225+ { actorId: "u-root", actorLogin: "root", action: "invite.create", target: "new@example.net" },
226+ { actorId: "u-root", actorLogin: "root", action: "early_access.invite", target: r.id, detail: { invite: "inv-1", from: "confirmed", via: "web" } },
227+ ]);
228+ await rejects(inviteRequest(s.env, s.dir, s.audit, s.root, r.id, "web"), 422, /already invited/);
229+ // A request whose address then got an account.
230+ s.DB.sqlite.prepare("INSERT INTO early_access (id, email, state, created_at, updated_at) VALUES ('0123456789abcdef0123456789abcdef', 'ann@example.com', 'confirmed', ?, ?)").run(at, at);
231+ await rejects(inviteRequest(s.env, s.dir, s.audit, s.root, "0123456789abcdef0123456789abcdef", "api"), 422, /already has an account \(ann\)/);
232+ await rejects(inviteRequest(s.env, s.dir, s.audit, s.root, "ffffffffffffffffffffffffffffffff", "api"), 404, /no early access request/);
233+ });
234+
235+ it("keeps a pending instance invitation instead of sending another", async () => {
236+ const s = setup();
237+ const r = await confirmed(s, "pending@example.net");
238+ s.DB.sqlite.prepare("INSERT INTO invites (id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv-old', 'pending@example.net', 'read', 'h', 'u-root', ?, '2999-01-01T00:00:00.000Z')").run(at);
239+ expect(await inviteRequest(s.env, s.dir, s.audit, s.root, r.id, "api")).toMatchObject({ state: "invited", invite_id: "inv-old" });
240+ expect(s.invites).toEqual([]);
241+ expect(s.audits.at(-1)).toMatchObject({ action: "early_access.invite", detail: { invite: "inv-old", existing_invitation: true } });
242+ });
243+
244+ it("declines without email, puts back where it was, and deletes without keeping the content", async () => {
245+ const s = setup();
246+ const r = await confirmed(s, "new@example.net");
247+ const mails = s.queued.length;
248+ expect(await declineRequest(s.env.DB, s.audit, s.root, r.id, false, "web")).toMatchObject({ state: "declined", decided_by: "root" });
249+ await rejects(declineRequest(s.env.DB, s.audit, s.root, r.id, false, "web"), 422, /already declined/);
250+ expect(await declineRequest(s.env.DB, s.audit, s.root, r.id, true, "api")).toMatchObject({ state: "confirmed", decided_by: null });
251+ await rejects(declineRequest(s.env.DB, s.audit, s.root, r.id, true, "api"), 422, /Only a declined request/);
252+ expect(s.queued).toHaveLength(mails);
253+ await deleteRequest(s.env.DB, s.audit, s.root, r.id, "web");
254+ expect(await new EarlyAccess(s.env.DB).get(r.id)).toBeNull();
255+ expect(s.audits.at(-1)).toEqual({ actorId: "u-root", actorLogin: "root", action: "early_access.delete", target: r.id, detail: { state: "confirmed", via: "web" } });
256+ expect(JSON.stringify(s.audits)).not.toMatch(/new@example\.net/);
257+ // An unconfirmed request that is declined then put back is unconfirmed again.
258+ await submitRequest(s.env, input("u@example.net"), "203.0.113.9");
259+ const u = (await new EarlyAccess(s.env.DB).byEmail("u@example.net"))!;
260+ await declineRequest(s.env.DB, s.audit, s.root, u.id, false, "web");
261+ expect((await declineRequest(s.env.DB, s.audit, s.root, u.id, true, "web")).state).toBe("unconfirmed");
262+ });
263+
264+ it("lists newest first with filters, search (LIKE characters taken literally), pages and counts", async () => {
265+ const s = setup();
266+ const ins = s.DB.sqlite.prepare("INSERT INTO early_access (id, email, name, company, use_case, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
267+ ins.run("a".repeat(32), "a@x.io", "Ann", "Acme", "CI for 100% of repos", "confirmed", "2026-10-01T00:00:00Z", at);
268+ ins.run("b".repeat(32), "b@x.io", "Bob", "Beta_Co", null, "invited", "2026-10-02T00:00:00Z", at);
269+ ins.run("c".repeat(32), "c@x.io", null, null, null, "confirmed", "2026-10-03T00:00:00Z", at);
270+ const store = new EarlyAccess(s.env.DB);
271+ expect((await store.list()).items.map((r) => r.email)).toEqual(["c@x.io", "b@x.io", "a@x.io"]);
272+ expect((await store.list({ state: "confirmed" })).items.map((r) => r.email)).toEqual(["c@x.io", "a@x.io"]);
273+ expect((await store.list({ q: "beta_" })).items.map((r) => r.email)).toEqual(["b@x.io"]);
274+ expect((await store.list({ q: "100%" })).items.map((r) => r.email)).toEqual(["a@x.io"]);
275+ expect((await store.list({ q: "%" })).items.map((r) => r.email)).toEqual(["a@x.io"]);
276+ expect((await store.list({ q: "_" })).items.map((r) => r.email)).toEqual(["b@x.io"]);
277+ expect(await store.list({ limit: 2 })).toMatchObject({ more: true });
278+ expect((await store.list({ limit: 2, offset: 2 })).items.map((r) => r.email)).toEqual(["a@x.io"]);
279+ expect(await store.counts()).toEqual({ confirmed: 2, unconfirmed: 0, invited: 1, declined: 0 });
280+ expect(requestJson(s.env, (await store.get("a".repeat(32)))!)).toMatchObject({ id: "a".repeat(32), state: "confirmed", email: "a@x.io", html_url: `${PUB}/settings/admin/early-access/${"a".repeat(32)}` });
281+ expect(requestJson(s.env, (await store.get("a".repeat(32)))!)).not.toHaveProperty("confirm_hash");
282+ });
283+});
284+
285+describe("removing your own request", () => {
286+ it("emails a one-time removal link only when there is a request, and deletes it with the link", async () => {
287+ const s = setup();
288+ expect(await requestRemoval(s.env, "nobody@example.net", "203.0.113.9")).toBe("quiet");
289+ expect(s.queued).toHaveLength(0);
290+ await submitRequest(s.env, input("new@example.net"), "203.0.113.9");
291+ const r = (await new EarlyAccess(s.env.DB).byEmail("new@example.net"))!;
292+ expect(await requestRemoval(s.env, "new@example.net", "203.0.113.9")).toBe("confirm");
293+ const job = s.queued.at(-1)!;
294+ expect(job).toMatchObject({ kind: "early_access_remove", to: { email: "new@example.net" } });
295+ expect((job as { data: { url: string } }).data.url).toMatch(/\/early-access\/remove\/confirm#/);
296+ // The removal token can't confirm the request.
297+ expect(await confirmRequest(s.env, s.audit, tokenOf(job))).toBeNull();
298+ expect(await removeByToken(s.env, s.audit, tokenOf(job))).toBe(true);
299+ expect(await new EarlyAccess(s.env.DB).get(r.id)).toBeNull();
300+ expect(await removeByToken(s.env, s.audit, tokenOf(job))).toBe(false);
301+ expect(s.audits.at(-1)).toEqual({ action: "early_access.delete", target: r.id, detail: { state: "unconfirmed", via: "self" } });
302+ });
303+
304+ it("counts towards the same daily limits", async () => {
305+ const s = setup();
306+ await submitRequest(s.env, input("new@example.net"), "203.0.113.9");
307+ for (let i = 1; i < EMAILS_PER_ADDRESS_PER_DAY; i++) await requestRemoval(s.env, "new@example.net", "203.0.113.9");
308+ expect(await requestRemoval(s.env, "new@example.net", "203.0.113.9")).toBe("limited");
309+ expect(s.queued).toHaveLength(EMAILS_PER_ADDRESS_PER_DAY);
310+ });
311+});
312+
313+describe("retention", () => {
314+ it("deletes unconfirmed requests a week after their last submission, and limit rows after a day", async () => {
315+ const s = setup();
316+ const old = "2026-09-01T00:00:00.000Z";
317+ const ins = s.DB.sqlite.prepare("INSERT INTO early_access (id, email, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?)");
318+ ins.run("a".repeat(32), "old@x.io", "unconfirmed", old, old);
319+ ins.run("b".repeat(32), "kept@x.io", "confirmed", old, old);
320+ ins.run("c".repeat(32), "fresh@x.io", "unconfirmed", old, "2026-09-28T00:00:00.000Z");
321+ s.DB.sqlite.prepare("INSERT INTO early_access_attempts (id, ip_hash, email_hash, created_at) VALUES ('t1', 'i', 'e', ?), ('t2', 'i', 'e', '2026-09-30T12:00:00.000Z')").run(old);
322+ const r = await pruneExpired(s.env.DB, new Date("2026-10-01T00:00:00.000Z"));
323+ expect(r).toMatchObject({ early_access: 1, early_access_attempts: 1 });
324+ expect((s.DB.sqlite.prepare("SELECT email FROM early_access ORDER BY email").all() as Array<{ email: string }>).map((x) => x.email)).toEqual(["fresh@x.io", "kept@x.io"]);
325+ });
326+});
modifiedapps/diffinery/test/unit/rate-limit.test.ts+2−0View file
@@ -31,6 +31,8 @@
3131 expect(bucketsFor("POST", "/signup/verify", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
3232 // Security reports, signed in or not, count per IP address like sign-up.
3333 expect(bucketsFor("POST", "/security/report", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
34+ for (const path of ["/early-access", "/early-access/confirm", "/early-access/remove", "/early-access/remove/confirm"]) expect(bucketsFor("POST", path, anon, "203.0.113.9"), path).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
35+ expect(bucketsFor("GET", "/early-access", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3436 expect(bucketsFor("POST", "/security/report", person, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
3537 expect(bucketsFor("GET", "/security/report", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3638 expect(bucketsFor("GET", "/signup", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
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 });
22+ expect(await pruneExpired(DB, at)).toEqual({ sessions: 1, notifications: 1, compute_usage: 1, early_access: 0, early_access_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"]);