Skip to content

Commit

Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)

syntaqxcommitted Parents3914bf68edc477Browse files
23 files+1792−290/23 viewed
+26−0
161161 An expired, revoked or used invite says which, and who sent it, so you
162162 can ask them for a new one; or ask for access from the same page.
163163
164+### Invite links for a group
165+
166+g1t sometimes hands one link to a group: an event's judges, readers of a
167+post, a community. It looks like
168+`https://g1t.sh/register?invite=g1t-k7m2-…` and opens sign-up with the
169+code filled in and the group named above the form, such as **Invited as
170+part of Launch week judges**.
171+
172+- **It makes your own account.** Each person who uses it gets a new
173+ account, and then makes their own workspace. It does not add you to
174+ anyone else's workspace; once you are in, a workspace's owners can add
175+ you from its People page.
176+- **It may be for some email domains only.** When it is, the email field
177+ says which, such as `example.com`, and sign-up takes only an address
178+ there. Use your address at that organization; you confirm it like any
179+ other.
180+- **It works a set number of times, until a set day.** Once every place
181+ is taken, or the day has passed, or g1t has stopped it, the link gets
182+ the same answer as any invite that cannot be used. Ask whoever shared
183+ it, or [ask for access](#asking-for-access).
184+
185+Using the link spends one place in the same step that makes your account,
186+so two people signing up at the same moment can never take more places
187+than it has. Anyone with an account can still [make invites](#making-invites)
188+of their own; group links are made by g1t staff only.
189+
164190 ### Making invites
165191
166192 1. Open [Settings → Invites](https://g1t.sh/settings/invites).
+31−1
7272 member.
7373 - **A person's page** (`/users/<username>`, linked from a workspace's
7474 members): their addresses (remove one, with a reason they see), their
75− security log, and **Delete account**. Delete only when the person asks
75+ security log, and **Delete account**. An account made with a shared
76+ invite link says **Joined through <label>** under its name, linking to
77+ the link on Invites. Delete only when the person asks
7678 (from one of the account's confirmed addresses) or for abuse: give the
7779 reason, which goes in sudo's audit log (`account_deleted`), and type the
7880 username (`admin_delete_account`). It does what deleting their own
129131 out again; what it wrote shows as `ghost`. Both go in sudo's audit log
130132 (`account_restored`, `account_purged`), naming the staff member. There
131133 is no API route for deleting an account; only the site and sudo can.
134+- **Invites** (`/invites`): g1t.sh is invite-only, and this page holds
135+ every way in, one tab each. **Waitlist**: people who asked for access;
136+ approve (identity mints an invite bound to the address and emails it,
137+ with an optional note) or dismiss, one at a time or ticked together.
138+ **Invites**: every invite code, searchable by a code's start, an email
139+ or a username; revoke a pending one. **Shared links**: one link for a
140+ group, such as the competition's judges, a post or a community. Make
141+ one with a label (required, and not secret: sign-up says "Invited as
142+ part of <label>"), how many accounts it makes (1 to 1000), its last day
143+ (14 days ahead unless changed; up to a year; it works until the end of
144+ that day, UTC) and, optionally, the email domains it is limited to
145+ (exact domains, up to 10). Each use makes a new account, which makes its
146+ own workspace: a shared link never joins anyone to an existing
147+ workspace, and uses nobody's invites. The list shows each link's state
148+ (live, used up, expired, revoked), its uses against its cap, its
149+ expiry, who made it, the link itself in a read-only field to select and
150+ copy while it is live (`https://g1t.sh/register?invite=<code>`;
151+ identity keeps only the code's hash and a copy sealed under
152+ IDENTITY_KEY), **Revoke**, and everyone who joined through it. Making
153+ and revoking go in sudo's audit log (`shared_invite_created`,
154+ `shared_invite_revoked`, filed under "Shared invite links"), naming
155+ the staff member (`admin_shared_invites`,
156+ `admin_create_shared_invite`, `admin_revoke_shared_invite`).
157+ **Grant & mint**: more invites for a person or a workspace (a negative
158+ number takes some back), and a one-off invite that uses nobody's.
159+ **Invite tree**: where a person came from (who invited them, staff, or
160+ the shared link they joined through) and whom they brought, three
161+ levels down.
132162 - **Aliases** (`/aliases`, under Customers): names that lead to a
133163 workspace, set by staff only; there is no way for a customer to make
134164 one, and nothing user-facing mentions them. `g1t`, the product's name,
+66−0
44 import {
55 INVITES_DONE,
66 MAX_BULK,
7+ MAX_SHARED_USES,
8+ dayAfter,
9+ domainsLine,
710 doneMessage,
811 invitesHref,
12+ joinedThrough,
13+ normalizeDomain,
914 parseGrant,
1015 parseIds,
1116 parseMintEmail,
1217 parseNote,
18+ parseSharedInvite,
1319 parseTab,
1420 parseWaitlistStatus,
21+ sharedInviteLink,
22+ sharedStatus,
23+ usesLine,
1524 } from "./invites.ts";
1625
1726 const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
7887 assert.equal(doneMessage("nope", "4"), null);
7988 assert.equal(doneMessage(null, null), null);
8089 });
90+
91+// 2026-10-08T12:00:00Z.
92+const NOW = Date.UTC(2026, 9, 8, 12);
93+
94+test("shared links have their own tab", () => {
95+ assert.equal(parseTab("shared"), "shared");
96+ assert.equal(invitesHref("shared"), "/invites?tab=shared");
97+ assert.ok(INVITES_DONE["shared-created"]);
98+ assert.match(INVITES_DONE["shared-revoked"]!, /ones it made stay/);
99+});
100+
101+test("a shared link needs a label, 1 to 1000 uses, and a day within a year", () => {
102+ const ok = parseSharedInvite(form({ label: " Cloudflare judges ", max_uses: "40", expires_on: "2026-10-14", domains: "" }), NOW);
103+ assert.deepEqual(ok, { ok: true, value: { label: "Cloudflare judges", maxUses: 40, expiresOn: "2026-10-14", domains: [] } });
104+ // No day: identity's 14 days.
105+ const later = parseSharedInvite(form({ label: "Judges", max_uses: "1" }), NOW);
106+ assert.ok(later.ok && later.value.expiresOn === null);
107+ assert.ok(!parseSharedInvite(form({ label: "", max_uses: "10" }), NOW).ok);
108+ assert.ok(!parseSharedInvite(form({ label: "x".repeat(81), max_uses: "10" }), NOW).ok);
109+ for (const uses of ["0", "1001", "-3", "2.5", "ten", ""]) {
110+ const parsed = parseSharedInvite(form({ label: "Judges", max_uses: uses }), NOW);
111+ assert.ok(!parsed.ok && parsed.error.includes(String(MAX_SHARED_USES)), uses);
112+ }
113+ assert.ok(parseSharedInvite(form({ label: "Judges", max_uses: "1000" }), NOW).ok);
114+ // Today works; yesterday, a day past a year, and days that do not exist do not.
115+ assert.ok(parseSharedInvite(form({ label: "Judges", max_uses: "5", expires_on: "2026-10-08" }), NOW).ok);
116+ assert.ok(parseSharedInvite(form({ label: "Judges", max_uses: "5", expires_on: "2027-10-08" }), NOW).ok);
117+ for (const day of ["2026-10-07", "2027-10-09", "2026-02-30", "14/10/2026"]) {
118+ assert.ok(!parseSharedInvite(form({ label: "Judges", max_uses: "5", expires_on: day }), NOW).ok, day);
119+ }
120+});
121+
122+test("a shared link's domains are tidied, checked and capped", () => {
123+ const parsed = parseSharedInvite(form({ label: "Judges", max_uses: "5", domains: "@Cloudflare.com, flagon.io cloudflare.com" }), NOW);
124+ assert.ok(parsed.ok);
125+ assert.deepEqual(parsed.value.domains, ["cloudflare.com", "flagon.io"]);
126+ const bad = parseSharedInvite(form({ label: "Judges", max_uses: "5", domains: "localhost" }), NOW);
127+ assert.ok(!bad.ok && bad.error.startsWith("localhost is not an email domain"));
128+ const many = Array.from({ length: 11 }, (_, n) => `d${n}.com`).join(",");
129+ assert.ok(!parseSharedInvite(form({ label: "Judges", max_uses: "5", domains: many }), NOW).ok);
130+ assert.equal(normalizeDomain(" @EXAMPLE.com. "), "example.com");
131+ assert.equal(normalizeDomain("-bad.com"), null);
132+ assert.equal(domainsLine([]), "Any email address");
133+ assert.equal(domainsLine(["cloudflare.com", "flagon.io"]), "Only addresses at cloudflare.com, flagon.io");
134+});
135+
136+test("a shared link reads as its state, its uses and its address", () => {
137+ assert.deepEqual(sharedStatus("live"), { label: "Live", tone: "lavender" });
138+ assert.equal(sharedStatus("used_up").label, "Used up");
139+ assert.equal(sharedStatus("expired").label, "Expired");
140+ assert.equal(sharedStatus("revoked").tone, "danger");
141+ assert.equal(usesLine({ uses: 3, maxUses: 40 }), "3 of 40 used");
142+ assert.equal(sharedInviteLink("g1t-k7m2-q9xd"), "https://g1t.sh/register?invite=g1t-k7m2-q9xd");
143+ assert.equal(joinedThrough("Cloudflare judges"), "Joined through Cloudflare judges");
144+ assert.equal(dayAfter(NOW, 14), "2026-10-22");
145+ assert.equal(dayAfter(NOW, 0), "2026-10-08");
146+});
+111−5
11 /**
2− * The Invites page's forms and tabs: the waitlist, every invite, grants of
3− * more invites, and a person's invite tree. No Workers imports, so it can
4− * be tested under Node. Identity checks everything again.
2+ * The Invites page's forms and tabs: the waitlist, every invite, shared
3+ * invite links, grants of more invites, and a person's invite tree. No
4+ * Workers imports, so it can be tested under Node. Identity checks
5+ * everything again.
56 */
6−import type { WaitlistStatus } from "@g1t/contracts";
7+import type { NewSharedInvite, SharedInvite, SharedInviteStatus, WaitlistStatus } from "@g1t/contracts";
78
89 import type { Parsed } from "./forms.ts";
910
10−export const INVITE_TABS = ["waitlist", "invites", "grant", "tree"] as const;
11+export const INVITE_TABS = ["waitlist", "invites", "shared", "grant", "tree"] as const;
1112 export type InviteTab = (typeof INVITE_TABS)[number];
1213
1314 export const TAB_LABEL: Record<InviteTab, string> = {
1415 waitlist: "Waitlist",
1516 invites: "Invites",
17+ shared: "Shared links",
1618 grant: "Grant & mint",
1719 tree: "Invite tree",
1820 };
7375 dismissed: "Dismissed.",
7476 revoked: "Invite revoked. It comes back to whoever it was charged to.",
7577 granted: "Granted. It applies at once.",
78+ "shared-created": "Shared link made. Copy it below and hand it to the group.",
79+ "shared-revoked": "Shared link revoked. It makes no more accounts; the ones it made stay.",
7680 };
7781
7882 /** The flash for `?done=` and, after deciding several at once, `?n=` of them. */
115119 if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note to ${MAX_NOTE} characters; it is ${note.length}.` };
116120 return { ok: true, value: note };
117121 }
122+
123+// --- Shared invite links ------------------------------------------------------------
124+
125+/** The most accounts one shared link makes; identity holds the same line. */
126+export const MAX_SHARED_USES = 1000;
127+/** The most characters a shared link's label keeps; identity holds the same line. */
128+export const MAX_SHARED_LABEL = 80;
129+/** The most email domains a shared link may be limited to; identity holds the same line. */
130+export const MAX_SHARED_DOMAINS = 10;
131+/** How long a shared link works unless staff choose a day. */
132+export const SHARED_TTL_DAYS = 14;
133+/** The furthest ahead its last day may be. */
134+export const SHARED_MAX_DAYS = 365;
135+
136+const DAY_MS = 24 * 60 * 60 * 1000;
137+const DATE = /^\d{4}-\d{2}-\d{2}$/;
138+const DOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
139+
140+/** The day `days` after `now`, as a date input holds it (UTC). */
141+export function dayAfter(now: number, days: number): string {
142+ return new Date(now + days * DAY_MS).toISOString().slice(0, 10);
143+}
144+
145+/** The address a shared link has: sign-up with its code filled in. */
146+export function sharedInviteLink(code: string, origin = "https://g1t.sh"): string {
147+ return `${origin.replace(/\/+$/, "")}/register?invite=${encodeURIComponent(code)}`;
148+}
149+
150+/** One email domain as staff typed it (`@Cloudflare.com` reads as `cloudflare.com`), if it is one. */
151+export function normalizeDomain(raw: string): string | null {
152+ const domain = raw.trim().replace(/^@+/, "").replace(/\.+$/, "").toLowerCase();
153+ if (domain.length < 3 || domain.length > 253 || !domain.includes(".")) return null;
154+ return domain.split(".").every((label) => DOMAIN_LABEL.test(label)) ? domain : null;
155+}
156+
157+/**
158+ * A new shared link, as typed: a label, 1 to 1000 uses, a last day from
159+ * today to a year ahead (none: 14 days), and up to 10 domains separated by
160+ * commas or spaces.
161+ */
162+export function parseSharedInvite(form: { get(name: string): unknown }, now = Date.now()): Parsed<NewSharedInvite> {
163+ const read = (name: string) => {
164+ const value = form.get(name);
165+ return typeof value === "string" ? value.trim() : "";
166+ };
167+ const label = read("label").replace(/\s+/g, " ");
168+ if (!label) return { ok: false, error: "Give the link a label, such as Cloudflare judges." };
169+ if (label.length > MAX_SHARED_LABEL) return { ok: false, error: `Keep the label to ${MAX_SHARED_LABEL} characters.` };
170+ const uses = read("max_uses");
171+ if (!/^\d+$/.test(uses) || Number(uses) < 1 || Number(uses) > MAX_SHARED_USES) {
172+ return { ok: false, error: `A shared link makes between 1 and ${MAX_SHARED_USES} accounts.` };
173+ }
174+ const expires = read("expires_on");
175+ let expiresOn: string | null = null;
176+ if (expires) {
177+ const day = DATE.test(expires) ? new Date(`${expires}T00:00:00Z`) : null;
178+ if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== expires) {
179+ return { ok: false, error: "Give the expiry as a date, such as 2026-10-28." };
180+ }
181+ if (expires < dayAfter(now, 0)) return { ok: false, error: "The expiry has passed. Choose today or a later day." };
182+ if (expires > dayAfter(now, SHARED_MAX_DAYS)) return { ok: false, error: `A shared link works for at most ${SHARED_MAX_DAYS} days.` };
183+ expiresOn = expires;
184+ }
185+ const domains: string[] = [];
186+ for (const typed of read("domains").split(/[\s,]+/).filter(Boolean)) {
187+ const domain = normalizeDomain(typed);
188+ if (!domain) return { ok: false, error: `${typed} is not an email domain. Write domains such as cloudflare.com.` };
189+ if (!domains.includes(domain)) domains.push(domain);
190+ }
191+ if (domains.length > MAX_SHARED_DOMAINS) return { ok: false, error: `Limit a link to at most ${MAX_SHARED_DOMAINS} domains.` };
192+ return { ok: true, value: { label, maxUses: Number(uses), expiresOn, domains } };
193+}
194+
195+/** How a shared link's state reads, and its badge's tone. */
196+export function sharedStatus(status: SharedInviteStatus): { label: string; tone: "lavender" | "mint" | "danger" | "plain" } {
197+ switch (status) {
198+ case "live":
199+ return { label: "Live", tone: "lavender" };
200+ case "used_up":
201+ return { label: "Used up", tone: "mint" };
202+ case "expired":
203+ return { label: "Expired", tone: "plain" };
204+ case "revoked":
205+ return { label: "Revoked", tone: "danger" };
206+ }
207+}
208+
209+/** How many of its uses are taken, in words. */
210+export function usesLine(link: Pick<SharedInvite, "uses" | "maxUses">): string {
211+ return `${link.uses} of ${link.maxUses} used`;
212+}
213+
214+/** Whom a shared link is for, by address. */
215+export function domainsLine(domains: string[]): string {
216+ if (domains.length === 0) return "Any email address";
217+ return `Only addresses at ${domains.join(", ")}`;
218+}
219+
220+/** What an account made with a shared link says about where it came from. */
221+export function joinedThrough(label: string): string {
222+ return `Joined through ${label}`;
223+}
+3−0
5959 assert.equal(accountPath("ws_acme"), "/workspaces/acme");
6060 assert.equal(accountPath("ent_bigco"), "/enterprises/ent_bigco");
6161 assert.equal(accountPath("stripe"), null);
62+ // Shared invite links are filed under `ws_invites`: a reserved name, never a workspace.
63+ assert.equal(accountPath("ws_invites"), "/invites?tab=shared");
64+ assert.equal(accountName("ws_invites"), "Shared invite links");
6265 assert.equal(accountPath("acme"), null);
6366 assert.equal(accountPath("ws_-bad"), null);
6467 assert.equal(accountPath(""), null);
+9−0
6969 const id = (account ?? "").trim().toLowerCase();
7070 // The model catalogue's changes (billing's catalogue.rs).
7171 if (id === "models") return "/agents";
72+ // Shared invite links (identity's shared_invites.rs): `invites` is a reserved name, never a workspace's.
73+ if (id === SHARED_INVITES_ACCOUNT) return "/invites?tab=shared";
7274 const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id);
7375 if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`;
7476 if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`;
7678 return null;
7779 }
7880
81+/** Where sudo's audit log files shared invite links. */
82+export const SHARED_INVITES_ACCOUNT = "ws_invites";
83+
7984 /** What to call an account: a workspace by its slug, an enterprise by its name when known. */
8085 export function accountName(account: string, names: Map<string, string> = new Map()): string {
8186 if (names.has(account)) return names.get(account) as string;
8287 if (account === "models") return "Agents & models";
88+ if (account === SHARED_INVITES_ACCOUNT) return "Shared invite links";
8389 if (account.startsWith("incident:")) return "Incident";
8490 if (account.startsWith("maintenance:")) return "Maintenance";
8591 if (account.startsWith("ws_")) return account.slice(3);
120126 // From identity: workspace aliases staff set or removed.
121127 alias_added: "Alias added",
122128 alias_removed: "Alias removed",
129+ // From identity: shared invite links staff made or revoked, filed under `ws_invites`.
130+ shared_invite_created: "Shared invite link made",
131+ shared_invite_revoked: "Shared invite link revoked",
123132 // From the status page (apps/status), merged in by the Audit log page.
124133 incident_declared: "Incident declared",
125134 incident_detected: "Incident detected",
+180−7
1−import { Check, MessageSquareText, Search, Ticket, X } from "lucide-react";
1+import { Check, Link2, MessageSquareText, Search, Ticket, X } from "lucide-react";
22 import { Link, data, redirect, useLocation } from "react-router";
33
4−import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts";
4+import type { Invite, InviteTree, InviteTreeNode, SharedInvite, WaitlistEntry } from "@g1t/contracts";
55
66 import type { Route } from "./+types/invites";
77 import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui";
1111 type InviteTab,
1212 MAX_BULK,
1313 MAX_NOTE,
14+ MAX_SHARED_DOMAINS,
15+ MAX_SHARED_LABEL,
16+ MAX_SHARED_USES,
17+ SHARED_MAX_DAYS,
18+ SHARED_TTL_DAYS,
1419 TAB_LABEL,
20+ dayAfter,
21+ domainsLine,
1522 doneMessage,
1623 invitesHref,
24+ joinedThrough,
1725 parseGrant,
1826 parseIds,
1927 parseMintEmail,
2028 parseNote,
29+ parseSharedInvite,
2130 parseTab,
2231 parseWaitlistStatus,
32+ sharedInviteLink,
33+ sharedStatus,
34+ usesLine,
2335 } from "~/lib/invites";
2436 import { identity } from "~/lib/services.server";
2537 import { settle } from "~/lib/settle";
3749 const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user";
3850 const done = url.searchParams.get("done");
3951 const selectAll = url.searchParams.get("select") === "all";
40− const [waitlist, invites, tree] = await Promise.all([
52+ const [waitlist, invites, tree, shared] = await Promise.all([
4153 tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null,
4254 tab === "invites" ? settle(identity.invites(query || null)) : null,
4355 tab === "tree" && name ? settle(kind === "workspace" ? identity.workspaceInvites(name) : identity.inviteTree(name)) : null,
56+ tab === "shared" ? settle(identity.sharedInvites()) : null,
4457 ]);
58+ const now = Date.now();
4559 return {
4660 tab,
4761 query,
5165 waitlist: waitlist?.ok ? waitlist.value : [],
5266 invites: invites?.ok ? invites.value : [],
5367 tree: tree?.ok ? tree.value : null,
54− error: [waitlist, invites, tree].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null,
68+ shared: shared?.ok ? shared.value : [],
69+ // The date inputs' default and bounds: 14 days, from today to a year ahead (UTC).
70+ expiry: { default: dayAfter(now, SHARED_TTL_DAYS), min: dayAfter(now, 0), max: dayAfter(now, SHARED_MAX_DAYS) },
71+ error: [waitlist, invites, tree, shared].map((result) => (result && !result.ok ? result.error : null)).find(Boolean) ?? null,
5572 done: doneMessage(done, url.searchParams.get("n")),
5673 selectAll,
5774 };
112129 if (!result.ok) return data({ error: result.error.message, section: "grant" }, { status: 422 });
113130 throw redirect(`${invitesHref("tree", { name, kind: target })}&done=granted`);
114131 }
132+ case "shared-create": {
133+ const link = parseSharedInvite(form);
134+ if (!link.ok) return data({ error: link.error, section: "shared" }, { status: 422 });
135+ // Identity records it in the audit log, naming the staff member.
136+ const result = await identity.createSharedInvite(link.value, staff.email);
137+ if (!result.ok) return data({ error: result.error.message, section: "shared" }, { status: 422 });
138+ throw redirect(`${invitesHref("shared")}&done=shared-created#${result.value.id}`);
139+ }
140+ case "shared-revoke": {
141+ const id = text(form, "id");
142+ const result = await identity.revokeSharedInvite(id, staff.email);
143+ if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
144+ throw back("shared-revoked");
145+ }
115146 case "mint": {
116147 const email = parseMintEmail(text(form, "email"));
117148 if (!email.ok) return data({ error: email.error, section: "mint" }, { status: 422 });
524555 <div>
525556 <dt className="text-xs text-faint">Invited by</dt>
526557 <dd className="font-mono">
527− {tree.invitedBy.length > 0
558+ {tree.shared ? (
559+ <Link to={`${invitesHref("shared")}#${tree.shared.id}`} className="font-sans hover:underline">
560+ {joinedThrough(tree.shared.label)}
561+ </Link>
562+ ) : tree.invitedBy.length > 0
528563 ? tree.invitedBy.map((username, index) => (
529564 <span key={username}>
530565 {index > 0 && <span className="text-faint"> ← </span>}
581616 );
582617 }
583618
619+/**
620+ * Shared invite links: make one for a group, copy it while it is live,
621+ * revoke it, and see who joined through it. Plain HTML, as all of sudo:
622+ * the link sits in a read-only field to select and copy.
623+ */
624+function SharedLinks({
625+ links,
626+ expiry,
627+ actionData,
628+}: {
629+ links: SharedInvite[];
630+ expiry: { default: string; min: string; max: string };
631+ actionData: ActionData;
632+}) {
633+ const { pathname, search } = useLocation();
634+ const createError = errorFor(actionData, { section: "shared" });
635+ return (
636+ <div className="grid gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
637+ <Section
638+ title="Make a shared link"
639+ description="One link for a group, such as a conference's judges or a community. Each use makes a new account, which makes its own workspace; the link never joins anyone to an existing one, and uses nobody's invites."
640+ >
641+ <form method="post" action={`${pathname}${search}`} className="space-y-3">
642+ <input type="hidden" name="intent" value="shared-create" />
643+ <Field label="Label" hint="Not secret: the sign-up page says “Invited as part of …”.">
644+ <Input name="label" required maxLength={MAX_SHARED_LABEL} placeholder="Cloudflare judges" />
645+ </Field>
646+ <div className="grid grid-cols-2 gap-3">
647+ <Field label="Accounts it makes" hint={`1 to ${MAX_SHARED_USES}.`}>
648+ <Input name="max_uses" type="number" required min={1} max={MAX_SHARED_USES} step={1} defaultValue={25} inputMode="numeric" />
649+ </Field>
650+ <Field label="Last day" hint="Works until the end of it (UTC).">
651+ <Input name="expires_on" type="date" required defaultValue={expiry.default} min={expiry.min} max={expiry.max} />
652+ </Field>
653+ </div>
654+ <Field label="Email domains (optional)" hint={`Only addresses at these, up to ${MAX_SHARED_DOMAINS}. Empty for any address.`}>
655+ <Input name="domains" maxLength={600} placeholder="cloudflare.com, example.org" spellCheck={false} autoCapitalize="none" />
656+ </Field>
657+ {createError && <Notice tone="error">{createError}</Notice>}
658+ <div className="flex justify-end">
659+ <Button type="submit" variant="lavender">
660+ <Link2 size={14} />
661+ Make link
662+ </Button>
663+ </div>
664+ </form>
665+ </Section>
666+ <div className="min-w-0 space-y-3">
667+ <h3 className="text-sm font-medium">Shared links</h3>
668+ {links.length === 0 ? (
669+ <EmptyState title="No shared links">Links you make appear here, newest first, with everyone who joined through each.</EmptyState>
670+ ) : (
671+ <ul className="space-y-3">
672+ {links.map((link) => {
673+ const state = sharedStatus(link.status);
674+ const error = errorFor(actionData, { id: link.id });
675+ return (
676+ <li key={link.id} id={link.id} className="scroll-mt-20 space-y-3 rounded-lg border border-line bg-surface px-4 py-3 sm:px-5">
677+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
678+ <span className="font-medium break-words">{link.label}</span>
679+ <Badge tone={state.tone}>{state.label}</Badge>
680+ <span className="text-xs text-muted tabular">{usesLine(link)}</span>
681+ </div>
682+ <dl className="grid gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
683+ <div>
684+ <dt className="inline text-faint">Code </dt>
685+ <dd className="inline font-mono">{link.hint}…</dd>
686+ </div>
687+ <div>
688+ <dt className="inline text-faint">Expires </dt>
689+ <dd className="inline">
690+ <When at={link.expiresAt} />
691+ </dd>
692+ </div>
693+ <div className="sm:col-span-2">{domainsLine(link.domains)}</div>
694+ <div className="sm:col-span-2 text-faint">
695+ Made by <span className="font-mono text-muted">{link.staff}</span> <When at={link.createdAt} />
696+ {link.revokedAt && (
697+ <>
698+ {" "}
699+ · revoked by <span className="font-mono text-muted">{link.revokedBy ?? "staff"}</span> <When at={link.revokedAt} />
700+ </>
701+ )}
702+ </div>
703+ </dl>
704+ {link.status === "live" &&
705+ (link.code ? (
706+ <Field label="Link (select it to copy)">
707+ <Input readOnly value={sharedInviteLink(link.code)} className="font-mono text-xs" aria-label={`Shared link for ${link.label}`} />
708+ </Field>
709+ ) : (
710+ <p className="text-xs text-faint">The link cannot be shown again here: identity keeps no copy without IDENTITY_KEY.</p>
711+ ))}
712+ {link.accounts.length > 0 ? (
713+ <details>
714+ <summary className="cursor-pointer text-xs text-muted select-none hover:text-fg">
715+ {link.accounts.length} joined through it
716+ </summary>
717+ <ul className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs">
718+ {link.accounts.map((account, index) => (
719+ <li key={`${account.username ?? "purged"}-${index}`}>
720+ {account.username ? (
721+ <Link to={`/users/${encodeURIComponent(account.username)}`} className="font-mono hover:underline">
722+ {account.username}
723+ </Link>
724+ ) : (
725+ <span className="text-faint">a purged account</span>
726+ )}{" "}
727+ <span className="text-faint">
728+ <When at={account.joinedAt} />
729+ </span>
730+ </li>
731+ ))}
732+ </ul>
733+ </details>
734+ ) : (
735+ <p className="text-xs text-faint">Nobody has joined through it yet.</p>
736+ )}
737+ {link.status !== "revoked" && (
738+ <form method="post" action={`${pathname}${search}#${link.id}`} className="flex justify-end">
739+ <input type="hidden" name="id" value={link.id} />
740+ <Button type="submit" name="intent" value="shared-revoke" variant="danger">
741+ Revoke
742+ </Button>
743+ </form>
744+ )}
745+ {error && <Notice tone="error">{error}</Notice>}
746+ </li>
747+ );
748+ })}
749+ </ul>
750+ )}
751+ </div>
752+ </div>
753+ );
754+}
755+
584756 export default function Invites({ loaderData, actionData }: Route.ComponentProps) {
585− const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll } = loaderData;
757+ const { tab, query, status, waitlist, invites, tree, name, kind, error, done, selectAll, shared, expiry } = loaderData;
586758 return (
587759 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
588760 <PageHeader
589761 title="Invites"
590− description="g1t.sh is invite-only. Approve people from the waitlist, find and revoke invites, grant more to a person or a workspace, and trace who brought whom."
762+ description="g1t.sh is invite-only. Approve people from the waitlist, find and revoke invites, hand a group one shared link, grant more to a person or a workspace, and trace who brought whom."
591763 />
592764 <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2">
593765 {INVITE_TABS.map((value) => (
617789 <InviteTable invites={invites} actionData={actionData} />
618790 </div>
619791 )}
792+ {tab === "shared" && <SharedLinks links={shared} expiry={expiry} actionData={actionData} />}
620793 {tab === "grant" && <GrantAndMint actionData={actionData} />}
621794 {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />}
622795 </div>
+9−0
1818 } from "~/lib/deleted-accounts";
1919 import { confirmsPurge, daysLeft } from "~/lib/deleted-workspaces";
2020 import { text } from "~/lib/forms";
21+import { joinedThrough } from "~/lib/invites";
2122 import { accountsAdmin, identity } from "~/lib/services.server";
2223 import { settle } from "~/lib/settle";
2324 import { requireStaff } from "~/lib/staff";
148149 description={
149150 <>
150151 Account <span className="font-mono">{user.id}</span>, made <When at={user.createdAt} />.{" "}
152+ {user.joinedThrough && (
153+ <>
154+ <Link to={`/invites?tab=shared#${user.joinedThrough.id}`} className="text-accent hover:underline">
155+ {joinedThrough(user.joinedThrough.label)}
156+ </Link>
157+ .{" "}
158+ </>
159+ )}
151160 {user.privateEmail ? "Keeps its address private on commits." : "Shows its primary address on commits."}
152161 </>
153162 }
+29−0
1313 looksAutomated,
1414 moreInvitesMailto,
1515 remainingLine,
16+ sharedDomainsHint,
17+ sharedInviteLine,
18+ sharedInviteLink,
1619 signUpCopy,
1720 suggestUsername,
1821 welcomeCookie,
110113 assert.equal(welcomes(null, "flagon-io"), false);
111114 assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/);
112115 });
116+
117+test("a shared invite link names its group above the sign-up form", () => {
118+ assert.equal(sharedInviteLine("Cloudflare judges"), "Invited as part of Cloudflare judges");
119+ assert.equal(sharedInviteLine(" Hacker News readers "), "Invited as part of Hacker News readers");
120+ // A one-person invite has no group, and says nothing of the kind.
121+ assert.equal(sharedInviteLine(null), null);
122+ assert.equal(sharedInviteLine(undefined), null);
123+ assert.equal(sharedInviteLine(" "), null);
124+});
125+
126+test("a shared invite link is sign-up with its code filled in", () => {
127+ assert.equal(sharedInviteLink(CODE), `https://g1t.sh/register?invite=${CODE}`);
128+ assert.equal(sharedInviteLink(CODE, "http://localhost:5173/"), `http://localhost:5173/register?invite=${CODE}`);
129+ // The register page reads the code back out of its own link.
130+ assert.equal(cleanCode(sharedInviteLink(CODE)), CODE);
131+});
132+
133+test("a shared link limited to domains says which, on the email field", () => {
134+ assert.equal(sharedDomainsHint([]), undefined);
135+ assert.equal(sharedDomainsHint(null), undefined);
136+ assert.equal(sharedDomainsHint(["cloudflare.com"]), "This invite is for addresses at cloudflare.com. Use yours there.");
137+ assert.equal(
138+ sharedDomainsHint(["a.com", "b.com", "c.com"]),
139+ "This invite is for addresses at a.com, b.com or c.com. Use yours there.",
140+ );
141+});
+19−0
2424 /** The /register address that opens on the invite-code field. */
2525 export const HAVE_AN_INVITE = "/register#invite";
2626
27+/** The address a shared invite link has: sign-up, with its code filled in. */
28+export function sharedInviteLink(code: string, origin = "https://g1t.sh"): string {
29+ return `${origin.replace(/\/+$/, "")}/register?invite=${encodeURIComponent(code)}`;
30+}
31+
32+/** What sign-up says above the form for a shared invite link's group. Null for a one-person invite. */
33+export function sharedInviteLine(label: string | null | undefined): string | null {
34+ const group = (label ?? "").trim();
35+ return group ? `Invited as part of ${group}` : null;
36+}
37+
38+/** The email field's hint for a shared invite link limited to some domains. */
39+export function sharedDomainsHint(domains: string[] | null | undefined): string | undefined {
40+ const list = (domains ?? []).filter(Boolean);
41+ if (list.length === 0) return undefined;
42+ const named = list.length === 1 ? list[0] : `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
43+ return `This invite is for addresses at ${named}. Use yours there.`;
44+}
45+
2746 /** The address an invite link has. */
2847 export function inviteLink(code: string, origin = "https://g1t.sh"): string {
2948 return `${origin.replace(/\/+$/, "")}/invite/${code}`;
+2−0
3636 const accepting = new URL(request.url).searchParams.get("accept") === "1";
3737 const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true });
3838 const invite = checked.ok ? checked.value : null;
39+ // A shared link for a group signs up on /register, which names the group.
40+ if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`);
3941 const lands = invite ? landingFor(invite) : null;
4042
4143 if (viewer && invite) {
+18−5
1111 import { githubSignInEnabled } from "../lib/github.server";
1212 import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1313 import { identity } from "../lib/services.server";
14−import { cleanCode, looksAutomated } from "../lib/invites";
14+import { cleanCode, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites";
1515 import { clientKey, registrationMode } from "../lib/registration.server";
1616 import {
1717 assertSameOrigin,
3333 const checked = code ? await identity.checkInvite(code, clientKey(request)) : null;
3434 const invite: InvitePreview | null = checked?.ok ? checked.value : null;
3535 // A good invite is used on its own page, which knows whom it is from and
36− // where it leads; it signs up, joins and lands in one go.
37− if (invite && code) throw redirect(`/invite/${encodeURIComponent(code)}`);
36+ // where it leads; it signs up, joins and lands in one go. A shared link
37+ // for a group signs up here: it joins nothing, and the form says which
38+ // group it is for.
39+ if (invite && code && !invite.sharedLabel) throw redirect(`/invite/${encodeURIComponent(code)}`);
3840 // Signing up with GitHub carries the invite code and `next` through it.
3941 const params = new URLSearchParams();
4042 if (code) params.set("invite", code);
9294 });
9395 }
9496
95−/** Who sent the invite and what it joins, above the form. */
97+/** Who sent the invite and what it joins, above the form; for a shared link, the group it is for. */
9698 function InvitedBy({ invite }: { invite: InvitePreview }) {
9799 const from = invite.invitedBy;
100+ const group = sharedInviteLine(invite.sharedLabel);
101+ if (group) {
102+ return (
103+ <div className="mb-6 flex items-center gap-3 rounded-lg border border-accent/30 bg-accent/5 p-3" role="status">
104+ <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
105+ <Ticket size={18} />
106+ </span>
107+ <p className="min-w-0 text-sm leading-5 font-medium text-fg">{group}</p>
108+ </div>
109+ );
110+ }
98111 return (
99112 <div className="mb-6 flex items-center gap-3 rounded-lg border border-accent/30 bg-accent/5 p-3" role="status">
100113 {invite.workspace ? (
172185 </Field>
173186 <Field
174187 label="Email"
175− hint={invite?.email ? `This invite is for ${invite.email}. Use that address.` : undefined}
188+ hint={invite?.email ? `This invite is for ${invite.email}. Use that address.` : sharedDomainsHint(invite?.sharedDomains)}
176189 >
177190 <Input name="email" type="email" autoComplete="email" required />
178191 </Field>
+4−0
508508 /// Set while it is deleted and not yet purged.
509509 #[serde(default)]
510510 pub deleted: Option<crate::account_deletion::DeletedAccount>,
511+ /// The shared invite link it was made with, if it was. Sudo shows
512+ /// "Joined through <label>".
513+ #[serde(default)]
514+ pub joined_through: Option<crate::identity::SharedInviteSource>,
511515 }
512516
513517 /// `admin_remove_email`: staff remove an address from an account, such as
+2−0
898898 #[derive(Debug, Serialize, serde::Deserialize)]
899899 #[serde(rename_all = "camelCase")]
900900 pub struct InviteRedeemed {
901+ /// The invite's id, or a shared invite link's (`sinv_…`) when one made
902+ /// the account.
901903 pub invite_id: String,
902904 pub user_id: String,
903905 pub inviter_id: Option<String>,
+127−0
13171317 pub for_viewer: Option<bool>,
13181318 /// RFC 3339.
13191319 pub expires_at: String,
1320+ /// For a shared invite link ([`SharedInvite`]): the group it was made
1321+ /// for, such as `Cloudflare judges`. Not secret; the sign-up page shows
1322+ /// it. Null for a one-person invite.
1323+ #[serde(default)]
1324+ pub shared_label: Option<String>,
1325+ /// For a shared invite link limited to some email domains: those
1326+ /// domains, such as `["cloudflare.com"]`. Empty for any address.
1327+ #[serde(default)]
1328+ pub shared_domains: Vec<String>,
13201329 }
13211330
13221331 /// `accept_invite`: a signed-in person uses a workspace invite made for
15571566 pub invites: Vec<Invite>,
15581567 /// Whom they invited, three levels down.
15591568 pub invited: Vec<InviteTreeNode>,
1569+ /// The shared invite link the account was made with, if it was.
1570+ #[serde(default)]
1571+ pub shared: Option<SharedInviteSource>,
1572+}
1573+
1574+// --- Shared invite links, staff only ---
1575+//
1576+// One link for a group (a conference's judges, a post, a community): up
1577+// to `max_uses` new accounts, until it expires or staff revoke it,
1578+// optionally only for addresses at some domains. Each use makes a new
1579+// account, which then makes its own workspace; a shared link never joins
1580+// anyone to an existing workspace, and uses nobody's allowance. Its code
1581+// looks and is stored like any invite code (only a hash, and a sealed copy
1582+// staff can copy again while it is live); the link is
1583+// `https://g1t.sh/register?invite=<code>`. See
1584+// services/identity/src/shared_invites.rs.
1585+
1586+/// How long a shared invite link works when staff give no date.
1587+pub const SHARED_INVITE_TTL_DAYS: u64 = 14;
1588+/// The furthest ahead a shared invite link's last day may be set.
1589+pub const SHARED_INVITE_MAX_DAYS: u64 = 365;
1590+/// The most accounts one shared invite link makes.
1591+pub const MAX_SHARED_INVITE_USES: u32 = 1000;
1592+/// The most characters a shared invite link's label keeps.
1593+pub const MAX_SHARED_INVITE_LABEL: usize = 80;
1594+/// The most email domains one shared invite link may be limited to.
1595+pub const MAX_SHARED_INVITE_DOMAINS: usize = 10;
1596+
1597+/// Where a shared invite link stands. Only a live one makes accounts.
1598+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1599+#[serde(rename_all = "snake_case")]
1600+pub enum SharedInviteStatus {
1601+ Live,
1602+ /// Every use is taken.
1603+ UsedUp,
1604+ Expired,
1605+ Revoked,
1606+}
1607+
1608+/// The shared invite link an account was made with.
1609+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1610+pub struct SharedInviteSource {
1611+ pub id: String,
1612+ pub label: String,
1613+}
1614+
1615+/// An account made with a shared invite link.
1616+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1617+#[serde(rename_all = "camelCase")]
1618+pub struct SharedInviteAccount {
1619+ /// Null once the account is purged.
1620+ pub username: Option<String>,
1621+ /// When it was made with the link. RFC 3339.
1622+ pub joined_at: String,
1623+}
1624+
1625+/// One shared invite link, as staff see it.
1626+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1627+#[serde(rename_all = "camelCase")]
1628+pub struct SharedInvite {
1629+ /// `sinv_…`.
1630+ pub id: String,
1631+ /// Whom it is for, such as `Cloudflare judges`.
1632+ pub label: String,
1633+ /// The code, while it is live (and IDENTITY_KEY is set).
1634+ pub code: Option<String>,
1635+ /// The code's first group, such as `g1t-k7m2`.
1636+ pub hint: String,
1637+ pub max_uses: u32,
1638+ /// Accounts made with it so far.
1639+ pub uses: u32,
1640+ /// Only addresses at these domains may use it; empty for any.
1641+ pub domains: Vec<String>,
1642+ pub status: SharedInviteStatus,
1643+ /// The staff member who made it, by email.
1644+ pub staff: String,
1645+ /// RFC 3339.
1646+ pub created_at: String,
1647+ /// RFC 3339.
1648+ pub expires_at: String,
1649+ pub revoked_at: Option<String>,
1650+ pub revoked_by: Option<String>,
1651+ /// The accounts made with it, oldest first.
1652+ pub accounts: Vec<SharedInviteAccount>,
1653+}
1654+
1655+/// `admin_shared_invites` takes `{}`: shared invite links, newest first,
1656+/// at most 200, each with the accounts it made. Returns
1657+/// `Vec<SharedInvite>`.
1658+///
1659+/// `admin_create_shared_invite`: staff make a shared invite link. Recorded
1660+/// in sudo's audit log. Returns `Outcome<SharedInvite>`, with the code.
1661+#[derive(Debug, Default, Serialize, Deserialize)]
1662+pub struct AdminCreateSharedInviteArgs {
1663+ /// Required, up to [`MAX_SHARED_INVITE_LABEL`] characters.
1664+ pub label: String,
1665+ /// 1 to [`MAX_SHARED_INVITE_USES`].
1666+ pub max_uses: u32,
1667+ /// The last day it works, `YYYY-MM-DD` (UTC; it works until the end of
1668+ /// that day), at most [`SHARED_INVITE_MAX_DAYS`] ahead. Null for
1669+ /// [`SHARED_INVITE_TTL_DAYS`] from now.
1670+ #[serde(default)]
1671+ pub expires_on: Option<String>,
1672+ /// Email domains it is limited to, such as `cloudflare.com`; empty for
1673+ /// any address. Up to [`MAX_SHARED_INVITE_DOMAINS`].
1674+ #[serde(default)]
1675+ pub domains: Vec<String>,
1676+ /// The staff member, by email.
1677+ pub staff: String,
1678+}
1679+
1680+/// `admin_revoke_shared_invite`: stops a shared invite link making any
1681+/// more accounts. Those it made stay. Recorded in sudo's audit log.
1682+/// Returns `Outcome<SharedInvite>`.
1683+#[derive(Debug, Serialize, Deserialize)]
1684+pub struct AdminRevokeSharedInviteArgs {
1685+ pub id: String,
1686+ pub staff: String,
15601687 }
15611688
15621689 #[cfg(test)]
+2−0
123123 deletion: AccountDeletion;
124124 /** Set while it is deleted and not yet purged. */
125125 deleted: DeletedAccount | null;
126+ /** The shared invite link it was made with, if it was: sudo shows "Joined through <label>". */
127+ joinedThrough: { id: string; label: string } | null;
126128 };
127129
128130 /** Where an account's two-factor authentication stands. */
+10−0
322322 call("admin_grant_invites", { target, name, amount, note, staff }),
323323 inviteTree: (username) => call("admin_invite_tree", { username }),
324324 workspaceInvites: (slug) => call("admin_workspace_invites", { slug }),
325+ sharedInvites: () => call("admin_shared_invites", {}),
326+ createSharedInvite: (link, staff) =>
327+ call("admin_create_shared_invite", {
328+ label: link.label,
329+ max_uses: link.maxUses,
330+ expires_on: link.expiresOn,
331+ domains: link.domains,
332+ staff,
333+ }),
334+ revokeSharedInvite: (id, staff) => call("admin_revoke_shared_invite", { id, staff }),
325335 deletedWorkspaces: () => call("admin_deleted_workspaces", {}),
326336 restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }),
327337 purgeWorkspace: (workspaceId, staff, confirm) => call("admin_purge_workspace", { workspaceId, staff, confirm }),
+75−0
264264 /** With a viewer: whether it is theirs (for one of their confirmed addresses, or used by them). */
265265 forViewer: boolean | null;
266266 expiresAt: string;
267+ /** A shared invite link's group, such as `Cloudflare judges`; null for a one-person invite. Not secret. */
268+ sharedLabel: string | null;
269+ /** The email domains a shared invite link is limited to; empty for any address. */
270+ sharedDomains: string[];
267271 };
268272
269273 export type WaitlistStatus = "waiting" | "invited" | "dismissed";
301305 invites: Invite[];
302306 /** Whom they invited, three levels down. */
303307 invited: InviteTreeNode[];
308+ /** The shared invite link the account was made with, if it was. */
309+ shared: SharedInviteSource | null;
304310 };
305311
312+// --- Shared invite links, staff only ---------------------------------------------------
313+//
314+// One link for a group (a conference's judges, a post, a community): up to
315+// `maxUses` new accounts, until it expires or staff revoke it, optionally only
316+// for addresses at some domains. Each use makes a new account, which makes its
317+// own workspace; it never joins an existing one and uses nobody's allowance.
318+// The link is `https://g1t.sh/register?invite=<code>`. Mirrors the shared
319+// invite types in `crates/contracts/src/identity.rs`.
320+
321+/** How long a shared invite link works when staff give no date. */
322+export const SHARED_INVITE_TTL_DAYS = 14;
323+/** The furthest ahead a shared invite link's last day may be set. */
324+export const SHARED_INVITE_MAX_DAYS = 365;
325+/** The most accounts one shared invite link makes. */
326+export const MAX_SHARED_INVITE_USES = 1000;
327+/** The most characters a shared invite link's label keeps. */
328+export const MAX_SHARED_INVITE_LABEL = 80;
329+/** The most email domains one shared invite link may be limited to. */
330+export const MAX_SHARED_INVITE_DOMAINS = 10;
331+
332+/** Only a live link makes accounts; `used_up`: every use is taken. */
333+export type SharedInviteStatus = "live" | "used_up" | "expired" | "revoked";
334+
335+/** The shared invite link an account was made with. */
336+export type SharedInviteSource = { id: string; label: string };
337+
338+/** One shared invite link, as staff see it. */
339+export type SharedInvite = {
340+ /** `sinv_…`. */
341+ id: string;
342+ /** Whom it is for, such as `Cloudflare judges`. */
343+ label: string;
344+ /** The code, while it is live. */
345+ code: string | null;
346+ /** The code's first group, such as `g1t-k7m2`. */
347+ hint: string;
348+ maxUses: number;
349+ /** Accounts made with it so far. */
350+ uses: number;
351+ /** Only addresses at these domains may use it; empty for any. */
352+ domains: string[];
353+ status: SharedInviteStatus;
354+ /** The staff member who made it, by email. */
355+ staff: string;
356+ createdAt: string;
357+ expiresAt: string;
358+ revokedAt: string | null;
359+ revokedBy: string | null;
360+ /** The accounts made with it, oldest first; `username` is null once one is purged. */
361+ accounts: { username: string | null; joinedAt: string }[];
362+};
363+
364+/** What staff make a shared invite link from. */
365+export type NewSharedInvite = {
366+ label: string;
367+ /** 1 to 1000. */
368+ maxUses: number;
369+ /** The last day it works, `YYYY-MM-DD` (UTC); null for 14 days from now. */
370+ expiresOn: string | null;
371+ /** Email domains it is limited to, such as `cloudflare.com`; empty for any address. */
372+ domains: string[];
373+};
374+
306375 /** The most rows one staff listing of invites or the waitlist returns. */
307376 export const ADMIN_INVITES_LIMIT = 500;
308377
344413 inviteTree(username: string): Promise<InviteTree | null>;
345414 /** A workspace's granted invites and the invites made for it, or null. */
346415 workspaceInvites(slug: string): Promise<InviteTree | null>;
416+ /** Shared invite links, newest first, each with the accounts it made. */
417+ sharedInvites(): Promise<SharedInvite[]>;
418+ /** Makes a shared invite link; the result carries its code. Recorded in the audit log. */
419+ createSharedInvite(link: NewSharedInvite, staff: string): Promise<Result<SharedInvite>>;
420+ /** Stops a shared invite link making more accounts; those it made stay. Recorded in the audit log. */
421+ revokeSharedInvite(id: string, staff: string): Promise<Result<SharedInvite>>;
347422
348423 /** Workspaces owners deleted that are not purged yet, newest first. */
349424 deletedWorkspaces(): Promise<DeletedWorkspace[]>;
+44−0
1+-- Shared invite links: one link staff hand to a group (a conference's
2+-- judges, a post, a community) that makes up to max_uses new accounts,
3+-- until it expires or is revoked. Each use makes a new account, which makes
4+-- its own workspace; a shared link never joins an existing one and uses
5+-- nobody's allowance. Every timestamp is RFC 3339 UTC. See
6+-- src/shared_invites.rs. Safe to apply twice.
7+
8+CREATE TABLE IF NOT EXISTS shared_invites (
9+ id TEXT PRIMARY KEY,
10+ -- Whom it is for, such as `Cloudflare judges`. Not secret: the sign-up
11+ -- page shows it.
12+ label TEXT NOT NULL,
13+ -- As invites.code_hash: SHA-256 of the code's 32 characters. The code
14+ -- itself is never stored.
15+ code_hash TEXT NOT NULL UNIQUE,
16+ -- The code's first group, such as `g1t-k7m2`.
17+ hint TEXT NOT NULL,
18+ -- AES-256-GCM under IDENTITY_KEY, bound to the id, so staff can copy
19+ -- the link again. Null when no key is set, and once it is revoked or
20+ -- every use is taken.
21+ sealed_code TEXT,
22+ max_uses INTEGER NOT NULL,
23+ -- Email domains it is limited to, lowercase, comma separated. Null for
24+ -- any address.
25+ domains TEXT,
26+ -- The staff member who made it, by email.
27+ staff TEXT NOT NULL,
28+ created_at TEXT NOT NULL,
29+ expires_at TEXT NOT NULL,
30+ revoked_at TEXT,
31+ revoked_by TEXT
32+);
33+CREATE INDEX IF NOT EXISTS shared_invites_created ON shared_invites (created_at);
34+
35+-- One row per account a shared link made: how many uses are taken (its
36+-- count, checked in the same statement that adds one), and where the
37+-- account came from. Kept when the account is purged, so a purge never
38+-- gives a use back.
39+CREATE TABLE IF NOT EXISTS shared_invite_uses (
40+ user_id TEXT PRIMARY KEY,
41+ shared_invite_id TEXT NOT NULL,
42+ created_at TEXT NOT NULL
43+);
44+CREATE INDEX IF NOT EXISTS shared_invite_uses_invite ON shared_invite_uses (shared_invite_id, created_at);
+1−0
11451145 log,
11461146 deletion,
11471147 deleted,
1148+ joined_through: self.shared_source(user_id).await?,
11481149 }))
11491150 }
11501151
+73−11
2121 //! into a workspace always makes an invite bound to it, and costs one only
2222 //! when the address has no account, so the answer never says which.
2323 //!
24+//! A code may instead be a shared invite link's, which staff hand to a
25+//! group: it makes up to a set number of accounts, each its own, and is
26+//! checked and spent here the same way (shared_invites.rs).
27+//!
2428 //! Vars: REGISTRATION_MODE (`invite` | `open`), INVITES_PER_USER,
2529 //! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs).
2630
3539 use worker::Result;
3640 use worker::wasm_bindgen::JsValue;
3741
42+use crate::shared_invites::{SharedAdmits, shared_admits, wrong_domain};
3843 use crate::{Identity, crypto};
3944
4045 /// Crockford base32, as ids use: no i, l, o or u.
200205 Invalid,
201206 /// It is bound to another address.
202207 WrongEmail,
208+ /// A shared invite link limited to email domains the address is not
209+ /// at (shared_invites.rs).
210+ WrongDomain,
203211 }
204212
205213 /// The parts of an invite that decide whether it admits someone.
486494 .collect()
487495 }
488496
489− fn invite_sealer(&self) -> Option<Sealer> {
497+ pub(crate) fn invite_sealer(&self) -> Option<Sealer> {
490498 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
491499 }
492500
692700 let required = self.invites_required();
693701 let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty());
694702 let mut invite = None;
703+ // A shared invite link's code instead (shared_invites.rs).
704+ let mut shared = None;
695705 match code {
696706 None if required => return Ok(Outcome::fail(FailureCode::Forbidden, MISSING)),
697707 None => {}
700710 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
701711 }
702712 let row = self.invite_by_code(code).await?;
713+ let link = match row {
714+ None => self.shared_by_code(code).await?,
715+ Some(_) => None,
716+ };
703717 let now = rfc3339(now_ms());
704− match admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), new.email, true) {
705− Ok(()) => invite = row,
718+ let verdict = match &link {
719+ Some(link) => {
720+ let domains = link.domains();
721+ let admits = SharedAdmits { status: link.status(&now), domains: &domains };
722+ shared_admits(Some(&admits), new.email)
723+ }
724+ None => admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), new.email, true),
725+ };
726+ match verdict {
727+ Ok(()) => (invite, shared) = (row, link),
706728 Err(_) if !required => {}
707729 Err(refusal) => {
708730 self.count_failure(new.client).await?;
709− let message = if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID };
731+ let message = match refusal {
732+ Refusal::WrongEmail => WRONG_EMAIL.to_owned(),
733+ Refusal::WrongDomain => wrong_domain(&link.map(|link| link.domains()).unwrap_or_default()),
734+ Refusal::Invalid => INVALID.to_owned(),
735+ };
710736 return Ok(Outcome::fail(FailureCode::Forbidden, message));
711737 }
712738 }
730756 new.email.into(),
731757 new.password_hash.into(),
732758 ];
733− let made = match &invite {
734− None => {
759+ let made = match (&invite, &shared) {
760+ // Take a use of the shared link, then make the account only if
761+ // this request took it: one transaction, counted in the
762+ // statement that takes it, so racing past its uses is
763+ // impossible.
764+ (None, Some(link)) => self
765+ .db
766+ .batch(self.shared_account_statements(link, &values, verified_at)?)
767+ .await
768+ .map(|_| ()),
769+ (None, None) => {
735770 self.db
736771 .prepare(format!(
737772 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
744779 }
745780 // Spend the code, then make the account only if this request
746781 // spent it: one transaction, so a second use finds it gone.
747− Some(row) => {
782+ (Some(row), _) => {
748783 let mut insert = values.to_vec();
749784 insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]);
750785 self.db
795830 {
796831 self.after_redeemed(&row, &user, true).await?;
797832 }
833+ // A shared link gives nothing to wait for: the account makes its
834+ // own workspace.
835+ if let Some(link) = shared {
836+ self.announce(
837+ "invite.redeemed",
838+ Some(&user.id),
839+ InviteRedeemed {
840+ invite_id: link.id,
841+ user_id: user.id.clone(),
842+ inviter_id: None,
843+ workspace_id: None,
844+ created_account: true,
845+ },
846+ )
847+ .await;
848+ }
798849 Ok(Outcome::Ok(user))
799850 }
800851
12591310 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
12601311 }
12611312 let now = rfc3339(now_ms());
1313+ let found = self.invite_by_code(&a.code).await?;
1314+ // A shared invite link's code, while it is live: its label and
1315+ // domains are for the sign-up page. Expired, revoked and used up
1316+ // get the one answer below, whatever `any_status` asks.
1317+ if found.is_none()
1318+ && let Some(link) = self.shared_by_code(&a.code).await?
1319+ && link.status(&now) == SharedInviteStatus::Live
1320+ {
1321+ return Ok(Outcome::Ok(self.shared_preview(&link)));
1322+ }
12621323 // A spent code is still a real one (160 random bits): saying what
12631324 // became of it tells a guesser nothing.
1264− let row = self
1265− .invite_by_code(&a.code)
1266− .await?
1267− .filter(|row| a.any_status || row.status(&now) == InviteStatus::Pending);
1325+ let row = found.filter(|row| a.any_status || row.status(&now) == InviteStatus::Pending);
12681326 let Some(row) = row else {
12691327 self.count_failure(a.client.as_deref()).await?;
12701328 return Ok(Outcome::fail(FailureCode::NotFound, INVALID));
13311389 has_account,
13321390 for_viewer,
13331391 expires_at: row.expires_at,
1392+ shared_label: None,
1393+ shared_domains: Vec::new(),
13341394 }))
13351395 }
13361396
20602120 grants: self.grants(GrantTarget::User, &user.id).await?,
20612121 invites,
20622122 invited: self.invited_by_user(&user.id, TREE_DEPTH).await?,
2123+ shared: self.shared_source(&user.id).await?,
20632124 }))
20642125 }
20652126
20822143 grants: self.grants(GrantTarget::Workspace, &id).await?,
20832144 invites,
20842145 invited: Vec::new(),
2146+ shared: None,
20852147 }))
20862148 }
20872149
+5−0
2525 mod job_tokens;
2626 mod run_credentials;
2727 mod security;
28+mod shared_invites;
2829 mod teams;
2930 mod throttle;
3031 mod token_reach;
10121013 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
10131014 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
10141015 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
1016+ // Shared invite links for a group; see shared_invites.rs.
1017+ "admin_shared_invites" => reply(&identity.admin_shared_invites().await?),
1018+ "admin_create_shared_invite" => reply(&identity.admin_create_shared_invite(args(body)?).await?),
1019+ "admin_revoke_shared_invite" => reply(&identity.admin_revoke_shared_invite(args(body)?).await?),
10151020 // Deleted workspaces, restored or purged by staff; see deletion.rs.
10161021 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
10171022 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
+946−0
1+//! Shared invite links: one link staff hand to a group (a conference's
2+//! judges, a post, a community), made in sudo.
3+//!
4+//! A shared link makes up to `max_uses` new accounts until it expires or
5+//! staff revoke it, optionally only for addresses at some email domains.
6+//! Each use makes a new account, which then makes its own workspace: a
7+//! shared link never joins anyone to an existing workspace, and uses
8+//! nobody's allowance. Its code is an ordinary invite code (`g1t-` and
9+//! eight groups), stored the same way: only its SHA-256, and a copy sealed
10+//! under IDENTITY_KEY so staff can copy the link again while it is live.
11+//!
12+//! Using one goes through [`Identity::create_account`] like any invite
13+//! (invites.rs): the same per-client failure throttle, the same one answer
14+//! for a code that is unknown, expired, revoked or used up, and a use
15+//! taken in the same transaction that makes the account. The statement
16+//! that takes a use counts the uses taken and adds one only while fewer
17+//! than `max_uses` are, and the account is made only if that row was
18+//! added, so people racing for the last use cannot both get one.
19+//!
20+//! Each use is a row in `shared_invite_uses`, which also says where the
21+//! account came from: sudo shows "Joined through <label>".
22+
23+use g1t_contracts::identity::*;
24+use g1t_contracts::time::{SQL_NOW, parse_rfc3339, rfc3339};
25+use g1t_contracts::{FailureCode, Outcome, new_id};
26+use g1t_kit::now_ms;
27+use serde::Deserialize;
28+use worker::Result;
29+use worker::wasm_bindgen::JsValue;
30+
31+use crate::Identity;
32+use crate::invites::{Refusal, code_hash, code_hint, format_code, new_code_body, normalize_code};
33+
34+const DAY_MS: u64 = 24 * 60 * 60 * 1000;
35+
36+/// What sudo's audit log files shared links under: `invites` is a reserved
37+/// name, so no workspace has it.
38+pub const AUDIT_ACCOUNT: &str = "invites";
39+
40+/// What someone whose address is at another domain is told. The domains
41+/// are no secret to whoever holds the link: the sign-up page lists them.
42+pub fn wrong_domain(domains: &[String]) -> String {
43+ let list = match domains {
44+ [] => String::new(),
45+ [one] => one.clone(),
46+ [rest @ .., last] => format!("{} or {last}", rest.join(", ")),
47+ };
48+ format!("This invite is only for email addresses at {list}. Sign up with your address there.")
49+}
50+
51+// --- Rules --------------------------------------------------------------------
52+
53+/// Where a shared link stands at `now`. Revoked first, then used up, then
54+/// expired: what staff did, before what time did.
55+pub fn shared_status(
56+ revoked: bool,
57+ expires_at: &str,
58+ uses: u32,
59+ max_uses: u32,
60+ now: &str,
61+) -> SharedInviteStatus {
62+ if revoked {
63+ SharedInviteStatus::Revoked
64+ } else if uses >= max_uses {
65+ SharedInviteStatus::UsedUp
66+ } else if expires_at <= now {
67+ SharedInviteStatus::Expired
68+ } else {
69+ SharedInviteStatus::Live
70+ }
71+}
72+
73+/// Whether `email` is at one of `domains`: the part after the last `@`,
74+/// exactly (a subdomain is another domain). Any address when `domains` is
75+/// empty.
76+pub fn domain_allowed(domains: &[String], email: &str) -> bool {
77+ if domains.is_empty() {
78+ return true;
79+ }
80+ let Some((_, domain)) = email.trim().rsplit_once('@') else {
81+ return false;
82+ };
83+ domains
84+ .iter()
85+ .any(|allowed| allowed.eq_ignore_ascii_case(domain))
86+}
87+
88+/// The parts of a shared link that decide whether it makes an account.
89+#[derive(Debug)]
90+pub struct SharedAdmits<'a> {
91+ pub status: SharedInviteStatus,
92+ pub domains: &'a [String],
93+}
94+
95+/// Whether a shared link makes an account for `email`. Anything but a
96+/// live link is [`Refusal::Invalid`], the one answer every unusable code
97+/// gets, so nobody learns whether a link was used up, revoked or expired.
98+pub fn shared_admits(link: Option<&SharedAdmits>, email: &str) -> std::result::Result<(), Refusal> {
99+ match link {
100+ Some(link) if link.status == SharedInviteStatus::Live => {
101+ if domain_allowed(link.domains, email) {
102+ Ok(())
103+ } else {
104+ Err(Refusal::WrongDomain)
105+ }
106+ }
107+ _ => Err(Refusal::Invalid),
108+ }
109+}
110+
111+/// One email domain as staff typed it (`@Cloudflare.com ` reads as
112+/// `cloudflare.com`), if it is one.
113+pub fn normalize_domain(input: &str) -> Option<String> {
114+ let domain = input
115+ .trim()
116+ .trim_start_matches('@')
117+ .trim_end_matches('.')
118+ .to_ascii_lowercase();
119+ let well_formed = (3..=253).contains(&domain.len())
120+ && domain.contains('.')
121+ && domain.split('.').all(|label| {
122+ !label.is_empty()
123+ && label.len() <= 63
124+ && !label.starts_with('-')
125+ && !label.ends_with('-')
126+ && label
127+ .bytes()
128+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
129+ });
130+ well_formed.then_some(domain)
131+}
132+
133+/// What staff asked for, checked: what a shared link is made from.
134+#[derive(Debug, PartialEq, Eq)]
135+pub struct SharedDraft {
136+ pub label: String,
137+ pub max_uses: u32,
138+ /// RFC 3339.
139+ pub expires_at: String,
140+ pub domains: Vec<String>,
141+}
142+
143+/// The end of `YYYY-MM-DD` (UTC), in g1t's format, if it is a real day.
144+fn end_of_day(date: &str) -> Option<String> {
145+ let date = date.trim();
146+ if date.len() != 10 {
147+ return None;
148+ }
149+ let end = format!("{date}T23:59:59.999Z");
150+ // Round-trips only for a day that exists: not 2026-02-30.
151+ let ms = parse_rfc3339(&end)?;
152+ (rfc3339(ms) == end).then_some(end)
153+}
154+
155+/// Checks what staff asked for at `now_ms`: a label, 1 to 1000 uses, an
156+/// expiry from today to a year ahead (14 days when none is given), and up
157+/// to 10 domains. Every problem is said the way sudo shows it.
158+pub fn check_draft(
159+ label: &str,
160+ max_uses: u32,
161+ expires_on: Option<&str>,
162+ domains: &[String],
163+ now_ms: u64,
164+) -> std::result::Result<SharedDraft, String> {
165+ let label = label.split_whitespace().collect::<Vec<_>>().join(" ");
166+ if label.is_empty() {
167+ return Err("Give the link a label, such as Cloudflare judges.".to_owned());
168+ }
169+ if label.chars().count() > MAX_SHARED_INVITE_LABEL {
170+ return Err(format!(
171+ "Keep the label to {MAX_SHARED_INVITE_LABEL} characters."
172+ ));
173+ }
174+ if !(1..=MAX_SHARED_INVITE_USES).contains(&max_uses) {
175+ return Err(format!(
176+ "A shared link makes between 1 and {MAX_SHARED_INVITE_USES} accounts."
177+ ));
178+ }
179+ let now = rfc3339(now_ms);
180+ let expires_at = match expires_on.map(str::trim).filter(|date| !date.is_empty()) {
181+ None => rfc3339(now_ms + SHARED_INVITE_TTL_DAYS * DAY_MS),
182+ Some(date) => {
183+ let Some(end) = end_of_day(date) else {
184+ return Err("Give the expiry as a date, such as 2026-10-28.".to_owned());
185+ };
186+ if end <= now {
187+ return Err("The expiry has passed. Choose today or a later day.".to_owned());
188+ }
189+ // The last day allowed is a year from today.
190+ if end[..10] > rfc3339(now_ms + SHARED_INVITE_MAX_DAYS * DAY_MS)[..10] {
191+ return Err(format!(
192+ "A shared link works for at most {SHARED_INVITE_MAX_DAYS} days."
193+ ));
194+ }
195+ end
196+ }
197+ };
198+ let mut checked: Vec<String> = Vec::new();
199+ for domain in domains
200+ .iter()
201+ .flat_map(|entry| entry.split([',', ' ', '\n', '\r', '\t']))
202+ {
203+ if domain.trim().is_empty() {
204+ continue;
205+ }
206+ let Some(domain) = normalize_domain(domain) else {
207+ return Err(format!(
208+ "{} is not an email domain. Write domains such as cloudflare.com.",
209+ domain.trim()
210+ ));
211+ };
212+ if !checked.contains(&domain) {
213+ checked.push(domain);
214+ }
215+ }
216+ if checked.len() > MAX_SHARED_INVITE_DOMAINS {
217+ return Err(format!(
218+ "Limit a link to at most {MAX_SHARED_INVITE_DOMAINS} domains."
219+ ));
220+ }
221+ Ok(SharedDraft {
222+ label,
223+ max_uses,
224+ expires_at,
225+ domains: checked,
226+ })
227+}
228+
229+/// The domains column as a list.
230+pub fn domains_of(column: Option<&str>) -> Vec<String> {
231+ column
232+ .unwrap_or_default()
233+ .split(',')
234+ .map(str::trim)
235+ .filter(|domain| !domain.is_empty())
236+ .map(str::to_owned)
237+ .collect()
238+}
239+
240+// --- Taking a use -------------------------------------------------------------
241+
242+/// Takes one use of shared link `?2` for new account `?1`: adds the row
243+/// only while the link is not revoked, not expired, and has fewer uses
244+/// than `max_uses`, counted in this same statement. Runs in one batch
245+/// (a transaction) with [`make_account_sql`], so either both happen or
246+/// neither does.
247+pub fn take_use_sql() -> String {
248+ format!(
249+ "INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at)
250+ SELECT ?1, s.id, {SQL_NOW} FROM shared_invites s
251+ WHERE s.id = ?2 AND s.revoked_at IS NULL AND s.expires_at > {SQL_NOW}
252+ AND (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses"
253+ )
254+}
255+
256+/// Makes the account (`?1` to `?4`: id, username, email, password hash)
257+/// only if [`take_use_sql`] took a use of link `?5` for it.
258+pub fn make_account_sql(verified_at: &str) -> String {
259+ format!(
260+ "INSERT INTO users (id, username, email, password_hash, email_verified_at)
261+ SELECT ?1, ?2, ?3, ?4, {verified_at}
262+ WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)"
263+ )
264+}
265+
266+/// Forgets the sealed code of link `?1` once its last use is taken: there
267+/// is nothing left to copy.
268+pub fn seal_used_up_sql() -> &'static str {
269+ "UPDATE shared_invites SET sealed_code = NULL
270+ WHERE id = ?1 AND (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = ?1) >= max_uses"
271+}
272+
273+/// Revokes link `?2` for staff member `?1`, once: its sealed code goes
274+/// with it, and the accounts it made stay.
275+pub fn revoke_sql() -> String {
276+ format!(
277+ "UPDATE shared_invites SET revoked_at = {SQL_NOW}, revoked_by = ?1, sealed_code = NULL
278+ WHERE id = ?2 AND revoked_at IS NULL RETURNING id"
279+ )
280+}
281+
282+// --- Rows ---------------------------------------------------------------------
283+
284+const COLUMNS: &str = "s.id, s.label, s.hint, s.sealed_code, s.max_uses, s.domains, s.staff, s.created_at, s.expires_at,
285+ s.revoked_at, s.revoked_by,
286+ (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) AS uses
287+ FROM shared_invites s";
288+
289+/// The most shared links sudo lists.
290+const LIST_LIMIT: usize = 200;
291+
292+#[derive(Debug, Deserialize)]
293+pub struct SharedRow {
294+ pub id: String,
295+ pub label: String,
296+ pub hint: String,
297+ pub sealed_code: Option<String>,
298+ pub max_uses: f64,
299+ pub domains: Option<String>,
300+ pub staff: String,
301+ pub created_at: String,
302+ pub expires_at: String,
303+ pub revoked_at: Option<String>,
304+ pub revoked_by: Option<String>,
305+ pub uses: f64,
306+}
307+
308+impl SharedRow {
309+ pub fn status(&self, now: &str) -> SharedInviteStatus {
310+ shared_status(
311+ self.revoked_at.is_some(),
312+ &self.expires_at,
313+ self.uses as u32,
314+ self.max_uses as u32,
315+ now,
316+ )
317+ }
318+
319+ pub fn domains(&self) -> Vec<String> {
320+ domains_of(self.domains.as_deref())
321+ }
322+}
323+
324+impl Identity {
325+ pub(crate) async fn shared_by_code(&self, code: &str) -> Result<Option<SharedRow>> {
326+ let Some(body) = normalize_code(code) else {
327+ return Ok(None);
328+ };
329+ self.db
330+ .prepare(format!("SELECT {COLUMNS} WHERE s.code_hash = ?"))
331+ .bind(&[code_hash(&body).into()])?
332+ .first::<SharedRow>(None)
333+ .await
334+ }
335+
336+ async fn shared_by_id(&self, id: &str) -> Result<Option<SharedRow>> {
337+ self.db
338+ .prepare(format!("SELECT {COLUMNS} WHERE s.id = ?"))
339+ .bind(&[id.into()])?
340+ .first::<SharedRow>(None)
341+ .await
342+ }
343+
344+ /// The statements that take a use of `link` and make the account, for
345+ /// create_account's batch: the account row comes to exist only if the
346+ /// use was taken for it.
347+ pub(crate) fn shared_account_statements(
348+ &self,
349+ link: &SharedRow,
350+ values: &[JsValue; 4],
351+ verified_at: &str,
352+ ) -> Result<Vec<worker::D1PreparedStatement>> {
353+ let user_id = values[0].clone();
354+ let mut make = values.to_vec();
355+ make.push(link.id.as_str().into());
356+ Ok(vec![
357+ self.db
358+ .prepare(take_use_sql())
359+ .bind(&[user_id, link.id.as_str().into()])?,
360+ self.db.prepare(make_account_sql(verified_at)).bind(&make)?,
361+ self.db
362+ .prepare(seal_used_up_sql())
363+ .bind(&[link.id.as_str().into()])?,
364+ ])
365+ }
366+
367+ /// What the sign-up page shows for a live shared link's code.
368+ pub(crate) fn shared_preview(&self, link: &SharedRow) -> InvitePreview {
369+ InvitePreview {
370+ kind: InviteKind::Account,
371+ status: InviteStatus::Pending,
372+ invited_by: None,
373+ workspace: None,
374+ repository: None,
375+ email: None,
376+ address: None,
377+ has_account: false,
378+ for_viewer: None,
379+ expires_at: link.expires_at.clone(),
380+ shared_label: Some(link.label.clone()),
381+ shared_domains: link.domains(),
382+ }
383+ }
384+
385+ /// The shared link an account was made with, if it was.
386+ pub(crate) async fn shared_source(&self, user_id: &str) -> Result<Option<SharedInviteSource>> {
387+ #[derive(Deserialize)]
388+ struct Row {
389+ id: String,
390+ label: String,
391+ }
392+ Ok(self
393+ .db
394+ .prepare(
395+ "SELECT s.id, s.label FROM shared_invite_uses u JOIN shared_invites s ON s.id = u.shared_invite_id
396+ WHERE u.user_id = ?",
397+ )
398+ .bind(&[user_id.into()])?
399+ .first::<Row>(None)
400+ .await?
401+ .map(|row| SharedInviteSource { id: row.id, label: row.label }))
402+ }
403+
404+ /// A shared link as staff see it, with its code while it is live.
405+ fn shown_shared(&self, row: SharedRow, accounts: Vec<SharedInviteAccount>) -> SharedInvite {
406+ let status = row.status(&rfc3339(now_ms()));
407+ let code = if status == SharedInviteStatus::Live {
408+ row.sealed_code
409+ .as_deref()
410+ .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id))
411+ } else {
412+ None
413+ };
414+ let domains = row.domains();
415+ SharedInvite {
416+ id: row.id,
417+ label: row.label,
418+ code,
419+ hint: row.hint,
420+ max_uses: row.max_uses as u32,
421+ uses: row.uses as u32,
422+ domains,
423+ status,
424+ staff: row.staff,
425+ created_at: row.created_at,
426+ expires_at: row.expires_at,
427+ revoked_at: row.revoked_at,
428+ revoked_by: row.revoked_by,
429+ accounts,
430+ }
431+ }
432+
433+ /// The accounts each of `ids` made, oldest first.
434+ async fn shared_accounts(&self, ids: &[String]) -> Result<Vec<(String, SharedInviteAccount)>> {
435+ if ids.is_empty() {
436+ return Ok(Vec::new());
437+ }
438+ #[derive(Deserialize)]
439+ struct Row {
440+ shared_invite_id: String,
441+ username: Option<String>,
442+ created_at: String,
443+ }
444+ let marks = vec!["?"; ids.len()].join(", ");
445+ let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect();
446+ Ok(self
447+ .db
448+ .prepare(format!(
449+ "SELECT u.shared_invite_id, us.username, u.created_at FROM shared_invite_uses u
450+ LEFT JOIN users us ON us.id = u.user_id
451+ WHERE u.shared_invite_id IN ({marks}) ORDER BY u.created_at, u.user_id"
452+ ))
453+ .bind(&binds)?
454+ .all()
455+ .await?
456+ .results::<Row>()?
457+ .into_iter()
458+ .map(|row| {
459+ (
460+ row.shared_invite_id,
461+ SharedInviteAccount {
462+ username: row.username,
463+ joined_at: row.created_at,
464+ },
465+ )
466+ })
467+ .collect())
468+ }
469+
470+ /// `admin_shared_invites`.
471+ pub async fn admin_shared_invites(&self) -> Result<Vec<SharedInvite>> {
472+ let rows = self
473+ .db
474+ .prepare(format!(
475+ "SELECT {COLUMNS} ORDER BY s.created_at DESC, s.id DESC LIMIT {LIST_LIMIT}"
476+ ))
477+ .all()
478+ .await?
479+ .results::<SharedRow>()?;
480+ let ids: Vec<String> = rows.iter().map(|row| row.id.clone()).collect();
481+ let mut accounts = self.shared_accounts(&ids).await?;
482+ Ok(rows
483+ .into_iter()
484+ .map(|row| {
485+ let (mine, rest): (Vec<_>, Vec<_>) =
486+ accounts.drain(..).partition(|(id, _)| *id == row.id);
487+ accounts = rest;
488+ let mine = mine.into_iter().map(|(_, account)| account).collect();
489+ self.shown_shared(row, mine)
490+ })
491+ .collect())
492+ }
493+
494+ /// `admin_create_shared_invite`.
495+ pub async fn admin_create_shared_invite(
496+ &self,
497+ a: AdminCreateSharedInviteArgs,
498+ ) -> Result<Outcome<SharedInvite>> {
499+ let staff = a.staff.trim();
500+ if staff.is_empty() {
501+ return Ok(Outcome::fail(
502+ FailureCode::Forbidden,
503+ "Say which staff member is making it.",
504+ ));
505+ }
506+ let now = now_ms();
507+ let draft = match check_draft(
508+ &a.label,
509+ a.max_uses,
510+ a.expires_on.as_deref(),
511+ &a.domains,
512+ now,
513+ ) {
514+ Ok(draft) => draft,
515+ Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
516+ };
517+ let body = new_code_body();
518+ let code = format_code(&body);
519+ let id = new_id("sinv", now);
520+ let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id));
521+ let domains = draft.domains.join(",");
522+ self.db
523+ .prepare(
524+ "INSERT INTO shared_invites (id, label, code_hash, hint, sealed_code, max_uses, domains, staff, created_at, expires_at)
525+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
526+ )
527+ .bind(&[
528+ id.as_str().into(),
529+ draft.label.as_str().into(),
530+ code_hash(&body).into(),
531+ code_hint(&body).into(),
532+ sealed.as_deref().map_or(JsValue::NULL, JsValue::from),
533+ f64::from(draft.max_uses).into(),
534+ if domains.is_empty() { JsValue::NULL } else { domains.as_str().into() },
535+ staff.into(),
536+ rfc3339(now).into(),
537+ draft.expires_at.as_str().into(),
538+ ])?
539+ .run()
540+ .await?;
541+ let only = if draft.domains.is_empty() {
542+ String::new()
543+ } else {
544+ format!(", only {}", draft.domains.join(", "))
545+ };
546+ self.record_for_staff(
547+ AUDIT_ACCOUNT,
548+ "shared_invite_created",
549+ &format!(
550+ "Shared invite link {} ({}) for {}: up to {} accounts until {}{only}",
551+ code_hint(&body),
552+ id,
553+ draft.label,
554+ draft.max_uses,
555+ &draft.expires_at[..10]
556+ ),
557+ staff,
558+ )
559+ .await;
560+ let Some(row) = self.shared_by_id(&id).await? else {
561+ return Ok(Outcome::fail(
562+ FailureCode::Conflict,
563+ "The link could not be made. Try again.",
564+ ));
565+ };
566+ let mut shown = self.shown_shared(row, Vec::new());
567+ shown.code = Some(code);
568+ Ok(Outcome::Ok(shown))
569+ }
570+
571+ /// `admin_revoke_shared_invite`.
572+ pub async fn admin_revoke_shared_invite(
573+ &self,
574+ a: AdminRevokeSharedInviteArgs,
575+ ) -> Result<Outcome<SharedInvite>> {
576+ let staff = a.staff.trim();
577+ if staff.is_empty() {
578+ return Ok(Outcome::fail(
579+ FailureCode::Forbidden,
580+ "Say which staff member is revoking it.",
581+ ));
582+ }
583+ let revoked = self
584+ .db
585+ .prepare(revoke_sql())
586+ .bind(&[staff.into(), a.id.as_str().into()])?
587+ .first::<serde_json::Value>(None)
588+ .await?;
589+ if revoked.is_none() {
590+ return Ok(Outcome::fail(
591+ FailureCode::Conflict,
592+ "That link is revoked already, or there is no such link.",
593+ ));
594+ }
595+ let Some(row) = self.shared_by_id(&a.id).await? else {
596+ return Ok(Outcome::fail(
597+ FailureCode::NotFound,
598+ "Shared invite link not found.",
599+ ));
600+ };
601+ self.record_for_staff(
602+ AUDIT_ACCOUNT,
603+ "shared_invite_revoked",
604+ &format!(
605+ "Revoked shared invite link {} ({}) for {} after {} of {} uses",
606+ row.hint, row.id, row.label, row.uses as u32, row.max_uses as u32
607+ ),
608+ staff,
609+ )
610+ .await;
611+ let accounts = self
612+ .shared_accounts(std::slice::from_ref(&row.id))
613+ .await?
614+ .into_iter()
615+ .map(|(_, account)| account)
616+ .collect();
617+ Ok(Outcome::Ok(self.shown_shared(row, accounts)))
618+ }
619+}
620+
621+#[cfg(test)]
622+mod tests {
623+ use super::*;
624+
625+ const NOW: &str = "2026-10-08T12:00:00.000Z";
626+ const LATER: &str = "2026-10-22T12:00:00.000Z";
627+ const EARLIER: &str = "2026-10-01T12:00:00.000Z";
628+ /// 2026-10-08T12:00:00.000Z.
629+ const NOW_MS: u64 = 1_791_460_800_000;
630+
631+ #[test]
632+ fn the_test_clock_is_what_it_says() {
633+ assert_eq!(rfc3339(NOW_MS), NOW);
634+ }
635+
636+ #[test]
637+ fn a_link_is_live_until_revoked_used_up_or_expired() {
638+ assert_eq!(
639+ shared_status(false, LATER, 0, 10, NOW),
640+ SharedInviteStatus::Live
641+ );
642+ assert_eq!(
643+ shared_status(false, LATER, 9, 10, NOW),
644+ SharedInviteStatus::Live
645+ );
646+ assert_eq!(
647+ shared_status(false, LATER, 10, 10, NOW),
648+ SharedInviteStatus::UsedUp
649+ );
650+ assert_eq!(
651+ shared_status(false, EARLIER, 3, 10, NOW),
652+ SharedInviteStatus::Expired
653+ );
654+ // It stops at its expiry, not a moment after.
655+ assert_eq!(
656+ shared_status(false, NOW, 3, 10, NOW),
657+ SharedInviteStatus::Expired
658+ );
659+ assert_eq!(
660+ shared_status(true, LATER, 3, 10, NOW),
661+ SharedInviteStatus::Revoked
662+ );
663+ // What staff did comes before what time did.
664+ assert_eq!(
665+ shared_status(true, EARLIER, 10, 10, NOW),
666+ SharedInviteStatus::Revoked
667+ );
668+ assert_eq!(
669+ shared_status(false, EARLIER, 10, 10, NOW),
670+ SharedInviteStatus::UsedUp
671+ );
672+ }
673+
674+ #[test]
675+ fn expired_revoked_and_used_up_links_all_get_the_one_answer() {
676+ let none: [String; 0] = [];
677+ for status in [
678+ SharedInviteStatus::UsedUp,
679+ SharedInviteStatus::Expired,
680+ SharedInviteStatus::Revoked,
681+ ] {
682+ let link = SharedAdmits {
683+ status,
684+ domains: &none,
685+ };
686+ assert_eq!(
687+ shared_admits(Some(&link), "ada@example.com"),
688+ Err(Refusal::Invalid),
689+ "{status:?}"
690+ );
691+ // Even at another domain: a dead link says nothing about whom it was for.
692+ let domains = ["cloudflare.com".to_owned()];
693+ let bound = SharedAdmits {
694+ status,
695+ domains: &domains,
696+ };
697+ assert_eq!(
698+ shared_admits(Some(&bound), "eve@example.com"),
699+ Err(Refusal::Invalid)
700+ );
701+ }
702+ assert_eq!(
703+ shared_admits(None, "ada@example.com"),
704+ Err(Refusal::Invalid)
705+ );
706+ let live = SharedAdmits {
707+ status: SharedInviteStatus::Live,
708+ domains: &none,
709+ };
710+ assert_eq!(shared_admits(Some(&live), "anyone@anywhere.dev"), Ok(()));
711+ }
712+
713+ #[test]
714+ fn a_link_limited_to_domains_admits_only_addresses_there() {
715+ let domains = ["cloudflare.com".to_owned(), "flagon.io".to_owned()];
716+ let live = SharedAdmits {
717+ status: SharedInviteStatus::Live,
718+ domains: &domains,
719+ };
720+ assert_eq!(shared_admits(Some(&live), "judge@cloudflare.com"), Ok(()));
721+ assert_eq!(shared_admits(Some(&live), " Judge@CloudFlare.COM "), Ok(()));
722+ assert_eq!(shared_admits(Some(&live), "chase@flagon.io"), Ok(()));
723+ assert_eq!(
724+ shared_admits(Some(&live), "eve@example.com"),
725+ Err(Refusal::WrongDomain)
726+ );
727+ // A subdomain, or a domain that only ends the same, is another domain.
728+ assert_eq!(
729+ shared_admits(Some(&live), "a@eu.cloudflare.com"),
730+ Err(Refusal::WrongDomain)
731+ );
732+ assert_eq!(
733+ shared_admits(Some(&live), "a@notcloudflare.com"),
734+ Err(Refusal::WrongDomain)
735+ );
736+ // The last @ decides.
737+ assert_eq!(
738+ shared_admits(Some(&live), "\"a@cloudflare.com\"@evil.com"),
739+ Err(Refusal::WrongDomain)
740+ );
741+ assert_eq!(
742+ shared_admits(Some(&live), "no-at-sign"),
743+ Err(Refusal::WrongDomain)
744+ );
745+ assert_eq!(
746+ wrong_domain(&domains),
747+ "This invite is only for email addresses at cloudflare.com or flagon.io. Sign up with your address there."
748+ );
749+ assert!(
750+ wrong_domain(&["a.com".into(), "b.com".into(), "c.com".into()])
751+ .contains("a.com, b.com or c.com")
752+ );
753+ }
754+
755+ #[test]
756+ fn a_use_is_taken_only_under_max_uses_in_the_statement_that_takes_it() {
757+ let take = take_use_sql();
758+ // The count, the cap, revocation and expiry are all checked in the
759+ // insert itself: no read-then-write gap for a race to slip into.
760+ assert!(take.starts_with("INSERT INTO shared_invite_uses"));
761+ assert!(take.contains("(SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses"));
762+ assert!(take.contains("s.revoked_at IS NULL"));
763+ assert!(take.contains(&format!("s.expires_at > {SQL_NOW}")));
764+ assert!(!take.contains("VALUES"));
765+ // The account is made only if this sign-up took the use.
766+ let make = make_account_sql("NULL");
767+ assert!(make.starts_with("INSERT INTO users"));
768+ assert!(make.contains("WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)"));
769+ assert!(make.contains("SELECT ?1, ?2, ?3, ?4, NULL"));
770+ // The sealed code goes once the last use does.
771+ assert!(seal_used_up_sql().contains(">= max_uses"));
772+ }
773+
774+ /// The take-a-use statement's rule, applied to sign-ups one after
775+ /// another as D1 runs them (one writer; each batch a transaction).
776+ fn race(max_uses: u32, signups: u32, revoked: bool, expires_at: &str) -> u32 {
777+ let mut uses = 0;
778+ for _ in 0..signups {
779+ if shared_status(revoked, expires_at, uses, max_uses, NOW) == SharedInviteStatus::Live {
780+ uses += 1;
781+ }
782+ }
783+ uses
784+ }
785+
786+ #[test]
787+ fn however_many_race_for_it_a_link_never_passes_max_uses() {
788+ assert_eq!(race(1, 50, false, LATER), 1);
789+ assert_eq!(race(25, 1000, false, LATER), 25);
790+ assert_eq!(race(1000, 999, false, LATER), 999);
791+ assert_eq!(race(10, 10, true, LATER), 0);
792+ assert_eq!(race(10, 10, false, EARLIER), 0);
793+ }
794+
795+ fn draft(
796+ label: &str,
797+ max_uses: u32,
798+ expires_on: Option<&str>,
799+ domains: &[&str],
800+ ) -> std::result::Result<SharedDraft, String> {
801+ let domains: Vec<String> = domains.iter().map(|d| (*d).to_owned()).collect();
802+ check_draft(label, max_uses, expires_on, &domains, NOW_MS)
803+ }
804+
805+ #[test]
806+ fn a_link_needs_a_label_and_one_to_a_thousand_uses() {
807+ let made = draft(" Cloudflare judges ", 40, None, &[]).unwrap();
808+ assert_eq!(made.label, "Cloudflare judges");
809+ assert_eq!(made.max_uses, 40);
810+ assert!(made.domains.is_empty());
811+ assert!(draft("", 10, None, &[]).unwrap_err().contains("label"));
812+ assert!(draft(" ", 10, None, &[]).unwrap_err().contains("label"));
813+ assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL + 1), 10, None, &[]).is_err());
814+ assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL), 10, None, &[]).is_ok());
815+ assert!(
816+ draft("Judges", 0, None, &[])
817+ .unwrap_err()
818+ .contains("between 1 and 1000")
819+ );
820+ assert!(draft("Judges", 1001, None, &[]).is_err());
821+ assert!(draft("Judges", 1, None, &[]).is_ok());
822+ assert!(draft("Judges", 1000, None, &[]).is_ok());
823+ }
824+
825+ #[test]
826+ fn a_link_expires_in_14_days_unless_given_a_day_within_a_year() {
827+ assert_eq!(
828+ draft("Judges", 10, None, &[]).unwrap().expires_at,
829+ "2026-10-22T12:00:00.000Z"
830+ );
831+ assert_eq!(
832+ draft("Judges", 10, Some(""), &[]).unwrap().expires_at,
833+ "2026-10-22T12:00:00.000Z"
834+ );
835+ // A day works until its end, UTC.
836+ assert_eq!(
837+ draft("Judges", 10, Some("2026-10-14"), &[])
838+ .unwrap()
839+ .expires_at,
840+ "2026-10-14T23:59:59.999Z"
841+ );
842+ assert_eq!(
843+ draft("Judges", 10, Some("2026-10-08"), &[])
844+ .unwrap()
845+ .expires_at,
846+ "2026-10-08T23:59:59.999Z"
847+ );
848+ assert!(
849+ draft("Judges", 10, Some("2026-10-07"), &[])
850+ .unwrap_err()
851+ .contains("passed")
852+ );
853+ assert!(draft("Judges", 10, Some("2027-10-08"), &[]).is_ok());
854+ assert!(
855+ draft("Judges", 10, Some("2027-10-09"), &[])
856+ .unwrap_err()
857+ .contains("365 days")
858+ );
859+ for bad in [
860+ "2026-02-30",
861+ "2026-13-01",
862+ "next week",
863+ "2026-10-8",
864+ "2026/10/14",
865+ ] {
866+ assert!(
867+ draft("Judges", 10, Some(bad), &[])
868+ .unwrap_err()
869+ .contains("as a date"),
870+ "{bad}"
871+ );
872+ }
873+ }
874+
875+ #[test]
876+ fn domains_are_tidied_and_checked() {
877+ let made = draft(
878+ "Judges",
879+ 10,
880+ None,
881+ &["@Cloudflare.com, flagon.io", "cloudflare.com\nexample.dev."],
882+ )
883+ .unwrap();
884+ assert_eq!(made.domains, ["cloudflare.com", "flagon.io", "example.dev"]);
885+ assert!(draft("Judges", 10, None, &["not a domain!"]).is_err());
886+ assert!(
887+ draft("Judges", 10, None, &["localhost"])
888+ .unwrap_err()
889+ .contains("localhost is not an email domain")
890+ );
891+ assert!(draft("Judges", 10, None, &["-bad.com"]).is_err());
892+ let eleven: Vec<String> = (0..11).map(|n| format!("d{n}.com")).collect();
893+ let eleven: Vec<&str> = eleven.iter().map(String::as_str).collect();
894+ assert!(
895+ draft("Judges", 10, None, &eleven)
896+ .unwrap_err()
897+ .contains("at most 10")
898+ );
899+ assert_eq!(
900+ normalize_domain(" @EXAMPLE.com. ").as_deref(),
901+ Some("example.com")
902+ );
903+ assert_eq!(
904+ domains_of(Some("cloudflare.com,flagon.io")),
905+ ["cloudflare.com", "flagon.io"]
906+ );
907+ assert!(domains_of(None).is_empty());
908+ assert!(domains_of(Some("")).is_empty());
909+ }
910+
911+ #[test]
912+ fn a_shared_code_is_an_ordinary_invite_code() {
913+ let body = new_code_body();
914+ let link = format!("https://g1t.sh/register?invite={}", format_code(&body));
915+ assert_eq!(normalize_code(&link).as_deref(), Some(body.as_str()));
916+ assert_eq!(code_hash(&body).len(), 64);
917+ assert_eq!(AUDIT_ACCOUNT, "invites");
918+ assert!(g1t_contracts::is_reserved_name(AUDIT_ACCOUNT));
919+ }
920+
921+ #[test]
922+ fn revoking_stops_new_accounts_and_forgets_the_code_once() {
923+ let revoke = revoke_sql();
924+ assert!(revoke.contains("revoked_by = ?1"));
925+ assert!(revoke.contains("sealed_code = NULL"));
926+ // Revoking twice changes nothing the second time.
927+ assert!(revoke.contains("AND revoked_at IS NULL"));
928+ // It deletes nothing: the accounts it made, and their uses, stay.
929+ assert!(!revoke.contains("DELETE"));
930+ let none: [String; 0] = [];
931+ let revoked = SharedAdmits { status: shared_status(true, LATER, 0, 10, NOW), domains: &none };
932+ assert_eq!(shared_admits(Some(&revoked), "ada@example.com"), Err(Refusal::Invalid));
933+ }
934+
935+ #[test]
936+ fn each_use_records_where_the_account_came_from_and_outlives_a_purge() {
937+ // The row that takes a use names the account and the link: sudo's
938+ // "Joined through <label>".
939+ assert!(take_use_sql().contains("INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at)"));
940+ assert!(take_use_sql().contains("SELECT ?1, s.id,"));
941+ // Purging the account keeps the use, so it is never given back.
942+ let purge = crate::account_deletion::purge_statements();
943+ assert!(!purge.is_empty());
944+ assert!(purge.iter().all(|(sql, _)| !sql.contains("shared_invite_uses")));
945+ }
946+}