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.

Account lifecycle: delete and export your account, recovery signs out other browsers, audited site admin grant and revoke (#152)

* Account lifecycle: delete and export your account, recovery signs out other browsers, audited site admin grant/revoke

- Settings -> Account: export your data (built by AccountWorkflow into a tar in R2:
  manifest, account.json, repositories as Git bundles, issues/PRs/comments/reviews you
  wrote; downloadable for 7 days, emailed when ready) and delete your account (needs a
  recent sign-in; refused while the only owner of an org, naming it, or the only site
  admin). Deletion removes repos (RepoDO destroy), packages, tokens, sessions, passkeys,
  agents, memberships, quotas and settings; the account row stays as a tombstone; work
  in other people's repositories is attributed to ghost in the background; the login is
  held for 90 days. Audited as account.delete.
- Site administration: delete someone's account, and Make admin / Remove admin with a
  recent sign-in, audited as site_admin.grant/revoke; the last site admin can't be removed.
- Recovery by email (/auth/access, unless already signed in to that account) and by
  sign-in link signs out every other browser, keeps access tokens, says so on the page,
  audited as session.revoke_others.
- Migration 0026, R2 lifecycle rule for exports/, docs, e2e test/e2e/accounts.test.mjs,
  passkeys e2e and unit test for recovery.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Sync: name each Diffinery job's runner image, and the deployed one when a run fails

A job that a container rollout stopped (the agents E2E shard on cd4c05c) can then be told
apart: started on the previous image (which #149 now turns away) or on the deployed one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 617abd0commit e647e03a930bbee8a3434ba064c38ad55081954bBrowse files
37 files changed+1785−50
Files (37)
addedapps/diffinery/docs/accounts/account-data.md+72−0View file
@@ -1,0 +1,72 @@
1+---
2+title: Exporting and deleting your account
3+description: Download an archive of your account, with your repositories as Git bundles and the issues you wrote, or delete your account and everything it owns.
4+---
5+
6+**Settings → Account** (`/settings/account`) is where you download a copy of your data and where you delete your account. Both ask you to have signed in within the last 2 hours; if you signed in earlier, Diffinery asks you to sign in again first and then brings you back.
7+
8+## Exporting your data
9+
10+Click **Start export**. Diffinery builds the archive in the background, which takes from a few seconds to a while for large repositories, and emails you when it's ready (when the instance sends email). The **Account** page shows it too: **Preparing your export…** while it is built, then **Export ready** with its size and a **Download** button. You can download it as often as you like for 7 days; after that it is deleted. Starting a new export replaces the old one. Only one export can be built at a time.
11+
12+Only you can download your export. Site administrators can't. Asking for an export and downloading it are both recorded in the [audit log](/docs/security/audit-log).
13+
14+### What's in the archive
15+
16+The download is a tar file named `diffinery-export-<username>-<date>.tar`. Unpack it with `tar -xf`. Everything is in one folder, `diffinery-export-<username>/`:
17+
18+| File | What it holds |
19+| --- | --- |
20+| `manifest.json` | The format version (`format`, now `1`), the instance's address, your username, when the archive was made, and every file in it with its size in bytes. |
21+| `account.json` | Your profile (username, display name, email address, when you joined, whether you are a site administrator, your profile picture's address), your settings (appearance, notification settings, watched repositories), your organizations and role in each, your agents, your access tokens and passkeys, your linked accounts and your model provider keys. |
22+| `repositories/<name>.json` | Each repository you own: description, visibility, default branch, topics, homepage, when it was created and last pushed, its clone URL, and the name of its bundle. |
23+| `repositories/<name>.bundle` | The repository's branches and tags as a Git bundle. |
24+| `issues/<owner>/<repository>.json` | For each repository you can see where you wrote something: the issues and pull requests you opened (`issues`, with `kind` saying which), the comments you wrote (`comments`, review comments with their file and line) and the reviews you submitted (`reviews`). |
25+
26+Access tokens, passkeys and model keys are listed by name and date only. Nothing in the archive can sign in as you or use your keys. Secrets of your repositories and Actions are not exported.
27+
28+To get a repository back from its bundle:
29+
30+```bash
31+git clone diffinery-export-alice/repositories/notes.bundle notes
32+```
33+
34+A repository with no branches or tags has no bundle, and one larger than 4 GiB isn't bundled; its JSON file has a `note` saying so, and `bundle` is `null`. Clone it from `clone_url` while your account exists. Issues, comments and reviews are exported up to 5,000 of each per repository.
35+
36+## Deleting your account
37+
38+The **Delete your account** box at the bottom of the **Account** page deletes your account at once. Type your username and click **Delete your account**. You are signed out and sent to the sign-in page.
39+
40+You can't delete your account while:
41+
42+- you are the only owner of an organization. The page names the organization. Make someone else an owner of it, or delete the organization, first (see [Organizations](/docs/accounts/organizations)).
43+- you are the only site administrator. Make someone else a site administrator first (see [Site administration](/docs/admin/site-administration#site-administrators)).
44+- a GitHub import you started, or one into your account, is still running. Wait for it to finish or cancel it.
45+
46+### What goes and what stays
47+
48+Deleted at once:
49+
50+- every repository you own, with its history, issues, pull requests, workflow runs, secrets, webhooks and releases. Their names, and any old names that redirected to them, stop working straight away; their storage is wiped in the background.
51+- your container images and other packages.
52+- your access tokens, passkeys and every browser session.
53+- your agents, with their tokens.
54+- your organization memberships, repository collaborator access and pending invitations.
55+- your notifications, watches, notification settings, linked accounts, model keys, saved terminal setups, quotas and usage, sign-in links and email history.
56+- your profile and profile picture. `https://diffinery.com/<username>` no longer exists.
57+
58+What stays:
59+
60+- issues, pull requests, comments, reviews and reactions you wrote in other people's repositories. They are shown as written by **ghost**, a placeholder for people who deleted their accounts. This happens in the background, so for a few minutes after the deletion some may still show your old username.
61+- commits you pushed to other people's repositories. They are part of those repositories' Git history and keep the name and email address in the commit.
62+- the [audit log](/docs/security/audit-log), which keeps what you did, under your old username.
63+
64+Diffinery emails you to say the account was deleted.
65+
66+### Your username
67+
68+Nobody, you included, can use your username for 90 days after the deletion: creating a person, organization or agent with it, or renaming one to it, is refused with `This username belonged to an account that was deleted recently; it can be used again from <date>` (`This name ...` when renaming). The same goes for the usernames of your agents.
69+
70+### Deletion by a site administrator
71+
72+Site administrators can delete anyone's account from **Site administration** (see [Deleting an account](/docs/admin/site-administration#deleting-an-account)). The same rules apply, and the person gets an email saying a site administrator deleted their account.
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+6−4View file
@@ -91,7 +91,7 @@
9191
9292 ## Confirming it's you
9393
94-Adding or removing a passkey, creating a token or an agent, changing your username, and deleting or transferring a repository or organization need a sign-in from the last 2 hours. If you signed in earlier, Diffinery shows **Confirm it's you**. Use your passkey (or **Confirm by email**, where email recovery is set up), and you go back to the page you were on. Then do the action again. See [Security overview](/docs/security/overview#confirming-its-you).
94+Adding or removing a passkey, creating a token or an agent, changing your username, deleting or transferring a repository or organization, exporting or deleting your account, and (for site administrators) deleting someone's account or changing who is a site administrator need a sign-in from the last 2 hours. If you signed in earlier, Diffinery shows **Confirm it's you**. Use your passkey (or **Confirm by email**, where email recovery is set up), and you go back to the page you were on. Then do the action again. See [Security overview](/docs/security/overview#confirming-its-you).
9595
9696 ## Sessions and signing out other browsers
9797
@@ -128,6 +128,8 @@
128128
129129 If you have lost every passkey (or the device that held your only passkey), there are two ways back in. Both let you add a new passkey to your existing account; your repositories, tokens and settings are untouched.
130130
131+Recovering your account signs out every other browser signed in to it, in case the device you lost is one of them. Only browser sessions end: your access tokens keep working, so scripts and Git keep running. If you think someone has a token, revoke it under **Settings → Access tokens** once you are back in. The page you land on says "Every other browser signed in to your account was signed out; your access tokens keep working.", and the audit log records it as `session.revoke_others`. Email recovery from a browser that is already signed in to the same account (Diffinery sends you there to [confirm it's you](#confirming-its-you)) doesn't sign anything out.
132+
131133 ### Sign-in link from an administrator
132134
133135 A site administrator can create a one-time sign-in link for you:
@@ -137,14 +139,14 @@
137139 3. Open the link (`https://diffinery.com/signin/<token>`). The **Add a passkey** page names the account it belongs to.
138140 4. Optionally enter a **Name for this passkey**, click **Create a passkey** and complete the browser prompt.
139141
140-You are signed in and taken to **Settings → Passkeys and sessions** with "Passkey "<name>" added. Use it to sign in from now on."
142+You are signed in and taken to **Settings → Passkeys and sessions** with "Passkey "<name>" added. Use it to sign in from now on." Every other browser signed in to your account is signed out.
141143
142144 | Sign-in link rule | Value |
143145 | --- | --- |
144146 | Lifetime | 24 hours from when it was created |
145147 | Uses | Once. After that, or after it expires, it shows "this sign-in link is invalid, already used, or expired". |
146148 | Accounts | People only, and not disabled accounts |
147-| What it does | Adds a passkey to the account and signs that browser in. It doesn't remove your other passkeys. |
149+| What it does | Adds a passkey to the account, signs that browser in and signs every other browser out. It doesn't remove your other passkeys or revoke your access tokens. |
148150
149151 Anyone with the link can add a passkey to your account, so don't forward the email. After you are back in, remove the passkey of the device you lost.
150152
@@ -157,7 +159,7 @@
157159 3. Enter the PIN. Cloudflare Access sends you back to Diffinery, which signs you in.
158160 4. If your account has no passkeys, you land on **Settings → Passkeys and sessions** with "You're signed in. Add a passkey now so you can sign in directly next time." Add one right away. If you still have passkeys, you go to the page you were trying to open.
159161
160-Every email-recovery sign-in sends a "New sign-in to your account" email and is recorded in the audit log.
162+Every email-recovery sign-in sends a "New sign-in to your account" email and is recorded in the audit log. Unless this browser was already signed in to the account, every other browser is signed out; your access tokens keep working.
161163
162164 When email recovery isn't set up on an instance, `/auth/access` returns 404 with "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't show the email recovery link.
163165
modifiedapps/diffinery/docs/accounts/profile-and-preferences.md+1−0View file
@@ -12,6 +12,7 @@
1212 | Page | Path | What it holds |
1313 | --- | --- | --- |
1414 | **Profile** | `/settings` | Profile picture, username, display name, and read-only account details |
15+| **Account** | `/settings/account` | Exporting your data and deleting your account. See [Exporting and deleting your account](/docs/accounts/account-data). |
1516 | **Appearance** | `/settings/appearance` | Light, dark or system theme |
1617 | **Passkeys and sessions** | `/settings/security` | Passkeys and signed-in browsers. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). |
1718 | **Access tokens** | `/settings/tokens` | Personal access tokens. See [Personal access tokens](/docs/accounts/tokens). |
modifiedapps/diffinery/docs/admin/site-administration.md+20−3View file
@@ -1,6 +1,6 @@
11 ---
22 title: Site administration
3-description: What site administrators do: manage people and access, invite people, issue sign-in links, stop Actions with kill switches, oversee imports, read the audit log and check email.
3+description: What site administrators do: manage people and access, appoint administrators, delete accounts, issue sign-in links, stop Actions, oversee imports, read the audit log and check email.
44 ---
55
66 Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**.
@@ -25,8 +25,17 @@
2525 - it is created with an email address on the list (from an invite, after opening the link emailed to that address, or by verifying the email address through email recovery at `/auth/access`), or
2626 - it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`.
2727
28-Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active, and there is no page for granting or removing site administration. See [Configuration](/docs/self-hosting/configuration) for the setting.
28+Bootstrap administrators don't need an invite. Adding an address to the list later doesn't promote an account that is already active; use **Make admin** instead. See [Configuration](/docs/self-hosting/configuration) for the setting.
2929
30+### Granting and removing site administration
31+
32+In the **Users** table on **Settings → People and invitations**, the **Site admin** column has a **Make admin** button for each active person who isn't a site administrator, and a **Remove admin** button for each one who is. Both ask you to confirm, and both need a sign-in from the last 2 hours: if yours is older, Diffinery asks you to sign in again and brings you back. You see "diffy is now a site administrator." or "diffy is no longer a site administrator."
33+
34+- Only active accounts can be made site administrators. Enable a pending or disabled account first.
35+- The last site administrator can never be removed: "Diffinery needs at least one site administrator: make someone else a site administrator first". You can remove yourself while there is another one; you then lose access to these pages.
36+- The person gets a security email saying who made the change.
37+- Each change is recorded in the [audit log](/docs/security/audit-log) as `site_admin.grant` or `site_admin.revoke`, with who made it and the person it was about.
38+
3039 ### First sign-in for administrators
3140
3241 On a new instance nobody has a passkey yet. A bootstrap administrator signs in through email recovery:
@@ -56,7 +65,7 @@
5665
5766 ### Users
5867
59-The **Users** table lists every person (not organizations or agents) with their picture and username, email address, when they joined and their status. For each user except yourself you can pick a status in the menu and click **Save**. You see "User updated."
68+The **Users** table lists every person (not organizations, agents or deleted accounts) with their picture and username, email address, when they joined, their status and whether they are a site administrator. Each row has **Make admin** or **Remove admin** (see [Granting and removing site administration](#granting-and-removing-site-administration)), **Sign-in link** (see [Sign-in links](#sign-in-links)) and **Delete** (see [Deleting an account](#deleting-an-account)). For each user except yourself you can pick a status in the menu and click **Save**. You see "User updated."
6069
6170 | Status in the menu | Value | Meaning |
6271 | --- | --- | --- |
@@ -81,6 +90,14 @@
8190
8291 Every status change is recorded in the audit log as `user.active`, `user.pending` or `user.disabled`.
8392
93+### Deleting an account
94+
95+Click **Delete** on the person's row in the **Users** table. A page shows what will go: their repositories (with how many), container images, tokens, passkeys, agents, settings and memberships. Type their username and click **Delete this account**. It needs a sign-in from the last 2 hours, like granting site administration. You see "Deleted the account diffy." and the person gets an email saying a site administrator deleted their account.
96+
97+The page says why, and the button is off, when the person is the only owner of an organization (it names each one), the only site administrator, or has a GitHub import running. Make someone else an owner, or delete the organization, first.
98+
99+Deleting an account is the same as the person deleting it themselves: everything they own goes, what they wrote in other people's repositories is shown as written by **ghost**, and their username can't be used for 90 days. See [Exporting and deleting your account](/docs/accounts/account-data#deleting-your-account). It can't be undone; to keep someone out while you decide, disable the account instead. Each deletion is recorded in the audit log as `account.delete`, with `"via":"admin"`.
100+
84101 ## Invites
85102
86103 Under **Invite someone to this instance**, enter an **Email address** and click **Send invitation** (the hint says "We email them a link. Opening it lets them pick a username and create a passkey."). The invite isn't tied to an organization or repository: it only lets the person create an account. Diffinery emails the link and also shows it to you under "Invite created for friend@example.com" with a copy button, with the note that it expires in 14 days and works once. Instance invitations can't be revoked.
modifiedapps/diffinery/docs/nav.json+2−1View file
@@ -17,7 +17,8 @@
1717 "accounts/permissions",
1818 "accounts/tokens",
1919 "accounts/notifications",
20- "accounts/profile-and-preferences"
20+ "accounts/profile-and-preferences",
21+ "accounts/account-data"
2122 ]
2223 },
2324 {
modifiedapps/diffinery/docs/security/audit-log.md+5−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+| `session.revoke_others` | Recovering an account signs out its other browsers: through email recovery at `/auth/access` from a browser that wasn't signed in to the account, or with a one-time sign-in link (see [Recover your account](/docs/accounts/passkeys-and-sign-in#recover-your-account)) | none | `via` (`access` or `signin-link`), `sessions` (how many were signed out) |
5556 | `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` |
5657 | `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 |
5758 | `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 |
@@ -59,6 +60,10 @@
5960 | `token.revoke` | A token is revoked on **Access tokens** or with `DELETE /api/v3/diffinery/tokens/{id}` | the token id | `via: "api"` for the API |
6061 | `signin_link.create` | A site admin creates a one-time sign-in link for someone | that user's login | none |
6162 | `user.active`, `user.pending`, `user.disabled` | A site admin changes a user's status | that user's login | none |
63+| `site_admin.grant`, `site_admin.revoke` | A site administrator makes someone a site administrator, or removes them, with **Make admin** or **Remove admin** in **Site administration** | that user's login | none |
64+| `account.delete` | Someone deletes their account on **Settings → Account**, or a site administrator deletes it (see [Exporting and deleting your account](/docs/accounts/account-data)) | the deleted account's login | `via` (`settings` or `admin`), `repositories` and `packages` (how many were deleted), `agents` (the logins of the agents deleted with it) |
65+| `account.export` | Someone asks for an export of their account on **Settings → Account** | none | `id` (the export's id) |
66+| `account.export_download` | Someone downloads their account export | none | `id`, `size` (in bytes) |
6267 | `account.link_github` | You link a GitHub account on **Settings → Linked accounts** (gist code or GitHub sign-in) or with `POST /api/v3/diffinery/user/linked-accounts/github` | `<host>/<GitHub login>`, such as `github.com/diffy` | none |
6368 | `account.unlink_github` | You unlink a GitHub account (**Unlink**, or `DELETE /api/v3/diffinery/user/linked-accounts/{id}`) | the link's id | none |
6469 | `account.link_cleared` | A site admin clears someone's GitHub link with `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | `<host>/<GitHub login>` | `account` (the id of the Diffinery account the link belonged to) |
modifiedapps/diffinery/docs/security/overview.md+3−1View file
@@ -62,7 +62,9 @@
6262 - changing your username;
6363 - issuing a sign-in link (site administrators);
6464 - deleting or transferring a repository, or purging its unreachable objects;
65-- deleting an organization.
65+- deleting an organization;
66+- exporting your account, downloading the export, or deleting your account;
67+- deleting someone's account, or making someone a site administrator or removing them (site administrators).
6668
6769 When a browser's sign-in is older than that, Diffinery asks you to **Confirm it's you** with your passkey (or by email recovery, where that is set up), then takes you back to the page. Then submit the form again. The same calls through the API with a browser session get `403 Confirm it's you: sign in with your passkey again to do this.`, with a `confirm` URL in the body. So a session left open on a shared computer, or a stolen session cookie, can't add its own passkey, mint a lasting token or delete your work. Tokens aren't affected: their scopes bound what they can do.
6870
modifiedapps/diffinery/docs/self-hosting/architecture.md+7−3View file
@@ -12,7 +12,7 @@
1212 | Web UI and public docs, REST API, git smart HTTP, Git LFS, MCP server, container registry, runner API, Actions cache and artifact services, release asset uploads and downloads, source archives, profile pictures | Workers, with the Hono router | The Worker itself (`src/index.ts`) |
1313 | One repository: refs, git objects, issues, pull requests, reviews, reactions, checks and commit statuses, releases, deployments and environments, workflow runs, secrets, webhooks, merge queue | Durable Object with SQLite storage (`RepoDO`), one per repository | `REPO` |
1414 | Accounts (with profile picture version and appearance setting), organizations, tokens, repository list, topics and access, invitations, notifications, audit log, schedules, package index, email settings and log, organization variables, GitHub imports and linked GitHub accounts | D1, read through the Sessions API | `DB` |
15-| Large git objects, LFS objects, archived job logs, caches, artifacts, container image layers and manifests, the event archive, release assets, cached source archives, profile pictures | R2 | `BUCKET` |
15+| Large git objects, LFS objects, archived job logs, caches, artifacts, container image layers and manifests, the event archive, release assets, cached source archives, profile pictures, account exports | R2 | `BUCKET` |
1616 | Webhooks (and retries of failed deliveries), notifications, audit fan-out | Queues (`diffinery-events`, dead-letter queue `diffinery-events-dlq`) | `EVENTS` |
1717 | Email jobs | Queues (`diffinery-mail`, dead-letter queue `diffinery-mail-dlq`) | `MAIL` |
1818 | Bounces and complaints | Queue `diffinery-mail-events`, fed by Email Sending's event subscriptions | (consumer only) |
@@ -20,9 +20,10 @@
2020 | Hosted job runners | Containers (`RunnerContainer`, one container per job) | `RUNNER` |
2121 | Agent sandboxes | Containers (`SandboxContainer`, one container per sandbox) | `SANDBOX` |
2222 | GitHub import orchestration (durable, with paged and retried steps) | Workflows (`diffinery-import`, class `ImportWorkflow`) | `IMPORT_WORKFLOW` |
23+| Account exports and the clean-up after an account is deleted (durable, with retried steps) | Workflows (`diffinery-account`, class `AccountWorkflow`) | `ACCOUNT_WORKFLOW` |
2324 | The Git transfer of a GitHub import | Containers (`ImportContainer`, one container per transfer) | `IMPORTER` |
2425 | Scheduled workflows | Durable Object alarm, every 5 minutes (`SchedulerDO`, one instance) | `SCHEDULER` |
25-| Token expiry reminders, email log pruning, registry cleanup, expiry of draft imports, a check that the scheduler's alarm is set | Cron Triggers, every 5 minutes | `scheduled` handler |
26+| Token expiry reminders, email log pruning, registry cleanup, expiry of draft imports and of account exports, a check that the scheduler's alarm is set | Cron Triggers, every 5 minutes | `scheduled` handler |
2627 | Live run logs and pull request updates in the browser | Durable Object WebSockets with hibernation | Through `REPO` |
2728 | Outgoing email | Email Sending | `EMAIL` |
2829 | Email-based account recovery and first admin sign-in | Cloudflare Access, on `/auth/access` only | `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` |
@@ -127,12 +128,13 @@
127128 | --- | --- | --- |
128129 | `repos/` | Git objects, LFS objects and release assets | Nothing by age. Kept until the repository or the object is deleted. |
129130 | `registry/` | Container image blobs and manifests | Nothing by age. The cron trigger removes blobs no image uses. |
130-| `avatars/` | Profile pictures | Nothing by age. Replaced when someone uploads a new picture. |
131+| `avatars/` | Profile pictures | Nothing by age. Replaced when someone uploads a new picture, and deleted with the account. |
131132 | `actions-cache/` | Actions caches | Nothing by age. Diffinery evicts caches itself (least recently used, and unused for 7 days). |
132133 | `artifacts/` | Workflow artifacts | Diffinery at the end of each artifact's retention, and a lifecycle rule after 90 days. |
133134 | `logs/` | Archived job logs | A lifecycle rule after 90 days. |
134135 | `actions-staging/` | Cache and artifact uploads in progress (blocks and chunks) | Diffinery when the upload finishes, and a lifecycle rule after 1 day. |
135136 | `archives/` | Cached source archives | A lifecycle rule after 30 days. They are built again on the next download. |
137+| `exports/` | Account exports and their parts while they are built | Diffinery 7 days after an export is ready (or when the person asks for a new one, or deletes the account), and a lifecycle rule after 8 days. |
136138 | `events/` | The event archive | A lifecycle rule after 400 days. |
137139
138140 A lifecycle rule also aborts multipart uploads left unfinished for a day, under every prefix. `scripts/bootstrap.sh` sets these rules on every deploy and replaces the bucket's lifecycle configuration when it does, so rules added by hand in the Cloudflare dashboard don't survive the next deploy. If it can't set them, the deploy stops with `could not set the R2 lifecycle rules (token has R2: Edit?)`. It then reads the rules back and stops with `an R2 lifecycle rule still deletes objects under repos/ (git objects, LFS); refusing to continue` if any rule would delete objects under `repos/` or in the whole bucket.
@@ -162,6 +164,8 @@
162164
163165 Each attempt of a GitHub import is one instance of the `diffinery-import` Workflow, with `<import id>-<attempt>` as the instance ID. `ImportWorkflow` runs every step with `step.do`, each retried up to 5 times with exponential backoff from 15 seconds and limited to 15 minutes per call, and turns GitHub rate limits into Workflow sleeps. Steps that page through GitHub are called again and again with a cursor until they report that they are done, so no single step runs long. See [Request flow: a GitHub import](#request-flow-a-github-import).
164166
167+Each account export is one instance of the `diffinery-account` Workflow, `export-<export ID>`, and so is the clean-up after an account is deleted, `delete-<account ID>`. `AccountWorkflow` runs every step with `step.do`, retried up to 5 times with exponential backoff from 10 seconds. An export writes the account, then each repository as a Git bundle (written by the repository's Durable Object), then the issues and pull requests the person wrote, a few repositories per step, as separate R2 objects, and finally joins them into one tar archive. The clean-up wipes each deleted repository's Durable Object and R2 objects, deletes the account's profile pictures and exports, and then asks every repository's Durable Object, 25 per step, to show the deleted account's issues, pull requests, comments, reviews and reactions as written by `ghost`. See [Deleting an account](/docs/accounts/account-data#deleting-your-account).
168+
165169 ## Containers
166170
167171 All three container classes use the same image, built from `containers/runner/Dockerfile` (Ubuntu 24.04 with Node.js 22, git, git-lfs, Python and build tools, plus the runner agent).
addedapps/diffinery/migrations/0026_account_lifecycle.sql+30−0View file
@@ -1,0 +1,30 @@
1+-- Account lifecycle (accounts/lifecycle.ts, accounts/export.ts): deleting your account, the cool-down on a
2+-- deleted account's username, and downloadable exports of an account's data.
3+
4+-- When a person deleted their account (or a site administrator deleted it). Like a deleted organization, the
5+-- row stays, disabled under a placeholder login, because the audit log and import history refer to it.
6+ALTER TABLE accounts ADD COLUMN deleted_at TEXT;
7+
8+-- Usernames freed by deleted accounts. Nobody can take one before available_at (90 days later), so links,
9+-- mentions and clones that pointed at the old account can't quietly lead to someone else.
10+CREATE TABLE IF NOT EXISTS released_logins (
11+ login TEXT PRIMARY KEY COLLATE NOCASE,
12+ account_id TEXT NOT NULL,
13+ released_at TEXT NOT NULL,
14+ available_at TEXT NOT NULL
15+);
16+
17+-- Exports of an account's data, built in the background (AccountWorkflow) as one tar archive in R2 under
18+-- exports/<id>/, downloadable from Settings → Account until expires_at (7 days after it is ready).
19+CREATE TABLE IF NOT EXISTS account_exports (
20+ id TEXT PRIMARY KEY,
21+ account_id TEXT NOT NULL REFERENCES accounts(id),
22+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'ready', 'failed')),
23+ r2_key TEXT,
24+ size INTEGER,
25+ error TEXT,
26+ created_at TEXT NOT NULL,
27+ finished_at TEXT,
28+ expires_at TEXT
29+);
30+CREATE INDEX IF NOT EXISTS account_exports_account ON account_exports(account_id, created_at);
modifiedapps/diffinery/package.json+1−1View file
@@ -9,7 +9,7 @@
99 "typecheck": "tsc --noEmit && tsc --noEmit -p islands",
1010 "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'",
1111 "cf-typegen": "wrangler types",
12- "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs",
12+ "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs test/e2e/accounts.test.mjs test/e2e/registry.test.mjs test/e2e/email.test.mjs test/e2e/security-txt.test.mjs test/e2e/security.test.mjs test/e2e/agentic.test.mjs test/e2e/tasks.test.mjs test/e2e/terminals.test.mjs test/e2e/review.test.mjs",
1313 "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev",
1414 "build": "node islands/build.mjs && node scripts/build-docs.mjs",
1515 "predev": "node islands/build.mjs && node scripts/build-docs.mjs",
addedapps/diffinery/src/accounts/export.ts+272−0View file
@@ -1,0 +1,272 @@
1+import type { Env } from "../env.js";
2+import type { Account } from "../lib/directory.js";
3+import { Directory } from "../lib/directory.js";
4+import { tarEnd, tarFileHeader, tarPadding } from "../lib/archive.js";
5+import { R2Sink } from "../lib/r2-sink.js";
6+import { deletePrefixes } from "../lib/storage-keys.js";
7+import { HttpError, newId, now } from "../lib/util.js";
8+import { MailStore } from "../mail/prefs.js";
9+import { enqueueMail } from "../mail/send.js";
10+
11+/**
12+ * Exports of an account's data (Settings → Account → Export your data), like GitHub's account archive.
13+ *
14+ * The person asks; AccountWorkflow builds the archive in the background, one piece per step, under
15+ * exports/<account id>/<export id>/parts/, then puts the pieces together into one uncompressed tar archive
16+ * (the Git bundles inside are already compressed) and emails the person a link. They download it from
17+ * Settings → Account for {@link EXPORT_DAYS} days; then the cron deletes it (and an R2 age rule on exports/ is
18+ * the backstop). One export at a time per account.
19+ *
20+ * Inside the archive, under `diffinery-export-<login>/`:
21+ *
22+ * | Path | What |
23+ * |------|------|
24+ * | `manifest.json` | format version, account, when it was made, and every file with its size |
25+ * | `account.json` | profile, settings (appearance, notification settings, watched repositories), organizations, agents, access tokens and passkeys (names and dates only, never secrets), linked accounts |
26+ * | `repositories/<name>.json` | each repository the account owns: description, visibility, default branch, topics, clone URL |
27+ * | `repositories/<name>.bundle` | its branches and tags as a Git bundle: `git clone <name>.bundle` |
28+ * | `issues/<owner>/<repo>.json` | issues and pull requests the account opened, comments and reviews it wrote, per repository it can see |
29+ *
30+ * A repository too large to bundle ({@link MAX_BUNDLE_BYTES}) has no bundle; its JSON says so and gives the clone URL.
31+ */
32+
33+export const EXPORT_DAYS = 7;
34+/** Archive format version, in manifest.json. */
35+export const EXPORT_FORMAT = 1;
36+/** Largest repository bundle in an export; tar's plain header holds sizes up to 8 GiB. */
37+export const MAX_BUNDLE_BYTES = 4 * 1024 ** 3;
38+/** Largest whole archive (R2 multipart: 10,000 parts of 8 MiB). */
39+const MAX_ARCHIVE_BYTES = 75 * 1024 ** 3;
40+/** How many repositories one step looks through for what the account wrote. */
41+export const AUTHORED_PAGE = 20;
42+
43+export interface ExportRow {
44+ id: string;
45+ account_id: string;
46+ status: "queued" | "running" | "ready" | "failed";
47+ r2_key: string | null;
48+ size: number | null;
49+ error: string | null;
50+ created_at: string;
51+ finished_at: string | null;
52+ expires_at: string | null;
53+}
54+
55+export interface ExportJob {
56+ kind: "export";
57+ exportId: string;
58+}
59+
60+const prefix = (row: Pick<ExportRow, "account_id" | "id">) => `exports/${row.account_id}/${row.id}/`;
61+const partsPrefix = (row: Pick<ExportRow, "account_id" | "id">) => `${prefix(row)}parts/`;
62+const json = (v: unknown) => new TextEncoder().encode(`${JSON.stringify(v, null, 2)}\n`);
63+
64+async function row(env: Env, id: string): Promise<ExportRow> {
65+ const r = await env.DB.prepare("SELECT * FROM account_exports WHERE id = ?").bind(id).first<ExportRow>();
66+ if (!r) throw new HttpError(404, "no such export");
67+ return r;
68+}
69+
70+async function owner(env: Env, r: ExportRow): Promise<Account> {
71+ const a = await new Directory(env).account(r.account_id);
72+ if (!a || a.deleted_at) throw new HttpError(404, "the account was deleted");
73+ return a;
74+}
75+
76+/** The account's exports, newest first (Settings → Account). Expired ones are left out. */
77+export async function exportsFor(env: Env, accountId: string): Promise<ExportRow[]> {
78+ const r = await env.DB.prepare("SELECT * FROM account_exports WHERE account_id = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT 5").bind(accountId, now()).all<ExportRow>();
79+ return r.results;
80+}
81+
82+/** Ask for an export. Refused while another one is being built. Earlier archives are replaced. */
83+export async function requestExport(env: Env, account: Account): Promise<ExportRow> {
84+ const busy = await env.DB.prepare("SELECT 1 FROM account_exports WHERE account_id = ? AND status IN ('queued', 'running') AND created_at > ?").bind(account.id, new Date(Date.now() - 86400_000).toISOString()).first();
85+ if (busy) throw new HttpError(409, "An export is already being prepared. We'll email you when it's ready.");
86+ const old = await env.DB.prepare("SELECT * FROM account_exports WHERE account_id = ?").bind(account.id).all<ExportRow>();
87+ await deletePrefixes(env.BUCKET, old.results.map(prefix));
88+ const id = newId();
89+ await env.DB.batch([
90+ env.DB.prepare("DELETE FROM account_exports WHERE account_id = ?").bind(account.id),
91+ env.DB.prepare("INSERT INTO account_exports (id, account_id, status, created_at) VALUES (?, ?, 'queued', ?)").bind(id, account.id, now()),
92+ ]);
93+ const job: ExportJob = { kind: "export", exportId: id };
94+ if (env.ACCOUNT_WORKFLOW) await env.ACCOUNT_WORKFLOW.create({ id: `export-${id}`, params: job });
95+ else await buildExport(env, id);
96+ return row(env, id);
97+}
98+
99+/** The whole export in one go: what AccountWorkflow does step by step (used when there is no workflow binding). */
100+async function buildExport(env: Env, id: string): Promise<void> {
101+ try {
102+ const plan = await startExport(env, id);
103+ await exportAccount(env, id);
104+ for (const r of plan.owned) await exportRepository(env, id, r);
105+ for (let i = 0; i < plan.visible.length; i += AUTHORED_PAGE) await exportAuthored(env, id, plan.visible.slice(i, i + AUTHORED_PAGE));
106+ await assembleExport(env, id);
107+ } catch (e) {
108+ await failExport(env, id, e instanceof Error ? e.message : String(e));
109+ }
110+}
111+
112+/** First step: mark the export running and list the repositories it covers. */
113+export async function startExport(env: Env, id: string): Promise<{ owned: string[]; visible: string[] }> {
114+ const r = await row(env, id);
115+ const account = await owner(env, r);
116+ await env.DB.prepare("UPDATE account_exports SET status = 'running' WHERE id = ?").bind(id).run();
117+ const dir = new Directory(env);
118+ const owned = (await env.DB.prepare("SELECT id FROM repos WHERE owner_id = ? ORDER BY name").bind(account.id).all<{ id: string }>()).results.map((x) => x.id);
119+ const visible = (await dir.reposVisibleTo(account)).map((x) => x.id);
120+ return { owned, visible: [...new Set([...owned, ...visible])] };
121+}
122+
123+/** account.json: the profile and settings. Tokens and passkeys by name only: nothing in the archive signs anyone in. */
124+export async function exportAccount(env: Env, id: string): Promise<void> {
125+ const r = await row(env, id);
126+ const a = await owner(env, r);
127+ const db = env.DB;
128+ const all = async <T>(q: string) => (await db.prepare(q).bind(a.id).all<T>()).results;
129+ const data = {
130+ login: a.login,
131+ name: a.name,
132+ email: a.email,
133+ created_at: a.created_at,
134+ site_admin: !!a.site_admin,
135+ avatar_url: `${env.PUBLIC_URL}/api/avatars/${encodeURIComponent(a.login)}`,
136+ settings: {
137+ appearance: a.appearance ?? "auto",
138+ notifications: await new MailStore(db).prefs(a.id),
139+ watching: await all("SELECT a.login || '/' || r.name AS repository, w.level FROM repo_watches w JOIN repos r ON r.id = w.repo_id JOIN accounts a ON a.id = r.owner_id WHERE w.account_id = ? ORDER BY 1"),
140+ },
141+ organizations: await all("SELECT o.login, m.role, m.created_at AS joined_at FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.login"),
142+ agents: await all("SELECT login, name, status, created_at FROM accounts WHERE kind = 'agent' AND owner_id = ? AND deleted_at IS NULL ORDER BY login"),
143+ tokens: (await all<{ name: string; prefix: string; scopes: string; expires_at: string; last_used_at: string | null; created_at: string }>("SELECT name, prefix, scopes, expires_at, last_used_at, created_at FROM tokens WHERE account_id = ? AND revoked_at IS NULL AND import_id IS NULL ORDER BY created_at")).map((t) => ({ ...t, scopes: JSON.parse(t.scopes) as string[] })),
144+ passkeys: await all("SELECT name, created_at, last_used_at, backed_up AS synced FROM passkeys WHERE account_id = ? ORDER BY created_at"),
145+ linked_accounts: await all("SELECT provider, api_base, external_login AS login, created_at FROM account_links WHERE account_id = ? ORDER BY created_at"),
146+ model_keys: await all("SELECT provider, hint, created_at FROM model_keys WHERE account_id = ? ORDER BY provider"),
147+ };
148+ await env.BUCKET.put(`${partsPrefix(r)}account.json`, json(data));
149+}
150+
151+/** repositories/<name>.json, and its Git bundle when the repository has commits and isn't too large. */
152+export async function exportRepository(env: Env, id: string, repoId: string): Promise<void> {
153+ const r = await row(env, id);
154+ const repo = await new Directory(env).repoById(repoId);
155+ if (!repo) return;
156+ const at = `${partsPrefix(r)}repositories/${repo.name}`;
157+ let bundle: string | null = null;
158+ let note: string | undefined;
159+ try {
160+ const b = await env.REPO.get(env.REPO.idFromName(repo.id)).writeBundle(`${at}.bundle`, MAX_BUNDLE_BYTES);
161+ if (b) bundle = `${repo.name}.bundle`;
162+ else note = "The repository has no branches or tags yet.";
163+ } catch (e) {
164+ if (!(e instanceof Error) || !/too large/.test(e.message)) throw e;
165+ note = `The repository is larger than ${MAX_BUNDLE_BYTES / 1024 ** 3} GiB, too large to bundle: clone it from clone_url.`;
166+ }
167+ const meta = {
168+ name: repo.name,
169+ full_name: `${repo.owner_login}/${repo.name}`,
170+ description: repo.description,
171+ visibility: repo.visibility,
172+ default_branch: repo.default_branch,
173+ archived: !!repo.archived,
174+ topics: JSON.parse(repo.topics ?? "[]") as string[],
175+ homepage: repo.homepage ?? "",
176+ created_at: repo.created_at,
177+ pushed_at: repo.pushed_at,
178+ clone_url: `${env.PUBLIC_URL}/${repo.owner_login}/${repo.name}.git`,
179+ bundle,
180+ ...(note ? { note } : {}),
181+ };
182+ await env.BUCKET.put(`${at}.json`, json(meta));
183+}
184+
185+/** issues/<owner>/<repo>.json for each of these repositories where the account wrote something. */
186+export async function exportAuthored(env: Env, id: string, repoIds: string[]): Promise<void> {
187+ const r = await row(env, id);
188+ const dir = new Directory(env);
189+ await Promise.all(
190+ repoIds.map(async (repoId) => {
191+ const repo = await dir.repoById(repoId);
192+ if (!repo) return;
193+ const mine = await env.REPO.get(env.REPO.idFromName(repo.id)).authoredBy([r.account_id]);
194+ if (!mine.issues.length && !mine.comments.length && !mine.reviews.length) return;
195+ await env.BUCKET.put(`${partsPrefix(r)}issues/${repo.owner_login}/${repo.name}.json`, json({ repository: `${repo.owner_login}/${repo.name}`, ...mine }));
196+ }),
197+ );
198+}
199+
200+/** Last step: the pieces become one tar archive, the pieces go, and the person hears that it's ready. */
201+export async function assembleExport(env: Env, id: string): Promise<void> {
202+ const r = await row(env, id);
203+ const a = await owner(env, r);
204+ const parts: Array<{ key: string; path: string; size: number }> = [];
205+ let cursor: string | undefined;
206+ do {
207+ const page = await env.BUCKET.list({ prefix: partsPrefix(r), cursor });
208+ for (const o of page.objects) parts.push({ key: o.key, path: o.key.slice(partsPrefix(r).length), size: o.size });
209+ cursor = page.truncated ? page.cursor : undefined;
210+ } while (cursor);
211+ parts.sort((x, y) => (x.path === "account.json" ? -1 : y.path === "account.json" ? 1 : x.path.localeCompare(y.path)));
212+ const day = now().slice(0, 10);
213+ const top = `diffinery-export-${a.login}`;
214+ const key = `${prefix(r)}${top}-${day}.tar`;
215+ const mtime = Math.floor(Date.now() / 1000);
216+ const manifest = json({ format: EXPORT_FORMAT, instance: env.PUBLIC_URL, account: a.login, created_at: now(), files: parts.map((p) => ({ path: p.path, size: p.size })) });
217+ const sink = new R2Sink(env.BUCKET, key, "application/x-tar", MAX_ARCHIVE_BYTES, "the export is too large");
218+ try {
219+ for (const b of tarFileHeader(`${top}/manifest.json`, manifest.length, mtime)) await sink.write(b);
220+ await sink.write(manifest);
221+ await sink.write(tarPadding(manifest.length));
222+ for (const p of parts) {
223+ const obj = await env.BUCKET.get(p.key);
224+ if (!obj) throw new Error(`a piece of the export is missing: ${p.path}`);
225+ for (const b of tarFileHeader(`${top}/${p.path}`, obj.size, mtime)) await sink.write(b);
226+ const reader = obj.body.getReader();
227+ for (;;) {
228+ const { done, value } = await reader.read();
229+ if (done) break;
230+ await sink.write(value);
231+ }
232+ await sink.write(tarPadding(obj.size));
233+ }
234+ await sink.write(tarEnd());
235+ } catch (e) {
236+ await sink.abort();
237+ throw e;
238+ }
239+ const size = await sink.close();
240+ await deletePrefixes(env.BUCKET, [partsPrefix(r)]);
241+ const expires = new Date(Date.now() + EXPORT_DAYS * 86400_000).toISOString();
242+ await env.DB.prepare("UPDATE account_exports SET status = 'ready', r2_key = ?, size = ?, finished_at = ?, expires_at = ? WHERE id = ?").bind(key, size, now(), expires, id).run();
243+ await enqueueMail(env, [{ kind: "export_ready", data: { url: `${env.PUBLIC_URL}/settings/account`, size, expiresAt: expires }, to: { accountId: a.id }, reason: "account", at: now() }]);
244+}
245+
246+/** The export failed for good: say why on Settings → Account, and drop its pieces. */
247+export async function failExport(env: Env, id: string, message: string): Promise<void> {
248+ const r = await env.DB.prepare("SELECT * FROM account_exports WHERE id = ?").bind(id).first<ExportRow>();
249+ if (!r) return;
250+ await deletePrefixes(env.BUCKET, [prefix(r)]).catch(() => false);
251+ await env.DB.prepare("UPDATE account_exports SET status = 'failed', error = ?, finished_at = ? WHERE id = ?").bind(message.slice(0, 500), now(), id).run();
252+}
253+
254+/** The archive of a ready, unexpired export of this account, for downloading. */
255+export async function exportArchive(env: Env, accountId: string, id: string): Promise<{ body: ReadableStream; size: number; name: string }> {
256+ const r = await env.DB.prepare("SELECT * FROM account_exports WHERE id = ? AND account_id = ?").bind(id, accountId).first<ExportRow>();
257+ if (!r || r.status !== "ready" || !r.r2_key) throw new HttpError(404, "no such export");
258+ if (!r.expires_at || r.expires_at < now()) throw new HttpError(410, "this export has expired; ask for a new one");
259+ const obj = await env.BUCKET.get(r.r2_key);
260+ if (!obj) throw new HttpError(410, "this export has expired; ask for a new one");
261+ return { body: obj.body, size: obj.size, name: r.r2_key.split("/").at(-1)! };
262+}
263+
264+/** Cron: delete exports past their 7 days, and ones stuck building for more than a day. */
265+export async function expireExports(env: Env): Promise<void> {
266+ const old = await env.DB.prepare("SELECT * FROM account_exports WHERE expires_at < ? OR (status IN ('queued', 'running', 'failed') AND created_at < ?) LIMIT 50")
267+ .bind(now(), new Date(Date.now() - 86400_000).toISOString())
268+ .all<ExportRow>();
269+ for (const r of old.results) {
270+ if (await deletePrefixes(env.BUCKET, [prefix(r)], 50)) await env.DB.prepare("DELETE FROM account_exports WHERE id = ?").bind(r.id).run();
271+ }
272+}
addedapps/diffinery/src/accounts/lifecycle.ts+196−0View file
@@ -1,0 +1,196 @@
1+import type { Env } from "../env.js";
2+import type { Account, Directory } from "../lib/directory.js";
3+import { deletePrefixes } from "../lib/storage-keys.js";
4+import { HttpError, now } from "../lib/util.js";
5+import { enqueueMail } from "../mail/send.js";
6+import { RegistryStore } from "../registry/store.js";
7+
8+/**
9+ * The end of an account's life, and who runs the instance.
10+ *
11+ * Deleting an account (Settings → Account, or a site administrator in Site administration) removes, at once,
12+ * the person's repositories (their rows; each repository's Durable Object and R2 objects go in the background
13+ * through the same `destroy()` as deleting one repository), container images, access tokens, browser
14+ * sessions, passkeys, agents (and their tokens), organization memberships, collaborator grants, invitations,
15+ * notifications and settings, linked accounts, model keys, quota rows, the email log and any exports. What
16+ * the person wrote in other people's repositories stays, attributed to the `ghost` user as on GitHub
17+ * (RepoDO.forgetAccounts, also in the background, which also removes self-hosted runners they registered).
18+ * The account row stays, disabled under the placeholder login `deleted-<id>`, because the audit log and import
19+ * history refer to it; its username can be taken again after {@link LOGIN_COOLDOWN_DAYS} days.
20+ *
21+ * Deleting is refused while the person is the only owner of an organization, the only site administrator, or
22+ * has an import into their account in progress.
23+ */
24+
25+/** Days before a deleted account's username can be used again (GitHub uses 90 too). */
26+export const LOGIN_COOLDOWN_DAYS = 90;
27+
28+/** What the background part of deleting an account works on (AccountWorkflow). */
29+export interface DeletionCleanup {
30+ kind: "delete";
31+ accountId: string;
32+ /** The person and their agents: whose work becomes the ghost's. */
33+ ids: string[];
34+ /** Their logins before the deletion (runners and merges are recorded by login). */
35+ logins: string[];
36+ repoIds: string[];
37+}
38+
39+const list = (names: string[]) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
40+
41+/** Why `account` can't be deleted now, as sentences addressed to `self` (the person) or to an administrator. Empty when it can. */
42+export async function deletionBlockers(env: Env, account: Account, self: boolean): Promise<string[]> {
43+ const who = self ? "You are" : `${account.login} is`;
44+ const out: string[] = [];
45+ const orgs = await env.DB.prepare(
46+ `SELECT o.login FROM org_members m JOIN accounts o ON o.id = m.org_id
47+ WHERE m.user_id = ? AND m.role = 'owner'
48+ AND NOT EXISTS (SELECT 1 FROM org_members x WHERE x.org_id = m.org_id AND x.role = 'owner' AND x.user_id != m.user_id)
49+ ORDER BY o.login`,
50+ )
51+ .bind(account.id)
52+ .all<{ login: string }>();
53+ const logins = orgs.results.map((o) => o.login);
54+ if (logins.length) {
55+ const one = logins.length === 1;
56+ out.push(`${who} the only owner of ${one ? "the organization" : "the organizations"} ${list(logins)}: make someone else an owner of ${one ? "it" : "each"}, or delete ${one ? "it" : "them"}, first.`);
57+ }
58+ if (account.site_admin) {
59+ const others = await env.DB.prepare("SELECT count(*) AS n FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND deleted_at IS NULL AND id != ?").bind(account.id).first<{ n: number }>();
60+ if (!others?.n) out.push(`${who} the only site administrator: make someone else a site administrator first.`);
61+ }
62+ const imports = await env.DB.prepare("SELECT count(*) AS n FROM imports WHERE (target_owner_id = ? OR created_by = ?) AND status IN ('queued', 'running')").bind(account.id, account.id).first<{ n: number }>();
63+ if (imports?.n) out.push(`An import ${self ? "you started, or into your account," : `by or into ${account.login}`} is still in progress: wait for it to finish or cancel it first.`);
64+ return out;
65+}
66+
67+/**
68+ * Delete a person's account (see the top of this file). `by` is the person themselves or a site administrator;
69+ * the caller has checked who may and asked them to sign in again. Returns how much went.
70+ */
71+export async function deleteAccount(env: Env, dir: Directory, account: Account, by: Account, via: "settings" | "admin"): Promise<{ repositories: number; packages: number; agents: number }> {
72+ if (account.kind !== "user" || account.deleted_at) throw new HttpError(404, "no such person");
73+ const blockers = await deletionBlockers(env, account, by.id === account.id);
74+ if (blockers.length) throw new HttpError(422, blockers.join(" "));
75+ const db = env.DB;
76+ const repos = (await db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(account.id).all<{ id: string }>()).results.map((r) => r.id);
77+ const agents = (await db.prepare("SELECT id, login FROM accounts WHERE kind = 'agent' AND owner_id = ? AND deleted_at IS NULL").bind(account.id).all<{ id: string; login: string }>()).results;
78+ const ids = [account.id, ...agents.map((a) => a.id)];
79+ const logins = [account.login, ...agents.map((a) => a.login)];
80+ const inIds = ids.map(() => "?").join(", ");
81+ const packages = (await db.prepare(`SELECT id FROM packages WHERE owner_id IN (${inIds})`).bind(...ids).all<{ id: string }>()).results.map((p) => p.id);
82+
83+ // Container images first: their tags and manifests go now, unused bytes at the registry's next cleanup.
84+ const registry = new RegistryStore(env);
85+ for (const id of packages) await registry.deletePackage(id);
86+
87+ const t = now();
88+ const until = new Date(Date.now() + LOGIN_COOLDOWN_DAYS * 86400_000).toISOString();
89+ const forIds = (q: string) => db.prepare(q.replace("(?)", `(${inIds})`)).bind(...ids);
90+ const forMe = (q: string) => db.prepare(q).bind(account.id);
91+ await db.batch([
92+ ...dir.repoRowDeletes({ ownerId: account.id }),
93+ forIds("DELETE FROM tokens WHERE account_id IN (?)"),
94+ forMe("DELETE FROM sessions WHERE account_id = ?"),
95+ forMe("DELETE FROM passkeys WHERE account_id = ?"),
96+ forMe("DELETE FROM signin_links WHERE account_id = ? AND used_at IS NULL"),
97+ forMe("DELETE FROM org_members WHERE user_id = ?"),
98+ forIds("DELETE FROM repo_access WHERE account_id IN (?)"),
99+ db.prepare("DELETE FROM invites WHERE accepted_at IS NULL AND (account_id = ? OR invited_by = ?)").bind(account.id, account.id),
100+ forIds("DELETE FROM notifications WHERE account_id IN (?)"),
101+ forMe("DELETE FROM notification_prefs WHERE account_id = ?"),
102+ forMe("DELETE FROM repo_watches WHERE account_id = ?"),
103+ forMe("DELETE FROM thread_subscriptions WHERE account_id = ?"),
104+ forMe("DELETE FROM known_devices WHERE account_id = ?"),
105+ forMe("DELETE FROM access_requests WHERE account_id = ?"),
106+ forMe("DELETE FROM account_links WHERE account_id = ?"),
107+ forIds("DELETE FROM model_keys WHERE account_id IN (?)"),
108+ forMe("DELETE FROM terminal_terms WHERE account_id = ?"),
109+ forIds("DELETE FROM account_quotas WHERE account_id IN (?)"),
110+ forIds("DELETE FROM compute_usage WHERE account_id IN (?)"),
111+ forIds("DELETE FROM login_redirects WHERE account_id IN (?)"),
112+ forMe("DELETE FROM email_log WHERE account_id = ?"),
113+ forMe("DELETE FROM account_exports WHERE account_id = ?"),
114+ // A GitHub token kept for a draft import goes with the draft.
115+ forMe("UPDATE imports SET token_ciphertext = NULL, status = CASE WHEN status = 'draft' THEN 'cancelled' ELSE status END WHERE created_by = ?"),
116+ // The usernames wait before anyone can take them, so old links and mentions don't lead to someone else.
117+ ...logins.map((login, i) => db.prepare("INSERT OR REPLACE INTO released_logins (login, account_id, released_at, available_at) VALUES (?, ?, ?, ?)").bind(login, ids[i]!, t, until)),
118+ db
119+ .prepare(
120+ `UPDATE accounts SET login = 'deleted-' || id, name = NULL, email = NULL, status = 'disabled', site_admin = 0, avatar_version = NULL, appearance = NULL,
121+ actions_suspended_at = NULL, actions_suspended_by = NULL, actions_suspended_reason = NULL, deleted_at = ? WHERE id IN (${inIds})`,
122+ )
123+ .bind(t, ...ids),
124+ ]);
125+
126+ await dir.audit({ actorId: by.id, actorLogin: by.login, action: "account.delete", target: account.login, detail: { via, repositories: repos.length, packages: packages.length, agents: agents.map((a) => a.login) } });
127+ if (account.email) await enqueueMail(env, [{ kind: "account_deleted", data: { login: account.login, by: by.id === account.id ? undefined : by.login, cooldownDays: LOGIN_COOLDOWN_DAYS }, to: { email: account.email, name: account.name ?? undefined }, reason: "account", at: t }]);
128+ await startCleanup(env, { kind: "delete", accountId: account.id, ids, logins, repoIds: repos });
129+ return { repositories: repos.length, packages: packages.length, agents: agents.length };
130+}
131+
132+/** Hand the slow part of a deletion to the background (AccountWorkflow); without the binding (unit tests) it runs now. */
133+async function startCleanup(env: Env, job: DeletionCleanup): Promise<void> {
134+ if (env.ACCOUNT_WORKFLOW) {
135+ await env.ACCOUNT_WORKFLOW.create({ id: `delete-${job.accountId}`, params: job });
136+ return;
137+ }
138+ for (const id of job.repoIds) await destroyRepository(env, id);
139+ await deleteAccountObjects(env, job.accountId);
140+ for (let after: string | null = ""; after !== null; ) after = await ghostPage(env, job, after);
141+}
142+
143+/** Wipe one of a deleted account's repositories: its Durable Object and R2 objects, as deleting a repository does. */
144+export async function destroyRepository(env: Env, repoId: string): Promise<void> {
145+ await env.REPO.get(env.REPO.idFromName(repoId)).destroy();
146+}
147+
148+/** A deleted account's own R2 objects: profile pictures and exports. True once they are all gone. */
149+export async function deleteAccountObjects(env: Env, accountId: string): Promise<boolean> {
150+ return deletePrefixes(env.BUCKET, [`avatars/${accountId}/`, `exports/${accountId}/`]);
151+}
152+
153+/** How many repositories one background step visits. */
154+export const GHOST_PAGE = 25;
155+
156+/**
157+ * Attribute a deleted account's work to the ghost in the next page of repositories (by id, after `after`).
158+ * Every repository is visited: someone can comment on any public repository, and keeps what they wrote in
159+ * repositories they lost access to. Returns where the next page starts, or null when done.
160+ */
161+export async function ghostPage(env: Env, job: DeletionCleanup, after: string): Promise<string | null> {
162+ const page = await env.DB.prepare("SELECT id FROM repos WHERE id > ? ORDER BY id LIMIT ?").bind(after, GHOST_PAGE).all<{ id: string }>();
163+ await Promise.all(page.results.map((r) => env.REPO.get(env.REPO.idFromName(r.id)).forgetAccounts(job.ids, job.logins)));
164+ return page.results.length < GHOST_PAGE ? null : page.results.at(-1)!.id;
165+}
166+
167+// ---------------------------------------------------------------- site administrators
168+
169+/**
170+ * Make a person a site administrator, or stop them being one. Only an active person can be made one, and the
171+ * last active site administrator can't be removed (the check and the change are one statement, so two
172+ * administrators removing each other can't both succeed). Audited as `site_admin.grant` or `site_admin.revoke`,
173+ * and the person gets an email. False when nothing changed.
174+ */
175+export async function setSiteAdmin(env: Env, dir: Directory, target: Account, grant: boolean, by: Account): Promise<boolean> {
176+ if (target.kind !== "user" || target.deleted_at) throw new HttpError(404, "no such person");
177+ let changed: number;
178+ if (grant) {
179+ if (target.status !== "active") throw new HttpError(422, `${target.login}'s account is ${target.status}: enable it before making them a site administrator`);
180+ changed = (await env.DB.prepare("UPDATE accounts SET site_admin = 1 WHERE id = ? AND site_admin = 0 AND status = 'active'").bind(target.id).run()).meta.changes ?? 0;
181+ } else {
182+ changed =
183+ (
184+ await env.DB.prepare(
185+ "UPDATE accounts SET site_admin = 0 WHERE id = ? AND site_admin = 1 AND EXISTS (SELECT 1 FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active' AND deleted_at IS NULL AND id != ?)",
186+ )
187+ .bind(target.id, target.id)
188+ .run()
189+ ).meta.changes ?? 0;
190+ if (!changed && target.site_admin) throw new HttpError(422, "Diffinery needs at least one site administrator: make someone else a site administrator first");
191+ }
192+ if (!changed) return false;
193+ await dir.audit({ actorId: by.id, actorLogin: by.login, action: grant ? "site_admin.grant" : "site_admin.revoke", target: target.login });
194+ await enqueueMail(env, [{ kind: "site_admin", data: { granted: grant, by: by.login }, to: { accountId: target.id }, reason: "security_alert", at: now() }]);
195+ return true;
196+}
addedapps/diffinery/src/accounts/workflow.ts+57−0View file
@@ -1,0 +1,57 @@
1+import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
2+import type { Env } from "../env.js";
3+import { AUTHORED_PAGE, assembleExport, exportAccount, exportAuthored, exportRepository, failExport, startExport, type ExportJob } from "./export.js";
4+import { deleteAccountObjects, destroyRepository, ghostPage, type DeletionCleanup } from "./lifecycle.js";
5+
6+export type AccountJob = ExportJob | DeletionCleanup;
7+
8+const RETRY = { retries: { limit: 5, delay: "10 seconds", backoff: "exponential" }, timeout: "15 minutes" } as const;
9+/** Bundling a large repository, or putting a large archive together, takes longer. */
10+const LONG = { ...RETRY, timeout: "60 minutes" } as const;
11+/** An overall cap on paged steps, so a broken page can't run forever (enough for 100,000 repositories). */
12+const MAX_PAGES = 4000;
13+
14+/**
15+ * The background part of the account lifecycle (accounts/): building an export (instance `export-<id>`), and
16+ * finishing a deletion (instance `delete-<account id>`): wiping the deleted repositories, the account's own
17+ * R2 objects, and attributing its work in every other repository to the ghost. Each step is retried on
18+ * failure; an export that still fails is marked failed with the reason.
19+ */
20+export class AccountWorkflow extends WorkflowEntrypoint<Env, AccountJob> {
21+ override async run(event: WorkflowEvent<AccountJob>, step: WorkflowStep): Promise<unknown> {
22+ const job = event.payload;
23+ if (job.kind === "export") return this.export(job.exportId, step);
24+ return this.cleanup(job, step);
25+ }
26+
27+ private async export(id: string, step: WorkflowStep): Promise<unknown> {
28+ try {
29+ const plan = await step.do("start", RETRY, () => startExport(this.env, id));
30+ await step.do("account", RETRY, () => exportAccount(this.env, id));
31+ for (const repoId of plan.owned) await step.do(`repository ${repoId}`, LONG, () => exportRepository(this.env, id, repoId));
32+ for (let i = 0; i < plan.visible.length; i += AUTHORED_PAGE) {
33+ const page = plan.visible.slice(i, i + AUTHORED_PAGE);
34+ await step.do(`authored ${i}`, RETRY, () => exportAuthored(this.env, id, page));
35+ }
36+ await step.do("assemble", LONG, () => assembleExport(this.env, id));
37+ return { ready: true };
38+ } catch (e) {
39+ const message = e instanceof Error ? e.message : String(e);
40+ await step.do("fail", RETRY, () => failExport(this.env, id, message));
41+ return { failed: message };
42+ }
43+ }
44+
45+ private async cleanup(job: DeletionCleanup, step: WorkflowStep): Promise<unknown> {
46+ for (const repoId of job.repoIds) await step.do(`destroy ${repoId}`, LONG, () => destroyRepository(this.env, repoId));
47+ for (let i = 0; i < 10; i++) {
48+ if (await step.do(`objects ${i}`, RETRY, () => deleteAccountObjects(this.env, job.accountId))) break;
49+ }
50+ let after: string | null = "";
51+ for (let i = 0; after !== null && i < MAX_PAGES; i++) {
52+ const from: string = after;
53+ after = await step.do(`ghost ${i}`, RETRY, () => ghostPage(this.env, job, from));
54+ }
55+ return { done: true };
56+ }
57+}
modifiedapps/diffinery/src/app.ts+3−0View file
@@ -34,6 +34,7 @@
3434 import { recordActivity } from "./agentic/today.js";
3535 import { modelProxy } from "./agentic/proxy.js";
3636 import type { MailJob } from "./mail/types.js";
37+import { expireExports } from "./accounts/export.js";
3738
3839 export const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
3940
@@ -243,6 +244,8 @@
243244 schedulerStub(env).ensure().catch((e) => console.error("scheduler alarm check failed", e)),
244245 // Token expiry reminders and email log pruning.
245246 mailCron(env, new Directory(env), at).catch((e) => console.error("mail cron failed", e)),
247+ // Account exports past their 7 days (accounts/export.ts).
248+ expireExports(env).catch((e) => console.error("export expiry failed", e)),
246249 // Expire abandoned draft bulk imports after 7 days, and null their stored GitHub token (Low 5).
247250 env.DB.prepare("UPDATE imports SET status = 'cancelled', token_ciphertext = NULL, error = 'draft expired', finished_at = ?, updated_at = ? WHERE status = 'draft' AND created_at < ?")
248251 .bind(now(), now(), new Date(Date.now() - 7 * 86400_000).toISOString())
modifiedapps/diffinery/src/auth/sessions.ts+4−2View file
@@ -108,8 +108,10 @@
108108 return (r.meta.changes ?? 0) > 0;
109109 }
110110
111- async endOtherSessions(accountId: string, keepId: string | null): Promise<void> {
112- await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run();
111+ /** Sign out every other browser of the account; returns how many were signed out. */
112+ async endOtherSessions(accountId: string, keepId: string | null): Promise<number> {
113+ const r = await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run();
114+ return r.meta.changes ?? 0;
113115 }
114116
115117 async endAllSessions(accountId: string): Promise<void> {
modifiedapps/diffinery/src/env.ts+4−0View file
@@ -5,6 +5,8 @@
55 import type { RunnerContainer, SandboxContainer } from "./runner/containers.js";
66 import type { MailJob } from "./mail/types.js";
77 import type { ProjectDO } from "./agentic/ProjectDO.js";
8+import type { ExportJob } from "./accounts/export.js";
9+import type { DeletionCleanup } from "./accounts/lifecycle.js";
810
911 export interface Env {
1012 DB: D1Database;
@@ -48,6 +50,8 @@
4850 /** Email jobs, sent by the queue consumer in mail/send.ts. */
4951 MAIL?: Queue<MailJob>;
5052 IMPORT_WORKFLOW: Workflow<{ importId: string }>;
53+ /** Account exports and the background part of deleting an account (accounts/workflow.ts). Missing in unit tests, which run the work at once. */
54+ ACCOUNT_WORKFLOW?: Workflow<ExportJob | DeletionCleanup>;
5155 /** GitHub imports copy Git data in this container (absent under `wrangler dev`). */
5256 IMPORTER?: DurableObjectNamespace<ImportContainer>;
5357 /** "external": an import agent started outside the Worker claims the Git transfer (local development and tests). */
modifiedapps/diffinery/src/http/api.ts+1−1View file
@@ -90,7 +90,7 @@
9090 api.get("/users/:login", async (c) => {
9191 requireAccount(c);
9292 const a = await c.get("dir").accountByLogin(c.req.param("login"));
93- if (!a) throw new HttpError(404, "not found");
93+ if (!a || a.deleted_at) throw new HttpError(404, "not found");
9494 return c.json(userJson(c, a));
9595 });
9696 // Quotas and what an account uses of them (lib/quotas.ts): your own, an organization's for its owners
modifiedapps/diffinery/src/index.ts+1−0View file
@@ -10,6 +10,7 @@
1010 export { RunnerContainer, SandboxContainer, SandboxPoolDO } from "./runner/containers.js";
1111 export { RunWorkflow } from "./runner/workflow.js";
1212 export { ImportWorkflow } from "./imports/workflow.js";
13+export { AccountWorkflow } from "./accounts/workflow.js";
1314 export { ImportContainer } from "./imports/container.js";
1415 export { ProjectDO } from "./agentic/ProjectDO.js";
1516 // Carries hosted containers' HTTP and HTTPS out under EGRESS=web (lib/egress.ts).
modifiedapps/diffinery/src/lib/archive.ts+17−0View file
@@ -69,6 +69,23 @@
6969 return parts;
7070 }
7171
72+/**
73+ * The records before a regular file's `size` bytes, for writing its content as a stream (account exports):
74+ * the content follows, then {@link tarPadding}.
75+ */
76+export function tarFileHeader(path: string, size: number, mtime: number): Uint8Array[] {
77+ const parts: Uint8Array[] = [];
78+ if (enc.encode(path).length > 100) {
79+ const pax = enc.encode(paxRecord("path", path));
80+ parts.push(tarHeader("PaxHeader", pax.length, 0o644, "x", mtime), pax, pad512(pax.length));
81+ }
82+ parts.push(tarHeader(path, size, 0o644, "0", mtime));
83+ return parts;
84+}
85+
86+/** The zeros that fill a file's last 512-byte block. */
87+export const tarPadding = (size: number): Uint8Array => pad512(size);
88+
7289 /** The two zero blocks that end a tar archive. */
7390 export const tarEnd = (): Uint8Array => new Uint8Array(1024);
7491
modifiedapps/diffinery/src/lib/directory.ts+45−11View file
@@ -25,6 +25,8 @@
2525 /** Organizations: the role every member gets on its repositories ("none", "read", "write" or "admin"). */
2626 base_role: string | null;
2727 created_at: string;
28+ /** When the account was deleted (accounts/lifecycle.ts); the row stays under a placeholder login. */
29+ deleted_at?: string | null;
2830 }
2931
3032 export interface Repo {
@@ -107,13 +109,33 @@
107109 let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user";
108110 for (let i = 0; i < 50; i++) {
109111 const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`;
110- if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.accountByLogin(tryLogin))) return tryLogin;
112+ if (!RESERVED_LOGINS.has(tryLogin.toLowerCase()) && !(await this.loginTaken(tryLogin))) return tryLogin;
111113 }
112114 candidate = `${candidate}-${randomToken(3)}`;
113115 return candidate;
114116 }
115117
116118 /**
119+ * When a deleted account's username becomes free again (accounts/lifecycle.ts LOGIN_COOLDOWN_DAYS), or null
120+ * when it isn't cooling down.
121+ */
122+ async loginCooldown(login: string): Promise<string | null> {
123+ const r = await this.db.prepare("SELECT available_at FROM released_logins WHERE login = ? AND available_at > ?").bind(login, now()).first<{ available_at: string }>();
124+ return r?.available_at ?? null;
125+ }
126+
127+ /** Whether an account has this login, or a deleted account had it too recently for anyone to take it. */
128+ async loginTaken(login: string): Promise<boolean> {
129+ return !!(await this.accountByLogin(login)) || !!(await this.loginCooldown(login));
130+ }
131+
132+ /** Refuse a login that a deleted account gave up less than 90 days ago. */
133+ private async checkCooldown(login: string, what = "This username"): Promise<void> {
134+ const until = await this.loginCooldown(login);
135+ if (until) throw new HttpError(422, `${what} belonged to an account that was deleted recently; it can be used again from ${until.slice(0, 10)}`);
136+ }
137+
138+ /**
117139 * Find or create the user behind a verified email (Access recovery, invite links, open sign-up,
118140 * dev sign-in). A new account waits for an invite unless it has one, is a bootstrap
119141 * administrator or `hints.active` says so (open sign-up).
@@ -147,6 +169,7 @@
147169 if (patch.login) {
148170 const clash = await this.accountByLogin(patch.login);
149171 if (clash && clash.id !== id) throw new HttpError(422, "login already taken");
172+ await this.checkCooldown(patch.login, "This name");
150173 }
151174 // A blank display name clears it, so pages fall back to the login.
152175 const name = patch.name === undefined ? undefined : (patch.name as string | null)?.trim() || null;
@@ -184,6 +207,7 @@
184207 async createOrg(login: string, name: string, creator: Account): Promise<Account> {
185208 if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name");
186209 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
210+ await this.checkCooldown(login, "This name");
187211 const id = newId();
188212 const t = now();
189213 await this.db.batch([
@@ -353,7 +377,7 @@
353377 }
354378
355379 async users(): Promise<Account[]> {
356- return (await this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' ORDER BY login").all<Account>()).results;
380+ return (await this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' AND deleted_at IS NULL ORDER BY login").all<Account>()).results;
357381 }
358382
359383 async setStatus(id: string, status: Account["status"]): Promise<void> {
@@ -374,6 +398,7 @@
374398 if (!isValidLogin(input.login)) throw new HttpError(422, "invalid or reserved agent name");
375399 const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`;
376400 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
401+ await this.checkCooldown(login, "This name");
377402 const id = newId();
378403 const ownerId = input.orgId ?? owner.id;
379404 if (input.orgId) {
@@ -732,20 +757,29 @@
732757 }
733758
734759 async deleteRepo(repo: Repo): Promise<void> {
735- await this.db.batch([
736- this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id),
737- // Invitations reference the repository without ON DELETE CASCADE, so they'd block the delete.
738- this.db.prepare("DELETE FROM invites WHERE repo_id = ?").bind(repo.id),
739- this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id),
740- // A deleted repository's old names stop redirecting.
741- this.db.prepare("DELETE FROM repo_redirects WHERE repo_id = ?").bind(repo.id),
742- this.db.prepare("DELETE FROM repos WHERE id = ?").bind(repo.id),
743- ]);
760+ await this.db.batch(this.repoRowDeletes({ repoId: repo.id }));
744761 // Wipes the Durable Object's storage, then the repository's R2 objects.
745762 await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy();
746763 }
747764
748765 /**
766+ * The directory's part of deleting one repository, or every repository an account owns (account deletion,
767+ * which then runs each Durable Object's `destroy()` in the background).
768+ */
769+ repoRowDeletes(which: { repoId: string } | { ownerId: string }): D1PreparedStatement[] {
770+ const [ids, key] = "repoId" in which ? ["?", which.repoId] : ["SELECT id FROM repos WHERE owner_id = ?", which.ownerId];
771+ return [
772+ this.db.prepare(`DELETE FROM repo_access WHERE repo_id IN (${ids})`).bind(key),
773+ // Invitations reference the repository without ON DELETE CASCADE, so they'd block the delete.
774+ this.db.prepare(`DELETE FROM invites WHERE repo_id IN (${ids})`).bind(key),
775+ this.db.prepare(`DELETE FROM schedules WHERE repo_id IN (${ids})`).bind(key),
776+ // A deleted repository's old names stop redirecting.
777+ this.db.prepare(`DELETE FROM repo_redirects WHERE repo_id IN (${ids})`).bind(key),
778+ this.db.prepare(`DELETE FROM repos WHERE ${"repoId" in which ? "id" : "owner_id"} = ?`).bind(key),
779+ ];
780+ }
781+
782+ /**
749783 * Move a repository to another owner, like GitHub's transfer, optionally renaming it. The repository
750784 * keeps its id, so its Durable Object (git data, issues, pull requests, runs, secrets, variables,
751785 * environments, webhooks, deploy keys, caches) and its R2 objects stay where they are; its packages
modifiedapps/diffinery/src/lib/storage-keys.ts+1−0View file
@@ -12,6 +12,7 @@
1212 * | `logs/` | archived job logs | deleted after 90 days |
1313 * | `actions-staging/` | uploads in progress (blocks, chunks, pushes) | deleted after 1 day |
1414 * | `archives/` | zipball/tarball cache (rebuilt on a miss) | deleted after 30 days |
15+ * | `exports/` | account exports (accounts/export.ts) | deleted after 8 days |
1516 * | `events/` | the event archive | deleted after 400 days |
1617 *
1718 * Every prefix also aborts multipart uploads left unfinished for a day.
modifiedapps/diffinery/src/lib/util.ts+6−0View file
@@ -81,6 +81,12 @@
8181 /** Top-level paths the app serves; no account may use them as its login. */
8282 export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
8383
84+/**
85+ * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
86+ * pages show the login, and its profile page says what it means (accounts/lifecycle.ts).
87+ */
88+export const GHOST = { id: "ghost", login: "ghost", kind: "user" } as const;
89+
8490 export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase());
8591
8692 export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== "..";
modifiedapps/diffinery/src/mail/samples.ts+4−0View file
@@ -72,6 +72,10 @@
7272 item("commit-blocked", "Commit blocked", { kind: "push_blocked", reason: "security_alert", data: { repo, kinds: ["AWS access key"], paths: ["config.env"] } }),
7373 item("account-disabled", "Account disabled", { kind: "account_status", reason: "account", data: { status: "disabled" } }),
7474 item("account-enabled", "Account enabled", { kind: "account_status", reason: "account", data: { status: "active" } }),
75+ item("account-deleted", "Account deleted", { kind: "account_deleted", reason: "account", data: { login: me, cooldownDays: 90 } }),
76+ item("site-admin-granted", "Made a site administrator", { kind: "site_admin", reason: "security_alert", data: { granted: true, by: "admin" } }),
77+ item("site-admin-revoked", "No longer a site administrator", { kind: "site_admin", reason: "security_alert", data: { granted: false, by: "admin" } }),
78+ item("export-ready", "Account export ready", { kind: "export_ready", reason: "account", data: { url: `${pub}/settings/account`, size: 48_300_000, expiresAt: exp } }),
7579 item("invite-org", "Invitation (organization)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, org: repo.split("/")[0], role: "member", expiresAt: exp } }),
7680 item("invite-repo", "Invitation (repository)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, repo, role: "write", expiresAt: exp } }),
7781 item("invite-account", "Invitation (by username)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/settings/invitations`, repo, role: "write", expiresAt: exp, existing: true } }),
modifiedapps/diffinery/src/mail/templates.ts+46−0View file
@@ -240,6 +240,7 @@
240240 const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
241241 const ROLE_TEXT: Record<string, string> = { read: "read", triage: "triage", write: "write", maintain: "maintain", admin: "admin", member: "member", owner: "owner" };
242242 const roleText = (r: string) => ROLE_TEXT[r] ?? r;
243+const mib = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
243244 const article = (w: string) => (/^[aeiou]/i.test(w) ? "an" : "a");
244245
245246 function repoContext(pub: string, repo: string) {
@@ -560,6 +561,51 @@
560561 blocks: [{ p: ["A site administrator enabled your account again. Sign in with your passkey, and create new access tokens: the old ones were revoked when the account was disabled."] }, { button: { label: "Sign in", url: `${pub}/login` } }],
561562 url: `${pub}/login`,
562563 };
564+ case "account_deleted":
565+ return {
566+ subject: "[Diffinery] Your account was deleted",
567+ preheader: `@${job.data.login} and its repositories are gone.`,
568+ eyebrow: { text: "Account", tone: "red" },
569+ title: ["Your Diffinery account ", { b: user(job.data.login) }, " was deleted"],
570+ blocks: [
571+ { p: [job.data.by ? `A site administrator, ${user(job.data.by)}, deleted your account.` : "You deleted your account.", " Your repositories, container images, access tokens, passkeys and settings are gone, and every browser was signed out. Issues, pull requests and comments you wrote in other people's repositories stay, shown as written by ", { b: "ghost" }, "."] },
572+ { p: [`Your username can be used again, by anyone, ${job.data.cooldownDays} days from now. This is the last email you get from this Diffinery instance.`], muted: true },
573+ ],
574+ };
575+ case "site_admin":
576+ return job.data.granted
577+ ? {
578+ subject: "[Diffinery] You are now a site administrator",
579+ preheader: `${user(job.data.by)} made you a site administrator.`,
580+ eyebrow: { text: "Security", tone: "amber" },
581+ title: ["You are now a site administrator"],
582+ blocks: [
583+ { p: [user(job.data.by), " made you a site administrator of this Diffinery instance. You have admin on every repository and organization, and the Site administration pages in Settings."] },
584+ { button: { label: "Open Site administration", url: `${pub}/settings/admin` } },
585+ { callout: ["If you didn't expect this, tell another site administrator."], tone: "warn" },
586+ ],
587+ url: `${pub}/settings/admin`,
588+ }
589+ : {
590+ subject: "[Diffinery] You are no longer a site administrator",
591+ preheader: `${user(job.data.by)} removed your site administration.`,
592+ eyebrow: { text: "Security", tone: "amber" },
593+ title: ["You are no longer a site administrator"],
594+ blocks: [{ p: [user(job.data.by), " removed you from the site administrators of this Diffinery instance. Your own repositories and organizations are unchanged."] }],
595+ };
596+ case "export_ready":
597+ return {
598+ subject: "[Diffinery] Your account export is ready",
599+ preheader: `Download it before ${day(job.data.expiresAt)}.`,
600+ eyebrow: { text: "Account", tone: "green" },
601+ title: ["Your account export is ready"],
602+ blocks: [
603+ { p: [`The archive of your account (${mib(job.data.size)}) is ready: your profile and settings, your repositories as Git bundles, and the issues, pull requests and comments you wrote. Download it from Settings → Account before `, at(job.data.expiresAt), "."] },
604+ { button: { label: "Download your export", url: job.data.url } },
605+ { p: ["You'll be asked to sign in again before the download starts. If you didn't ask for an export, sign out your other browsers and tell a site administrator."], muted: true },
606+ ],
607+ url: job.data.url,
608+ };
563609 case "invite": {
564610 const d = job.data;
565611 const what = d.org ? `join the ${d.org} organization` : d.repo ? `collaborate on ${d.repo}` : "join Diffinery";
modifiedapps/diffinery/src/mail/types.ts+6−0View file
@@ -83,6 +83,9 @@
8383 | { kind: "push_bypassed"; data: { repo: string; pusher: string; kinds: string[]; reason: string; refs?: string[] } }
8484 | { kind: "push_blocked"; data: { repo: string; kinds: string[]; refs?: string[]; paths?: string[] } }
8585 | { kind: "account_status"; data: { status: "disabled" | "active" } }
86+ | { kind: "account_deleted"; data: { login: string; by?: string; cooldownDays: number } }
87+ | { kind: "site_admin"; data: { granted: boolean; by: string } }
88+ | { kind: "export_ready"; data: { url: string; size: number; expiresAt: string } }
8689 | { kind: "invite"; data: { inviter: string; url: string; org?: string; repo?: string; role: string; expiresAt: string; existing?: boolean } }
8790 | { kind: "invite_accepted"; data: { login: string; email: string; org?: string; repo?: string } }
8891 | { kind: "welcome"; data: { login: string } }
@@ -124,7 +127,10 @@
124127 case "push_bypassed":
125128 case "push_blocked":
126129 case "account_status":
130+ case "site_admin":
127131 return "security";
132+ case "account_deleted":
133+ case "export_ready":
128134 case "invite":
129135 case "welcome":
130136 case "signin_link":
modifiedapps/diffinery/src/repo/RepoDO.ts+91−1View file
@@ -18,8 +18,10 @@
1818 isTreeMode,
1919 mergeBase,
2020 parseCommit,
21+ packStream,
2122 parsePack,
2223 parseTree,
24+ planFetch,
2325 peelToCommit,
2426 readCommit,
2527 readTree,
@@ -46,7 +48,8 @@
4648 } from "../git/protocol.js";
4749 import { fsckObjects } from "../git/fsck.js";
4850 import { scanForSecrets, secretBypassReason, type SecretHit } from "../git/secret-scan.js";
49-import { HttpError, isValidRefName, now } from "../lib/util.js";
51+import { GHOST, HttpError, isValidRefName, now } from "../lib/util.js";
52+import { R2Sink } from "../lib/r2-sink.js";
5053 import { archivePrefix, deletePrefixes, repoPrefixes, type SpilledBody } from "../lib/storage-keys.js";
5154 import { storageRefusal } from "../lib/quotas.js";
5255 import { Actions } from "./actions.js";
@@ -107,6 +110,9 @@
107110 /** The close code that asks a page to reconnect at once (not a failure: no backoff, no reload). */
108111 export const LIVE_REAUTH_CODE = 4001;
109112
113+/** A row of what someone wrote (RepoDO.authoredBy): column name to value. */
114+type AuthoredRow = Record<string, string | number | null>;
115+
110116 export class RepoDO extends DurableObject<Env> {
111117 readonly sql: SqlStorage;
112118 store!: RepoObjectStore;
@@ -220,6 +226,90 @@
220226 };
221227 }
222228
229+ // ---------------------------------------------------------------- deleted and exported accounts (accounts/)
230+
231+ /**
232+ * After an account is deleted its work here stays, attributed to the `ghost` user as on GitHub: issues and
233+ * pull requests, comments, reviews, releases, statuses, deployments, events, workflow runs and reactions.
234+ * Self-hosted runners it registered here (by login) are removed. Returns how many rows changed.
235+ */
236+ async forgetAccounts(ids: string[], logins: string[]): Promise<number> {
237+ if (!ids.length) return 0;
238+ const inIds = ids.map(() => "?").join(", ");
239+ let changed = 0;
240+ const run = (q: string, ...b: unknown[]) => void (changed += this.sql.exec(q, ...b).rowsWritten);
241+ const people: Array<[table: string, prefix: string]> = [
242+ ["issues", "author"],
243+ ["comments", "author"],
244+ ["reviews", "author"],
245+ ["releases", "author"],
246+ ["statuses", "creator"],
247+ ["deployments", "creator"],
248+ ["deployment_statuses", "creator"],
249+ ["issue_events", "actor"],
250+ ["checks", "creator"],
251+ ["release_assets", "uploader"],
252+ ["reactions", "user"],
253+ ];
254+ for (const [table, p] of people) {
255+ // Reactions are one per person: where the ghost already reacted the same way, the deleted account's goes.
256+ run(`UPDATE OR IGNORE ${table} SET ${p}_id = ?, ${p}_login = ?, ${p}_kind = ? WHERE ${p}_id IN (${inIds})`, GHOST.id, GHOST.login, GHOST.kind, ...ids);
257+ }
258+ run(`DELETE FROM reactions WHERE user_id IN (${inIds})`, ...ids);
259+ run(`UPDATE deployment_reviews SET user_id = ?, user_login = ? WHERE user_id IN (${inIds})`, GHOST.id, GHOST.login, ...ids);
260+ run(`UPDATE runs SET actor = json_object('id', ?, 'login', ?, 'kind', ?) WHERE json_extract(actor, '$.id') IN (${inIds})`, GHOST.id, GHOST.login, GHOST.kind, ...ids);
261+ if (logins.length) {
262+ const lower = logins.map((l) => l.toLowerCase());
263+ const inLogins = lower.map(() => "?").join(", ");
264+ run(`UPDATE pulls SET merged_by = ? WHERE lower(merged_by) IN (${inLogins})`, GHOST.login, ...lower);
265+ run(`DELETE FROM runners WHERE lower(created_by) IN (${inLogins})`, ...lower);
266+ run(`DELETE FROM runner_registrations WHERE lower(created_by) IN (${inLogins})`, ...lower);
267+ }
268+ return changed;
269+ }
270+
271+ /** What these accounts wrote here, for an account export: issues and pull requests they opened, their comments and reviews (at most `limit` of each). */
272+ async authoredBy(ids: string[], limit = 5000): Promise<{ issues: AuthoredRow[]; comments: AuthoredRow[]; reviews: AuthoredRow[] }> {
273+ if (!ids.length || !this.readMeta()) return { issues: [], comments: [], reviews: [] };
274+ const inIds = ids.map(() => "?").join(", ");
275+ const rows = (q: string) => this.sql.exec<AuthoredRow>(q, ...ids, limit).toArray();
276+ return {
277+ issues: rows(`SELECT number, kind, title, body, state, state_reason, created_at, updated_at, closed_at FROM issues WHERE author_id IN (${inIds}) ORDER BY number LIMIT ?`),
278+ comments: rows(`SELECT id, number, body, path, line, created_at, updated_at FROM comments WHERE author_id IN (${inIds}) ORDER BY created_at LIMIT ?`),
279+ reviews: rows(`SELECT id, number, state, body, commit_id, submitted_at FROM reviews WHERE author_id IN (${inIds}) ORDER BY submitted_at LIMIT ?`),
280+ };
281+ }
282+
283+ /**
284+ * Write a Git bundle (version 2) of the repository's branches and tags to R2 at `key`, for an account export.
285+ * The pack streams out one object at a time, as a clone's does. Null for an empty repository; a bundle past
286+ * `limit` bytes is refused with a 413 and nothing is kept.
287+ */
288+ async writeBundle(key: string, limit: number): Promise<{ size: number; refs: number } | null> {
289+ if (!this.readMeta()) return null;
290+ const refs = this.refs().filter((r) => r.name.startsWith("refs/heads/") || r.name.startsWith("refs/tags/"));
291+ if (!refs.length) return null;
292+ const plan = await planFetch(this.store, [...new Set(refs.map((r) => r.oid))], []);
293+ const head = this.getRef(this.headRef);
294+ const sink = new R2Sink(this.env.BUCKET, key, "application/x-git-bundle", limit, "the repository is too large to bundle");
295+ const store = this.store;
296+ async function* entries() {
297+ for (const oid of plan.oids) {
298+ const raw = await store.readRaw(oid);
299+ if (!raw) throw new Error(`missing object ${oid}`);
300+ yield { type: raw.type, size: raw.size, deflated: raw.deflated };
301+ }
302+ }
303+ try {
304+ await sink.write(utf8(`# v2 git bundle\n${head ? `${head} HEAD\n` : ""}${refs.map((r) => `${r.oid} ${r.name}\n`).join("")}\n`));
305+ for await (const part of packStream(plan.oids.length, entries())) await sink.write(part);
306+ return { size: await sink.close(), refs: refs.length };
307+ } catch (e) {
308+ await sink.abort();
309+ throw e;
310+ }
311+ }
312+
223313 /** Keep the object alive for work that finishes after the current request. */
224314 background(p: Promise<unknown>): void {
225315 this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e)));
addedapps/diffinery/src/web/account-lifecycle.tsx+274−0View file
@@ -1,0 +1,274 @@
1+/** @jsxImportSource hono/jsx */
2+import type { Child, FC } from "hono/jsx";
3+import { deleteCookie } from "hono/cookie";
4+import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js";
5+import { SESSION_COOKIE } from "../auth/sessions.js";
6+import type { Account } from "../lib/directory.js";
7+import { HttpError } from "../lib/util.js";
8+import { EXPORT_DAYS, exportArchive, exportsFor, requestExport, type ExportRow } from "../accounts/export.js";
9+import { LOGIN_COOLDOWN_DAYS, deleteAccount, deletionBlockers, setSiteAdmin } from "../accounts/lifecycle.js";
10+import { ADMIN_TABS, SettingsNav, type SettingsTab } from "./account.js";
11+import { ComputePausedBanner } from "./kill-switches.js";
12+import { csrfToken } from "./csrf.js";
13+import { Form, Time, act, flash, fmtBytes, post, prm, render, type WebApp } from "./ui.js";
14+
15+/**
16+ * The account lifecycle in the web UI (accounts/): Settings → Account (export your data, delete your account),
17+ * and in Site administration deleting someone's account and making people site administrators. Deleting,
18+ * exporting and changing site administrators all ask for a sign-in from the last 2 hours.
19+ */
20+
21+const settingsPage = (c: AppContext, me: Account, tab: SettingsTab, title: string, body: Child) =>
22+ render(
23+ c,
24+ { title },
25+ <div class="grid left">
26+ <SettingsNav me={me} tab={tab} />
27+ <div>
28+ {ADMIN_TABS.includes(tab) ? <ComputePausedBanner env={c.env} /> : null}
29+ {body}
30+ </div>
31+ </div>,
32+ );
33+
34+const requireSiteAdmin = (c: AppContext): Account => {
35+ const me = requireAccount(c);
36+ if (!me.site_admin) throw new HttpError(403, "site administrators only");
37+ return me;
38+};
39+
40+/** A person (not an organization, agent or deleted account), by id. */
41+async function person(c: AppContext, id: string): Promise<Account> {
42+ const a = await c.get("dir").account(id);
43+ if (!a || a.kind !== "user" || a.deleted_at) throw new HttpError(404, "user not found");
44+ return a;
45+}
46+
47+const DangerBox: FC<{ title: string; children?: Child }> = ({ title, children }) => (
48+ <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)">
49+ <div class="head" style="background:var(--red-soft)">
50+ <h2 class="bad">{title}</h2>
51+ </div>
52+ {children}
53+ </div>
54+);
55+
56+const Blockers: FC<{ blockers: string[] }> = ({ blockers }) =>
57+ blockers.length ? (
58+ <div class="flash error" role="alert" data-blockers>
59+ {blockers.map((b) => (
60+ <div>{b}</div>
61+ ))}
62+ </div>
63+ ) : null;
64+
65+/** What deleting an account removes and keeps, for the person and for administrators. */
66+const WhatGoes: FC<{ who: string; repos: number }> = ({ who, repos }) => (
67+ <ul class="small muted">
68+ <li>
69+ {who} {repos === 1 ? "repository" : `${repos} repositories`} with their history, issues, pull requests, runs and secrets, {who === "Your" ? "your" : "their"} container images, access tokens, passkeys, agents, settings and memberships are deleted. Every browser is signed out.
70+ </li>
71+ <li>Issues, pull requests and comments written in other people's repositories stay, shown as written by ghost.</li>
72+ <li>The username can be used again, by anyone, {LOGIN_COOLDOWN_DAYS} days later.</li>
73+ </ul>
74+);
75+
76+const ExportStatus: FC<{ e: ExportRow }> = ({ e }) => {
77+ if (e.status === "ready")
78+ return (
79+ <li data-export="ready">
80+ <div class="grow">
81+ <b>Export ready</b> <span class="badge green">{fmtBytes(e.size ?? 0)}</span>
82+ <div class="meta">
83+ Made <Time at={e.finished_at} /> · available until {e.expires_at?.slice(0, 10)}
84+ </div>
85+ </div>
86+ <a class="btn sm primary" href={`/settings/account/exports/${e.id}/download`}>
87+ Download
88+ </a>
89+ </li>
90+ );
91+ if (e.status === "failed")
92+ return (
93+ <li data-export="failed">
94+ <div class="grow">
95+ <b class="bad">The export failed</b>
96+ <div class="meta">{e.error}</div>
97+ </div>
98+ </li>
99+ );
100+ return (
101+ <li data-export="building">
102+ <div class="grow">
103+ <b>Preparing your export…</b>
104+ <div class="meta">
105+ Asked <Time at={e.created_at} />. We'll email you when it's ready; this page shows it too.
106+ </div>
107+ </div>
108+ </li>
109+ );
110+};
111+
112+export function registerAccountLifecycle(web: WebApp): void {
113+ // ------------------------------------------------------------ Settings → Account
114+ web.get("/settings/account", async (c) => {
115+ const me = requireAccount(c);
116+ if (me.kind !== "user") throw new HttpError(403, "only people have these settings");
117+ const t = csrfToken(c);
118+ const [exports, blockers, repos] = await Promise.all([
119+ exportsFor(c.env, me.id),
120+ deletionBlockers(c.env, me, true),
121+ c.env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(me.id).first<{ n: number }>(),
122+ ]);
123+ const building = exports.some((e) => e.status === "queued" || e.status === "running");
124+ return render(
125+ c,
126+ { title: "Account", refresh: building ? 10 : undefined },
127+ <div class="grid left">
128+ <SettingsNav me={me} tab="account" />
129+ <div>
130+ <h1>Account</h1>
131+ <div class="box">
132+ <div class="head">
133+ <h2>Export your data</h2>
134+ </div>
135+ <div class="body">
136+ <p class="muted m0">
137+ Download an archive of your account: your profile and settings, each repository you own as a Git bundle (clone it with <code>git clone name.bundle</code>), and the issues, pull requests, comments and reviews you wrote. We build it in the background and email you when it's ready; you can download it here for {EXPORT_DAYS} days. Tokens and passkeys are listed by name only.
138+ </p>
139+ </div>
140+ {exports.length ? (
141+ <ul class="list">
142+ {exports.map((e) => (
143+ <ExportStatus e={e} />
144+ ))}
145+ </ul>
146+ ) : null}
147+ <div class="body" style="border-top:1px solid var(--border)">
148+ <Form action="/settings/account/export" t={t} class="inline">
149+ <button class="btn" disabled={building}>
150+ {exports.some((e) => e.status === "ready") ? "Start a new export" : "Start export"}
151+ </button>
152+ </Form>
153+ </div>
154+ </div>
155+ <DangerBox title="Delete your account">
156+ <Form action="/settings/account/delete" t={t} match={me.login} class="body">
157+ <Blockers blockers={blockers} />
158+ <WhatGoes who="Your" repos={repos?.n ?? 0} />
159+ <p class="muted small">
160+ This can't be undone. Type your username, <b class="mono">{me.login}</b>, to confirm.
161+ </p>
162+ <div class="row">
163+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm your username" />
164+ <button class="btn danger" disabled={blockers.length > 0}>
165+ Delete your account
166+ </button>
167+ </div>
168+ </Form>
169+ </DangerBox>
170+ </div>
171+ </div>,
172+ );
173+ });
174+
175+ post(web, "/settings/account/export", async (c) => {
176+ const me = requireAccount(c);
177+ return act(c, "/settings/account", async () => {
178+ if (me.kind !== "user") throw new HttpError(403, "only people can export their account");
179+ requireRecentSignIn(c, "/settings/account");
180+ const e = await requestExport(c.env, me);
181+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "account.export", detail: { id: e.id } }));
182+ flash(c, "notice", e.status === "ready" ? "Your export is ready." : "We're preparing your export. We'll email you when it's ready.");
183+ });
184+ });
185+
186+ web.get("/settings/account/exports/:id/download", async (c) => {
187+ const me = requireAccount(c);
188+ // The archive holds every repository and the account's data: only the person in a browser, never a token
189+ // (a repository-limited or read-only token would otherwise reach all of it).
190+ const p = c.get("principal");
191+ if (p.type !== "account" || (p.via !== "session" && p.via !== "dev")) throw new HttpError(403, "downloading an account export needs a browser session");
192+ requireRecentSignIn(c, "/settings/account");
193+ const a = await exportArchive(c.env, me.id, prm(c, "id"));
194+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "account.export_download", detail: { id: prm(c, "id"), size: a.size } }));
195+ return new Response(a.body, { headers: { "content-type": "application/x-tar", "content-length": String(a.size), "content-disposition": `attachment; filename="${a.name}"`, "cache-control": "private, no-store" } });
196+ });
197+
198+ post(web, "/settings/account/delete", async (c, f) => {
199+ const me = requireAccount(c);
200+ return act(c, "/settings/account", async () => {
201+ if (me.kind !== "user") throw new HttpError(403, "only people can delete their account here");
202+ if (f.s("confirm").trim() !== me.login) throw new HttpError(422, `type your username, ${me.login}, to confirm`);
203+ requireRecentSignIn(c, "/settings/account");
204+ await deleteAccount(c.env, c.get("dir"), me, me, "settings");
205+ deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true });
206+ flash(c, "notice", `Your account ${me.login} was deleted.`);
207+ return "/login";
208+ });
209+ });
210+
211+ // ------------------------------------------------------------ Site administration
212+ web.get("/settings/admin/users/:id/delete", async (c) => {
213+ const me = requireSiteAdmin(c);
214+ const target = await person(c, prm(c, "id"));
215+ const [blockers, repos] = await Promise.all([deletionBlockers(c.env, target, target.id === me.id), c.env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(target.id).first<{ n: number }>()]);
216+ return settingsPage(
217+ c,
218+ me,
219+ "admin",
220+ `Delete ${target.login}`,
221+ <>
222+ <h1>Delete {target.login}</h1>
223+ <p class="muted">
224+ <a href="/settings/admin">Site administration</a> · {target.email ?? "no email address"} · joined <Time at={target.created_at} />
225+ </p>
226+ <DangerBox title={`Delete the account ${target.login}`}>
227+ <Form action={`/settings/admin/users/${target.id}/delete`} t={csrfToken(c)} match={target.login} class="body">
228+ <Blockers blockers={blockers} />
229+ <WhatGoes who={target.id === me.id ? "Your" : `${target.login}'s`} repos={repos?.n ?? 0} />
230+ <p class="muted small">
231+ They get an email saying a site administrator deleted their account. This can't be undone. Type <b class="mono">{target.login}</b> to confirm.
232+ </p>
233+ <div class="row">
234+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm the username" />
235+ <button class="btn danger" disabled={blockers.length > 0}>
236+ Delete this account
237+ </button>
238+ </div>
239+ </Form>
240+ </DangerBox>
241+ </>,
242+ );
243+ });
244+
245+ post(web, "/settings/admin/users/:id/delete", async (c, f) => {
246+ const me = requireSiteAdmin(c);
247+ const back = `/settings/admin/users/${prm(c, "id")}/delete`;
248+ return act(c, back, async () => {
249+ const target = await person(c, prm(c, "id"));
250+ if (f.s("confirm").trim() !== target.login) throw new HttpError(422, `type ${target.login} to confirm deletion`);
251+ requireRecentSignIn(c, back);
252+ await deleteAccount(c.env, c.get("dir"), target, me, "admin");
253+ if (target.id === me.id) {
254+ deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true });
255+ return "/login";
256+ }
257+ flash(c, "notice", `Deleted the account ${target.login}.`);
258+ return "/settings/admin";
259+ });
260+ });
261+
262+ post(web, "/settings/admin/users/:id/site-admin", async (c, f) => {
263+ const me = requireSiteAdmin(c);
264+ return act(c, "/settings/admin", async () => {
265+ const target = await person(c, prm(c, "id"));
266+ const grant = f.s("grant") === "1";
267+ requireRecentSignIn(c, "/settings/admin");
268+ const changed = await setSiteAdmin(c.env, c.get("dir"), target, grant, me);
269+ flash(c, "notice", !changed ? `${target.login} ${grant ? "already is" : "isn't"} a site administrator.` : grant ? `${target.login} is now a site administrator.` : `${target.login} is no longer a site administrator.`);
270+ // Removing yourself takes you out of these pages.
271+ return !grant && changed && target.id === me.id ? "/settings" : undefined;
272+ });
273+ });
274+}
modifiedapps/diffinery/src/web/account.tsx+45−6View file
@@ -5,7 +5,7 @@
55 import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
66 import { loadRepo } from "../http/common.js";
77 import { movedLoginPath } from "../lib/redirects.js";
8-import { HttpError } from "../lib/util.js";
8+import { GHOST, HttpError } from "../lib/util.js";
99 import type { Role } from "../repo/types.js";
1010 import { ICONS } from "./assets.js";
1111 import { KCheckbox, KRadio, KSelect } from "./kumo.js";
@@ -644,7 +644,7 @@
644644
645645 // ---------------------------------------------------------------- /settings
646646
647-export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "linked" | "admin" | "email" | "audit" | "kill-switches" | "errors";
647+export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "linked" | "admin" | "email" | "audit" | "kill-switches" | "errors";
648648 /** The site administration pages: they show when hosted compute is paused. */
649649 export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "email", "audit", "kill-switches", "errors"];
650650
@@ -662,6 +662,9 @@
662662 <a href="/settings" class={tab === "profile" ? "on" : ""}>
663663 Profile
664664 </a>
665+ <a href="/settings/account" class={tab === "account" ? "on" : ""}>
666+ Account
667+ </a>
665668 <a href="/settings/appearance" class={tab === "appearance" ? "on" : ""}>
666669 Appearance
667670 </a>
@@ -960,7 +963,7 @@
960963 <div class="empty">No browser sessions.</div>
961964 )}
962965 <div class="body small muted" style="border-top:1px solid var(--border)">
963- A browser stays signed in for 30 days after you last use it, and at most 90 days after you sign in. Adding passkeys, creating tokens and agents, and deleting or transferring repositories ask you to sign in again when your sign-in is more than 2 hours old.
966+ A browser stays signed in for 30 days after you last use it, and at most 90 days after you sign in. Adding passkeys, creating tokens and agents, and deleting or transferring repositories ask you to sign in again when your sign-in is more than 2 hours old. Recovering your account by email or with a sign-in link signs out every other browser; access tokens keep working.
964967 </div>
965968 {sessions.some((s) => s.id !== current) ? (
966969 <div class="body" style="border-top:1px solid var(--border)">
@@ -1015,7 +1018,7 @@
10151018 post(web, "/settings/security/sessions/others", async (c) => {
10161019 const me = browserAccount(c);
10171020 const p = c.get("principal");
1018- return act(c, "/settings/security", async () => new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null), "Signed out of every other browser.");
1021+ return act(c, "/settings/security", async () => void (await new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null)), "Signed out of every other browser.");
10191022 });
10201023
10211024 // Tokens
@@ -1514,6 +1517,7 @@
15141517 <th>Email</th>
15151518 <th>Joined</th>
15161519 <th>Status</th>
1520+ <th>Site admin</th>
15171521 <th />
15181522 </tr>
15191523 </thead>
@@ -1535,10 +1539,28 @@
15351539 </Form>
15361540 )}
15371541 </td>
1538- <td class="right">
1542+ <td>
1543+ {u.site_admin ? (
1544+ <Form action={`/settings/admin/users/${u.id}/site-admin`} t={t} class="inline" confirm={u.id === me.id ? "Stop being a site administrator? You lose access to these pages." : `Remove ${u.login} from the site administrators?`}>
1545+ <input type="hidden" name="grant" value="0" />
1546+ <button class="btn sm">Remove admin</button>
1547+ </Form>
1548+ ) : u.status === "active" ? (
1549+ <Form action={`/settings/admin/users/${u.id}/site-admin`} t={t} class="inline" confirm={`Make ${u.login} a site administrator? They get admin on every repository and organization, and these pages.`}>
1550+ <input type="hidden" name="grant" value="1" />
1551+ <button class="btn sm">Make admin</button>
1552+ </Form>
1553+ ) : (
1554+ <span class="faint small">—</span>
1555+ )}
1556+ </td>
1557+ <td class="right nowrap">
15391558 <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}>
15401559 <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button>
1541- </Form>
1560+ </Form>{" "}
1561+ <a class="btn sm danger" href={`/settings/admin/users/${u.id}/delete`}>
1562+ Delete
1563+ </a>
15421564 </td>
15431565 </tr>
15441566 ))}
@@ -1875,6 +1897,21 @@
18751897 const dir = c.get("dir");
18761898 const t = csrfToken(c);
18771899 const acct = await dir.accountByLogin(prm(c, "owner"));
1900+ if (!acct && prm(c, "owner").toLowerCase() === GHOST.login) {
1901+ return render(
1902+ c,
1903+ { title: GHOST.login },
1904+ <div style="max-width:720px;margin:24px auto" class="box">
1905+ <div class="body row" style="gap:18px">
1906+ <Avatar login={GHOST.login} size="xl" />
1907+ <div>
1908+ <h1 class="m0">{GHOST.login}</h1>
1909+ <p class="muted">This is a placeholder for people who deleted their accounts. Issues, pull requests and comments they wrote in other people's repositories are shown as written by ghost.</p>
1910+ </div>
1911+ </div>
1912+ </div>,
1913+ );
1914+ }
18781915 if (!acct) {
18791916 // A renamed person's or organization's old login leads to the new one.
18801917 const renamed = await dir.renamedAccount(prm(c, "owner"));
@@ -1884,6 +1921,8 @@
18841921 }
18851922 // A deleted organization keeps its account row for the audit log (Directory.deleteOrg); its page is gone.
18861923 if (acct.kind === "org" && acct.status === "disabled") throw new HttpError(404, "not found");
1924+ // So does a deleted person's (accounts/lifecycle.ts).
1925+ if (acct.deleted_at) throw new HttpError(404, "not found");
18871926 if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301);
18881927
18891928 if (acct.kind === "agent") {
modifiedapps/diffinery/src/web/auth.tsx+31−11View file
@@ -15,7 +15,7 @@
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, now } from "../lib/util.js";
18+import { HttpError, b64url, fromB64url, isValidLogin, newId, now, sha256 } 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";
@@ -69,13 +69,18 @@
6969 return { device: deviceLabel(c.req.header("user-agent")), ip: c.req.header("cf-connecting-ip") ?? undefined, location };
7070 }
7171
72-async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> {
72+/** Sign this browser in; returns the new session's id. */
73+async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<string> {
7374 const old = getCookie(c, SESSION_COOKIE);
7475 if (old) await auth.endSession(old);
7576 const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null);
7677 setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 });
78+ return sha256(token);
7779 }
7880
81+/** What recovering an account (by email, or with a sign-in link) does to the account's other browsers. */
82+const RECOVERY_NOTE = "Every other browser signed in to your account was signed out; your access tokens keep working.";
83+
7984 function setChallenge(c: AppContext, token: string): void {
8085 setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 });
8186 }
@@ -152,7 +157,7 @@
152157 async function newAccountNames(c: AppContext, body: Record<string, unknown>, email: string): Promise<{ login: string; name: string }> {
153158 const login = str(body.login).trim() || loginFromEmail(email);
154159 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.`);
160+ if (await c.get("dir").loginTaken(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`);
156161 return { login, name: str(body.name).trim().slice(0, 100) };
157162 }
158163
@@ -376,7 +381,7 @@
376381 )}
377382 {accessConfigured(c) ? (
378383 <p>
379- Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>.
384+ Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>. Recovering signs out every other browser; access tokens keep working.
380385 </p>
381386 ) : (
382387 <p>Lost your passkey? Ask an administrator for a sign-in link.</p>
@@ -399,6 +404,7 @@
399404 This link lets you add a passkey to <b>{link.account.login}</b>
400405 {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey.
401406 </p>
407+ <p class="muted small">Using it signs out every other browser signed in to this account, in case a lost device is still signed in. Access tokens keep working.</p>
402408 <form data-passkey="register" data-next="/settings/security">
403409 <input type="hidden" name="link" value={token} />
404410 <div class="field">
@@ -554,7 +560,7 @@
554560 const inv = await auth.inviteByHash(s.inviteHash);
555561 if (!inv) throw new HttpError(404, "This invite was used or expired in the meantime.");
556562 if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
557- if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
563+ if (await dir.loginTaken(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
558564 // Creating the account accepts every open invite for this email, including this one.
559565 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
560566 // Two registrations racing for one invite: only the one that created the account gets a passkey on it.
@@ -570,7 +576,7 @@
570576 if (!signupOpen(c.env)) throw new HttpError(404, "Sign-up is closed on this instance.");
571577 const signups = new Signups(c.env);
572578 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.`);
579+ if (await dir.loginTaken(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
574580 if (!(await signups.useTicket(s.ticketHash))) throw new HttpError(404, "This sign-up was used or expired in the meantime. Start again at /signup.");
575581 // Open sign-up makes an active account (and accepts any open invites for the address).
576582 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId, active: true });
@@ -586,12 +592,17 @@
586592 account = found;
587593 await auth.addPasskey({ ...key, account_id: account.id });
588594 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link", passkeyId: key.id, ...signinContext(c) } }));
589- flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`);
595+ flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on. ${RECOVERY_NOTE}`);
590596 redirect = "/settings/security";
591597 } else {
592598 throw new HttpError(400, "unexpected sign-in attempt");
593599 }
594- await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id);
600+ const sid = await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id);
601+ // A sign-in link is account recovery: a lost or stolen device may still be signed in, so only this browser stays.
602+ if (s.kind === "link") {
603+ const signedOut = await auth.endOtherSessions(account.id, sid);
604+ c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.revoke_others", detail: { via: "signin-link", sessions: signedOut } }));
605+ }
595606 return c.json({ redirect });
596607 });
597608
@@ -697,12 +708,21 @@
697708 const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined });
698709 if (account.status === "disabled") throw new HttpError(403, "Your account is disabled.");
699710 const auth = new Auth(c.env);
700- await startSession(c, auth, account.id, "access", null);
711+ // Someone already signed in to this account here is confirming it's them (requireRecentSignIn); anyone else is
712+ // recovering the account, and a lost or stolen device may still be signed in: every other browser is signed out.
713+ const p = c.get("principal");
714+ const confirming = p.type === "account" && p.via === "session" && p.account.id === account.id;
715+ const sid = await startSession(c, auth, account.id, "access", null);
716+ const signedOut = confirming ? 0 : await auth.endOtherSessions(account.id, sid);
701717 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access", ...signinContext(c) } }));
718+ if (signedOut) c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.revoke_others", detail: { via: "access", sessions: signedOut } }));
702719 if (account.status === "pending") return c.redirect("/", 302);
703720 const keys = await auth.passkeys(account.id);
704- if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302);
705- flash(c, "notice", "You're signed in. Add a passkey now so you can sign in directly next time.");
721+ if (keys.length) {
722+ if (signedOut) flash(c, "notice", `You're signed in. ${RECOVERY_NOTE}`);
723+ return c.redirect(safeNext(c.req.query("next")), 302);
724+ }
725+ flash(c, "notice", `You're signed in.${signedOut ? ` ${RECOVERY_NOTE}` : ""} Add a passkey now so you can sign in directly next time.`);
706726 return c.redirect("/settings/security", 302);
707727 });
708728 }
modifiedapps/diffinery/src/web/routes.tsx+3−0View file
@@ -3,6 +3,7 @@
33 import type { Env } from "../env.js";
44 import type { AppVars } from "../auth/principal.js";
55 import { registerAccount } from "./account.js";
6+import { registerAccountLifecycle } from "./account-lifecycle.js";
67 import { registerTasks } from "../agentic/tasks-web.js";
78 import { registerReviews } from "../agentic/review-web.js";
89 import { registerProject } from "../agentic/project-web.js";
@@ -51,6 +52,8 @@
5152 registerReviews(web);
5253 registerProject(web);
5354 registerTerminals(web);
55+// Settings → Account and the account pages of site administration: fixed /settings paths, before /:owner.
56+registerAccountLifecycle(web);
5457 registerAccount(web);
5558 registerRepoSettings(web);
5659 registerActions(web);
addedapps/diffinery/test/e2e/accounts.test.mjs+408−0View file
@@ -1,0 +1,408 @@
1+// End-to-end account lifecycle against a real `wrangler dev` Worker with seeded accounts, tokens and sessions.
2+//
3+// pnpm --filter @diffinery/app e2e
4+//
5+// Covers: deleting your own account (refused while you are an organization's only owner, which it names;
6+// then your repositories, tokens, sessions and agents go, your comments elsewhere show as ghost, and your
7+// username waits 90 days), a site administrator deleting someone's account, exporting an account (a tar
8+// archive with a clonable Git bundle and the issues you wrote), and granting and revoking site
9+// administration (signed in recently, audited, never the last site administrator).
10+import { spawn, execFileSync } from "node:child_process";
11+import { createHash, randomBytes } from "node:crypto";
12+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13+import { tmpdir } from "node:os";
14+import { join, dirname, resolve } from "node:path";
15+import { fileURLToPath } from "node:url";
16+import { createServer } from "node:net";
17+import { test, before, after } from "node:test";
18+import assert from "node:assert/strict";
19+
20+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
21+const work = mkdtempSync(join(tmpdir(), "diffinery-accounts-"));
22+let base;
23+let dev;
24+let devLog = "";
25+
26+const hash = (s) => createHash("sha256").update(s).digest("hex");
27+const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
28+const sess = (name) => `${name}-session-${randomBytes(12).toString("hex")}`;
29+const T = { sully: tok("sully"), alice: tok("alice"), alicebot: tok("alicebot"), bob: tok("bob"), carl: tok("carl"), erin: tok("erin"), dan: tok("dan") };
30+const S = { sully: sess("sully"), sullyOld: sess("sullyold"), alice: sess("alice"), aliceOther: sess("aliceother"), bob: sess("bob"), carl: sess("carl"), erin: sess("erin"), dan: sess("dan") };
31+
32+const freePort = () =>
33+ new Promise((res) => {
34+ const s = createServer();
35+ s.listen(0, "127.0.0.1", () => {
36+ const { port } = s.address();
37+ s.close(() => res(port));
38+ });
39+ });
40+
41+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42+async function until(what, fn, timeoutMs = 120_000) {
43+ const end = Date.now() + timeoutMs;
44+ while (Date.now() < end) {
45+ const v = await fn();
46+ if (v) return v;
47+ await sleep(500);
48+ }
49+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
50+}
51+
52+/** Call the REST API as a token, a session cookie (`{ cookie }`) or anonymously (null). */
53+async function api(method, path, body, as) {
54+ const headers = { "content-type": "application/json" };
55+ if (typeof as === "string") headers.authorization = `Bearer ${as}`;
56+ else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`;
57+ const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
58+ const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e)));
59+ const text = await res.text();
60+ let json = null;
61+ try {
62+ json = text ? JSON.parse(text) : null;
63+ } catch {
64+ json = text;
65+ }
66+ return { status: res.status, json };
67+}
68+async function ok(method, path, body, as) {
69+ const r = await api(method, path, body, as);
70+ assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
71+ return r.json;
72+}
73+async function status(expected, method, path, body, as) {
74+ const r = await api(method, path, body, as);
75+ assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
76+ return r.json;
77+}
78+const b64 = (s) => Buffer.from(s).toString("base64");
79+
80+/** A web page as someone signed in with `session`. */
81+async function page(session, path) {
82+ const res = await fetch(`${base}${path}`, { headers: { cookie: `__Host-dfy_session=${session}` }, redirect: "manual" });
83+ return { status: res.status, location: res.headers.get("location"), text: await res.text(), headers: res.headers };
84+}
85+
86+/** A web form post signed in with `session`, with the CSRF pair taken from `from`. The flash message comes back decoded. */
87+async function submit(session, from, action, fields) {
88+ const cookie = `__Host-dfy_session=${session}`;
89+ const res = await fetch(`${base}${from}`, { headers: { cookie } });
90+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
91+ const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
92+ assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`);
93+ const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } });
94+ return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) };
95+}
96+
97+/** The audit log's rows (site administrators' page), as text. */
98+async function auditRows() {
99+ const html = (await page(S.sully, "/settings/audit")).text;
100+ return [...html.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((m) => m[1].replace(/<[^>]+>/g, " ").replace(/&quot;/g, '"').replace(/\s+/g, " "));
101+}
102+
103+/** Files in an uncompressed tar archive (ustar, with pax headers for long names). */
104+function untar(buf) {
105+ const files = new Map();
106+ let i = 0;
107+ let longName = null;
108+ while (i + 512 <= buf.length) {
109+ const h = buf.subarray(i, i + 512);
110+ if (h.every((b) => b === 0)) break;
111+ const field = (from, len) => h.subarray(from, from + len).toString("utf8").replace(/\0[\s\S]*$/, "");
112+ const size = parseInt(field(124, 12).trim(), 8);
113+ const type = String.fromCharCode(h[156]);
114+ const body = buf.subarray(i + 512, i + 512 + size);
115+ i += 512 + Math.ceil(size / 512) * 512;
116+ if (type === "x") {
117+ longName = /\d+ path=([^\n]*)\n/.exec(body.toString("utf8"))?.[1] ?? null;
118+ continue;
119+ }
120+ files.set(longName ?? field(0, 100), Buffer.from(body));
121+ longName = null;
122+ }
123+ return files;
124+}
125+
126+before(async () => {
127+ const port = await freePort();
128+ base = `http://127.0.0.1:${port}`;
129+ const persist = join(work, "state");
130+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
131+ wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
132+ const at = new Date().toISOString();
133+ const hoursAgo = (h) => new Date(Date.now() - h * 3600_000).toISOString();
134+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
135+ const account = (id, login, kind, owner = null) => `INSERT INTO accounts (id, login, kind, name, email, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', ${kind === "user" ? `'${login}@example.com'` : "NULL"}, 'active', ${owner ? `'${owner}'` : "NULL"}, '${at}')`;
136+ const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`;
137+ const session = (account, cookie, signedIn = at) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${signedIn}', '${at}', '${later}')`;
138+ wrangler(
139+ "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
140+ [
141+ account("u-sully", "sully", "user"),
142+ "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'",
143+ account("u-alice", "alice", "user"),
144+ account("a-alicebot", "alice-bot", "agent", "u-alice"),
145+ account("u-bob", "bob", "user"),
146+ account("u-carl", "carl", "user"),
147+ account("u-erin", "erin", "user"),
148+ account("u-dan", "dan", "user"),
149+ account("o-solo", "solo", "org"),
150+ account("o-team", "team", "org"),
151+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-solo', 'u-alice', 'owner', '${at}'), ('o-team', 'u-alice', 'owner', '${at}'), ('o-team', 'u-bob', 'owner', '${at}')`,
152+ `INSERT INTO passkeys (id, account_id, public_key, device_type, name, created_at) VALUES ('pk-alice', 'u-alice', 'AAAA', 'singleDevice', 'Laptop', '${at}')`,
153+ ...Object.entries(T).map(([who, t]) => token(`t-${who}`, who === "alicebot" ? "a-alicebot" : `u-${who}`, t)),
154+ session("u-sully", S.sully),
155+ // sully signed in on another browser 3 hours ago: too long ago for sensitive actions.
156+ session("u-sully", S.sullyOld, hoursAgo(3)),
157+ session("u-alice", S.alice),
158+ session("u-alice", S.aliceOther),
159+ session("u-bob", S.bob),
160+ session("u-carl", S.carl),
161+ session("u-erin", S.erin),
162+ session("u-dan", S.dan),
163+ ].join("; "),
164+ );
165+ dev = spawn(
166+ "pnpm",
167+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"],
168+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
169+ );
170+ dev.stdout.on("data", (d) => (devLog += d));
171+ dev.stderr.on("data", (d) => (devLog += d));
172+ await until(
173+ "wrangler dev",
174+ async () => {
175+ try {
176+ return (await fetch(`${base}/healthz`)).ok;
177+ } catch {
178+ return false;
179+ }
180+ },
181+ 120_000,
182+ );
183+});
184+
185+after(() => {
186+ if (dev?.pid) {
187+ try {
188+ process.kill(-dev.pid, "SIGTERM");
189+ } catch {}
190+ }
191+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
192+});
193+
194+test("deleting your account: refused while you are an organization's only owner; then everything goes and your comments show as ghost", async () => {
195+ // ------------------------------------------------------------ alice's own repository, and her work in bob's
196+ await ok("POST", "/user/repos", { name: "notes" }, T.alice);
197+ await ok("PUT", "/repos/alice/notes/contents/README.md", { message: "init", content: b64("# notes\n") }, T.alice);
198+ await ok("POST", "/user/repos", { name: "app", visibility: "public" }, T.bob);
199+ await ok("PUT", "/repos/bob/app/contents/README.md", { message: "init", content: b64("# app\n") }, T.bob);
200+ const issue = await ok("POST", "/repos/bob/app/issues", { title: "Crash on start", body: "It crashes." }, T.alice);
201+ assert.equal(issue.user.login, "alice");
202+ await ok("POST", `/repos/bob/app/issues/${issue.number}/comments`, { body: "Here is the stack trace." }, T.alice);
203+ await ok("POST", `/repos/bob/app/issues/${issue.number}/comments`, { body: "Thanks, looking." }, T.bob);
204+ await ok("GET", "/user", undefined, T.alicebot);
205+ assert.equal((await api("GET", "/user", undefined, { cookie: S.aliceOther })).status, 200);
206+
207+ // ------------------------------------------------------------ refused while she is the only owner of solo (team has bob too)
208+ const settings = await page(S.alice, "/settings/account");
209+ assert.equal(settings.status, 200);
210+ assert.match(settings.text, /Delete your account/);
211+ assert.match(settings.text, /You are the only owner of the organization solo/);
212+ assert.doesNotMatch(settings.text, /\bteam\b/);
213+ let r = await submit(S.alice, "/settings/account", "/settings/account/delete", { confirm: "alice" });
214+ assert.equal(r.status, 303);
215+ assert.equal(r.location, "/settings/account");
216+ assert.match(r.flash, /You are the only owner of the organization solo: make someone else an owner of it, or delete it, first/);
217+ await ok("GET", "/user", undefined, T.alice);
218+
219+ // She deletes solo; a wrong confirmation still deletes nothing.
220+ await status(202, "DELETE", "/orgs/solo", undefined, T.alice);
221+ r = await submit(S.alice, "/settings/account", "/settings/account/delete", { confirm: "alicе" });
222+ assert.match(r.flash, /type your username, alice, to confirm/);
223+ await ok("GET", "/repos/alice/notes", undefined, T.alice);
224+
225+ // ------------------------------------------------------------ deleted
226+ r = await submit(S.alice, "/settings/account", "/settings/account/delete", { confirm: "alice" });
227+ assert.equal(r.status, 303);
228+ assert.equal(r.location, "/login");
229+ assert.match(r.flash, /Your account alice was deleted/);
230+
231+ // Tokens (hers and her agent's) and every browser session are dead.
232+ await status(401, "GET", "/user", undefined, T.alice);
233+ await status(401, "GET", "/user", undefined, T.alicebot);
234+ await status(401, "GET", "/user", undefined, { cookie: S.aliceOther });
235+ await status(401, "GET", "/user", undefined, { cookie: S.alice });
236+ // Her repository is gone, for everyone and by every name.
237+ await status(404, "GET", "/repos/alice/notes", undefined, T.sully);
238+ await status(404, "GET", "/repos/deleted-u-alice/notes", undefined, T.sully);
239+ assert.equal((await fetch(`${base}/alice/notes.git/info/refs?service=git-upload-pack`, { headers: { authorization: `Bearer ${T.sully}` } })).status, 404);
240+ // Her profile and memberships are gone.
241+ await status(404, "GET", "/users/alice", undefined, T.bob);
242+ assert.equal((await page(S.bob, "/alice")).status, 404);
243+ assert.deepEqual((await ok("GET", "/orgs/team/members", undefined, T.bob)).map((m) => m.login), ["bob"]);
244+
245+ // What she wrote in bob's repository stays, as ghost's; bob's own comment is untouched.
246+ const ghosted = await until("alice's issue attributed to ghost", async () => {
247+ const i = await ok("GET", `/repos/bob/app/issues/${issue.number}`, undefined, T.bob);
248+ return i.user.login === "ghost" ? i : null;
249+ });
250+ assert.equal(ghosted.title, "Crash on start");
251+ const comments = await ok("GET", `/repos/bob/app/issues/${issue.number}/comments`, undefined, T.bob);
252+ assert.deepEqual(comments.map((c) => [c.user.login, c.body]), [["ghost", "Here is the stack trace."], ["bob", "Thanks, looking."]]);
253+ const ghostPage = await page(S.bob, "/ghost");
254+ assert.equal(ghostPage.status, 200);
255+ assert.match(ghostPage.text, /placeholder for people who deleted their accounts/);
256+
257+ // Her username waits 90 days before anyone can take it.
258+ const taken = await status(422, "POST", "/user/orgs", { login: "alice" }, T.bob);
259+ assert.match(taken.message, /belonged to an account that was deleted recently; it can be used again from \d{4}-\d{2}-\d{2}/);
260+ r = await submit(S.bob, "/settings", "/settings/profile", { login: "Alice", name: "Bob" });
261+ assert.match(r.flash, /belonged to an account that was deleted recently/);
262+ assert.equal((await ok("GET", "/user", undefined, T.bob)).login, "bob");
263+
264+ // Audited, with what went.
265+ const row = (await auditRows()).find((t) => / account\.delete /.test(t) && / alice /.test(t));
266+ assert.ok(row, "account.delete is in the audit log");
267+ assert.match(row, /"via":"settings","repositories":1,"packages":0,"agents":\["alice-bot"\]/);
268+});
269+
270+test("a site administrator deletes someone's account, signed in recently; their own is refused while they are the only one", async () => {
271+ // Not for other people.
272+ assert.equal((await page(S.bob, "/settings/admin/users/u-carl/delete")).status, 403);
273+ const confirm = await page(S.sully, "/settings/admin/users/u-carl/delete");
274+ assert.equal(confirm.status, 200);
275+ assert.match(confirm.text, /Delete the account carl/);
276+ assert.match((await page(S.sully, "/settings/admin")).text, /href="\/settings\/admin\/users\/u-carl\/delete"/);
277+
278+ // A sign-in from 3 hours ago must confirm it's them first; nothing is deleted.
279+ let r = await submit(S.sullyOld, "/settings/admin/users/u-carl/delete", "/settings/admin/users/u-carl/delete", { confirm: "carl" });
280+ assert.equal(r.status, 303);
281+ assert.match(r.location, /^\/login\?confirm=1&next=/);
282+ await ok("GET", "/user", undefined, T.carl);
283+
284+ r = await submit(S.sully, "/settings/admin/users/u-carl/delete", "/settings/admin/users/u-carl/delete", { confirm: "carl" });
285+ assert.equal(r.status, 303);
286+ assert.equal(r.location, "/settings/admin");
287+ assert.match(r.flash, /Deleted the account carl/);
288+ await status(401, "GET", "/user", undefined, T.carl);
289+ await status(401, "GET", "/user", undefined, { cookie: S.carl });
290+ assert.doesNotMatch((await page(S.sully, "/settings/admin")).text, /carl@example\.com/);
291+ const row = (await auditRows()).find((t) => / account\.delete /.test(t) && / carl /.test(t));
292+ assert.ok(row, "the admin deletion is audited");
293+ assert.match(row, /sully/);
294+ assert.match(row, /"via":"admin"/);
295+
296+ // sully is the only site administrator: deleting that account is refused, and says why.
297+ const own = await page(S.sully, "/settings/admin/users/u-sully/delete");
298+ assert.match(own.text, /You are the only site administrator: make someone else a site administrator first/);
299+ r = await submit(S.sully, "/settings/account", "/settings/account/delete", { confirm: "sully" });
300+ assert.match(r.flash, /only site administrator/);
301+ await ok("GET", "/user", undefined, T.sully);
302+});
303+
304+test("exporting your account: a tar archive with a clonable Git bundle and the issues you wrote, for you only", async () => {
305+ await ok("POST", "/user/repos", { name: "diary" }, T.erin);
306+ await ok("PUT", "/repos/erin/diary/contents/README.md", { message: "first entry", content: b64("# Dear diary\n") }, T.erin);
307+ const mine = await ok("POST", "/repos/erin/diary/issues", { title: "Export me", body: "An issue in my own repository." }, T.erin);
308+ const theirs = await ok("POST", "/repos/bob/app/issues", { title: "Erin was here", body: "An issue in someone else's repository." }, T.erin);
309+
310+ const settings = await page(S.erin, "/settings/account");
311+ assert.match(settings.text, /Export your data/);
312+ let r = await submit(S.erin, "/settings/account", "/settings/account/export", {});
313+ assert.equal(r.status, 303);
314+ assert.match(r.flash, /We're preparing your export|Your export is ready/);
315+
316+ const href = await until("the export", async () => {
317+ const p = (await page(S.erin, "/settings/account")).text;
318+ assert.doesNotMatch(p, /data-export="failed"/, "the export failed");
319+ return /href="(\/settings\/account\/exports\/[^"]+\/download)"/.exec(p)?.[1] ?? null;
320+ });
321+ // Nobody else can download it.
322+ assert.equal((await page(S.bob, href)).status, 404);
323+ // Not with a token, even erin's own: the archive is the whole account.
324+ const viaToken = await fetch(`${base}${href}`, { headers: { authorization: `Bearer ${T.erin}` } });
325+ assert.equal(viaToken.status, 403);
326+ await viaToken.arrayBuffer();
327+ const res = await fetch(`${base}${href}`, { headers: { cookie: `__Host-dfy_session=${S.erin}` } });
328+ assert.equal(res.status, 200);
329+ assert.equal(res.headers.get("content-type"), "application/x-tar");
330+ assert.match(res.headers.get("content-disposition") ?? "", /attachment; filename="diffinery-export-erin-\d{4}-\d{2}-\d{2}\.tar"/);
331+ const archive = Buffer.from(await res.arrayBuffer());
332+ const file = join(work, "export.tar");
333+ writeFileSync(file, archive);
334+ // A real tar archive.
335+ const listed = execFileSync("tar", ["-tf", file], { encoding: "utf8" }).trim().split("\n");
336+ const top = "diffinery-export-erin";
337+ for (const p of ["manifest.json", "account.json", "repositories/diary.json", "repositories/diary.bundle", "issues/erin/diary.json", "issues/bob/app.json"]) assert.ok(listed.includes(`${top}/${p}`), `${p} in ${listed.join(", ")}`);
338+ const files = untar(archive);
339+ const manifest = JSON.parse(files.get(`${top}/manifest.json`));
340+ assert.equal(manifest.format, 1);
341+ assert.equal(manifest.account, "erin");
342+ const acct = JSON.parse(files.get(`${top}/account.json`));
343+ assert.equal(acct.login, "erin");
344+ assert.equal(acct.email, "erin@example.com");
345+ assert.deepEqual(acct.tokens.map((t) => t.name), ["e2e"]);
346+ assert.doesNotMatch(files.get(`${top}/account.json`).toString(), new RegExp(T.erin));
347+ const meta = JSON.parse(files.get(`${top}/repositories/diary.json`));
348+ assert.equal(meta.bundle, "diary.bundle");
349+ assert.equal(meta.clone_url, `${base}/erin/diary.git`);
350+ // The bundle clones.
351+ writeFileSync(join(work, "diary.bundle"), files.get(`${top}/repositories/diary.bundle`));
352+ execFileSync("git", ["bundle", "verify", join(work, "diary.bundle")], { stdio: "pipe" });
353+ execFileSync("git", ["clone", "-q", join(work, "diary.bundle"), join(work, "diary")], { stdio: "pipe" });
354+ assert.equal(readFileSync(join(work, "diary", "README.md"), "utf8"), "# Dear diary\n");
355+ // The issues erin opened, in her repository and in bob's.
356+ const own = JSON.parse(files.get(`${top}/issues/erin/diary.json`));
357+ assert.deepEqual(own.issues.map((i) => [i.number, i.title]), [[mine.number, "Export me"]]);
358+ const elsewhere = JSON.parse(files.get(`${top}/issues/bob/app.json`));
359+ assert.deepEqual(elsewhere.issues.map((i) => [i.number, i.title]), [[theirs.number, "Erin was here"]]);
360+ assert.ok(manifest.files.some((f) => f.path === "repositories/diary.bundle" && f.size === files.get(`${top}/repositories/diary.bundle`).length));
361+
362+ // Asking again replaces it; the request and the download are audited.
363+ const rows = await auditRows();
364+ assert.ok(rows.some((t) => / account\.export /.test(t) && / erin /.test(t)));
365+ assert.ok(rows.some((t) => / account\.export_download /.test(t) && / erin /.test(t)));
366+});
367+
368+test("site administration: granting and revoking needs a recent sign-in, is audited, and never removes the last site administrator", async () => {
369+ const user = async (t) => (await ok("GET", "/user", undefined, t)).site_admin;
370+ // sully is the only site administrator: removing that is refused.
371+ let r = await submit(S.sully, "/settings/admin", "/settings/admin/users/u-sully/site-admin", { grant: "0" });
372+ assert.equal(r.status, 303);
373+ assert.match(r.flash, /Diffinery needs at least one site administrator: make someone else a site administrator first/);
374+ assert.equal(await user(T.sully), true);
375+
376+ // People who aren't site administrators can't grant it.
377+ r = await submit(S.dan, "/settings/account", "/settings/admin/users/u-dan/site-admin", { grant: "1" });
378+ assert.notEqual(r.status, 303);
379+ assert.equal(await user(T.dan), false);
380+
381+ // A sign-in from 3 hours ago must confirm it's them first.
382+ r = await submit(S.sullyOld, "/settings/admin", "/settings/admin/users/u-dan/site-admin", { grant: "1" });
383+ assert.match(r.location, /^\/login\?confirm=1&next=%2Fsettings%2Fadmin$/);
384+ assert.equal(await user(T.dan), false);
385+
386+ // Granted from the users table.
387+ assert.match((await page(S.sully, "/settings/admin")).text, /action="\/settings\/admin\/users\/u-dan\/site-admin"/);
388+ r = await submit(S.sully, "/settings/admin", "/settings/admin/users/u-dan/site-admin", { grant: "1" });
389+ assert.equal(r.location, "/settings/admin");
390+ assert.match(r.flash, /dan is now a site administrator/);
391+ assert.equal(await user(T.dan), true);
392+ assert.equal((await page(S.dan, "/settings/admin")).status, 200);
393+
394+ // dan removes sully; then dan is the last one and can't step down.
395+ r = await submit(S.dan, "/settings/admin", "/settings/admin/users/u-sully/site-admin", { grant: "0" });
396+ assert.match(r.flash, /sully is no longer a site administrator/);
397+ assert.equal(await user(T.sully), false);
398+ assert.equal((await page(S.sully, "/settings/admin")).status, 403);
399+ r = await submit(S.dan, "/settings/admin", "/settings/admin/users/u-dan/site-admin", { grant: "0" });
400+ assert.match(r.flash, /needs at least one site administrator/);
401+ assert.equal(await user(T.dan), true);
402+
403+ // Both changes are in the audit log, with who did them.
404+ const html = (await page(S.dan, "/settings/audit")).text;
405+ const rows = [...html.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((m) => m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " "));
406+ assert.ok(rows.some((t) => / sully /.test(t) && / site_admin\.grant /.test(t) && / dan /.test(t)), "the grant is audited");
407+ assert.ok(rows.some((t) => / dan /.test(t) && / site_admin\.revoke /.test(t) && / sully /.test(t)), "the revocation is audited");
408+});
modifiedapps/diffinery/test/e2e/entry.ts+1−0View file
@@ -9,6 +9,7 @@
99 export { RunnerContainer, SandboxContainer } from "../../src/runner/containers.js";
1010 export { RunWorkflow } from "../../src/runner/workflow.js";
1111 export { ImportWorkflow } from "../../src/imports/workflow.js";
12+export { AccountWorkflow } from "../../src/accounts/workflow.js";
1213 export { ImportContainer } from "../../src/imports/container.js";
1314
1415 export default { fetch: app.fetch, queue, scheduled } satisfies ExportedHandler<Env, DiffineryEvent | MailJob>;
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+17−1View file
@@ -6,7 +6,8 @@
66 // Covers: signed-out redirects, joining from an invite link with a new passkey
77 // (only from the emailed copy, which proves the address; the invite also applies
88 // its org membership), adding and removing
9-// passkeys, signing out and back in, admin-issued one-time sign-in links,
9+// passkeys, signing out and back in, admin-issued one-time sign-in links (which sign out the account's
10+// other browsers but leave its tokens),
1011 // single-use invites and links, and refusing cross-site and token-driven calls.
1112 // Open sign-up (SIGNUP=open) runs on a second Worker: an emailed code, then a
1213 // passkey, and the sign-up pages answering 404 while it is off.
@@ -30,6 +31,9 @@
3031
3132 const INVITE = "invite-token-for-pal-0123456789abcdef";
3233 const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef";
34+/** boss is also signed in on another browser (say, a lost laptop), and has an access token. */
35+const BOSS_OTHER = "boss-other-browser-0123456789abcdef";
36+const BOSS_TOKEN = "dfy_boss_token_0123456789abcdef";
3337 const hash = (s) => createHash("sha256").update(s).digest("hex");
3438 const MASTER_KEY = randomBytes(32).toString("base64");
3539 /** The proof only the invitation email carries (src/auth/invite-proof.ts). */
@@ -102,6 +106,8 @@
102106 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`,
103107 `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`,
104108 `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`,
109+ `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(BOSS_OTHER)}', 'boss1', 'passkey', '${at}', '${at}', '${later}')`,
110+ `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('tboss', 'boss1', 'e2e', '${hash(BOSS_TOKEN)}', '${BOSS_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`,
105111 ].join("; "),
106112 );
107113 dev = spawn(
@@ -218,10 +224,20 @@
218224
219225 // ------------------------------------------------------------ admin sign-in links
220226 const boss = await person();
227+ const whoAmI = (headers) => fetch(`${base}/api/v3/user`, { headers });
228+ assert.equal((await whoAmI({ cookie: `__Host-dfy_session=${BOSS_OTHER}` })).status, 200);
221229 await boss.page.goto(`${base}/signin/${ADMIN_LINK}`);
222230 assert.match(await text(boss.page), /add a passkey to boss/);
231+ assert.match(await text(boss.page), /signs out every other browser signed in to this account/);
223232 await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]);
224233 assert.match(await text(boss.page), /Use it to sign in from now on/);
234+ // A sign-in link is account recovery: boss's other browser is signed out, the access token keeps working.
235+ assert.match(await text(boss.page), /Every other browser signed in to your account was signed out; your access tokens keep working/);
236+ assert.equal((await whoAmI({ cookie: `__Host-dfy_session=${BOSS_OTHER}` })).status, 401);
237+ assert.equal((await whoAmI({ authorization: `Bearer ${BOSS_TOKEN}` })).status, 200);
238+ await boss.page.goto(`${base}/settings/security`);
239+ assert.equal(await boss.page.locator("text=this browser").count(), 1);
240+ assert.equal(await boss.page.locator("button:has-text('Sign out everywhere else')").count(), 0);
225241 const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`);
226242 assert.equal(usedLink.status(), 404);
227243
addedapps/diffinery/test/unit/recovery.test.ts+92−0View file
@@ -1,0 +1,92 @@
1+/**
2+ * Account recovery by email (/auth/access, behind Cloudflare Access) signs out every other browser of the
3+ * account and leaves its access tokens alone; a signed-in person confirming it's them keeps their other
4+ * browsers. Runs the real router over node:sqlite with every migration, and a stand-in for Access's keys.
5+ */
6+import { createHash } from "node:crypto";
7+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
8+import { app } from "../../src/app.js";
9+import { web } from "../../src/web/routes.js";
10+import type { Env } from "../../src/env.js";
11+import { testD1 } from "./stubs/d1.js";
12+
13+app.route("/", web);
14+
15+const ORIGIN = "https://diffinery.example";
16+const TEAM = "team.example";
17+const AUD = "recovery-aud";
18+const hash = (s: string) => createHash("sha256").update(s).digest("hex");
19+const b64url = (b: ArrayBuffer | Uint8Array | string) => Buffer.from(typeof b === "string" ? b : new Uint8Array(b)).toString("base64url");
20+
21+let keys: CryptoKeyPair;
22+let jwk: JsonWebKey & { kid?: string };
23+
24+/** A Cloudflare Access assertion for `email`, signed with the stand-in key. */
25+async function assertion(email: string): Promise<string> {
26+ const head = b64url(JSON.stringify({ alg: "RS256", kid: "k1" }));
27+ const body = b64url(JSON.stringify({ email, aud: [AUD], iss: `https://${TEAM}`, exp: Math.floor(Date.now() / 1000) + 300 }));
28+ const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", keys.privateKey, new TextEncoder().encode(`${head}.${body}`));
29+ return `${head}.${body}.${b64url(sig)}`;
30+}
31+
32+function setup() {
33+ const DB = testD1();
34+ const at = new Date().toISOString();
35+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
36+ DB.sqlite.exec(`INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('u-ana', 'ana', 'user', 'Ana', 'ana@example.com', 'active', '${at}')`);
37+ for (const cookie of ["ana-laptop-session-000000000001", "ana-phone-session-0000000000002"]) {
38+ DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', 'u-ana', 'passkey', '${at}', '${at}', '${later}')`);
39+ }
40+ DB.sqlite.exec(`INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t-ana', 'u-ana', 'ci', '${hash("dfy_ana")}', 'dfy_ana', '["write"]', '${later}', '${at}')`);
41+ const env = { PUBLIC_URL: ORIGIN, DB, ACCESS_TEAM_DOMAIN: TEAM, ACCESS_AUD: AUD, BOOTSTRAP_ADMINS: "" } as unknown as Env;
42+ const waits: Promise<unknown>[] = [];
43+ const ctx = { waitUntil: (p: Promise<unknown>) => void waits.push(p), passThroughOnException() {}, props: {} } as unknown as ExecutionContext;
44+ const get = async (path: string, headers: Record<string, string>) => {
45+ const res = await app.fetch(new Request(`${ORIGIN}${path}`, { headers }), env, ctx);
46+ await Promise.allSettled(waits);
47+ return res;
48+ };
49+ const sessions = () => (DB.sqlite.prepare("SELECT id FROM sessions WHERE account_id = 'u-ana'").all() as Array<{ id: string }>).map((s) => s.id);
50+ const audit = () => DB.sqlite.prepare("SELECT action, detail FROM audit_log ORDER BY at").all() as Array<{ action: string; detail: string | null }>;
51+ const tokenRevoked = () => (DB.sqlite.prepare("SELECT revoked_at FROM tokens WHERE id = 't-ana'").get() as { revoked_at: string | null }).revoked_at;
52+ return { get, sessions, audit, tokenRevoked };
53+}
54+
55+describe("account recovery by email", () => {
56+ // Generated once: the Worker caches Access's keys by id.
57+ beforeAll(async () => {
58+ keys = (await crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, true, ["sign", "verify"])) as CryptoKeyPair;
59+ jwk = { ...(await crypto.subtle.exportKey("jwk", keys.publicKey)), kid: "k1" };
60+ const real = globalThis.fetch;
61+ vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
62+ if (String(input) === `https://${TEAM}/cdn-cgi/access/certs`) return Response.json({ keys: [jwk] });
63+ return real(input, init);
64+ });
65+ });
66+ afterAll(() => vi.unstubAllGlobals());
67+
68+ it("signs out every other browser, keeps access tokens, and says so", async () => {
69+ const t = setup();
70+ expect(t.sessions()).toHaveLength(2);
71+ const res = await t.get("/auth/access?next=/settings", { "cf-access-jwt-assertion": await assertion("ana@example.com") });
72+ expect(res.status).toBe(302);
73+ const cookie = /__Host-dfy_session=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
74+ expect(cookie).toBeTruthy();
75+ // Only the browser that just recovered the account is signed in.
76+ expect(t.sessions()).toEqual([hash(cookie!)]);
77+ expect(t.tokenRevoked()).toBeNull();
78+ // The notice travels in the flash cookie (encoded twice).
79+ expect(decodeURIComponent(decodeURIComponent(res.headers.get("set-cookie") ?? ""))).toMatch(/Every other browser signed in to your account was signed out; your access tokens keep working/);
80+ expect(t.audit().find((a) => a.action === "session.revoke_others")?.detail).toMatch(/"via":"access","sessions":2/);
81+ });
82+
83+ it("keeps the other browsers when someone already signed in confirms it's them", async () => {
84+ const t = setup();
85+ const res = await t.get("/auth/access?next=/settings", { "cf-access-jwt-assertion": await assertion("ana@example.com"), cookie: "__Host-dfy_session=ana-laptop-session-000000000001" });
86+ expect(res.status).toBe(302);
87+ const cookie = /__Host-dfy_session=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
88+ // The confirming browser's old session is replaced by a fresh one; the phone stays signed in.
89+ expect(t.sessions().sort()).toEqual([hash(cookie!), hash("ana-phone-session-0000000000002")].sort());
90+ expect(t.audit().some((a) => a.action === "session.revoke_others")).toBe(false);
91+ });
92+});
modifiedapps/diffinery/wrangler.jsonc+5−2View file
@@ -70,7 +70,9 @@
7070 ],
7171 "workflows": [
7272 { "name": "diffinery-run", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" },
73- { "name": "diffinery-import", "binding": "IMPORT_WORKFLOW", "class_name": "ImportWorkflow" }
73+ { "name": "diffinery-import", "binding": "IMPORT_WORKFLOW", "class_name": "ImportWorkflow" },
74+ // Account exports and deletions (src/accounts/workflow.ts).
75+ { "name": "diffinery-account", "binding": "ACCOUNT_WORKFLOW", "class_name": "AccountWorkflow" }
7476 ],
7577 // A deploy that changes the image rolls the running containers: once a container has been up
7678 // longer than rollout_active_grace_period seconds, it gets SIGTERM and is killed 15 minutes later.
@@ -132,7 +134,8 @@
132134 },
133135 "workflows": [
134136 { "name": "diffinery-run-dev", "binding": "RUN_WORKFLOW", "class_name": "RunWorkflow" },
135- { "name": "diffinery-import-dev", "binding": "IMPORT_WORKFLOW", "class_name": "ImportWorkflow" }
137+ { "name": "diffinery-import-dev", "binding": "IMPORT_WORKFLOW", "class_name": "ImportWorkflow" },
138+ { "name": "diffinery-account-dev", "binding": "ACCOUNT_WORKFLOW", "class_name": "AccountWorkflow" }
136139 ],
137140 "triggers": { "crons": [] },
138141 "containers": []
modifiedscripts/bootstrap.sh+1−0View file
@@ -65,6 +65,7 @@
6565 {"id":"logs-90d","enabled":true,"conditions":{"prefix":"logs/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":7776000}}},
6666 {"id":"actions-staging-1d","enabled":true,"conditions":{"prefix":"actions-staging/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":86400}}},
6767 {"id":"archives-30d","enabled":true,"conditions":{"prefix":"archives/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":2592000}}},
68+ {"id":"exports-8d","enabled":true,"conditions":{"prefix":"exports/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":691200}}},
6869 {"id":"events-400d","enabled":true,"conditions":{"prefix":"events/"},"deleteObjectsTransition":{"condition":{"type":"Age","maxAge":34560000}}}
6970 ]}'
7071 # Applied on every deploy; a failure stops it, since a wrong rule can delete data.
modifiedscripts/diffinery-wait-runs.sh+7−2View file
@@ -47,9 +47,10 @@
4747 run=$(api "/actions/runs/$id")
4848 jq -r '"\(.name) [\(.event)]: \(.conclusion)\(try " in \((.updated_at | fromdateiso8601) - (.created_at | fromdateiso8601))s" catch "") \(.html_url)"' <<<"$run"
4949 jobs=$(api "/actions/runs/$id/jobs")
50- # Each job's time, and its steps that took over 20 seconds, to see where a sync spends it.
50+ # Each job's time and runner image, and its steps that took over 20 seconds, to see where a sync
51+ # spends it (and whether a job a rollout stopped had started on the previous image).
5152 jq -r 'def secs: try ((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)) catch null;
52- .jobs[] | " \(.name): \(.conclusion)\(if .failure then " - " + (.failure.message // "") else "" end)\(if secs then " (\(secs)s)" else "" end)",
53+ .jobs[] | " \(.name): \(.conclusion)\(if .failure then " - " + (.failure.message // "") else "" end)\(if secs then " (\(secs)s)" else "" end)\(if .runner.image then " on \(.runner.image)" else "" end)",
5354 (.steps[]? | select((secs // 0) > 20) | " \(secs)s \(.name)")' <<<"$jobs"
5455 # A skipped run (every job's `if` was false) is fine; a skipped Deploy after a failed CI
5556 # still fails the sync through CI.
@@ -62,5 +63,9 @@
6263 done
6364 fi
6465 done
66+if [ "$failed" != 0 ]; then
67+ img=$(curl -fsS --retry 2 "$DIFFINERY/api/v3/meta" | jq -r '.runner_image // "unknown"') || img=unknown
68+ echo "the last deploy's runner image is $img"
69+fi
6570 [ "$failed" = 0 ] && echo "every Diffinery run on $sha succeeded"
6671 exit "$failed"