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.

Invite links must prove the email address before an account is made (#49)

An invite link worked as an unverified claim to its email address: the
inviter sees the link, so any active user could invite any address, open
the link themselves and register that address, picking up every pending
invite for it, and site administration if the address was in
BOOTSTRAP_ADMINS. Logins in BOOTSTRAP_ADMINS could be claimed by anyone.

- The emailed invite link carries an HMAC proof; without it the invite page
  offers "Email me the link" (rate limited) and passkey registration refuses.
- Joining checks the account made is the one the invite was for.
- BOOTSTRAP_ADMINS matches email addresses only.

Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo
Canberk Demirci authored · GitHub committed signed1 parent d4d7881commit cb3e5500131863fc480ed3d60160c6a15183f89cBrowse files
9 files changed+128−21
Files (9)
modifiedapps/diffinery/docs/accounts/invitations.md+1−1View file
@@ -9,7 +9,7 @@
99
1010 1. Someone with permission creates an invite for an email address. The invite can be for the whole instance, for an organization (with an organization role) or for a repository (with a repository role).
1111 2. Diffinery emails the invite link to that address, and shows the link to the person who created it so they can also send it another way.
12-3. The invited person opens `https://diffinery.com/invite/<token>`, picks a username, creates a passkey and is signed in.
12+3. The invited person opens the link from the invitation email, picks a username, creates a passkey and is signed in. The emailed link carries a proof that they can read mail at that address. The link the inviter sees (`https://diffinery.com/invite/<token>`) doesn't: opening it shows **Confirm your email** with an **Email me the link** button, which sends the link to the invited address again (at most once every 2 minutes and 5 times a day).
1313 4. Their account is active, and every open invite for their email address is accepted at once: they join each organization and repository they were invited to.
1414 5. They land on the organization or repository of the invite they opened (or the dashboard, for an instance invite).
1515
modifiedapps/diffinery/docs/admin/site-administration.md+2−2View file
@@ -19,9 +19,9 @@
1919
2020 ### Who becomes a site administrator
2121
22-The instance setting `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc` under `vars`) is a comma-separated list of email addresses and usernames. An account becomes a site administrator when:
22+The instance setting `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc` under `vars`) is a comma-separated list of email addresses. An account becomes a site administrator when:
2323
24-- it is created with an email address or username on the list (from an invite, or by verifying the email address through email recovery at `/auth/access`), or
24+- 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
2525 - it was waiting for an invite (pending) and the person verifies an email address on the list at `/auth/access`.
2626
2727 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.
modifiedapps/diffinery/docs/self-hosting/configuration.md+2−2View file
@@ -23,7 +23,7 @@
2323 | --- | --- | --- | --- |
2424 | `PUBLIC_URL` | `https://diffinery.com` | Yes | The canonical origin of the instance. See [PUBLIC_URL](#public_url). On deploy, `scripts/bootstrap.sh` replaces it with `https://$DIFFINERY_DOMAIN` (default `https://diffinery.com`), whatever `wrangler.jsonc` says. |
2525 | `MAIL_FROM` | `notifications@diffinery.com` | No | Sender address of every email. The display name is "Diffinery", or `<actor> (Diffinery)` for emails about someone's action. On deploy, `scripts/bootstrap.sh` replaces it with `notifications@$MAIL_DOMAIN` (default `notifications@$DIFFINERY_DOMAIN`). When empty, Diffinery sends from `notifications@<hostname of PUBLIC_URL>`. The domain must be onboarded to Email Sending. |
26-| `BOOTSTRAP_ADMINS` | A comma-separated list of emails and logins | For the first sign-in | Emails or logins (case-insensitive) that become site administrators. See [BOOTSTRAP_ADMINS](#bootstrap_admins). |
26+| `BOOTSTRAP_ADMINS` | A comma-separated list of emails | For the first sign-in | Emails (case-insensitive) that become site administrators. See [BOOTSTRAP_ADMINS](#bootstrap_admins). |
2727 | `ACCESS_TEAM_DOMAIN` | Empty (filled in on deploy) | For email sign-in | Your Cloudflare Zero Trust team domain, like `example.cloudflareaccess.com`. Used to fetch the Access signing keys and to check the token issuer. |
2828 | `ACCESS_AUD` | Empty (filled in on deploy) | For email sign-in | The audience tag of the Access application that guards `/auth/access`. |
2929 | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). |
@@ -45,7 +45,7 @@
4545
4646 ### BOOTSTRAP_ADMINS
4747
48-A comma-separated list; spaces around entries are ignored and matching is case-insensitive. An entry can be an email address or a login.
48+A comma-separated list of email addresses; spaces around entries are ignored and matching is case-insensitive. Logins in the list are ignored, because anyone could pick a free login, while an email address has to be proven (by email sign-in or by opening the link in an invitation email).
4949
5050 An account becomes a site administrator from this list only when it is created (through email sign-in at `/auth/access`, by joining from an invitation, or through `DEV_AUTH` locally), or when an account that is still waiting for an invite signs in through `/auth/access`. Accounts that already exist are not promoted or demoted when you change the list later, and there is no page for granting or removing site administration. See [Site administration](/docs/admin/site-administration).
5151
modifiedapps/diffinery/docs/self-hosting/deploy.md+1−1View file
@@ -51,7 +51,7 @@
5151 }
5252 ```
5353
54-- `BOOTSTRAP_ADMINS`: comma-separated emails (or logins) of the people who become site administrators when they first sign in. Replace the existing list with your own.
54+- `BOOTSTRAP_ADMINS`: comma-separated emails of the people who become site administrators when they first sign in. Replace the existing list with your own.
5555 - Leave `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` empty; bootstrap fills them in.
5656 - `routes`, `PUBLIC_URL` and `MAIL_FROM` can stay as they are. The deployed config gets a route for `<DIFFINERY_DOMAIN>/*`, `PUBLIC_URL` set to `https://<DIFFINERY_DOMAIN>` and `MAIL_FROM` set to `notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`. You can still edit them to match, so that the file reads correctly, but the deploy doesn't use their values.
5757
addedapps/diffinery/src/auth/invite-proof.ts+26−0View file
@@ -1,0 +1,26 @@
1+import type { Env } from "../env.js";
2+import { b64url, safeEqual } from "../lib/util.js";
3+
4+/**
5+ * Proof that someone can read the invited address's email. The invite link shown to the
6+ * person who sent it creates nothing on its own: only the copy emailed to the invited
7+ * address carries this proof, so nobody can join as an address they don't own (and so
8+ * collect its other invitations, or a bootstrap administrator's rights).
9+ * The key is derived from MASTER_KEY, so emailed links stay valid across deploys.
10+ */
11+async function key(env: Env): Promise<CryptoKey> {
12+ // Local development has no MASTER_KEY unless one is passed; production always does.
13+ const secret = env.MASTER_KEY ?? (env.DEV_AUTH ? "diffinery-dev-invite-proof" : null);
14+ if (!secret) throw new Error("MASTER_KEY is not set, so invitation emails can't be signed");
15+ return crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-invite-proof-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
16+}
17+
18+/** The proof for the invite whose token hashes to `tokenHash`. */
19+export async function inviteProof(env: Env, tokenHash: string): Promise<string> {
20+ return b64url(new Uint8Array(await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(tokenHash))));
21+}
22+
23+export async function validInviteProof(env: Env, tokenHash: string, proof: string | undefined | null): Promise<boolean> {
24+ if (!proof || proof.length > 100) return false;
25+ return safeEqual(proof, await inviteProof(env, tokenHash));
26+}
modifiedapps/diffinery/src/lib/directory.ts+28−10View file
@@ -5,6 +5,7 @@
55 import { mailForAudit, tokenCreated } from "../mail/notify.js";
66 import { enqueueMail } from "../mail/send.js";
77 import { archivePrefix, deletePrefixes } from "./storage-keys.js";
8+import { inviteProof } from "../auth/invite-proof.js";
89
910 export interface Account {
1011 id: string;
@@ -83,9 +84,13 @@
8384 return this.db.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").bind(email).first<Account>();
8485 }
8586
86- private isBootstrapAdmin(email: string, login?: string): boolean {
87+ /**
88+ * Only a verified email address makes a bootstrap administrator. Logins on the list are
89+ * ignored: whoever creates an account picks its login, so a login proves nothing.
90+ */
91+ private isBootstrapAdmin(email: string): boolean {
8792 const list = (this.env.BOOTSTRAP_ADMINS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
88- return list.includes(email.toLowerCase()) || (!!login && list.includes(login.toLowerCase()));
93+ return list.includes(email.toLowerCase());
8994 }
9095
9196 private async uniqueLogin(base: string): Promise<string> {
@@ -102,13 +107,13 @@
102107 async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> {
103108 const existing = await this.accountByEmail(email);
104109 if (existing) {
105- if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) {
110+ if (existing.status === "pending" && this.isBootstrapAdmin(email)) {
106111 await this.db.prepare("UPDATE accounts SET status = 'active', site_admin = 1 WHERE id = ?").bind(existing.id).run();
107112 return { ...existing, status: "active", site_admin: 1 };
108113 }
109114 return existing;
110115 }
111- const admin = this.isBootstrapAdmin(email, hints.login);
116+ const admin = this.isBootstrapAdmin(email);
112117 const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!);
113118 const id = hints.id ?? newId();
114119 const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first();
@@ -426,18 +431,31 @@
426431 // If the person already exists, activate them now so the invite also works from the dashboard.
427432 if (existing?.status === "pending") await this.setStatus(existing.id, "active");
428433 const url = `${this.env.PUBLIC_URL}/invite/${token}`;
429- const org = input.orgId ? await this.account(input.orgId) : null;
430- const repo = input.repoId ? await this.repoById(input.repoId) : null;
434+ await this.mailInvite({ id, email: input.email, org_id: input.orgId ?? null, repo_id: input.repoId ?? null, role: input.role, invited_by: inviter.id, expires_at: new Date(Date.now() + 14 * 86400_000).toISOString() }, token);
435+ return { id, url, token };
436+ }
437+
438+ /**
439+ * Email an invitation to its address. Only this emailed link carries the proof that lets
440+ * someone create an account for the address (auth/invite-proof.ts); the plain link shown to
441+ * the inviter leads to a page that sends this email.
442+ */
443+ async mailInvite(inv: { id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }, token: string): Promise<void> {
444+ const proof = await inviteProof(this.env, await sha256(token));
445+ const url = `${this.env.PUBLIC_URL}/invite/${token}?proof=${proof}`;
446+ const inviter = inv.invited_by ? await this.account(inv.invited_by) : null;
447+ const org = inv.org_id ? await this.account(inv.org_id) : null;
448+ const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null;
449+ const existing = await this.accountByEmail(inv.email);
431450 await enqueueMail(this.env, [
432451 {
433452 kind: "invite",
434- data: { inviter: inviter.login, url, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: input.role, expiresAt: new Date(Date.now() + 14 * 86400_000).toISOString() },
435- to: existing ? { accountId: existing.id } : { email: input.email },
453+ data: { inviter: inviter?.login ?? "diffinery", url, org: org?.login, repo: repo ? `${repo.owner_login}/${repo.name}` : undefined, role: inv.role, expiresAt: inv.expires_at },
454+ to: existing ? { accountId: existing.id } : { email: inv.email },
436455 reason: "invitation",
437456 at: now(),
438457 },
439458 ]);
440- return { id, url, token };
441459 }
442460
443461 async invites(filter: { orgId?: string; repoId?: string; site?: boolean; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> {
@@ -453,7 +471,7 @@
453471 async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> {
454472 const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>();
455473 if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired");
456- if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
474+ if (inv.email && inv.email.toLowerCase() !== (user.email ?? "").toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`);
457475 await this.applyInvite(inv, user);
458476 return { orgId: inv.org_id ?? null, repoId: inv.repo_id ?? null };
459477 }
modifiedapps/diffinery/src/web/auth.tsx+44−1View file
@@ -17,7 +17,9 @@
1717 import { verifyAccessJwt } from "../lib/jwt.js";
1818 import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js";
1919 import { ICONS } from "./assets.js";
20-import { flash, post, prm, render, statusOf, type WebApp } from "./ui.js";
20+import { Form, act, flash, post, prm, render, statusOf, type WebApp } from "./ui.js";
21+import { csrfToken } from "./csrf.js";
22+import { validInviteProof } from "../auth/invite-proof.js";
2123
2224 /**
2325 * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a
@@ -146,6 +148,22 @@
146148 </AuthCard>,
147149 );
148150 }
151+ // The link alone isn't enough to create the account: the copy emailed to the address carries a proof.
152+ const proof = c.req.query("proof");
153+ if (!(await validInviteProof(c.env, inv.hash, proof))) {
154+ return render(
155+ c,
156+ { title: "Join Diffinery" },
157+ <AuthCard title="Confirm your email">
158+ <p class="muted">
159+ This invitation is for <b>{inv.email}</b>. To make sure it's you, open the link in the invitation email sent to that address. If you can't find it, we can send it again.
160+ </p>
161+ <Form action={`/invite/${encodeURIComponent(token)}/email`} t={csrfToken(c)}>
162+ <button class="btn primary block">Email me the link</button>
163+ </Form>
164+ </AuthCard>,
165+ );
166+ }
149167 return render(
150168 c,
151169 { title: "Join Diffinery" },
@@ -155,6 +173,7 @@
155173 </p>
156174 <form data-passkey="register" data-next="/">
157175 <input type="hidden" name="invite" value={token} />
176+ <input type="hidden" name="proof" value={proof} />
158177 <div class="field">
159178 <label for="login">Username</label>
160179 <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" />
@@ -288,6 +307,7 @@
288307 const inv = await auth.invite(inviteToken);
289308 if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired.");
290309 if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again.");
310+ if (!(await validInviteProof(c.env, inv.hash, str(body.proof)))) throw new HttpError(403, `Open the link in the invitation email sent to ${inv.email} to create your account.`);
291311 if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`);
292312 const login = str(body.login).trim() || loginFromEmail(inv.email);
293313 if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved.");
@@ -366,6 +386,8 @@
366386 if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
367387 // Creating the account accepts every open invite for this email, including this one.
368388 account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId });
389+ // Two registrations racing for one invite: only the one that created the account gets a passkey on it.
390+ if (account.id !== s.accountId) throw new HttpError(409, `${s.email} already has an account.`);
369391 await auth.addPasskey({ ...key, account_id: account.id });
370392 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } }));
371393 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
@@ -389,6 +411,27 @@
389411 return c.json({ redirect });
390412 });
391413
414+ // ------------------------------------------------------------ invitations: resend the email that proves the address
415+
416+ post(web, "/invite/:token/email", async (c) => {
417+ const token = prm(c, "token");
418+ const back = `/invite/${encodeURIComponent(token)}`;
419+ const inv = await new Auth(c.env).invite(token);
420+ if (!inv?.email) throw new HttpError(404, "this invite is invalid, already used, or expired");
421+ return act(c, back, async () => {
422+ // At most one email every 2 minutes and 5 a day for an address, counting the first one.
423+ const since = (ms: number) => new Date(Date.now() - ms).toISOString();
424+ const sent = await c.env.DB.prepare("SELECT sum(created_at > ?) AS recent, count(*) AS day FROM email_log WHERE kind = 'invite' AND lower(to_addr) = lower(?) AND created_at > ?")
425+ .bind(since(120_000), inv.email, since(86400_000))
426+ .first<{ recent: number | null; day: number }>();
427+ if ((sent?.recent ?? 0) > 0) throw new HttpError(429, `We emailed ${inv.email} a moment ago. Check that inbox (and its spam folder) for the link.`);
428+ if ((sent?.day ?? 0) >= 5) throw new HttpError(429, `We have emailed ${inv.email} several times today. Ask the person who invited you to send a new invitation.`);
429+ const row = await c.env.DB.prepare("SELECT id, email, org_id, repo_id, role, invited_by, expires_at FROM invites WHERE id = ?").bind(inv.id).first<{ id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }>();
430+ if (!row) throw new HttpError(404, "this invite is invalid, already used, or expired");
431+ await c.get("dir").mailInvite(row, token);
432+ }, `We emailed the link to ${inv.email}. Open it from that inbox to create your account.`);
433+ });
434+
392435 // ------------------------------------------------------------ sign out
393436
394437 post(web, "/auth/logout", async (c) => {
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+23−3View file
@@ -4,11 +4,12 @@
44 // pnpm --filter @diffinery/app e2e
55 //
66 // Covers: signed-out redirects, joining from an invite link with a new passkey
7-// (which also applies the invite's org membership), adding and removing
7+// (only from the emailed copy, which proves the address; the invite also applies
8+// its org membership), adding and removing
89 // passkeys, signing out and back in, admin-issued one-time sign-in links,
910 // single-use invites and links, and refusing cross-site and token-driven calls.
1011 import { spawn, execFileSync } from "node:child_process";
11-import { createHash } from "node:crypto";
12+import { createHash, createHmac, randomBytes } from "node:crypto";
1213 import { mkdtempSync, rmSync } from "node:fs";
1314 import { tmpdir } from "node:os";
1415 import { join, dirname, resolve } from "node:path";
@@ -28,6 +29,9 @@
2829 const INVITE = "invite-token-for-pal-0123456789abcdef";
2930 const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef";
3031 const hash = (s) => createHash("sha256").update(s).digest("hex");
32+const MASTER_KEY = randomBytes(32).toString("base64");
33+/** The proof only the invitation email carries (src/auth/invite-proof.ts). */
34+const proofFor = (token) => createHmac("sha256", `diffinery-invite-proof-v1:${MASTER_KEY}`).update(hash(token)).digest("base64url");
3135
3236 const freePort = () =>
3337 new Promise((res) => {
@@ -100,7 +104,7 @@
100104 );
101105 dev = spawn(
102106 "pnpm",
103- ["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:", "--show-interactive-dev-session=false"],
107+ ["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:${MASTER_KEY}`, "--var", "BOOTSTRAP_ADMINS:pal", "--show-interactive-dev-session=false"],
104108 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
105109 );
106110 dev.stdout.on("data", (d) => (devLog += d));
@@ -144,7 +148,20 @@
144148 assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i);
145149
146150 // ------------------------------------------------------------ join from an invite link
151+ // The bare link (what the inviter sees) creates nothing: it offers to email the address.
147152 await page.goto(`${base}/invite/${INVITE}`);
153+ assert.match(await text(page), /Confirm your email/);
154+ assert.equal(await page.locator("#login").count(), 0);
155+ const options = (body) => fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
156+ for (const proof of [undefined, "", "forged", proofFor("another-invite")]) {
157+ const r = await options({ invite: INVITE, login: "pal", proof });
158+ assert.equal(r.status, 403, `proof ${proof}`);
159+ assert.match((await r.json()).message, /invitation email sent to pal@example\.com/);
160+ }
161+ await Promise.all([page.waitForLoadState("load"), page.click("text=Email me the link")]);
162+ assert.match(await text(page), /We emailed the link to pal@example\.com/);
163+ // The emailed link carries the proof.
164+ await page.goto(`${base}/invite/${INVITE}?proof=${proofFor(INVITE)}`);
148165 assert.match(await text(page), /invited as pal@example\.com/);
149166 await page.fill("#login", "pal");
150167 await page.fill("#name", "Pal Friend");
@@ -160,6 +177,9 @@
160177 // The invite's org membership was applied.
161178 await page.goto(`${base}/settings/orgs`);
162179 assert.match(await text(page), /crew/);
180+ // Picking a login that is on BOOTSTRAP_ADMINS doesn't make anyone a site administrator: only a verified email does.
181+ await page.goto(`${base}/api/v3/user`);
182+ assert.equal(JSON.parse(await page.textContent("body")).site_admin, false);
163183
164184 // Invites work once.
165185 const stranger = await person();
modifiedapps/diffinery/wrangler.jsonc+1−1View file
@@ -18,7 +18,7 @@
1818 "vars": {
1919 "PUBLIC_URL": "https://diffinery.com",
2020 "MAIL_FROM": "notifications@diffinery.com",
21- "BOOTSTRAP_ADMINS": "cnbrkdmrci,iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com",
21+ "BOOTSTRAP_ADMINS": "iletisim@canberkdemirci.com,canberkdemirci@icloud.com,iletisim@altugankarali.com",
2222 "ACCESS_TEAM_DOMAIN": "",
2323 "ACCESS_AUD": ""
2424 },