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.

Early access: self-service removal, end-to-end tests, layout polish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jAs6Yp1agcb2w5FdELuEd
Claude authored signed1 parent afe2644commit 9af02b791863d53ac38ca6d2e7345325383f6f32Browse files
3 files changed+427−8
modifiedapps/diffinery/src/web/assets.ts+6−5View file
@@ -263,9 +263,10 @@
263263 .features{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:24px 0}
264264 .features .box{padding:18px}.features h3{margin:0 0 6px}
265265 /* Early access (web/early-access.tsx): the request form, its result pages and the admin list. */
266-.ea-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--accent);background:var(--accent-soft);border-radius:999px;padding:3px 12px;margin:0 0 16px}
266+.ea-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--link);background:var(--accent-soft);border-radius:999px;padding:3px 12px;margin:0 0 16px}
267267 a.ea-eyebrow:hover{text-decoration:none;filter:brightness(1.08)}
268-.ea{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);gap:56px;align-items:start;max-width:1040px;margin:48px auto 24px}
268+.ea{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);grid-template-areas:"head card" "more card";column-gap:56px;align-items:start;max-width:1040px;margin:48px auto 24px}
269+.ea-head{grid-area:head}.ea-more{grid-area:more}.ea-card{grid-area:card}
269270 .ea h1{font-size:36px;letter-spacing:-.025em;margin:0 0 12px}
270271 .ea-lede{font-size:16px;color:var(--fg2);margin:0 0 28px;max-width:520px}
271272 .ea-points{list-style:none;padding:0;margin:0 0 28px;display:grid;gap:16px}
@@ -283,14 +284,14 @@
283284 .ea-privacy{font-size:12px;color:var(--fg2);margin:14px 0 0}
284285 .ea-done{max-width:580px;margin:64px auto;text-align:center}
285286 .ea-done>p{font-size:15px}
286-.ea-done .ic{display:inline-grid;place-items:center;width:60px;height:60px;border-radius:999px;background:var(--accent-soft);color:var(--accent);margin:0 0 18px}
287+.ea-done .ic{display:inline-grid;place-items:center;width:60px;height:60px;border-radius:999px;background:var(--accent-soft);color:var(--link);margin:0 0 18px}
287288 .ea-done .ic svg{width:28px;height:28px}
288289 .ea-done form{margin-top:18px}
289-.ea-av{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:999px;background:var(--accent-soft);color:var(--accent);font-weight:600;font-size:14px}
290+.ea-av{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:999px;background:var(--accent-soft);color:var(--link);font-weight:600;font-size:14px}
290291 .ea-av.lg{width:48px;height:48px;font-size:20px}
291292 .ea-use{margin-top:6px;color:var(--fg);font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
292293 .ea-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
293-@media (max-width:900px){.ea{grid-template-columns:1fr;gap:28px;margin-top:24px}.ea h1{font-size:28px}.ea-list>li{flex-wrap:wrap}.ea-actions{width:100%;justify-content:flex-start;padding-left:42px}}
294+@media (max-width:900px){.ea{grid-template-columns:1fr;grid-template-areas:"head" "card" "more";row-gap:24px;margin-top:24px}.ea h1{font-size:28px}.ea-list>li{flex-wrap:wrap}.ea-actions{width:100%;justify-content:flex-start;padding-left:42px}}
294295 .stats{display:flex;gap:18px;flex-wrap:wrap;color:var(--fg2);font-size:13px}
295296 .stats b{color:var(--fg)}
296297 .tx{display:flex;flex-direction:column;gap:10px}.tx .markdown{margin:0}
modifiedapps/diffinery/src/web/early-access.tsx+7−3View file
@@ -84,10 +84,12 @@
8484 c,
8585 { title: "Request early access", status: o.status, turnstile: !!siteKey, description: "Diffinery is invite-only for now. Request early access to Git hosting, pull requests and GitHub Actions–compatible CI built for agents, running on Cloudflare." },
8686 <div class="ea">
87- <section>
87+ <section class="ea-head">
8888 <span class="ea-eyebrow">{raw(ICONS.rocket)} Early access</span>
8989 <h1>Request early access to Diffinery</h1>
9090 <p class="ea-lede">Diffinery is invite-only while we open it up team by team. Tell us a little about yourself and we'll email you an invitation when there's a place for you.</p>
91+ </section>
92+ <section class="ea-more">
9193 <ul class="ea-points">
9294 <Point icon={ICONS.repo} title="Git hosting and pull requests">
9395 Repositories, reviews with intent, issues, releases and a container registry.
@@ -249,7 +251,7 @@
249251 <span class="ic">{raw(ICONS.check)}</span>
250252 <h1>You're on the list</h1>
251253 <p>
252- Thanks for confirming, <b>{r.name || r.email}</b>. The team reviews requests in order and we'll email <b>{r.email}</b> an invitation when there's a place for you.
254+ Thanks for confirming, <b>{r.name || r.email}</b>. The team reviews each request, and we'll email <b>{r.email}</b> an invitation when there's a place for you.
253255 </p>
254256 <p class="muted">The invitation lets you pick a username and sign in with a passkey: your fingerprint, face or screen lock, with no password to remember.</p>
255257 <p class="row" style="justify-content:center">
@@ -379,7 +381,9 @@
379381
380382 web.get("/early-access", (c) => {
381383 if (!earlyAccessOpen(c.env)) return c.redirect("/signup", 302);
382- if (c.get("principal").type === "account") return c.redirect("/", 302);
384+ // Local development's fixed identity (DEV_AUTH) still sees the form, like /signup.
385+ const p = c.get("principal");
386+ if (p.type === "account" && p.via !== "dev") return c.redirect("/", 302);
383387 return requestPage(c);
384388 });
385389
addedapps/diffinery/test/e2e/early-access.test.mjs+414−0View file
@@ -1,0 +1,414 @@
1+// End-to-end early access (lib/early-access.ts, web/early-access.tsx) on a real `wrangler dev` Worker:
2+// the home page, sign-in page and closed sign-up lead to /early-access; a request is confirmed with
3+// the link from its email (double opt-in); the form answers the same for known addresses and accounts;
4+// bots, bursts, oversized and hostile input get nowhere; only site administrators see requests, on the
5+// web and through the API; inviting sends the instance invitation; declining, putting back, deleting and
6+// removing your own request work and are audited without the request's content.
7+//
8+// The Worker keeps env.dev's DEV_AUTH so it keeps whole copies of the emails it sends (for reading the
9+// links). Visitors who aren't signed in are requests with another Host header: local development's fixed
10+// identity only applies to requests addressed to this machine.
11+//
12+// pnpm --filter @diffinery/app exec node --test test/e2e/early-access.test.mjs
13+import { spawn, execFileSync } from "node:child_process";
14+import { createHash, randomBytes } from "node:crypto";
15+import { mkdtempSync, rmSync } from "node:fs";
16+import { tmpdir } from "node:os";
17+import { join, dirname, resolve } from "node:path";
18+import { fileURLToPath } from "node:url";
19+import { createServer } from "node:net";
20+import { request as httpRequest } from "node:http";
21+import { test, after } from "node:test";
22+import assert from "node:assert/strict";
23+
24+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
25+const work = mkdtempSync(join(tmpdir(), "diffinery-early-access-e2e-"));
26+const running = [];
27+
28+const freePort = () =>
29+ new Promise((res) => {
30+ const s = createServer();
31+ s.listen(0, "127.0.0.1", () => {
32+ const { port } = s.address();
33+ s.close(() => res(port));
34+ });
35+ });
36+const hash = (s) => createHash("sha256").update(s).digest("hex");
37+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38+
39+/**
40+ * A fetch-like request over node:http, which (unlike fetch) sends the Host header it is given: the
41+ * Worker sees a visitor's requests addressed to VISITOR. Redirects are never followed.
42+ */
43+function send(url, { method = "GET", headers = {}, body } = {}) {
44+ const data = body === undefined ? undefined : Buffer.from(typeof body === "string" ? body : body.toString());
45+ return new Promise((res, rej) => {
46+ const r = httpRequest(url, { method, headers: { ...headers, ...(data ? { "content-length": String(data.length) } : {}) } }, (resp) => {
47+ const chunks = [];
48+ resp.on("data", (c) => chunks.push(c));
49+ resp.on("end", () => {
50+ const text = Buffer.concat(chunks).toString("utf8");
51+ const h = resp.headers;
52+ res({
53+ status: resp.statusCode,
54+ headers: { get: (k) => (Array.isArray(h[k.toLowerCase()]) ? h[k.toLowerCase()].join(", ") : (h[k.toLowerCase()] ?? null)) },
55+ text: async () => text,
56+ json: async () => JSON.parse(text),
57+ });
58+ });
59+ });
60+ r.on("error", rej);
61+ if (data) r.write(data);
62+ r.end();
63+ });
64+}
65+
66+function stopWorker(dev) {
67+ try {
68+ process.kill(-dev.pid, "SIGTERM");
69+ } catch {}
70+}
71+
72+after(() => {
73+ for (const dev of running) stopWorker(dev);
74+ if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
75+});
76+
77+const T = { root: `dfy_root_${randomBytes(12).toString("hex")}`, ann: `dfy_ann_${randomBytes(12).toString("hex")}` };
78+const S = { root: `root-session-${randomBytes(12).toString("hex")}`, ann: `ann-session-${randomBytes(12).toString("hex")}` };
79+/** The Host a visitor who isn't signed in uses (see the header comment). */
80+const VISITOR = "visitor.example";
81+
82+let w;
83+async function worker() {
84+ if (w) return w;
85+ const port = await freePort();
86+ const base = `http://127.0.0.1:${port}`;
87+ const persist = join(work, "state");
88+ const at = new Date().toISOString();
89+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
90+ const person = (id, login, email, admin) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${email}', 'active', ${admin}, '${at}')`;
91+ 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}')`;
92+ const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`;
93+ const seed = [person("u-root", "root", "root@example.com", 1), person("u-ann", "ann", "ann@example.com", 0), token("t-root", "u-root", T.root), token("t-ann", "u-ann", T.ann), session("u-root", S.root), session("u-ann", S.ann)];
94+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
95+ wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
96+ wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; "));
97+ const args = ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"];
98+ const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true });
99+ let log = "";
100+ dev.stdout.on("data", (d) => (log += d));
101+ dev.stderr.on("data", (d) => (log += d));
102+ running.push(dev);
103+ const end = Date.now() + 120_000;
104+ while (Date.now() < end) {
105+ try {
106+ if ((await fetch(`${base}/healthz`)).ok) break;
107+ } catch {}
108+ await sleep(500);
109+ }
110+ assert.ok((await fetch(`${base}/healthz`).catch(() => ({ ok: false }))).ok, `wrangler dev did not start\n${log.slice(-4000)}`);
111+
112+ /** Headers for a visitor (no `who`), or a signed-in person's browser. */
113+ const headers = (who, extra = {}) => ({ ...(who ? { cookie: `__Host-dfy_session=${S[who]}` } : { host: VISITOR }), ...extra });
114+ const page = (path, who) => send(`${base}${path}`, { headers: headers(who) });
115+ const api = async (method, path, body, tok) => {
116+ const r = await send(`${base}/api/v3${path}`, { method, headers: { "content-type": "application/json", ...(tok ? { authorization: `Bearer ${tok}` } : { host: VISITOR }) }, body: body === undefined ? undefined : JSON.stringify(body) });
117+ const text = await r.text();
118+ let json = null;
119+ try {
120+ json = text ? JSON.parse(text) : null;
121+ } catch {
122+ json = text;
123+ }
124+ return { status: r.status, json };
125+ };
126+ /** A form's CSRF pair and timing stamp, read from `path` the way a browser does. */
127+ const formOf = async (path, who) => {
128+ const res = await page(path, who);
129+ assert.equal(res.status, 200, `GET ${path} -> ${res.status}`);
130+ const html = await res.text();
131+ const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1];
132+ const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
133+ assert.ok(csrf && pair, `no CSRF pair on ${path}`);
134+ return { csrf, pair, stamp: /name="stamp" value="([^"]*)"/.exec(html)?.[1] ?? "", html };
135+ };
136+ /** POST a form; `form` is from formOf. `ip` sets the address the Worker sees. */
137+ const post = async (action, form, fields, who, ip) => {
138+ const body = new URLSearchParams({ _csrf: form.csrf, ...(form.stamp ? { stamp: form.stamp } : {}), ...fields });
139+ const cookie = [who ? `__Host-dfy_session=${S[who]}` : "", `dfy_csrf=${form.pair}`].filter(Boolean).join("; ");
140+ const r = await send(`${base}${action}`, { method: "POST", body, headers: { ...headers(who), cookie, "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...(ip ? { "cf-connecting-ip": ip } : {}) } });
141+ // Apostrophes come back as &#39;; nothing else is unescaped, so the escaping checks still hold.
142+ return { status: r.status, location: r.headers.get("location"), html: (await r.text()).replace(/&#39;/g, "'") };
143+ };
144+ /** Every email the Worker sent, newest first, with the whole message (DEV_AUTH keeps it). */
145+ const emails = async () => {
146+ const r = await api("GET", "/diffinery/emails?all=1&per_page=200", undefined, T.root);
147+ assert.equal(r.status, 200, JSON.stringify(r.json));
148+ return r.json;
149+ };
150+ const email = async (what, pred) => {
151+ const end = Date.now() + 60_000;
152+ while (Date.now() < end) {
153+ const m = (await emails()).find(pred);
154+ if (m) return m;
155+ await sleep(500);
156+ }
157+ assert.fail(`timed out waiting for the email: ${what}\n${log.slice(-3000)}`);
158+ };
159+ const linkIn = (m, path) => {
160+ const text = m.dev_copy?.text ?? "";
161+ const found = new RegExp(`${base.replace(/[.]/g, "\\.")}${path}#([A-Za-z0-9_-]{20,100})`).exec(text);
162+ assert.ok(found, `no ${path} link in:\n${text}`);
163+ return found[1];
164+ };
165+ /** A form whose stamp is old enough to count as a person's (3 seconds). */
166+ const ready = async (path = "/early-access", who) => {
167+ const f = await formOf(path, who);
168+ await sleep(3200);
169+ return f;
170+ };
171+ w = { base, page, api, formOf, post, emails, email, linkIn, ready, log: () => log };
172+ return w;
173+}
174+
175+/** A page with the address typed and the per-page nonce taken out, to compare two answers. */
176+const same = (html, address) => html.replace(address, "ADDRESS").replace(/nonce="[^"]*"/g, "");
177+const request = (email, over = {}) => ({ email, name: "", company: "", use_case: "", hosting: "", website: "", ...over });
178+const byEmail = async (api, address) => (await api("GET", `/diffinery/admin/early-access?q=${encodeURIComponent(address)}`, undefined, T.root)).json.find((r) => r.email === address);
179+let nextIp = 1;
180+/** A fresh address for the Worker to see each request from, so the daily limit per network doesn't mix tests. */
181+const ip = () => `198.51.100.${nextIp++}`;
182+
183+test("early access: the home page, the sign-in page and closed sign-up lead to the form", async () => {
184+ const { page } = await worker();
185+ const home = await (await page("/")).text();
186+ assert.match(home, /href="\/early-access"[^>]*>\s*Request early access/);
187+ assert.match(await (await page("/login")).text(), /href="\/early-access">request early access</);
188+ const signup = await page("/signup");
189+ assert.equal(signup.status, 302);
190+ assert.equal(signup.headers.get("location"), "/early-access");
191+ const form = await page("/early-access");
192+ assert.equal(form.status, 200);
193+ const html = await form.text();
194+ assert.match(html, /Request early access to Diffinery/);
195+ for (const name of ["email", "name", "company", "use_case", "hosting", "website", "stamp"]) assert.match(html, new RegExp(`name="${name}"`), name);
196+ assert.doesNotMatch(html, /<select|type="checkbox"/, "Kumo controls only");
197+ assert.match(html, /href="\/early-access\/remove"/);
198+ // Signed in, the form isn't needed.
199+ const annForm = await page("/early-access", "ann");
200+ assert.equal(annForm.status, 302);
201+ assert.equal(annForm.headers.get("location"), "/");
202+ // Search engines find the form, not the confirmation pages.
203+ const robots = await (await page("/robots.txt")).text();
204+ assert.match(robots, /Disallow: \/early-access\/confirm/);
205+ assert.match(robots, /Disallow: \/early-access\/remove/);
206+ assert.match(await (await page("/sitemap.xml")).text(), /\/early-access<\/loc>/);
207+});
208+
209+test("early access: a request is confirmed with the link from its email, then waits for site administrators", async () => {
210+ const { page, api, post, email, linkIn, ready } = await worker();
211+ const f = await ready();
212+ const hostile = { name: `Ada <script>alert(1)</script>`, company: "Acme & Co", use_case: "Move 40 repos.\n<img src=x onerror=alert(1)>", hosting: "github" };
213+ const sent = await post("/early-access", f, request("Ada@Example.net", hostile), undefined, ip());
214+ assert.equal(sent.status, 200, sent.html.slice(0, 600));
215+ assert.match(sent.html, /Check your inbox/);
216+ assert.match(sent.html, /ada@example\.net/);
217+ const m = await email("the confirmation", (e) => e.kind === "early_access_confirm" && e.to_addr === "ada@example.net");
218+ assert.equal(m.subject, "[Diffinery] Confirm your early access request");
219+ assert.doesNotMatch(JSON.stringify(m.dev_copy), /Ada|Acme|onerror|Move 40/, "nothing typed in the form goes into the email");
220+ const token = linkIn(m, "/early-access/confirm");
221+
222+ // Unconfirmed: not waiting yet.
223+ const before = await byEmail(api, "ada@example.net");
224+ assert.equal(before.state, "unconfirmed");
225+ assert.doesNotMatch(await (await page("/settings/admin/early-access", "root")).text(), /ada@example\.net/);
226+
227+ const confirmPage = await page("/early-access/confirm");
228+ assert.equal(confirmPage.status, 200);
229+ const cf = { ...(await w.formOf("/early-access/confirm")), stamp: "" };
230+ const wrong = await post("/early-access/confirm", cf, { token: "x".repeat(40) });
231+ assert.equal(wrong.status, 410);
232+ assert.match(wrong.html, /This link doesn't work any more/);
233+ const ok = await post("/early-access/confirm", cf, { token });
234+ assert.equal(ok.status, 200, ok.html.slice(0, 600));
235+ assert.match(ok.html, /You're on the list/);
236+ assert.ok(ok.html.includes("Ada &lt;script&gt;alert(1)&lt;/script&gt;"), "the name is escaped");
237+ assert.equal((await post("/early-access/confirm", cf, { token })).status, 410, "a link works once");
238+
239+ const after = await byEmail(api, "ada@example.net");
240+ assert.equal(after.state, "confirmed");
241+ assert.deepEqual({ name: after.name, company: after.company, use_case: after.use_case, hosting: after.hosting }, { name: hostile.name, company: hostile.company, use_case: hostile.use_case, hosting: "github" });
242+ assert.ok(!("confirm_hash" in after));
243+ const admin = await (await page("/settings/admin/early-access", "root")).text();
244+ assert.match(admin, /ada@example\.net/);
245+ assert.ok(!admin.includes("<script>alert(1)") && !admin.includes("<img src=x"), "everything is escaped");
246+ assert.match(admin, /Early access\s*<span class="badge blue"[^>]*>\s*1\s*</, "the sidebar counts what waits");
247+ const detail = await (await page(`/settings/admin/early-access/${after.id}`, "root")).text();
248+ assert.ok(detail.includes("Move 40 repos.\n&lt;img src=x onerror=alert(1)&gt;"), "the use is plain text");
249+ assert.match(await (await page("/notifications", "root")).text(), /New early access requests are waiting for you/);
250+ assert.match(await (await page("/settings/admin", "root")).text(), /1 confirmed early access request is waiting/);
251+});
252+
253+test("early access: the same answer for an address that already asked or has an account; only its inbox learns which", async () => {
254+ const { api, post, email, emails, ready } = await worker();
255+ const f = await ready();
256+ const again = await post("/early-access", f, request("ada@example.net", { name: "Mallory" }), undefined, ip());
257+ assert.equal(again.status, 200);
258+ assert.match(again.html, /Check your inbox/);
259+ const known = await email("already asked", (e) => e.kind === "early_access_known" && e.to_addr === "ada@example.net");
260+ assert.equal(known.subject, "[Diffinery] We already have your early access request");
261+ assert.equal((await byEmail(api, "ada@example.net")).name, "Ada <script>alert(1)</script>", "a confirmed request isn't changed");
262+ const account = await post("/early-access", f, request("ANN@example.com"), undefined, ip());
263+ assert.equal(account.status, 200);
264+ assert.equal(same(account.html, /ann@example\.com/gi), same(again.html, /ada@example\.net/g), "the page is the same either way");
265+ const note = await email("already an account", (e) => e.kind === "early_access_known" && e.to_addr === "ann@example.com");
266+ assert.equal(note.subject, "[Diffinery] You already have a Diffinery account");
267+ assert.equal(await byEmail(api, "ann@example.com"), undefined);
268+ assert.ok((await emails()).every((e) => e.kind !== "early_access_confirm" || e.to_addr !== "ann@example.com"));
269+});
270+
271+test("early access: bots, bad input and big bodies get nowhere", async () => {
272+ const { api, post, emails, formOf, ready } = await worker();
273+ const fresh = await formOf("/early-access");
274+ // Too fast after the page was shown, a filled honeypot, or a forged stamp: the usual page, nothing kept or sent.
275+ const fast = await post("/early-access", fresh, request("bot1@example.net"), undefined, ip());
276+ assert.equal(fast.status, 200);
277+ assert.match(fast.html, /Check your inbox/);
278+ const f = await ready();
279+ const honey = await post("/early-access", f, request("bot2@example.net", { website: "https://spam.example" }), undefined, ip());
280+ assert.match(honey.html, /Check your inbox/);
281+ const forged = await post("/early-access", { ...f, stamp: `${Date.now() - 10_000}.${"A".repeat(43)}` }, request("bot3@example.net"), undefined, ip());
282+ assert.match(forged.html, /Check your inbox/);
283+ const missing = await post("/early-access", { ...f, stamp: "" }, request("bot4@example.net"), undefined, ip());
284+ assert.match(missing.html, /Check your inbox/);
285+ for (const a of ["bot1@example.net", "bot2@example.net", "bot3@example.net", "bot4@example.net"]) assert.equal(await byEmail(api, a), undefined, a);
286+
287+ // Bad input is refused with the reason, keeping what was typed.
288+ const long = await post("/early-access", f, request("long@example.net", { use_case: "x".repeat(1001), name: "Keep me" }), undefined, ip());
289+ assert.equal(long.status, 422);
290+ assert.match(long.html, /Keep what you'd use Diffinery for to 1,000 characters/);
291+ assert.match(long.html, /value="Keep me"/);
292+ const bad = await post("/early-access", f, request("not-an-address"), undefined, ip());
293+ assert.equal(bad.status, 422);
294+ assert.match(bad.html, /Enter a valid email address/);
295+ const hosting = await post("/early-access", f, request("h@example.net", { hosting: "<script>" }), undefined, ip());
296+ assert.equal(hosting.status, 422);
297+ assert.ok(!hosting.html.includes('value="<script>"'));
298+ const huge = await post("/early-access", f, request("huge@example.net", { use_case: "y".repeat(40_000) }), undefined, ip());
299+ assert.equal(huge.status, 413);
300+ await sleep(1500);
301+ const tried = new Set(["bot1", "bot2", "bot3", "bot4", "long", "h", "huge"].map((n) => `${n}@example.net`));
302+ assert.deepEqual((await emails()).filter((e) => tried.has(e.to_addr)), [], "nothing was emailed");
303+});
304+
305+test("early access: only site administrators see requests (404 for anyone else)", async () => {
306+ const { page, api, post, formOf } = await worker();
307+ const ada = await byEmail(api, "ada@example.net");
308+ assert.ok(ada, "the request from the tests above");
309+ for (const path of ["/settings/admin/early-access", `/settings/admin/early-access/${ada.id}`]) {
310+ assert.equal((await page(path, "ann")).status, 404, `${path} for a person who isn't a site administrator`);
311+ const anon = await page(path);
312+ assert.equal(anon.status, 302, `${path} sends a visitor to sign in`);
313+ assert.match(anon.headers.get("location") ?? "", /^\/login\?next=/);
314+ }
315+ const annForm = await formOf("/settings", "ann");
316+ for (const action of ["invite", "decline", "delete"]) assert.equal((await post(`/settings/admin/early-access/${ada.id}/${action}`, annForm, {}, "ann")).status, 404, action);
317+ assert.equal((await api("GET", "/diffinery/admin/early-access", undefined, T.ann)).status, 404);
318+ assert.equal((await api("GET", `/diffinery/admin/early-access/${ada.id}`, undefined, T.ann)).status, 404);
319+ assert.equal((await api("POST", `/diffinery/admin/early-access/${ada.id}/invite`, undefined, T.ann)).status, 404);
320+ assert.equal((await api("PATCH", `/diffinery/admin/early-access/${ada.id}`, { state: "declined" }, T.ann)).status, 404);
321+ assert.equal((await api("DELETE", `/diffinery/admin/early-access/${ada.id}`, undefined, T.ann)).status, 404);
322+ assert.equal((await api("GET", "/diffinery/admin/early-access")).status, 401);
323+ assert.doesNotMatch(await (await page("/settings", "ann")).text(), /href="\/settings\/admin\/early-access"/);
324+ assert.equal((await byEmail(api, "ada@example.net")).state, "confirmed", "nothing changed");
325+});
326+
327+test("early access: invite, decline, put back and delete, on the web and through the API, audited", async () => {
328+ const { page, api, post, formOf, email, emails, linkIn, ready } = await worker();
329+ const ada = await byEmail(api, "ada@example.net");
330+ // Invite from the web: the instance invitation goes to the address.
331+ const adminForm = await formOf("/settings/admin/early-access", "root");
332+ const invited = await post(`/settings/admin/early-access/${ada.id}/invite`, adminForm, { back: "/settings/admin/early-access" }, "root");
333+ assert.equal(invited.status, 303);
334+ assert.equal(invited.location, "/settings/admin/early-access");
335+ const now = await byEmail(api, "ada@example.net");
336+ assert.equal(now.state, "invited");
337+ assert.equal(now.decided_by, "root");
338+ assert.ok(now.invite_id);
339+ const inv = await email("the invitation", (e) => e.kind === "invite" && e.to_addr === "ada@example.net");
340+ assert.match(inv.subject, /invited you to Diffinery/);
341+ assert.equal((await api("POST", `/diffinery/admin/early-access/${ada.id}/invite`, undefined, T.root)).status, 422, "not twice");
342+ assert.match(await (await page("/settings/admin/early-access?state=invited", "root")).text(), /invited by root/);
343+ // Asking again now says so.
344+ const f = await ready();
345+ await post("/early-access", f, request("ada@example.net"), undefined, ip());
346+ const already = await email("already invited", (e) => e.kind === "early_access_known" && e.to_addr === "ada@example.net" && /already invited/.test(e.subject));
347+ assert.equal(already.subject, "[Diffinery] You're already invited");
348+
349+ // A second request: decline and put back through the API (no email), then delete on the web.
350+ await post("/early-access", f, request("bob@example.net", { use_case: "secret plans" }), undefined, ip());
351+ const token = linkIn(await email("bob's confirmation", (e) => e.kind === "early_access_confirm" && e.to_addr === "bob@example.net"), "/early-access/confirm");
352+ await post("/early-access/confirm", { ...(await formOf("/early-access/confirm")), stamp: "" }, { token });
353+ const bob = await byEmail(api, "bob@example.net");
354+ assert.equal(bob.state, "confirmed");
355+ const sent = (await emails()).length;
356+ const declined = await api("PATCH", `/diffinery/admin/early-access/${bob.id}`, { state: "declined" }, T.root);
357+ assert.equal(declined.status, 200, JSON.stringify(declined.json));
358+ assert.equal(declined.json.state, "declined");
359+ assert.equal((await api("PATCH", `/diffinery/admin/early-access/${bob.id}`, { state: "invited" }, T.root)).status, 422);
360+ assert.equal((await api("PATCH", `/diffinery/admin/early-access/${bob.id}`, { state: "confirmed" }, T.root)).json.state, "confirmed");
361+ await sleep(1500);
362+ assert.equal((await emails()).length, sent, "declining emails nobody");
363+ const del = await post(`/settings/admin/early-access/${bob.id}/delete`, await formOf(`/settings/admin/early-access/${bob.id}`, "root"), {}, "root");
364+ assert.equal(del.status, 303);
365+ assert.equal(del.location, "/settings/admin/early-access");
366+ assert.equal((await api("GET", `/diffinery/admin/early-access/${bob.id}`, undefined, T.root)).status, 404);
367+
368+ const audit = await (await page("/settings/audit", "root")).text();
369+ for (const action of ["early_access.confirm", "early_access.invite", "early_access.decline", "early_access.restore", "early_access.delete", "invite.create"]) assert.ok(audit.includes(action), `${action} is audited`);
370+ assert.ok(!audit.includes("secret plans") && !audit.includes("onerror"), "the audit log holds no request content");
371+});
372+
373+test("early access: people remove their own request with an emailed link", async () => {
374+ const { api, post, formOf, email, emails, linkIn, ready } = await worker();
375+ const f = await ready();
376+ await post("/early-access", f, request("carol@example.net"), undefined, ip());
377+ await email("carol's confirmation", (e) => e.kind === "early_access_confirm" && e.to_addr === "carol@example.net");
378+ assert.ok(await byEmail(api, "carol@example.net"));
379+ const rf = await ready("/early-access/remove");
380+ const none = await post("/early-access/remove", rf, { email: "nobody@example.net", website: "" }, undefined, ip());
381+ assert.equal(none.status, 200);
382+ assert.match(none.html, /If there's an early access request for <b>nobody@example\.net<\/b>/);
383+ const asked = await post("/early-access/remove", rf, { email: "Carol@Example.net", website: "" }, undefined, ip());
384+ assert.equal(same(asked.html, /carol@example\.net/gi), same(none.html, /nobody@example\.net/g), "the same page either way");
385+ const m = await email("the removal link", (e) => e.kind === "early_access_remove" && e.to_addr === "carol@example.net");
386+ assert.equal(m.subject, "[Diffinery] Remove your early access request");
387+ assert.ok((await emails()).every((e) => e.to_addr !== "nobody@example.net"), "no request, no email");
388+ const token = linkIn(m, "/early-access/remove/confirm");
389+ const cf = { ...(await formOf("/early-access/remove/confirm")), stamp: "" };
390+ const gone = await post("/early-access/remove/confirm", cf, { token });
391+ assert.equal(gone.status, 200);
392+ assert.match(gone.html, /Your request is removed/);
393+ assert.equal(await byEmail(api, "carol@example.net"), undefined);
394+ assert.equal((await post("/early-access/remove/confirm", cf, { token })).status, 410);
395+});
396+
397+test("early access: daily limits per network and per address", async () => {
398+ const { api, post, emails, ready } = await worker();
399+ const f = await ready();
400+ // One address: three emails a day, then the page stays the same and nothing more is sent.
401+ const flood = "flood@example.net";
402+ for (let i = 0; i < 4; i++) assert.equal((await post("/early-access", f, request(flood), undefined, ip())).status, 200);
403+ await sleep(1500);
404+ assert.equal((await emails()).filter((e) => e.to_addr === flood).length, 3);
405+ // One network: ten submissions a day, then a visible refusal.
406+ const net = "203.0.113.77";
407+ for (let i = 0; i < 10; i++) assert.equal((await post("/early-access", f, request(`n${i}@example.net`), undefined, net)).status, 200, `submission ${i + 1}`);
408+ const over = await post("/early-access", f, request("n10@example.net"), undefined, net);
409+ assert.equal(over.status, 429);
410+ assert.match(over.html, /Too many requests from your network today/);
411+ assert.equal(await byEmail(api, "n10@example.net"), undefined);
412+ // Another network still gets through.
413+ assert.equal((await post("/early-access", f, request("other@example.net"), undefined, ip())).status, 200);
414+});