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.

Open sign-up behind SIGNUP=open (off by default) (#126)

With SIGNUP=open, /signup asks for an email address (with a Cloudflare
Turnstile widget), emails a 6-digit code (stored as an HMAC, 10 minutes,
5 tries; 5 a day per address, 20 per IP), and a right code leads to the
invite-style passkey step, bound to a single-use 30-minute ticket. The
passkey register endpoints create an active account, start the session and
audit `signup`. An address that already has an account gets the same page
and a "someone tried to sign up" email instead of a code. Without Turnstile
keys sign-up answers 503, except on a localhost PUBLIC_URL. Off, /signup is
404 and the sign-in page still says invite-only.

Migration 0020_signup.sql, new mail kinds signup_code and signup_attempt,
/signup POSTs in the RL_AUTH bucket, docs, unit tests and a passkeys e2e test.


Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent a9eccbbcommit cdef7045bfda816de3a469e0842d262ae25b189dBrowse files
24 files changed+839−44
Files (24)
modifiedapps/diffinery/docs/accounts/invitations.md+2−2View file
@@ -3,7 +3,7 @@
33 description: How people join an invite-only Diffinery instance, who can invite, Add people by username or email, accepting or declining invitations, revoking them, the CLI and API, and access requests.
44 ---
55
6-Diffinery is invite-only: nobody can sign up from the sign-in page. 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.
6+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
88 ## How sign-up works
99
@@ -331,7 +331,7 @@
331331
332332 ## How invitations differ from GitHub
333333
334-- On GitHub anyone can create an account. On Diffinery the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance.
334+- On GitHub anyone can create an account. On an invite-only Diffinery instance (the default) the only ways in are an invite, an approved access request, or being listed as a bootstrap administrator of the instance; with open sign-up, anyone who can read mail at their address can join too.
335335 - GitHub organization and repository invitations go to existing GitHub users. Diffinery invites can also go to an email address, and the same link creates the account.
336336 - **Add people** takes a username or an email address, as on GitHub. Someone added by username gets an invitation to accept under **Settings → Invitations**, as on GitHub. An email address always gets an invitation link, whether or not it has an account.
337337 - Pending invitations of every kind can be revoked, in the web UI, the API or over MCP; otherwise they expire after 14 days.
modifiedapps/diffinery/docs/accounts/notifications.md+5−2View file
@@ -47,7 +47,7 @@
4747 | Group | Emails | Can be turned off | Default |
4848 | --- | --- | --- | --- |
4949 | Security | New sign-in from a browser or device not seen before (and every email-recovery sign-in), passkey added or removed, access token created or about to expire, agent added, model API key added, push blocked or push protection bypassed, account disabled or enabled | No | On |
50-| Account | Invitations, welcome, sign-in links, test email, agent review's daily limit reached | No | On |
50+| Account | Invitations, welcome, sign-in links, sign-up codes, test email, agent review's daily limit reached | No | On |
5151 | Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, were asked to review, were @mentioned in or subscribed to: new issues and pull requests, comments, reviews, pushes, merges, closes, reopens, assignments and review requests | Yes | On |
5252 | Watching | Everything else in repositories you watch with **All activity** | Yes | On |
5353 | Actions | Workflow runs you started (failed runs only, unless you turn off **Only failed runs**), and scheduled runs in repositories you watch | Yes | On, failures only |
@@ -219,6 +219,7 @@
219219 | `invitation` | Someone invited this email address or your account to Diffinery, an organization or a repository. |
220220 | `admin` | You are an administrator (of the organization, repository or instance). |
221221 | `account` | You have a Diffinery account (account and membership changes). |
222+| `signup` | Someone asked to create an account with this address through open sign-up (the sign-up code). |
222223
223224 ## Every email Diffinery sends
224225
@@ -240,7 +241,9 @@
240241 | `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 |
241242 | `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again |
242243 | `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**) |
243-| `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite |
244+| `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up |
245+| `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. |
246+| `signup_attempt` | `[Diffinery] Someone tried to sign up with your email address` | Account | `account` | With open sign-up, someone asked for a code for your address, which already has an account (no code is sent) |
244247 | `signin_link` | `[Diffinery] Your one-time sign-in link` | Account | `account` | A site administrator created a sign-in link for you |
245248 | `review_limit_reached` | `[acme/website] Agent review reached its daily limit` | Account | `your_activity` | [Agent review](/docs/agents/review) on a repository whose reviews use your model key reached its daily limit (at most once a day per repository) |
246249 | `test` | `[Diffinery] Test email` | Account | `account` | You clicked **Send me a test email** (or used the API) |
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+23−5View file
@@ -1,9 +1,9 @@
11 ---
22 title: Passkeys and sign-in
3-description: How people sign in to Diffinery with passkeys, manage passkeys and browser sessions, and recover an account when a passkey is lost.
3+description: How people sign in to Diffinery with passkeys, join from an invite or open sign-up, manage passkeys and browser sessions, and recover a lost passkey.
44 ---
55
6-People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey.
6+People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite or through open sign-up, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey.
77
88 Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](/docs/accounts/tokens)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](/docs/accounts/profile-and-preferences).
99
@@ -22,7 +22,7 @@
2222 | Time to complete the browser prompt | 2 minutes. The sign-in attempt itself expires after 5 minutes. |
2323 | Browser session length | 30 days, extended by another 30 days on each day you use it, up to 90 days after you signed in. |
2424
25-Diffinery is invite-only. You can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)).
25+Diffinery is invite-only unless the instance turns on open sign-up. On an invite-only instance (the default) you can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)). With open sign-up, the sign-in page also links to **Create an account** (see [Open sign-up](#open-sign-up)).
2626
2727 ## Sign in
2828
@@ -47,6 +47,20 @@
4747
4848 If the email address already has an account, the invite page asks you to sign in with that account's passkey first; after that the same page shows an **Accept invite** button. See [Invitations](/docs/accounts/invitations) for how invites are created, how long they last and what each kind grants.
4949
50+## Open sign-up
51+
52+An instance whose operator set `SIGNUP=open` (see [Configuration](/docs/self-hosting/configuration#signup)) lets anyone create an account. The sign-in page then says **New here? Create an account**, which leads to `/signup`:
53+
54+1. Enter your email address and, if the page shows one, complete the Cloudflare Turnstile check. Click **Email me a code**.
55+2. The page says "We sent a code to <address>". Find the email "Your sign-up code" and enter its 6 digits under **Code**, then click **Continue**. The code works once, for 10 minutes; a wrong code says how many tries are left (5 in all), and **send a new code** sends another one (up to 5 a day for an address).
56+3. Pick a **Username** and an optional **Display name**, as when joining from an invite, and click **Create a passkey and join**. This step must be finished within 30 minutes.
57+
58+Diffinery creates your account (active right away, no invite needed), saves the passkey, signs you in and sends a welcome email. Any open invites for your address are accepted at the same time.
59+
60+If the address already has an account, the page reads exactly the same, so nobody can use it to find out who has an account. No code is sent: the account's owner gets an email "Someone tried to sign up with your email address" with a link to sign in instead.
61+
62+On an invite-only instance `/signup` doesn't exist (`404`). Limits on codes are listed in [Limits](/docs/reference/limits#sign-in-and-sessions).
63+
5064 ## Manage your passkeys
5165
5266 Go to **Settings → Passkeys and sessions** (`/settings/security`). The **Passkeys** box lists every passkey on your account with:
@@ -100,7 +114,8 @@
100114 | New sign-in to your account | You sign in with a passkey from a browser and passkey combination Diffinery hasn't seen for your account before, and every time you sign in through email recovery. It shows when, the device, the location (city and country) and the IP address. |
101115 | A passkey was added to your account | You add a passkey in settings, or with a sign-in link from an administrator. |
102116 | A passkey was removed from your account | You remove a passkey. |
103-| Welcome to Diffinery | You create your account from an invite. |
117+| Welcome to Diffinery | You create your account from an invite or through open sign-up. |
118+| Someone tried to sign up with your email address | With open sign-up, someone asked for a sign-up code for your address. No account was created and no code was sent. |
104119 | Your one-time sign-in link | A site administrator creates a sign-in link for you. |
105120
106121 A browser is identified by the passkey used plus its operating system and browser name (for example `Mac · Chrome`). Signing in again from the same browser with the same passkey doesn't send another email. The device you joined or added a passkey from counts as known.
@@ -164,7 +179,7 @@
164179 ## How Diffinery sign-in differs from GitHub
165180
166181 - Diffinery has no passwords and no username-and-password sign-in, so there is no password reset and no separate two-factor setup: the passkey's user verification is the second factor.
167-- You can't sign up from the sign-in page. Accounts come from invites.
182+- Unless the instance turns on open sign-up, you can't sign up from the sign-in page: accounts come from invites. With open sign-up, an emailed code confirms your address instead of a password.
168183 - Account recovery is a one-time link from a site administrator, or email recovery when the instance has it set up. There are no recovery codes.
169184 - Git over HTTPS and the API never accept a passkey or browser session; they take a personal access token, much like GitHub.
170185
@@ -180,6 +195,9 @@
180195 | "This device already has a passkey for this account." | When adding a passkey: pick a different authenticator. |
181196 | "This browser does not support passkeys..." | Update your browser or use another one. |
182197 | "this invite is invalid, already used, or expired" | Ask the person who invited you for a new invite. |
198+| "That code has expired or was entered wrong too many times. Send a new code." | Sign-up codes last 10 minutes and allow 5 tries. Click **send a new code**. |
199+| "We have sent several codes to <address> today." | At most 5 sign-up codes a day go to one address. Check your spam folder, or try again tomorrow. |
200+| "Sign-up needs Turnstile configured" | The instance turned on open sign-up without Turnstile keys. Ask its administrators for an invite. |
183201 | "this sign-in link is invalid, already used, or expired" | Sign-in links work once within 24 hours. Ask a site administrator for a new one. |
184202 | "Waiting for an invite" page after email recovery | Your email address has no Diffinery account. Ask someone to invite that address, or click **Request access**. |
185203 | Lost every passkey | Use [email recovery](#email-recovery-through-authaccess) if the sign-in page offers it, otherwise ask a site administrator for a [sign-in link](#sign-in-link-from-an-administrator). |
modifiedapps/diffinery/docs/reference/limits.md+5−1View file
@@ -17,8 +17,12 @@
1717 | Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. |
1818 | Sign-in link lifetime | 24 hours, single use | Links a site administrator sends with **Sign-in link** in **Settings → People and invitations**. |
1919 | Invitation lifetime | 14 days | Invitations to the site, an organization or a repository. |
20+| Sign-up code | 10 minutes, single use, 5 tries | With open sign-up (`SIGNUP=open`): the 6-digit code emailed from `/signup`. After 5 codes entered for it, right or wrong, it stops working (`That code has expired or was entered wrong too many times`). Sending a new code ends the old one. |
21+| Sign-up codes per address | 5 in 24 hours | Codes asked for one email address, counting the note sent instead when the address already has an account. The next one gets `429 We have sent several codes to <address> today`. |
22+| Sign-up codes per IP address | 20 in 24 hours | Codes asked for from one IP address. The next one gets `429 Too many sign-ups from your network today`. |
23+| Sign-up passkey step | 30 minutes, single use | After the code is accepted, the page for picking a username and creating a passkey. Start again at `/signup` once it runs out. |
2024 | Email recovery session at Cloudflare Access | 15 minutes | The Cloudflare Access application that guards `/auth/access` (set by `scripts/bootstrap.sh`). It only covers the email check; the Diffinery session that follows lasts 30 days. |
21-| Username when joining from an invite | 39 characters | Letters, digits, `-` and `_`. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). |
25+| Username when joining from an invite or signing up | 39 characters | Letters, digits, `-` and `_`. Some names are reserved (for example `api`, `settings`, `login`, `admin`, `docs`). |
2226 | Display name when joining | 100 characters | Longer names are cut off. |
2327 | Passkey name | 100 characters | Longer names are cut off. |
2428 | 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. |
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -404,7 +404,7 @@
404404
405405 | What | Limit | Counted by |
406406 | --- | --- | --- |
407-| Sign-in, passkey registration, invitation and sign-in links | 20 a minute | IP address |
407+| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`) | 20 a minute | IP address |
408408 | Anything else without signing in (web pages, API, git) | 300 a minute | IP address |
409409 | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account |
410410 | Creating repositories, organizations, forks, issues, pull requests, releases, comments and reviews, and MCP tools that change something | 60 a minute | Account |
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
@@ -52,6 +52,7 @@
5252 | `passkey.add` | A passkey is added from **Passkeys and sessions**, or through a one-time sign-in link | none | `name`, `passkeyId`, `device`, `ip`, `location`; `via: "signin-link"` when added through a link |
5353 | `passkey.remove` | A passkey is removed | none | `name` |
5454 | `session.revoke` | You sign out one of your other browsers with **Sign out** on **Passkeys and sessions** | none | none |
55+| `signup` | Someone creates an account and passkey through open sign-up (`SIGNUP=open`), after confirming their email with a code | none | `method` (`passkey`), `email`, `passkeyId`, `device`, `ip`, `location` |
5556 | `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite (from its link, **Settings → Invitations**, the **Waiting for an invite** page or the API) | for an existing user who opened the link, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location`; from **Settings → Invitations** or the API: `id` (the invitation id), and `via: "api"` for the API |
5657 | `invite.decline` | Someone declines an invitation on **Settings → Invitations**, the **Waiting for an invite** page, or with `DELETE /api/v3/user/repository_invitations/{id}` | none | `id` (the invitation id), and `via: "api"` for the API |
5758 | `token.create` | A personal access token is created on **Access tokens** or with `POST /api/v3/diffinery/tokens` | your login (web) | `name` and `scope`/`scopes`, and for the web the number of repositories it is restricted to |
modifiedapps/diffinery/docs/self-hosting/configuration.md+20−3View file
@@ -11,7 +11,7 @@
1111 | --- | --- | --- |
1212 | `apps/diffinery/wrangler.jsonc` | Worker name, route, variables, bindings, container settings, cron, and the `dev` environment. | Yes. This is the source of truth. |
1313 | `apps/diffinery/wrangler.deploy.jsonc` | `wrangler.jsonc` without the `dev` environment, plus the real D1 database ID, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, and the route, `PUBLIC_URL` and `MAIL_FROM` for your hostname. Written by `scripts/render-config.mjs` on every deploy and ignored by git. | No. It is regenerated on each deploy. |
14-| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`, and optionally `GITHUB_OAUTH_CLIENT_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. |
14+| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`, and optionally `GITHUB_OAUTH_CLIENT_SECRET` and `TURNSTILE_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. |
1515 | Repository secrets and variables | Cloudflare credentials and options for the Deploy workflow, and a token for the live test and sync workflows. | Yes, in your forge's repository settings. |
1616 | `apps/diffinery/.dev.vars` | Local secrets for `wrangler dev`, such as `MASTER_KEY`. Ignored by git. | Yes, for local development only. |
1717
@@ -28,6 +28,8 @@
2828 | `ACCESS_AUD` | Empty (filled in on deploy) | For email sign-in | The audience tag of the Access application that guards `/auth/access`. |
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). |
31+| `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` | Not set | With `SIGNUP=open` | The site key of the Cloudflare Turnstile widget on the sign-up page. See [SIGNUP](#signup). |
3133 | `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). |
3234 | `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`5`), agent sandboxes open at once (`5`) and repositories (`500`). `-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). |
3335 | `IMPORT_GHE_HOST` | Not set | No | The host name of a GitHub Enterprise Server (like `ghe.example.com`) that imports may download release assets from. Without it, imports download release assets only from github.com and its asset hosts, and assets of an Enterprise import are skipped with a warning. |
@@ -55,8 +57,22 @@
5557
5658 A comma-separated list of email addresses; spaces around entries are ignored and matching is case-insensitive. Logins in the list are ignored, because anyone could pick a free login, while an email address has to be proven (by email sign-in or by opening the link in an invitation email).
5759
58-An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](/docs/admin/site-administration).
60+An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation or through open sign-up, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](/docs/admin/site-administration).
5961
62+### SIGNUP
63+
64+With `SIGNUP=open`, the sign-in page links to **Create an account** (`/signup`). Someone enters their email address, gets a 6-digit code by email, enters it, picks a username and creates a passkey; their account is active at once (see [Open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up)). Unset, or any other value, the instance is invite-only as before: `/signup` answers `404` and the sign-in page says so.
65+
66+Open sign-up needs a Cloudflare Turnstile widget, so scripts can't use it to send email:
67+
68+1. In the Cloudflare dashboard, open **Turnstile**, add a widget for your hostname (the one in `PUBLIC_URL`) and copy its site key and secret key.
69+2. Add `"SIGNUP": "open"` and `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc`; the deploy keeps them.
70+3. Store the secret: `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`.
71+
72+Each code request is checked with Turnstile's `siteverify` API, with the visitor's IP address. Without both keys, `/signup` answers `503` with "Sign-up needs Turnstile configured", except when `PUBLIC_URL` is on the local machine (`localhost`, `127.x.x.x` or `[::1]`): local development and the end-to-end tests skip the check. Only the sign-up page's content security policy allows scripts and frames from `https://challenges.cloudflare.com`.
73+
74+The limits on codes (5 a day per address, 20 a day per IP address, 10 minutes and 5 tries each) are in [Limits](/docs/reference/limits#sign-in-and-sessions). Sign-up emails always send: they need the `EMAIL` binding and the `MAIL` queue like every other email.
75+
6076 ### DEV_AUTH
6177
6278 When `DEV_AUTH` is set, `ACCESS_AUD` is empty and both `PUBLIC_URL` and the request's own address are on the local machine (`localhost`, `127.x.x.x` or `[::1]`), every request without credentials (no token, no session) acts as the account with the email in `DEV_AUTH`, with full access. That account is created on first use and is a site administrator if the email is also in `BOOTSTRAP_ADMINS`. While `DEV_AUTH` is set, the email log also keeps each whole message, and `GET /api/v3/diffinery/emails` returns it, so tests can read emails without delivering them.
@@ -69,9 +85,10 @@
6985
7086 | Secret | Format | Created by | What it does | If it is missing |
7187 | --- | --- | --- | --- | --- |
72-| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. |
88+| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Open sign-up can't send codes. |
7389 | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. |
7490 | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. |
91+| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | You, optionally | With `SIGNUP=open` and `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code passed the Turnstile widget. See [SIGNUP](#signup). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured" (local development skips the check). Without `SIGNUP=open` it isn't used. |
7592
7693 Neither secret is rotated automatically. Changing `MASTER_KEY` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out; keep a copy somewhere safe. To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`.
7794
addedapps/diffinery/migrations/0020_signup.sql+27−0View file
@@ -1,0 +1,27 @@
1+-- Open sign-up (SIGNUP=open): emailed one-time codes and the tickets that let a verified
2+-- address create its account with a passkey. Only hashes are stored.
3+
4+-- One row per code request. An address that already has an account gets a row too (its code is
5+-- never sent), so the daily limits count every sign-up email and wrong codes answer the same.
6+CREATE TABLE signup_codes (
7+ id TEXT PRIMARY KEY,
8+ email TEXT NOT NULL, -- lower case
9+ ip TEXT NOT NULL,
10+ code_hash TEXT NOT NULL, -- HMAC of the 6-digit code
11+ attempts INTEGER NOT NULL DEFAULT 0, -- codes entered, right or wrong
12+ created_at TEXT NOT NULL,
13+ expires_at TEXT NOT NULL,
14+ used_at TEXT
15+);
16+CREATE INDEX signup_codes_email ON signup_codes(email, created_at);
17+CREATE INDEX signup_codes_ip ON signup_codes(ip, created_at);
18+
19+-- A verified address may create its account (pick a username, create a passkey) once.
20+CREATE TABLE signup_tickets (
21+ id TEXT PRIMARY KEY,
22+ token_hash TEXT NOT NULL UNIQUE, -- sha256 of the token in the browser's form
23+ email TEXT NOT NULL,
24+ created_at TEXT NOT NULL,
25+ expires_at TEXT NOT NULL,
26+ used_at TEXT
27+);
modifiedapps/diffinery/src/auth/dev.ts+2−1View file
@@ -1,7 +1,8 @@
11 import type { Env } from "../env.js";
22
33 const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/;
4-const loopback = (url: string | undefined): boolean => {
4+/** Whether `url` is on this machine (localhost, 127.x or [::1]). */
5+export const loopback = (url: string | undefined): boolean => {
56 try {
67 return !!url && LOOPBACK.test(new URL(url).hostname);
78 } catch {
modifiedapps/diffinery/src/auth/sessions.ts+1−0View file
@@ -44,6 +44,7 @@
4444 | { kind: "account"; accountId: string }
4545 | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string }
4646 | { kind: "link"; linkHash: string; accountId: string }
47+ | { kind: "signup"; ticketHash: string; email: string; login: string; name: string; accountId: string }
4748 | { kind: "login" };
4849
4950 const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString();
addedapps/diffinery/src/auth/signup.ts+169−0View file
@@ -1,0 +1,169 @@
1+import { loopback } from "./dev.js";
2+import type { Env } from "../env.js";
3+import type { Account } from "../lib/directory.js";
4+import { HttpError, b64url, newId, now, randomToken, safeEqual, sha256 } from "../lib/util.js";
5+
6+/**
7+ * Open sign-up (SIGNUP=open). Someone asks for a code at /signup, proves they read mail at
8+ * the address by entering it, and gets a short-lived ticket that lets them pick a username
9+ * and create a passkey, the way an invite link does. Off, the instance stays invite-only.
10+ * Codes and tickets are stored only as hashes.
11+ */
12+
13+export const CODE_MINUTES = 10;
14+export const TICKET_MINUTES = 30;
15+/** Codes entered for one code, right or wrong; after this many it stops working. */
16+export const MAX_CODE_ATTEMPTS = 5;
17+/** Sign-up emails (a code, or the "you already have an account" note) per address and per IP address in 24 hours. */
18+export const CODES_PER_EMAIL_PER_DAY = 5;
19+export const CODES_PER_IP_PER_DAY = 20;
20+
21+export const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js";
22+export const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
23+const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
24+
25+export function signupOpen(env: Pick<Env, "SIGNUP">): boolean {
26+ return env.SIGNUP === "open";
27+}
28+
29+/**
30+ * How sign-up checks for a person: "verify" with Turnstile, "skip" in local development
31+ * (PUBLIC_URL on this machine) without it, and "missing" when a deployed instance has no
32+ * Turnstile keys, which refuses sign-up rather than leave it open to scripts.
33+ */
34+export function turnstileMode(env: Pick<Env, "TURNSTILE_SITE_KEY" | "TURNSTILE_SECRET" | "PUBLIC_URL">): "verify" | "skip" | "missing" {
35+ if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET) return "verify";
36+ return loopback(env.PUBLIC_URL) ? "skip" : "missing";
37+}
38+
39+/** Ask Cloudflare whether the widget's token is good. Any failure counts as no. */
40+export async function verifyTurnstile(secret: string, token: string, ip: string | null): Promise<boolean> {
41+ if (!token || token.length > 2048) return false;
42+ const body = new URLSearchParams({ secret, response: token });
43+ if (ip) body.set("remoteip", ip);
44+ try {
45+ const r = await fetch(SITEVERIFY, { method: "POST", body });
46+ if (!r.ok) return false;
47+ return ((await r.json()) as { success?: boolean }).success === true;
48+ } catch (e) {
49+ console.error("turnstile siteverify failed", e);
50+ return false;
51+ }
52+}
53+
54+/** An address worth sending a code to, lower-cased; null when it isn't one. */
55+export function normalEmail(v: string): string | null {
56+ const e = v.trim().toLowerCase();
57+ return e.length <= 254 && /^[^\s@<>()",;]+@[^\s@<>()",;]+\.[^\s@<>()",;]+$/.test(e) ? e : null;
58+}
59+
60+/** Six random digits, each code equally likely. */
61+export function newCode(): string {
62+ const buf = new Uint32Array(1);
63+ // 4,294,000,000 is the largest multiple of a million below 2^32: drawing below it avoids modulo bias.
64+ for (;;) {
65+ crypto.getRandomValues(buf);
66+ if (buf[0]! < 4_294_000_000) return String(buf[0]! % 1_000_000).padStart(6, "0");
67+ }
68+}
69+
70+/**
71+ * The stored form of a code: an HMAC keyed from MASTER_KEY over the code's row id and the
72+ * code, so a copy of the database alone can't try the million codes.
73+ */
74+export async function codeHash(env: Pick<Env, "MASTER_KEY" | "PUBLIC_URL">, id: string, code: string): Promise<string> {
75+ // Local development has no MASTER_KEY unless one is passed; production always does.
76+ const secret = env.MASTER_KEY ?? (loopback(env.PUBLIC_URL) ? "diffinery-dev-signup" : null);
77+ if (!secret) throw new Error("MASTER_KEY is not set, so sign-up codes can't be stored");
78+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-signup-code-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
79+ return b64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}:${code}`))));
80+}
81+
82+const minutes = (n: number): string => new Date(Date.now() + n * 60_000).toISOString();
83+
84+export type CodeCheck = { ok: true } | { ok: false; left: number };
85+
86+/** Sign-up codes and tickets in D1. */
87+export class Signups {
88+ constructor(private env: Env) {}
89+
90+ private get db() {
91+ return this.env.DB;
92+ }
93+
94+ /** The person account using this address, whatever its case. */
95+ async accountFor(email: string): Promise<Account | null> {
96+ return this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' AND lower(email) = ? LIMIT 1").bind(email.toLowerCase()).first<Account>();
97+ }
98+
99+ /**
100+ * A new code for `email`, requested from `ip`, after the daily limits; older codes for the
101+ * address stop working. An address that already has an account gets a code too, which is
102+ * never sent: wrong guesses then answer exactly as they would for a new address.
103+ */
104+ async issue(email: string, ip: string): Promise<{ code: string; expiresAt: string }> {
105+ const since = new Date(Date.now() - 86400_000).toISOString();
106+ const sent = await this.db
107+ .prepare("SELECT (SELECT count(*) FROM signup_codes WHERE email = ? AND created_at > ?) AS email, (SELECT count(*) FROM signup_codes WHERE ip = ? AND created_at > ?) AS ip")
108+ .bind(email, since, ip, since)
109+ .first<{ email: number; ip: number }>();
110+ if ((sent?.email ?? 0) >= CODES_PER_EMAIL_PER_DAY) throw new HttpError(429, `We have sent several codes to ${email} today. Check that inbox (and its spam folder), or try again tomorrow.`);
111+ if ((sent?.ip ?? 0) >= CODES_PER_IP_PER_DAY) throw new HttpError(429, "Too many sign-ups from your network today. Try again tomorrow.");
112+ const id = newId();
113+ const code = newCode();
114+ const at = now();
115+ const expiresAt = minutes(CODE_MINUTES);
116+ await this.db.batch([
117+ this.db.prepare("UPDATE signup_codes SET expires_at = ? WHERE email = ? AND used_at IS NULL AND expires_at > ?").bind(at, email, at),
118+ this.db.prepare("INSERT INTO signup_codes (id, email, ip, code_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)").bind(id, email, ip, await codeHash(this.env, id, code), at, expiresAt),
119+ // Rows older than a day no longer count towards any limit.
120+ this.db.prepare("DELETE FROM signup_codes WHERE created_at < ?").bind(since),
121+ ]);
122+ return { code, expiresAt };
123+ }
124+
125+ /**
126+ * Check a code against the address's live one. Every try counts, so at most
127+ * MAX_CODE_ATTEMPTS codes are ever compared; a right one works once.
128+ */
129+ async check(email: string, code: string): Promise<CodeCheck> {
130+ if (!/^\d{6}$/.test(code)) throw new HttpError(422, "Enter the 6-digit code from the email.");
131+ const t = now();
132+ const row = await this.db
133+ .prepare(
134+ "UPDATE signup_codes SET attempts = attempts + 1 WHERE id = (SELECT id FROM signup_codes WHERE email = ? AND used_at IS NULL AND expires_at > ? ORDER BY created_at DESC LIMIT 1) AND attempts < ? RETURNING id, code_hash, attempts",
135+ )
136+ .bind(email, t, MAX_CODE_ATTEMPTS)
137+ .first<{ id: string; code_hash: string; attempts: number }>();
138+ if (!row) return { ok: false, left: 0 };
139+ if (safeEqual(row.code_hash, await codeHash(this.env, row.id, code))) {
140+ const r = await this.db.prepare("UPDATE signup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL").bind(t, row.id).run();
141+ if ((r.meta.changes ?? 0) > 0) return { ok: true };
142+ }
143+ return { ok: false, left: Math.max(0, MAX_CODE_ATTEMPTS - row.attempts) };
144+ }
145+
146+ /** A single-use ticket for a verified address; returns the token for the passkey form. */
147+ async createTicket(email: string): Promise<string> {
148+ const token = randomToken(32);
149+ await this.db.batch([
150+ this.db.prepare("DELETE FROM signup_tickets WHERE expires_at < ?").bind(now()),
151+ this.db.prepare("INSERT INTO signup_tickets (id, token_hash, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)").bind(newId(), await sha256(token), email, now(), minutes(TICKET_MINUTES)),
152+ ]);
153+ return token;
154+ }
155+
156+ /** An unused, unexpired ticket by its token. */
157+ async ticket(token: string): Promise<{ hash: string; email: string } | null> {
158+ if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null;
159+ const hash = await sha256(token);
160+ const row = await this.db.prepare("SELECT email FROM signup_tickets WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ email: string }>();
161+ return row ? { hash, email: row.email } : null;
162+ }
163+
164+ /** Mark a ticket used; false when it was used first or expired. */
165+ async useTicket(hash: string): Promise<boolean> {
166+ const r = await this.db.prepare("UPDATE signup_tickets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run();
167+ return (r.meta.changes ?? 0) > 0;
168+ }
169+}
modifiedapps/diffinery/src/env.ts+9−0View file
@@ -71,11 +71,20 @@
7171 ARTIFACT_QUOTA_BYTES?: string;
7272 /** Local development only: act as this email when Access is not configured. */
7373 DEV_AUTH?: string;
74+ /**
75+ * "open": anyone can create an account at /signup by confirming an emailed code (auth/signup.ts).
76+ * Anything else, or unset, keeps the instance invite-only.
77+ */
78+ SIGNUP?: string;
79+ /** Cloudflare Turnstile widget on the sign-up page; open sign-up needs it and TURNSTILE_SECRET outside local development. */
80+ TURNSTILE_SITE_KEY?: string;
7481
7582 /** Secret: base64 32 bytes; root key for envelope encryption of secrets. */
7683 MASTER_KEY?: string;
7784 /** Secret: ES256 private JWK (JSON) used to sign OIDC tokens for jobs. */
7885 OIDC_PRIVATE_KEY?: string;
86+ /** Secret: Cloudflare Turnstile secret key, to check the sign-up page's widget (siteverify). */
87+ TURNSTILE_SECRET?: string;
7988 /** Optional GitHub Enterprise host (e.g. github.example.com) the importer may download release assets from. */
8089 IMPORT_GHE_HOST?: string;
8190 }
modifiedapps/diffinery/src/lib/directory.ts+7−3View file
@@ -113,8 +113,12 @@
113113 return candidate;
114114 }
115115
116- /** Find or create the user behind a verified email (Access recovery, invite links, dev sign-in). */
117- async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> {
116+ /**
117+ * Find or create the user behind a verified email (Access recovery, invite links, open sign-up,
118+ * dev sign-in). A new account waits for an invite unless it has one, is a bootstrap
119+ * administrator or `hints.active` says so (open sign-up).
120+ */
121+ async userForEmail(email: string, hints: { name?: string; login?: string; id?: string; active?: boolean } = {}): Promise<Account> {
118122 const existing = await this.accountByEmail(email);
119123 if (existing) {
120124 if (existing.status === "pending" && this.isBootstrapAdmin(email)) {
@@ -129,7 +133,7 @@
129133 const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first();
130134 await this.db
131135 .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)")
132- .bind(id, login, hints.name ?? null, email, admin || hasInvite ? "active" : "pending", admin ? 1 : 0, now())
136+ .bind(id, login, hints.name ?? null, email, admin || hasInvite || hints.active ? "active" : "pending", admin ? 1 : 0, now())
133137 .run();
134138 if (hasInvite) await this.acceptInvitesForEmail(id, email);
135139 return (await this.account(id))!;
modifiedapps/diffinery/src/lib/rate-limit.ts+2−2View file
@@ -8,7 +8,7 @@
88 * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't
99 * configured (local development) or that fails lets the request through.
1010 *
11- * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, per IP address.
11+ * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, per IP address.
1212 * - RL_ANON: everything else from someone who isn't signed in, per IP address.
1313 * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account.
1414 * - RL_CREATE: creating things other people see or that cost storage (repositories,
@@ -30,7 +30,7 @@
3030
3131 /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
3232 const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
33-const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/))/;
33+const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/))/;
3434 const CREATE: Array<[method: string, path: RegExp]> = [
3535 ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
3636 ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
modifiedapps/diffinery/src/mail/notify.ts+2−1View file
@@ -330,7 +330,8 @@
330330 break;
331331 }
332332 case "invite.accept":
333- // A new account made from an invite (passkey sign-up) gets a welcome email.
333+ case "signup":
334+ // A new account made from an invite or open sign-up (with a passkey) gets a welcome email.
334335 if (e.actorId && s("method") === "passkey") {
335336 if (s("device")) await store.seeDevice(e.actorId, await deviceFingerprint(s("device")!, s("passkeyId")), s("device")!);
336337 jobs = to([e.actorId], { kind: "welcome", data: { login: e.actorLogin ?? "" } }, "account");
modifiedapps/diffinery/src/mail/samples.ts+2−0View file
@@ -78,6 +78,8 @@
7878 item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }),
7979 item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }),
8080 item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
81+ item("signup-code", "Sign-up code", { kind: "signup_code", reason: "signup", data: { code: "042917", expiresAt: new Date(Date.now() + 600_000).toISOString() } }),
82+ item("signup-attempt", "Sign-up with an existing address", { kind: "signup_attempt", reason: "account", data: { email: "diffy@example.com" } }),
8183 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." } }),
8284 item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }),
8385 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
modifiedapps/diffinery/src/mail/templates.ts+28−0View file
@@ -165,6 +165,7 @@
165165 invitation: "You are receiving this because someone invited this email address to Diffinery. If you weren't expecting it, you can ignore this email.",
166166 admin: "You are receiving this because you are an administrator.",
167167 account: "You are receiving this because you have a Diffinery account.",
168+ 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.",
168169 };
169170
170171 const LOGO = (url: string) => `${url}/icon-512.png`;
@@ -623,6 +624,33 @@
623624 ],
624625 url: job.data.url,
625626 };
627+ case "signup_code":
628+ return {
629+ // The code stays out of the subject, which the email log keeps.
630+ subject: "[Diffinery] Your sign-up code",
631+ preheader: `Your code is ${job.data.code}. It expires in 10 minutes.`,
632+ eyebrow: { text: "Sign-up", tone: "blue" },
633+ title: ["Confirm your email address"],
634+ blocks: [
635+ { p: ["Enter this code on the Diffinery sign-up page to confirm your email address. Then you pick a username and create a passkey."] },
636+ { code: job.data.code },
637+ { p: ["It works once and expires ", at(job.data.expiresAt), ". If you didn't ask to sign up, ignore this email: nothing happens without the code."], muted: true },
638+ ],
639+ };
640+ case "signup_attempt":
641+ return {
642+ subject: "[Diffinery] Someone tried to sign up with your email address",
643+ preheader: "No new account was created. Sign in with your passkey instead.",
644+ eyebrow: { text: "Account", tone: "amber" },
645+ title: ["Someone tried to sign up with your email address"],
646+ blocks: [
647+ { p: ["Someone asked to create a new Diffinery account for ", { b: job.data.email }, ", which already belongs to your account", ...(login ? [" ", { b: user(login) }] : []), ". No account was created and no code was sent."] },
648+ { p: ["If it was you, sign in with your passkey. If you lost it, the sign-in page shows how to recover your account."] },
649+ { button: { label: "Sign in", url: `${pub}/login` } },
650+ { p: ["If it wasn't you, you can ignore this email: nobody can sign up with your address without reading your mail."], muted: true },
651+ ],
652+ url: `${pub}/login`,
653+ };
626654 case "access_request":
627655 return {
628656 subject: `[Diffinery] ${job.data.email} asked for access`,
modifiedapps/diffinery/src/mail/types.ts+6−1View file
@@ -19,7 +19,8 @@
1919 | "your_activity"
2020 | "invitation"
2121 | "admin"
22- | "account";
22+ | "account"
23+ | "signup";
2324
2425 /**
2526 * Settings groups. `security` and `account` always send: they protect the
@@ -86,6 +87,8 @@
8687 | { kind: "invite_accepted"; data: { login: string; email: string; org?: string; repo?: string } }
8788 | { kind: "welcome"; data: { login: string } }
8889 | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } }
90+ | { kind: "signup_code"; data: { code: string; expiresAt: string } }
91+ | { kind: "signup_attempt"; data: { email: string } }
8992 | { kind: "access_request"; data: { email: string; login: string; note: string } }
9093 | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
9194 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
@@ -124,6 +127,8 @@
124127 case "invite":
125128 case "welcome":
126129 case "signin_link":
130+ case "signup_code":
131+ case "signup_attempt":
127132 // It is about the payer's own model spend, so it always sends.
128133 case "review_limit_reached":
129134 case "test":
modifiedapps/diffinery/src/web/auth.tsx+214−18View file
@@ -15,17 +15,19 @@
1515 import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js";
1616 import type { Account } from "../lib/directory.js";
1717 import { verifyAccessJwt } from "../lib/jwt.js";
18-import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js";
18+import { HttpError, b64url, fromB64url, isValidLogin, newId, now } from "../lib/util.js";
1919 import { ICONS } from "./assets.js";
2020 import { Form, act, confirmUrl, flash, post, prm, render, statusOf, type WebApp } from "./ui.js";
2121 import { csrfToken } from "./csrf.js";
2222 import { validInviteProof } from "../auth/invite-proof.js";
23+import { CODE_MINUTES, Signups, normalEmail, signupOpen, turnstileMode, verifyTurnstile } from "../auth/signup.js";
24+import { enqueueMail } from "../mail/send.js";
2325
2426 /**
2527 * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a
26- * session cookie. New people create their passkey from an invite link, and
27- * admins can issue one-time sign-in links to add a passkey to an existing
28- * account. Cloudflare Access, when configured, protects only /auth/access and
28+ * session cookie. New people create their passkey from an invite link (or,
29+ * with SIGNUP=open, after confirming their email at /signup), and admins can
30+ * issue one-time sign-in links to add a passkey to an existing account. Cloudflare Access, when configured, protects only /auth/access and
2931 * serves as an email-based recovery path.
3032 */
3133
@@ -131,6 +133,29 @@
131133 return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD);
132134 }
133135
136+/** Username and display name for a new account (invites and open sign-up). */
137+const NewAccountFields: FC<{ email: string }> = ({ email }) => (
138+ <>
139+ <div class="field">
140+ <label for="login">Username</label>
141+ <input id="login" name="login" type="text" value={loginFromEmail(email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
142+ <div class="hint">Your repositories live at /{"{username}"}/…</div>
143+ </div>
144+ <div class="field">
145+ <label for="name">Display name</label>
146+ <input id="name" name="name" type="text" maxlength={100} autocomplete="name" />
147+ </div>
148+ </>
149+);
150+
151+/** The username and display name a new account asked for, once they check out. */
152+async function newAccountNames(c: AppContext, body: Record<string, unknown>, email: string): Promise<{ login: string; name: string }> {
153+ const login = str(body.login).trim() || loginFromEmail(email);
154+ if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
155+ if (await c.get("dir").accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
156+ return { login, name: str(body.name).trim().slice(0, 100) };
157+}
158+
134159 /** Invite landing for someone without an account: create a passkey and join in one step. */
135160 export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> {
136161 const auth = new Auth(c.env);
@@ -188,15 +213,7 @@
188213 <form data-passkey="register" data-next="/">
189214 <input type="hidden" name="invite" value={token} />
190215 <input type="hidden" name="proof" value={proof} />
191- <div class="field">
192- <label for="login">Username</label>
193- <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
194- <div class="hint">Your repositories live at /{"{username}"}/…</div>
195- </div>
196- <div class="field">
197- <label for="name">Display name</label>
198- <input id="name" name="name" type="text" maxlength={100} autocomplete="name" />
199- </div>
216+ <NewAccountFields email={inv.email} />
200217 <PasskeyMessage />
201218 <button class="btn primary block">Create a passkey and join</button>
202219 </form>
@@ -210,6 +227,103 @@
210227 </noscript>
211228 );
212229
230+// ---------------------------------------------------------------- open sign-up (SIGNUP=open, auth/signup.ts)
231+
232+const TURNSTILE_MISSING = "Sign-up needs Turnstile configured: this instance has no TURNSTILE_SITE_KEY and TURNSTILE_SECRET. Ask its administrators for an invite.";
233+
234+const StepError: FC<{ error?: string }> = ({ error }) => (error ? <div class="flash error" role="alert">{error}</div> : null);
235+
236+/** Step 1: the email address (and the Turnstile widget). */
237+function signupPage(c: AppContext, o: { email?: string; error?: string; status?: number } = {}): Response | Promise<Response> {
238+ const mode = turnstileMode(c.env);
239+ const siteKey = mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
240+ return render(
241+ c,
242+ { title: "Create an account", status: mode === "missing" ? 503 : o.status, turnstile: !!siteKey },
243+ <AuthCard title="Create your Diffinery account">
244+ {mode === "missing" ? (
245+ <StepError error={TURNSTILE_MISSING} />
246+ ) : (
247+ <>
248+ <p class="muted">Enter your email address. We'll send you a code to confirm it, then you pick a username and create a passkey. There is no password.</p>
249+ <StepError error={o.error} />
250+ <Form action="/signup" t={csrfToken(c)}>
251+ <div class="field">
252+ <label for="email">Email address</label>
253+ <input id="email" name="email" type="email" value={o.email} required maxlength={254} autocomplete="email" />
254+ </div>
255+ {siteKey ? <div class="cf-turnstile" data-sitekey={siteKey} data-theme="auto" style="margin-bottom:12px"></div> : null}
256+ <button class="btn primary block">Email me a code</button>
257+ </Form>
258+ </>
259+ )}
260+ <div class="authfoot muted small">
261+ <p>
262+ Already have an account? <a href="/login">Sign in</a>.
263+ </p>
264+ </div>
265+ </AuthCard>,
266+ );
267+}
268+
269+/** Step 2: the code from the email. */
270+function signupCodePage(c: AppContext, o: { email: string; error?: string; status?: number }): Response | Promise<Response> {
271+ return render(
272+ c,
273+ { title: "Enter your code", status: o.status },
274+ <AuthCard title="Check your email">
275+ <p class="muted">
276+ We sent a code to <b>{o.email}</b>. Enter it here within {CODE_MINUTES} minutes.
277+ </p>
278+ <StepError error={o.error} />
279+ <Form action="/signup/verify" t={csrfToken(c)}>
280+ <input type="hidden" name="email" value={o.email} />
281+ <div class="field">
282+ <label for="code">Code</label>
283+ <input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength={6} required autocomplete="one-time-code" />
284+ </div>
285+ <button class="btn primary block">Continue</button>
286+ </Form>
287+ <div class="authfoot muted small">
288+ <p>
289+ No email? Check your spam folder, or <a href="/signup">send a new code</a>.
290+ </p>
291+ </div>
292+ </AuthCard>,
293+ );
294+}
295+
296+/** Step 3: a username and a passkey, bound to the single-use ticket for the confirmed address. */
297+function signupPasskeyPage(c: AppContext, email: string, ticket: string): Response | Promise<Response> {
298+ return render(
299+ c,
300+ { title: "Join Diffinery" },
301+ <AuthCard title="Join Diffinery">
302+ <p class="muted">
303+ <b>{email}</b> is confirmed. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember.
304+ </p>
305+ <PasskeyUnsupported />
306+ <form data-passkey="register" data-next="/">
307+ <input type="hidden" name="signup" value={ticket} />
308+ <NewAccountFields email={email} />
309+ <PasskeyMessage />
310+ <button class="btn primary block">Create a passkey and join</button>
311+ </form>
312+ </AuthCard>,
313+ );
314+}
315+
316+/** Run a sign-up step. A refusal shows on that step's page with its status, keeping what was typed. */
317+async function signupStep(fn: () => Promise<Response>, page: (error: string, status: number) => Response | Promise<Response>): Promise<Response> {
318+ try {
319+ return await fn();
320+ } catch (e) {
321+ const s = statusOf(e);
322+ if (s.status >= 500 && s.status !== 503) throw e;
323+ return page(s.message, s.status);
324+ }
325+}
326+
213327 export function registerAuth(web: WebApp): void {
214328 web.get("/login", async (c) => {
215329 const next = safeNext(c.req.query("next"));
@@ -253,7 +367,13 @@
253367 <button class="btn primary block">Sign in with a passkey</button>
254368 </form>
255369 <div class="authfoot muted small">
256- <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p>
370+ {signupOpen(c.env) ? (
371+ <p>
372+ New here? <a href="/signup">Create an account</a>.
373+ </p>
374+ ) : (
375+ <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p>
376+ )}
257377 {accessConfigured(c) ? (
258378 <p>
259379 Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>.
@@ -344,19 +464,27 @@
344464 let existing: PasskeyRow[] = [];
345465 const inviteToken = str(body.invite);
346466 const linkToken = str(body.link);
467+ const signupToken = str(body.signup);
347468 if (inviteToken) {
348469 const inv = await auth.invite(inviteToken);
349470 if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired.");
350471 if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again.");
351472 if (!(await validInviteProof(c.env, inv.hash, str(body.proof)))) throw new HttpError(403, `Open the link in the invitation email sent to ${inv.email} to create your account.`);
352473 if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`);
353- const login = str(body.login).trim() || loginFromEmail(inv.email);
354- if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
355- if (await dir.accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
356- const name = str(body.name).trim().slice(0, 100);
474+ const { login, name } = await newAccountNames(c, body, inv.email);
357475 const id = newId();
358476 subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id };
359477 user = { id, name: login, displayName: name || login };
478+ } else if (signupToken) {
479+ if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
480+ const signups = new Signups(c.env);
481+ const ticket = await signups.ticket(signupToken);
482+ if (!ticket) throw new HttpError(404, "This sign-up expired or was already used. Start again at /signup.");
483+ if (await signups.accountFor(ticket.email)) throw new HttpError(409, `${ticket.email} already has an account. Sign in with its passkey.`);
484+ const { login, name } = await newAccountNames(c, body, ticket.email);
485+ const id = newId();
486+ subject = { kind: "signup", ticketHash: ticket.hash, email: ticket.email, login, name, accountId: id };
487+ user = { id, name: login, displayName: name || login };
360488 } else if (linkToken) {
361489 const link = await auth.signinLink(linkToken);
362490 if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired.");
@@ -438,6 +566,19 @@
438566 const repo = inv.repo_id ? await dir.repoById(inv.repo_id) : null;
439567 const org = !repo && inv.org_id ? await dir.account(inv.org_id) : null;
440568 redirect = repo ? `/${repo.owner_login}/${repo.name}` : org ? `/${org.login}` : "/";
569+ } else if (s.kind === "signup") {
570+ if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
571+ const signups = new Signups(c.env);
572+ if (await signups.accountFor(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
573+ if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
574+ if (!(await signups.useTicket(s.ticketHash))) throw new HttpError(404, "This sign-up was used or expired in the meantime. Start again at /signup.");
575+ // Open sign-up makes an active account (and accepts any open invites for the address).
576+ account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId, active: true });
577+ if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`);
578+ await auth.addPasskey({ ...key, account_id: account.id });
579+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "signup", detail: { method: "passkey", email: s.email, passkeyId: key.id, ...signinContext(c) } }));
580+ flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
581+ redirect = "/";
441582 } else if (s.kind === "link") {
442583 if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime.");
443584 const found = await dir.account(s.accountId);
@@ -475,6 +616,61 @@
475616 }, `We emailed the link to ${inv.email}. Open it from that inbox to create your account.`);
476617 });
477618
619+ // ------------------------------------------------------------ open sign-up
620+
621+ // Off, sign-up doesn't exist: its pages answer 404 like any unknown path, before any CSRF check.
622+ for (const path of ["/signup", "/signup/*"]) {
623+ web.use(path, async (c, next) => {
624+ if (!signupOpen(c.env)) throw new HttpError(404, "not found");
625+ await next();
626+ });
627+ }
628+
629+ web.get("/signup", async (c) => {
630+ const p = c.get("principal");
631+ if (p.type === "account" && p.via !== "dev") return c.redirect("/", 302);
632+ return signupPage(c);
633+ });
634+
635+ post(web, "/signup", async (c, form) => {
636+ const typed = form.s("email").trim().slice(0, 254);
637+ return signupStep(
638+ async () => {
639+ const mode = turnstileMode(c.env);
640+ if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
641+ const email = normalEmail(typed);
642+ if (!email) throw new HttpError(422, "Enter a valid email address.");
643+ const ip = c.req.header("cf-connecting-ip") ?? null;
644+ if (mode === "verify" && !(await verifyTurnstile(c.env.TURNSTILE_SECRET!, form.s("cf-turnstile-response"), ip))) throw new HttpError(403, "We couldn't confirm that you're a person. Please try again.");
645+ const signups = new Signups(c.env);
646+ const existing = await signups.accountFor(email);
647+ const issued = await signups.issue(email, ip ?? "unknown");
648+ // The next page reads the same either way, so it never tells whether an address has an account.
649+ // Its owner learns from the email: a code, or (no code) a note that they already have one.
650+ await enqueueMail(c.env, [
651+ existing ? { kind: "signup_attempt", data: { email }, to: { accountId: existing.id }, reason: "account", at: now() } : { kind: "signup_code", data: issued, to: { email }, reason: "signup", at: now() },
652+ ]);
653+ return signupCodePage(c, { email });
654+ },
655+ (error, status) => signupPage(c, { email: typed, error, status }),
656+ );
657+ });
658+
659+ post(web, "/signup/verify", async (c, form) => {
660+ const email = normalEmail(form.s("email"));
661+ if (!email) return signupPage(c, { error: "Enter a valid email address.", status: 422 });
662+ return signupStep(
663+ async () => {
664+ const signups = new Signups(c.env);
665+ const r = await signups.check(email, form.s("code").trim());
666+ if (!r.ok && r.left > 0) throw new HttpError(400, `That code is wrong. You can try ${r.left} more time${r.left === 1 ? "" : "s"}.`);
667+ if (!r.ok) throw new HttpError(410, "That code has expired or was entered wrong too many times. Send a new code.");
668+ return signupPasskeyPage(c, email, await signups.createTicket(email));
669+ },
670+ (error, status) => signupCodePage(c, { email, error, status }),
671+ );
672+ });
673+
478674 // ------------------------------------------------------------ sign out
479675
480676 post(web, "/auth/logout", async (c) => {
modifiedapps/diffinery/src/web/ui.tsx+8−3View file
@@ -17,6 +17,7 @@
1717 import { MailStore, type WatchLevel } from "../mail/prefs.js";
1818 import { SeoHead, SITE } from "./seo.js";
1919 import { avatarPath } from "../http/avatars.js";
20+import { TURNSTILE_ORIGIN, TURNSTILE_SCRIPT } from "../auth/signup.js";
2021
2122 export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
2223 export type Stub = ReturnType<typeof repoStub>;
@@ -296,6 +297,8 @@
296297 scriptSrc?: string[];
297298 /** Reload the page every this many seconds (progress pages without live updates). */
298299 refresh?: number;
300+ /** Load Cloudflare Turnstile (the sign-up page): its script and iframe come from challenges.cloudflare.com. */
301+ turnstile?: boolean;
299302 }
300303
301304 export const WATCH_OPTIONS = [
@@ -367,11 +370,12 @@
367370 );
368371 };
369372
370-function csp(nonce: string, c: AppContext): string {
373+function csp(nonce: string, c: AppContext, turnstile?: boolean): string {
371374 const host = new URL(c.req.url).host;
372375 return [
373376 "default-src 'self'",
374- `script-src 'nonce-${nonce}'`,
377+ `script-src 'nonce-${nonce}'${turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}`,
378+ ...(turnstile ? [`frame-src ${TURNSTILE_ORIGIN}`] : []),
375379 "style-src 'self' 'unsafe-inline'",
376380 // blob: previews a picture before it is uploaded.
377381 "img-src 'self' https: data: blob:",
@@ -414,7 +418,7 @@
414418 const p = c.get("principal");
415419 const me = p?.type === "account" ? p.account : null;
416420 const active = me?.status === "active";
417- c.header("content-security-policy", csp(nonce, c));
421+ c.header("content-security-policy", csp(nonce, c, opts.turnstile));
418422 c.header("x-frame-options", "DENY");
419423 c.header("x-content-type-options", "nosniff");
420424 c.header("referrer-policy", "same-origin");
@@ -440,6 +444,7 @@
440444 <style>{raw(CSS)}</style>
441445 <link rel="stylesheet" href={kumoAssets().css} />
442446 {opts.head ?? null}
447+ {opts.turnstile ? <script nonce={nonce} src={TURNSTILE_SCRIPT} async defer></script> : null}
443448 </head>
444449 <body
445450 data-repo={r ? `${r.repo.owner_login}/${r.repo.name}` : undefined}
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+131−0View file
@@ -8,6 +8,8 @@
88 // its org membership), adding and removing
99 // passkeys, signing out and back in, admin-issued one-time sign-in links,
1010 // single-use invites and links, and refusing cross-site and token-driven calls.
11+// Open sign-up (SIGNUP=open) runs on a second Worker: an emailed code, then a
12+// passkey, and the sign-up pages answering 404 while it is off.
1113 import { spawn, execFileSync } from "node:child_process";
1214 import { createHash, createHmac, randomBytes } from "node:crypto";
1315 import { mkdtempSync, rmSync } from "node:fs";
@@ -346,6 +348,135 @@
346348 }
347349 });
348350
351+test("open sign-up: off by default; on, an emailed code, then a passkey", async () => {
352+ // ------------------------------------------------------------ off (this file's Worker has no SIGNUP)
353+ assert.equal((await fetch(`${base}/signup`)).status, 404);
354+ for (const path of ["/signup", "/signup/verify"]) {
355+ const r = await fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "email=a%40example.com&code=123456" });
356+ assert.equal(r.status, 404, path);
357+ }
358+ const closed = await (await fetch(`${base}/login`)).text();
359+ assert.match(closed, /Diffinery is invite-only/);
360+ assert.doesNotMatch(closed, /href="\/signup"/);
361+
362+ // ------------------------------------------------------------ on: a second Worker with SIGNUP=open
363+ // It keeps env.dev's DEV_AUTH, so emails are kept whole (for reading the code) and its dev account
364+ // (dev@diffinery.local, a site admin) can list them. Without Turnstile keys, localhost skips the check.
365+ const port = await freePort();
366+ const site = `http://localhost:${port}`;
367+ const persist = join(work, "signup-state");
368+ execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
369+ const open = spawn(
370+ "pnpm",
371+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${site}`, "--var", `MASTER_KEY:${MASTER_KEY}`, "--var", "SIGNUP:open", "--show-interactive-dev-session=false"],
372+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
373+ );
374+ open.stdout.on("data", (d) => (devLog += d));
375+ open.stderr.on("data", (d) => (devLog += d));
376+ try {
377+ await until("wrangler dev with SIGNUP=open", async () => {
378+ try {
379+ return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
380+ } catch {
381+ return false;
382+ }
383+ }, 120_000);
384+ const minted = await fetch(`${site}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "signup-e2e", scopes: ["admin"] }) });
385+ assert.equal(minted.status, 201, await minted.clone().text());
386+ const adminToken = (await minted.json()).token;
387+ /** Every email the Worker sent, newest first, with the whole message. */
388+ const emails = async () => {
389+ const r = await fetch(`${site}/api/v3/diffinery/emails?all=1&per_page=200`, { headers: { authorization: `Bearer ${adminToken}` } });
390+ assert.equal(r.status, 200);
391+ return r.json();
392+ };
393+ const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred));
394+
395+ const newbie = await person();
396+ const { page } = newbie;
397+ await page.goto(`${site}/login`);
398+ assert.match(await text(page), /New here\? Create an account/);
399+ await Promise.all([page.waitForURL(`${site}/signup`), page.click("text=Create an account")]);
400+ assert.match(await text(page), /We'll send you a code/);
401+ assert.equal(await page.locator("select, input[type=checkbox]").count(), 0);
402+ await page.fill("#email", "Newbie@Example.com");
403+ await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Email me a code')")]);
404+ assert.match(await text(page), /We sent a code to newbie@example\.com/);
405+ const sent = await email("sign-up code", (e) => e.kind === "signup_code" && e.to_addr === "newbie@example.com");
406+ assert.doesNotMatch(sent.subject, /\d{6}/);
407+ const code = /^ {4}(\d{6})$/m.exec(sent.dev_copy.text)?.[1];
408+ assert.ok(code, sent.dev_copy.text);
409+
410+ // A wrong code is refused and counted.
411+ const other = String((Number(code) + 1) % 1_000_000).padStart(6, "0");
412+ await page.fill("#code", other);
413+ const [refused] = await Promise.all([page.waitForResponse((r) => r.url().endsWith("/signup/verify")), page.click("button:has-text('Continue')")]);
414+ assert.equal(refused.status(), 400);
415+ assert.match(await text(page), /That code is wrong\. You can try 4 more times\./);
416+
417+ // The right one leads to the passkey step, bound to a single-use ticket.
418+ await page.fill("#code", code);
419+ await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Continue')")]);
420+ assert.match(await text(page), /newbie@example\.com is confirmed/);
421+ const ticket = await page.inputValue("input[name=signup]");
422+ assert.equal(await page.inputValue("#login"), "newbie");
423+ await page.fill("#login", "newbie");
424+ await page.fill("#name", "New Person");
425+ await Promise.all([page.waitForURL(`${site}/`), page.click("text=Create a passkey and join")]);
426+ assert.match(await text(page), /Welcome to Diffinery, newbie/);
427+ assert.doesNotMatch(await text(page), /Waiting for an invite/);
428+ assert.equal((await newbie.credentials()).length, 1);
429+ await page.goto(`${site}/api/v3/user`);
430+ const me = JSON.parse(await page.textContent("body"));
431+ assert.equal(me.login, "newbie");
432+ assert.equal(me.site_admin, false);
433+ await page.goto(`${site}/new`);
434+ assert.match(await text(page), /Create a new repository|Repository name/);
435+ await email("welcome", (e) => e.kind === "welcome" && e.to_addr === "newbie@example.com");
436+ // The ticket and the code worked once.
437+ const again = await fetch(`${site}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ signup: ticket, login: "newbie2" }) });
438+ assert.equal(again.status, 404);
439+
440+ // The audit log records the sign-up (this browser is the dev site admin).
441+ const stranger = await person();
442+ await stranger.page.goto(`${site}/settings/audit`);
443+ assert.ok(await stranger.page.locator("tr:has-text('newbie') .badge:text-is('signup')").count());
444+
445+ // ------------------------------------------------------------ an address with an account
446+ // Same page as for a new address; its owner gets a note instead of a code.
447+ await stranger.page.goto(`${site}/signup`);
448+ await stranger.page.fill("#email", "dev@diffinery.local");
449+ await Promise.all([stranger.page.waitForLoadState("load"), stranger.page.click("button:has-text('Email me a code')")]);
450+ assert.match(await text(stranger.page), /We sent a code to dev@diffinery\.local/);
451+ const note = await email("someone tried to sign up", (e) => e.kind === "signup_attempt");
452+ assert.equal(note.to_addr, "dev@diffinery.local");
453+ assert.match(note.dev_copy.text, /already belongs to your account/);
454+ assert.doesNotMatch(note.dev_copy.text, /^ {4}\d{6}$/m);
455+ assert.equal((await emails()).filter((e) => e.kind === "signup_code" && e.to_addr === "dev@diffinery.local").length, 0);
456+ // A guess there answers like any wrong code.
457+ await stranger.page.fill("#code", "000000");
458+ const [guess] = await Promise.all([stranger.page.waitForResponse((r) => r.url().endsWith("/signup/verify")), stranger.page.click("button:has-text('Continue')")]);
459+ assert.equal(guess.status(), 400);
460+
461+ // ------------------------------------------------------------ at most 5 codes a day for an address
462+ await stranger.page.goto(`${site}/signup`);
463+ const csrf = await stranger.page.inputValue("input[name=_csrf]");
464+ const ask = () => stranger.page.request.post(`${site}/signup`, { form: { _csrf: csrf, email: "limit@example.com" }, maxRedirects: 0 });
465+ for (let i = 0; i < 5; i++) {
466+ const r = await ask();
467+ assert.equal(r.status(), 200, `request ${i + 1}`);
468+ assert.match(await r.text(), /We sent a code to <b>limit@example\.com<\/b>/);
469+ }
470+ const limited = await ask();
471+ assert.equal(limited.status(), 429);
472+ assert.match(await limited.text(), /We have sent several codes to limit@example\.com today/);
473+ } finally {
474+ try {
475+ process.kill(-open.pid, "SIGTERM");
476+ } catch {}
477+ }
478+});
479+
349480 test("favicons, robots.txt, sitemap and search metadata", async () => {
350481 const get = (p) => fetch(`${base}${p}`, { redirect: "manual" });
351482 const ico = await get("/favicon.ico");
modifiedapps/diffinery/test/unit/email.test.ts+13−1View file
@@ -15,7 +15,7 @@
1515 describe("email templates", () => {
1616 it("has a sample for every kind of email", () => {
1717 const kinds = new Set(samples.map((s) => s.job.kind));
18- for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) {
18+ for (const k of ["signin_new", "passkey_added", "passkey_removed", "token_created", "token_expiring", "agent_created", "model_key_added", "review_limit_reached", "push_bypassed", "push_blocked", "account_status", "invite", "invite_accepted", "welcome", "signin_link", "signup_code", "signup_attempt", "access_request", "org_membership", "repo_access", "repo_created", "repo_deleted", "repo_transferred", "thread", "run", "test"]) {
1919 expect(kinds.has(k as MailJob["kind"]), k).toBe(true);
2020 }
2121 });
@@ -41,6 +41,18 @@
4141 }
4242 });
4343
44+ it("puts a sign-up code in the message but not the subject (the email log keeps subjects)", async () => {
45+ const job = samples.find((s) => s.id === "signup-code")!.job;
46+ const msg = await buildMessage(env, job, { email: "new@example.com" });
47+ expect(msg.subject).not.toContain("042917");
48+ expect(msg.text).toMatch(/^ {4}042917$/m);
49+ expect(msg.html).toContain("042917");
50+ expect(categoryOf(job)).toBe("account");
51+ const attempt = await buildMessage(env, samples.find((s) => s.id === "signup-attempt")!.job, { email: "diffy@example.com", login: "diffy", accountId: "acc1" });
52+ expect(attempt.text).toMatch(/already belongs to your account @diffy/);
53+ expect(attempt.text).not.toMatch(/\b\d{6}\b/);
54+ });
55+
4456 it("threads issue and pull request emails like GitHub", async () => {
4557 const opened = samples.find((s) => s.id === "pr-opened")!.job;
4658 const comment = samples.find((s) => s.id === "comment")!.job;
modifiedapps/diffinery/test/unit/rate-limit.test.ts+3−0View file
@@ -27,6 +27,9 @@
2727 it("counts sign-in attempts per IP address, and other anonymous requests separately", () => {
2828 expect(bucketsFor("POST", "/auth/passkey/login/options", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
2929 expect(bucketsFor("POST", "/invite/abc/email", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
30+ expect(bucketsFor("POST", "/signup", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
31+ expect(bucketsFor("POST", "/signup/verify", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
32+ expect(bucketsFor("GET", "/signup", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3033 expect(bucketsFor("GET", "/login", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3134 expect(bucketsFor("GET", "/api/v3/repos/acme/app", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3235 expect(bucketsFor("GET", "/acme/app.git/info/refs", anon, "198.51.100.1")).toEqual([["RL_ANON", "ip:198.51.100.1"]]);
addedapps/diffinery/test/unit/signup.test.ts+158−0View file
@@ -1,0 +1,158 @@
1+import { readFileSync } from "node:fs";
2+import { DatabaseSync } from "node:sqlite";
3+import { afterEach, describe, expect, it, vi } from "vitest";
4+import type { Env } from "../../src/env.js";
5+import { CODES_PER_EMAIL_PER_DAY, CODES_PER_IP_PER_DAY, MAX_CODE_ATTEMPTS, Signups, codeHash, newCode, normalEmail, signupOpen, turnstileMode, verifyTurnstile } from "../../src/auth/signup.js";
6+
7+/** A minimal D1 over node:sqlite with the sign-up tables (migration 0019) and accounts. */
8+function d1(): { DB: D1Database; db: DatabaseSync } {
9+ const db = new DatabaseSync(":memory:");
10+ db.exec("CREATE TABLE accounts (id TEXT PRIMARY KEY, login TEXT, kind TEXT, email TEXT, status TEXT)");
11+ db.exec(readFileSync(new URL("../../migrations/0020_signup.sql", import.meta.url), "utf8"));
12+ const stmt = (sql: string, args: unknown[] = []) => ({
13+ bind: (...a: unknown[]) => stmt(sql, a),
14+ first: async () => (db.prepare(sql).get(...(args as never[])) as unknown) ?? null,
15+ all: async () => ({ results: db.prepare(sql).all(...(args as never[])) }),
16+ run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...(args as never[])).changes) } }),
17+ });
18+ const DB = { prepare: (sql: string) => stmt(sql), batch: async (list: Array<ReturnType<typeof stmt>>) => Promise.all(list.map((s) => s.run())) };
19+ return { DB: DB as unknown as D1Database, db };
20+}
21+
22+const setup = () => {
23+ const { DB, db } = d1();
24+ const env = { DB, PUBLIC_URL: "https://diffinery.example", MASTER_KEY: "test-master-key" } as unknown as Env;
25+ return { env, db, signups: new Signups(env) };
26+};
27+
28+const wrong = (code: string) => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
29+
30+describe("open sign-up switch", () => {
31+ it("is on only for SIGNUP=open", () => {
32+ expect(signupOpen({ SIGNUP: "open" })).toBe(true);
33+ for (const v of [undefined, "", "closed", "OPEN", "true", "1"]) expect(signupOpen({ SIGNUP: v }), String(v)).toBe(false);
34+ });
35+
36+ it("needs both Turnstile keys, except in local development", () => {
37+ expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "site", TURNSTILE_SECRET: "secret" })).toBe("verify");
38+ expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "site" })).toBe("missing");
39+ expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SECRET: "secret" })).toBe("missing");
40+ expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example" })).toBe("missing");
41+ expect(turnstileMode({ PUBLIC_URL: "http://localhost:8787" })).toBe("skip");
42+ expect(turnstileMode({ PUBLIC_URL: "http://127.0.0.1:8787" })).toBe("skip");
43+ expect(turnstileMode({ PUBLIC_URL: "http://localhost.evil.example" })).toBe("missing");
44+ });
45+});
46+
47+describe("Turnstile", () => {
48+ afterEach(() => vi.unstubAllGlobals());
49+
50+ it("asks siteverify with the secret, the token and the visitor's IP, and trusts only success", async () => {
51+ const calls: Array<{ url: string; body: string }> = [];
52+ let answer: unknown = { success: true };
53+ vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
54+ calls.push({ url, body: String(init.body) });
55+ return Response.json(answer);
56+ });
57+ expect(await verifyTurnstile("s3cret", "tok", "203.0.113.9")).toBe(true);
58+ expect(calls[0]!.url).toBe("https://challenges.cloudflare.com/turnstile/v0/siteverify");
59+ expect(Object.fromEntries(new URLSearchParams(calls[0]!.body))).toEqual({ secret: "s3cret", response: "tok", remoteip: "203.0.113.9" });
60+ answer = { success: false, "error-codes": ["invalid-input-response"] };
61+ expect(await verifyTurnstile("s3cret", "tok", null)).toBe(false);
62+ expect(await verifyTurnstile("s3cret", "", null)).toBe(false);
63+ expect(calls.length).toBe(2);
64+ vi.stubGlobal("fetch", async () => {
65+ throw new Error("network down");
66+ });
67+ expect(await verifyTurnstile("s3cret", "tok", null)).toBe(false);
68+ });
69+});
70+
71+describe("sign-up codes", () => {
72+ it("are six random digits, stored only as a keyed hash", async () => {
73+ const codes = new Set(Array.from({ length: 200 }, () => newCode()));
74+ for (const c of codes) expect(c).toMatch(/^\d{6}$/);
75+ expect(codes.size).toBeGreaterThan(190);
76+ const { env, db, signups } = setup();
77+ const { code } = await signups.issue("new@example.com", "203.0.113.9");
78+ const row = db.prepare("SELECT * FROM signup_codes").get() as Record<string, string>;
79+ expect(JSON.stringify(row)).not.toContain(code);
80+ expect(row.code_hash).toBe(await codeHash(env, row.id!, code));
81+ expect(await codeHash({ ...env, MASTER_KEY: "another" }, row.id!, code)).not.toBe(row.code_hash);
82+ expect(await codeHash(env, "another-row", code)).not.toBe(row.code_hash);
83+ });
84+
85+ it("accepts the right code once", async () => {
86+ const { signups } = setup();
87+ const { code, expiresAt } = await signups.issue("new@example.com", "203.0.113.9");
88+ expect(Date.parse(expiresAt) - Date.now()).toBeGreaterThan(9 * 60_000);
89+ expect(Date.parse(expiresAt) - Date.now()).toBeLessThanOrEqual(10 * 60_000);
90+ expect(await signups.check("new@example.com", code)).toEqual({ ok: true });
91+ expect(await signups.check("new@example.com", code)).toEqual({ ok: false, left: 0 });
92+ // Another address's code is another address's.
93+ const other = await signups.issue("other@example.com", "203.0.113.9");
94+ expect((await signups.check("new@example.com", other.code)).ok).toBe(false);
95+ });
96+
97+ it("dies after five wrong codes", async () => {
98+ const { signups } = setup();
99+ const { code } = await signups.issue("new@example.com", "203.0.113.9");
100+ for (let left = MAX_CODE_ATTEMPTS - 1; left >= 0; left--) expect(await signups.check("new@example.com", wrong(code))).toEqual({ ok: false, left });
101+ expect(await signups.check("new@example.com", code)).toEqual({ ok: false, left: 0 });
102+ await expect(signups.check("new@example.com", "12345")).rejects.toThrow(/6-digit/);
103+ });
104+
105+ it("expires after 10 minutes, and when a new code is sent", async () => {
106+ const { db, signups } = setup();
107+ const first = await signups.issue("new@example.com", "203.0.113.9");
108+ const second = await signups.issue("new@example.com", "203.0.113.9");
109+ expect((await signups.check("new@example.com", first.code)).ok).toBe(first.code === second.code);
110+ db.prepare("UPDATE signup_codes SET expires_at = ?").run(new Date(Date.now() - 1000).toISOString());
111+ expect(await signups.check("new@example.com", second.code)).toEqual({ ok: false, left: 0 });
112+ });
113+
114+ it("sends at most 5 a day to an address and 20 a day from an IP address", async () => {
115+ const { db, signups } = setup();
116+ for (let i = 0; i < CODES_PER_EMAIL_PER_DAY; i++) await signups.issue("busy@example.com", "198.51.100.1");
117+ await expect(signups.issue("busy@example.com", "198.51.100.2")).rejects.toMatchObject({ status: 429 });
118+ for (let i = 0; i < CODES_PER_IP_PER_DAY - CODES_PER_EMAIL_PER_DAY; i++) await signups.issue(`p${i}@example.com`, "198.51.100.1");
119+ await expect(signups.issue("fresh@example.com", "198.51.100.1")).rejects.toMatchObject({ status: 429 });
120+ await signups.issue("fresh@example.com", "198.51.100.3");
121+ // A day later the count starts over.
122+ db.prepare("UPDATE signup_codes SET created_at = ?").run(new Date(Date.now() - 86400_000 - 1000).toISOString());
123+ await signups.issue("busy@example.com", "198.51.100.1");
124+ });
125+
126+ it("finds an existing account whatever the case of its address", async () => {
127+ const { db, signups } = setup();
128+ db.prepare("INSERT INTO accounts VALUES ('a1', 'pal', 'user', 'Pal@Example.com', 'active')").run();
129+ expect((await signups.accountFor("pal@example.com"))?.id).toBe("a1");
130+ expect(await signups.accountFor("nobody@example.com")).toBeNull();
131+ });
132+});
133+
134+describe("sign-up tickets", () => {
135+ it("work once, and only for 30 minutes", async () => {
136+ const { db, signups } = setup();
137+ const token = await signups.createTicket("new@example.com");
138+ const t = await signups.ticket(token);
139+ expect(t?.email).toBe("new@example.com");
140+ expect(JSON.stringify(db.prepare("SELECT * FROM signup_tickets").all())).not.toContain(token);
141+ expect(await signups.useTicket(t!.hash)).toBe(true);
142+ expect(await signups.useTicket(t!.hash)).toBe(false);
143+ expect(await signups.ticket(token)).toBeNull();
144+ const late = await signups.createTicket("new@example.com");
145+ const row = db.prepare("SELECT expires_at FROM signup_tickets WHERE used_at IS NULL").get() as { expires_at: string };
146+ expect(Date.parse(row.expires_at) - Date.now()).toBeGreaterThan(29 * 60_000);
147+ db.prepare("UPDATE signup_tickets SET expires_at = ?").run(new Date(Date.now() - 1000).toISOString());
148+ expect(await signups.ticket(late)).toBeNull();
149+ expect(await signups.ticket("not a token")).toBeNull();
150+ });
151+});
152+
153+describe("sign-up addresses", () => {
154+ it("lower-cases addresses and refuses what isn't one", () => {
155+ expect(normalEmail(" New.Person@Example.COM ")).toBe("new.person@example.com");
156+ for (const bad of ["", "nobody", "a@b", "a b@example.com", "<a@example.com>", `${"x".repeat(250)}@example.com`]) expect(normalEmail(bad), bad).toBeNull();
157+ });
158+});