Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)
23 files+1792−290/23 viewed
| 161 | 161 | An expired, revoked or used invite says which, and who sent it, so you | |
| 162 | 162 | can ask them for a new one; or ask for access from the same page. | |
| 163 | 163 | ||
| 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 | + | ||
| 164 | 190 | ### Making invites | |
| 165 | 191 | ||
| 166 | 192 | 1. Open [Settings → Invites](https://g1t.sh/settings/invites). |
| 72 | 72 | member. | |
| 73 | 73 | - **A person's page** (`/users/<username>`, linked from a workspace's | |
| 74 | 74 | 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 | |
| 76 | 78 | (from one of the account's confirmed addresses) or for abuse: give the | |
| 77 | 79 | reason, which goes in sudo's audit log (`account_deleted`), and type the | |
| 78 | 80 | username (`admin_delete_account`). It does what deleting their own | |
| ⋯ | |||
| 129 | 131 | out again; what it wrote shows as `ghost`. Both go in sudo's audit log | |
| 130 | 132 | (`account_restored`, `account_purged`), naming the staff member. There | |
| 131 | 133 | 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. | |
| 132 | 162 | - **Aliases** (`/aliases`, under Customers): names that lead to a | |
| 133 | 163 | workspace, set by staff only; there is no way for a customer to make | |
| 134 | 164 | one, and nothing user-facing mentions them. `g1t`, the product's name, | |
| 4 | 4 | import { | |
| 5 | 5 | INVITES_DONE, | |
| 6 | 6 | MAX_BULK, | |
| 7 | + | MAX_SHARED_USES, | |
| 8 | + | dayAfter, | |
| 9 | + | domainsLine, | |
| 7 | 10 | doneMessage, | |
| 8 | 11 | invitesHref, | |
| 12 | + | joinedThrough, | |
| 13 | + | normalizeDomain, | |
| 9 | 14 | parseGrant, | |
| 10 | 15 | parseIds, | |
| 11 | 16 | parseMintEmail, | |
| 12 | 17 | parseNote, | |
| 18 | + | parseSharedInvite, | |
| 13 | 19 | parseTab, | |
| 14 | 20 | parseWaitlistStatus, | |
| 21 | + | sharedInviteLink, | |
| 22 | + | sharedStatus, | |
| 23 | + | usesLine, | |
| 15 | 24 | } from "./invites.ts"; | |
| 16 | 25 | ||
| 17 | 26 | const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null }); | |
| ⋯ | |||
| 78 | 87 | assert.equal(doneMessage("nope", "4"), null); | |
| 79 | 88 | assert.equal(doneMessage(null, null), null); | |
| 80 | 89 | }); | |
| 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 | + | }); | |
| 1 | 1 | /** | |
| 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. | |
| 5 | 6 | */ | |
| 6 | − | import type { WaitlistStatus } from "@g1t/contracts"; | |
| 7 | + | import type { NewSharedInvite, SharedInvite, SharedInviteStatus, WaitlistStatus } from "@g1t/contracts"; | |
| 7 | 8 | ||
| 8 | 9 | import type { Parsed } from "./forms.ts"; | |
| 9 | 10 | ||
| 10 | − | export const INVITE_TABS = ["waitlist", "invites", "grant", "tree"] as const; | |
| 11 | + | export const INVITE_TABS = ["waitlist", "invites", "shared", "grant", "tree"] as const; | |
| 11 | 12 | export type InviteTab = (typeof INVITE_TABS)[number]; | |
| 12 | 13 | ||
| 13 | 14 | export const TAB_LABEL: Record<InviteTab, string> = { | |
| 14 | 15 | waitlist: "Waitlist", | |
| 15 | 16 | invites: "Invites", | |
| 17 | + | shared: "Shared links", | |
| 16 | 18 | grant: "Grant & mint", | |
| 17 | 19 | tree: "Invite tree", | |
| 18 | 20 | }; | |
| ⋯ | |||
| 73 | 75 | dismissed: "Dismissed.", | |
| 74 | 76 | revoked: "Invite revoked. It comes back to whoever it was charged to.", | |
| 75 | 77 | 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.", | |
| 76 | 80 | }; | |
| 77 | 81 | ||
| 78 | 82 | /** The flash for `?done=` and, after deciding several at once, `?n=` of them. */ | |
| ⋯ | |||
| 115 | 119 | if (note.length > MAX_NOTE) return { ok: false, error: `Keep the note to ${MAX_NOTE} characters; it is ${note.length}.` }; | |
| 116 | 120 | return { ok: true, value: note }; | |
| 117 | 121 | } | |
| 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 | + | } | |
| 59 | 59 | assert.equal(accountPath("ws_acme"), "/workspaces/acme"); | |
| 60 | 60 | assert.equal(accountPath("ent_bigco"), "/enterprises/ent_bigco"); | |
| 61 | 61 | 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"); | |
| 62 | 65 | assert.equal(accountPath("acme"), null); | |
| 63 | 66 | assert.equal(accountPath("ws_-bad"), null); | |
| 64 | 67 | assert.equal(accountPath(""), null); |
| 69 | 69 | const id = (account ?? "").trim().toLowerCase(); | |
| 70 | 70 | // The model catalogue's changes (billing's catalogue.rs). | |
| 71 | 71 | 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"; | |
| 72 | 74 | const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id); | |
| 73 | 75 | if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`; | |
| 74 | 76 | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; | |
| ⋯ | |||
| 76 | 78 | return null; | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 81 | + | /** Where sudo's audit log files shared invite links. */ | |
| 82 | + | export const SHARED_INVITES_ACCOUNT = "ws_invites"; | |
| 83 | + | ||
| 79 | 84 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ | |
| 80 | 85 | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 81 | 86 | if (names.has(account)) return names.get(account) as string; | |
| 82 | 87 | if (account === "models") return "Agents & models"; | |
| 88 | + | if (account === SHARED_INVITES_ACCOUNT) return "Shared invite links"; | |
| 83 | 89 | if (account.startsWith("incident:")) return "Incident"; | |
| 84 | 90 | if (account.startsWith("maintenance:")) return "Maintenance"; | |
| 85 | 91 | if (account.startsWith("ws_")) return account.slice(3); | |
| ⋯ | |||
| 120 | 126 | // From identity: workspace aliases staff set or removed. | |
| 121 | 127 | alias_added: "Alias added", | |
| 122 | 128 | 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", | |
| 123 | 132 | // From the status page (apps/status), merged in by the Audit log page. | |
| 124 | 133 | incident_declared: "Incident declared", | |
| 125 | 134 | incident_detected: "Incident detected", | |
| 1 | − | import { Check, MessageSquareText, Search, Ticket, X } from "lucide-react"; | |
| 1 | + | import { Check, Link2, MessageSquareText, Search, Ticket, X } from "lucide-react"; | |
| 2 | 2 | import { Link, data, redirect, useLocation } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { Invite, InviteTree, InviteTreeNode, WaitlistEntry } from "@g1t/contracts"; | |
| 4 | + | import type { Invite, InviteTree, InviteTreeNode, SharedInvite, WaitlistEntry } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/invites"; | |
| 7 | 7 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Select, Textarea, When } from "~/components/ui"; | |
| ⋯ | |||
| 11 | 11 | type InviteTab, | |
| 12 | 12 | MAX_BULK, | |
| 13 | 13 | MAX_NOTE, | |
| 14 | + | MAX_SHARED_DOMAINS, | |
| 15 | + | MAX_SHARED_LABEL, | |
| 16 | + | MAX_SHARED_USES, | |
| 17 | + | SHARED_MAX_DAYS, | |
| 18 | + | SHARED_TTL_DAYS, | |
| 14 | 19 | TAB_LABEL, | |
| 20 | + | dayAfter, | |
| 21 | + | domainsLine, | |
| 15 | 22 | doneMessage, | |
| 16 | 23 | invitesHref, | |
| 24 | + | joinedThrough, | |
| 17 | 25 | parseGrant, | |
| 18 | 26 | parseIds, | |
| 19 | 27 | parseMintEmail, | |
| 20 | 28 | parseNote, | |
| 29 | + | parseSharedInvite, | |
| 21 | 30 | parseTab, | |
| 22 | 31 | parseWaitlistStatus, | |
| 32 | + | sharedInviteLink, | |
| 33 | + | sharedStatus, | |
| 34 | + | usesLine, | |
| 23 | 35 | } from "~/lib/invites"; | |
| 24 | 36 | import { identity } from "~/lib/services.server"; | |
| 25 | 37 | import { settle } from "~/lib/settle"; | |
| ⋯ | |||
| 37 | 49 | const kind = url.searchParams.get("kind") === "workspace" ? "workspace" : "user"; | |
| 38 | 50 | const done = url.searchParams.get("done"); | |
| 39 | 51 | const selectAll = url.searchParams.get("select") === "all"; | |
| 40 | − | const [waitlist, invites, tree] = await Promise.all([ | |
| 52 | + | const [waitlist, invites, tree, shared] = await Promise.all([ | |
| 41 | 53 | tab === "waitlist" ? settle(identity.waitlist(query || null, status === "all" ? null : status)) : null, | |
| 42 | 54 | tab === "invites" ? settle(identity.invites(query || null)) : null, | |
| 43 | 55 | tab === "tree" && name ? settle(kind === "workspace" ? identity.workspaceInvites(name) : identity.inviteTree(name)) : null, | |
| 56 | + | tab === "shared" ? settle(identity.sharedInvites()) : null, | |
| 44 | 57 | ]); | |
| 58 | + | const now = Date.now(); | |
| 45 | 59 | return { | |
| 46 | 60 | tab, | |
| 47 | 61 | query, | |
| ⋯ | |||
| 51 | 65 | waitlist: waitlist?.ok ? waitlist.value : [], | |
| 52 | 66 | invites: invites?.ok ? invites.value : [], | |
| 53 | 67 | 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, | |
| 55 | 72 | done: doneMessage(done, url.searchParams.get("n")), | |
| 56 | 73 | selectAll, | |
| 57 | 74 | }; | |
| ⋯ | |||
| 112 | 129 | if (!result.ok) return data({ error: result.error.message, section: "grant" }, { status: 422 }); | |
| 113 | 130 | throw redirect(`${invitesHref("tree", { name, kind: target })}&done=granted`); | |
| 114 | 131 | } | |
| 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 | + | } | |
| 115 | 146 | case "mint": { | |
| 116 | 147 | const email = parseMintEmail(text(form, "email")); | |
| 117 | 148 | if (!email.ok) return data({ error: email.error, section: "mint" }, { status: 422 }); | |
| ⋯ | |||
| 524 | 555 | <div> | |
| 525 | 556 | <dt className="text-xs text-faint">Invited by</dt> | |
| 526 | 557 | <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 | |
| 528 | 563 | ? tree.invitedBy.map((username, index) => ( | |
| 529 | 564 | <span key={username}> | |
| 530 | 565 | {index > 0 && <span className="text-faint"> ← </span>} | |
| ⋯ | |||
| 581 | 616 | ); | |
| 582 | 617 | } | |
| 583 | 618 | ||
| 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 | + | ||
| 584 | 756 | 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; | |
| 586 | 758 | return ( | |
| 587 | 759 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 588 | 760 | <PageHeader | |
| 589 | 761 | 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." | |
| 591 | 763 | /> | |
| 592 | 764 | <nav aria-label="Invites" className="mt-5 flex flex-wrap gap-2"> | |
| 593 | 765 | {INVITE_TABS.map((value) => ( | |
| ⋯ | |||
| 617 | 789 | <InviteTable invites={invites} actionData={actionData} /> | |
| 618 | 790 | </div> | |
| 619 | 791 | )} | |
| 792 | + | {tab === "shared" && <SharedLinks links={shared} expiry={expiry} actionData={actionData} />} | |
| 620 | 793 | {tab === "grant" && <GrantAndMint actionData={actionData} />} | |
| 621 | 794 | {tab === "tree" && <Tree tree={tree} name={name} kind={kind} actionData={actionData} />} | |
| 622 | 795 | </div> | |
| 18 | 18 | } from "~/lib/deleted-accounts"; | |
| 19 | 19 | import { confirmsPurge, daysLeft } from "~/lib/deleted-workspaces"; | |
| 20 | 20 | import { text } from "~/lib/forms"; | |
| 21 | + | import { joinedThrough } from "~/lib/invites"; | |
| 21 | 22 | import { accountsAdmin, identity } from "~/lib/services.server"; | |
| 22 | 23 | import { settle } from "~/lib/settle"; | |
| 23 | 24 | import { requireStaff } from "~/lib/staff"; | |
| ⋯ | |||
| 148 | 149 | description={ | |
| 149 | 150 | <> | |
| 150 | 151 | 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 | + | )} | |
| 151 | 160 | {user.privateEmail ? "Keeps its address private on commits." : "Shows its primary address on commits."} | |
| 152 | 161 | </> | |
| 153 | 162 | } | |
| 13 | 13 | looksAutomated, | |
| 14 | 14 | moreInvitesMailto, | |
| 15 | 15 | remainingLine, | |
| 16 | + | sharedDomainsHint, | |
| 17 | + | sharedInviteLine, | |
| 18 | + | sharedInviteLink, | |
| 16 | 19 | signUpCopy, | |
| 17 | 20 | suggestUsername, | |
| 18 | 21 | welcomeCookie, | |
| ⋯ | |||
| 110 | 113 | assert.equal(welcomes(null, "flagon-io"), false); | |
| 111 | 114 | assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/); | |
| 112 | 115 | }); | |
| 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 | + | }); | |
| 24 | 24 | /** The /register address that opens on the invite-code field. */ | |
| 25 | 25 | export const HAVE_AN_INVITE = "/register#invite"; | |
| 26 | 26 | ||
| 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 | + | ||
| 27 | 46 | /** The address an invite link has. */ | |
| 28 | 47 | export function inviteLink(code: string, origin = "https://g1t.sh"): string { | |
| 29 | 48 | return `${origin.replace(/\/+$/, "")}/invite/${code}`; |
| 36 | 36 | const accepting = new URL(request.url).searchParams.get("accept") === "1"; | |
| 37 | 37 | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true }); | |
| 38 | 38 | 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)}`); | |
| 39 | 41 | const lands = invite ? landingFor(invite) : null; | |
| 40 | 42 | ||
| 41 | 43 | if (viewer && invite) { |
| 11 | 11 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 12 | 12 | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | − | import { cleanCode, looksAutomated } from "../lib/invites"; | |
| 14 | + | import { cleanCode, looksAutomated, sharedDomainsHint, sharedInviteLine } from "../lib/invites"; | |
| 15 | 15 | import { clientKey, registrationMode } from "../lib/registration.server"; | |
| 16 | 16 | import { | |
| 17 | 17 | assertSameOrigin, | |
| ⋯ | |||
| 33 | 33 | const checked = code ? await identity.checkInvite(code, clientKey(request)) : null; | |
| 34 | 34 | const invite: InvitePreview | null = checked?.ok ? checked.value : null; | |
| 35 | 35 | // 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)}`); | |
| 38 | 40 | // Signing up with GitHub carries the invite code and `next` through it. | |
| 39 | 41 | const params = new URLSearchParams(); | |
| 40 | 42 | if (code) params.set("invite", code); | |
| ⋯ | |||
| 92 | 94 | }); | |
| 93 | 95 | } | |
| 94 | 96 | ||
| 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. */ | |
| 96 | 98 | function InvitedBy({ invite }: { invite: InvitePreview }) { | |
| 97 | 99 | 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 | + | } | |
| 98 | 111 | return ( | |
| 99 | 112 | <div className="mb-6 flex items-center gap-3 rounded-lg border border-accent/30 bg-accent/5 p-3" role="status"> | |
| 100 | 113 | {invite.workspace ? ( | |
| ⋯ | |||
| 172 | 185 | </Field> | |
| 173 | 186 | <Field | |
| 174 | 187 | 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)} | |
| 176 | 189 | > | |
| 177 | 190 | <Input name="email" type="email" autoComplete="email" required /> | |
| 178 | 191 | </Field> | |
| 508 | 508 | /// Set while it is deleted and not yet purged. | |
| 509 | 509 | #[serde(default)] | |
| 510 | 510 | 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>, | |
| 511 | 515 | } | |
| 512 | 516 | ||
| 513 | 517 | /// `admin_remove_email`: staff remove an address from an account, such as |
| 898 | 898 | #[derive(Debug, Serialize, serde::Deserialize)] | |
| 899 | 899 | #[serde(rename_all = "camelCase")] | |
| 900 | 900 | pub struct InviteRedeemed { | |
| 901 | + | /// The invite's id, or a shared invite link's (`sinv_…`) when one made | |
| 902 | + | /// the account. | |
| 901 | 903 | pub invite_id: String, | |
| 902 | 904 | pub user_id: String, | |
| 903 | 905 | pub inviter_id: Option<String>, |
| 1317 | 1317 | pub for_viewer: Option<bool>, | |
| 1318 | 1318 | /// RFC 3339. | |
| 1319 | 1319 | 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>, | |
| 1320 | 1329 | } | |
| 1321 | 1330 | ||
| 1322 | 1331 | /// `accept_invite`: a signed-in person uses a workspace invite made for | |
| ⋯ | |||
| 1557 | 1566 | pub invites: Vec<Invite>, | |
| 1558 | 1567 | /// Whom they invited, three levels down. | |
| 1559 | 1568 | 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, | |
| 1560 | 1687 | } | |
| 1561 | 1688 | ||
| 1562 | 1689 | #[cfg(test)] | |
| 123 | 123 | deletion: AccountDeletion; | |
| 124 | 124 | /** Set while it is deleted and not yet purged. */ | |
| 125 | 125 | 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; | |
| 126 | 128 | }; | |
| 127 | 129 | ||
| 128 | 130 | /** Where an account's two-factor authentication stands. */ |
| 322 | 322 | call("admin_grant_invites", { target, name, amount, note, staff }), | |
| 323 | 323 | inviteTree: (username) => call("admin_invite_tree", { username }), | |
| 324 | 324 | 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 }), | |
| 325 | 335 | deletedWorkspaces: () => call("admin_deleted_workspaces", {}), | |
| 326 | 336 | restoreWorkspace: (workspaceId, staff) => call("admin_restore_workspace", { workspaceId, staff }), | |
| 327 | 337 | purgeWorkspace: (workspaceId, staff, confirm) => call("admin_purge_workspace", { workspaceId, staff, confirm }), |
| 264 | 264 | /** With a viewer: whether it is theirs (for one of their confirmed addresses, or used by them). */ | |
| 265 | 265 | forViewer: boolean | null; | |
| 266 | 266 | 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[]; | |
| 267 | 271 | }; | |
| 268 | 272 | ||
| 269 | 273 | export type WaitlistStatus = "waiting" | "invited" | "dismissed"; | |
| ⋯ | |||
| 301 | 305 | invites: Invite[]; | |
| 302 | 306 | /** Whom they invited, three levels down. */ | |
| 303 | 307 | invited: InviteTreeNode[]; | |
| 308 | + | /** The shared invite link the account was made with, if it was. */ | |
| 309 | + | shared: SharedInviteSource | null; | |
| 304 | 310 | }; | |
| 305 | 311 | ||
| 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 | + | ||
| 306 | 375 | /** The most rows one staff listing of invites or the waitlist returns. */ | |
| 307 | 376 | export const ADMIN_INVITES_LIMIT = 500; | |
| 308 | 377 | ||
| ⋯ | |||
| 344 | 413 | inviteTree(username: string): Promise<InviteTree | null>; | |
| 345 | 414 | /** A workspace's granted invites and the invites made for it, or null. */ | |
| 346 | 415 | 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>>; | |
| 347 | 422 | ||
| 348 | 423 | /** Workspaces owners deleted that are not purged yet, newest first. */ | |
| 349 | 424 | deletedWorkspaces(): Promise<DeletedWorkspace[]>; | |
| 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); |
| 1145 | 1145 | log, | |
| 1146 | 1146 | deletion, | |
| 1147 | 1147 | deleted, | |
| 1148 | + | joined_through: self.shared_source(user_id).await?, | |
| 1148 | 1149 | })) | |
| 1149 | 1150 | } | |
| 1150 | 1151 |
| 21 | 21 | //! into a workspace always makes an invite bound to it, and costs one only | |
| 22 | 22 | //! when the address has no account, so the answer never says which. | |
| 23 | 23 | //! | |
| 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 | + | //! | |
| 24 | 28 | //! Vars: REGISTRATION_MODE (`invite` | `open`), INVITES_PER_USER, | |
| 25 | 29 | //! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs). | |
| 26 | 30 | ||
| ⋯ | |||
| 35 | 39 | use worker::Result; | |
| 36 | 40 | use worker::wasm_bindgen::JsValue; | |
| 37 | 41 | ||
| 42 | + | use crate::shared_invites::{SharedAdmits, shared_admits, wrong_domain}; | |
| 38 | 43 | use crate::{Identity, crypto}; | |
| 39 | 44 | ||
| 40 | 45 | /// Crockford base32, as ids use: no i, l, o or u. | |
| ⋯ | |||
| 200 | 205 | Invalid, | |
| 201 | 206 | /// It is bound to another address. | |
| 202 | 207 | WrongEmail, | |
| 208 | + | /// A shared invite link limited to email domains the address is not | |
| 209 | + | /// at (shared_invites.rs). | |
| 210 | + | WrongDomain, | |
| 203 | 211 | } | |
| 204 | 212 | ||
| 205 | 213 | /// The parts of an invite that decide whether it admits someone. | |
| ⋯ | |||
| 486 | 494 | .collect() | |
| 487 | 495 | } | |
| 488 | 496 | ||
| 489 | − | fn invite_sealer(&self) -> Option<Sealer> { | |
| 497 | + | pub(crate) fn invite_sealer(&self) -> Option<Sealer> { | |
| 490 | 498 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) | |
| 491 | 499 | } | |
| 492 | 500 | ||
| ⋯ | |||
| 692 | 700 | let required = self.invites_required(); | |
| 693 | 701 | let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty()); | |
| 694 | 702 | let mut invite = None; | |
| 703 | + | // A shared invite link's code instead (shared_invites.rs). | |
| 704 | + | let mut shared = None; | |
| 695 | 705 | match code { | |
| 696 | 706 | None if required => return Ok(Outcome::fail(FailureCode::Forbidden, MISSING)), | |
| 697 | 707 | None => {} | |
| ⋯ | |||
| 700 | 710 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 701 | 711 | } | |
| 702 | 712 | 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 | + | }; | |
| 703 | 717 | 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), | |
| 706 | 728 | Err(_) if !required => {} | |
| 707 | 729 | Err(refusal) => { | |
| 708 | 730 | 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 | + | }; | |
| 710 | 736 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 711 | 737 | } | |
| 712 | 738 | } | |
| ⋯ | |||
| 730 | 756 | new.email.into(), | |
| 731 | 757 | new.password_hash.into(), | |
| 732 | 758 | ]; | |
| 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) => { | |
| 735 | 770 | self.db | |
| 736 | 771 | .prepare(format!( | |
| 737 | 772 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) | |
| ⋯ | |||
| 744 | 779 | } | |
| 745 | 780 | // Spend the code, then make the account only if this request | |
| 746 | 781 | // spent it: one transaction, so a second use finds it gone. | |
| 747 | − | Some(row) => { | |
| 782 | + | (Some(row), _) => { | |
| 748 | 783 | let mut insert = values.to_vec(); | |
| 749 | 784 | insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]); | |
| 750 | 785 | self.db | |
| ⋯ | |||
| 795 | 830 | { | |
| 796 | 831 | self.after_redeemed(&row, &user, true).await?; | |
| 797 | 832 | } | |
| 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 | + | } | |
| 798 | 849 | Ok(Outcome::Ok(user)) | |
| 799 | 850 | } | |
| 800 | 851 | ||
| ⋯ | |||
| 1259 | 1310 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 1260 | 1311 | } | |
| 1261 | 1312 | 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 | + | } | |
| 1262 | 1323 | // A spent code is still a real one (160 random bits): saying what | |
| 1263 | 1324 | // 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); | |
| 1268 | 1326 | let Some(row) = row else { | |
| 1269 | 1327 | self.count_failure(a.client.as_deref()).await?; | |
| 1270 | 1328 | return Ok(Outcome::fail(FailureCode::NotFound, INVALID)); | |
| ⋯ | |||
| 1331 | 1389 | has_account, | |
| 1332 | 1390 | for_viewer, | |
| 1333 | 1391 | expires_at: row.expires_at, | |
| 1392 | + | shared_label: None, | |
| 1393 | + | shared_domains: Vec::new(), | |
| 1334 | 1394 | })) | |
| 1335 | 1395 | } | |
| 1336 | 1396 | ||
| ⋯ | |||
| 2060 | 2120 | grants: self.grants(GrantTarget::User, &user.id).await?, | |
| 2061 | 2121 | invites, | |
| 2062 | 2122 | invited: self.invited_by_user(&user.id, TREE_DEPTH).await?, | |
| 2123 | + | shared: self.shared_source(&user.id).await?, | |
| 2063 | 2124 | })) | |
| 2064 | 2125 | } | |
| 2065 | 2126 | ||
| ⋯ | |||
| 2082 | 2143 | grants: self.grants(GrantTarget::Workspace, &id).await?, | |
| 2083 | 2144 | invites, | |
| 2084 | 2145 | invited: Vec::new(), | |
| 2146 | + | shared: None, | |
| 2085 | 2147 | })) | |
| 2086 | 2148 | } | |
| 2087 | 2149 | ||
| 25 | 25 | mod job_tokens; | |
| 26 | 26 | mod run_credentials; | |
| 27 | 27 | mod security; | |
| 28 | + | mod shared_invites; | |
| 28 | 29 | mod teams; | |
| 29 | 30 | mod throttle; | |
| 30 | 31 | mod token_reach; | |
| ⋯ | |||
| 1012 | 1013 | "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?), | |
| 1013 | 1014 | "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?), | |
| 1014 | 1015 | "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?), | |
| 1015 | 1020 | // Deleted workspaces, restored or purged by staff; see deletion.rs. | |
| 1016 | 1021 | "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?), | |
| 1017 | 1022 | "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?), | |
| 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 | + | } |