В понедельник выходит новый инженер. Вы вставляете в канал для новичков ссылку для вступления, он кликает по ней и уже в проекте — без переписки по почте и без ожидания письма с подтверждением, которое улетело в спам.
Через четыре месяца канал на месте, и ссылка тоже. Она всё ещё работает. Работает для подрядчика, которого добавили в канал на шестой неделе, для всех, кому он её переслал, и для того, кто однажды откроет выгруженный архив.
Ссылка-приглашение — это пропуск на предъявителя. Она не знает, кто вы, только то, что она у вас есть. В этом и всё её удобство, и вся проблема. Ниже — что должно быть у ссылки, чтобы её можно было безопасно вставить куда угодно, что я сделал в Kookee и какие части оказались сложнее, чем выглядели.
У приглашения есть адресат, у ссылки — нет
У приглашения по почте есть адресат. Система заранее знает, кого пускать, — владельца адреса, на который ушло письмо, — и токен из письма никому другому не подойдёт. Это точно, но медленно: кто-то должен вписать адреса, письмо должно дойти, получатель должен его найти.
Ссылка — обратный компромисс. Она работает мгновенно, и адресата у неё нет. Кто её открыл, того она и пускает.
Большинству команд нужно и то и другое — по той же причине, по которой нужны и список гостей, и дверь. Ошибка — делать ссылку так, будто это то же приглашение, только без ввода адресов.
Четыре вопроса, на которые должна отвечать ссылка
Кто может ею воспользоваться? Без ответа честное описание функции звучит так: «любой человек в интернете, который увидел эту строку, вступает в ваш проект». Ссылка, которую можно ограничить доменами из рабочих адресов ваших коллег, — совсем другая вещь, чем ссылка без такого ограничения.
Сколько она действует? Любая бессрочная ссылка рано или поздно становится ссылкой, про которую никто не помнит, кто её создал. Значение по умолчанию важнее самой настройки: если по умолчанию стоит «никогда», большинство ссылок так и останутся бессрочными.
Кто может её отозвать? Отзыв должен занимать один клик, а список действующих ссылок — лежать там, куда человек заглянет. Пропуск, которого нет ни в одном списке, нельзя отозвать.
Что она даёт? Самую младшую роль, с которой можно работать, — и всегда только её. Ссылка, которая выдаёт права администратора, выдаёт и право создавать новые ссылки.
Правило домена ничего не стоит без подтверждённого адреса
Очевидный способ сузить ссылку — ограничить её почтовым доменом. Неочевидно другое: ограничение что-то значит, только если адрес подтверждён.
if (link.allowedDomains?.length) {
const allowed = link.allowedDomains.includes(domainOf(user.email));
if (!user.emailVerified || !allowed) throw Forbidden('INVITE_LINK_DOMAIN_MISMATCH');
}
Уберите проверку emailVerified, и функция начнёт работать наоборот: любой, кто открыл ссылку, регистрируется как someone@yourcompany.com, не открывает письмо с подтверждением и заходит. Тогда правило домена — просто намёк на то, кого вы надеялись увидеть в проекте.
Под этим лежат ещё два решения поменьше.
Записи в списке разрешённых доменов сравнивает программа, а не человек. Кириллическая «а» — другой символ, чем латинская «a», хотя выглядят они одинаково, поэтому омоглиф в списке — открытая дверь, которая выглядит запертой. Записи состоят из латинских букв ASCII, цифр и дефисов, а интернационализированный домен вводится в форме punycode:
// Латинские буквы ASCII, цифры и дефисы, с доменом верхнего уровня. IDN-домены вводятся как xn--…
const DOMAIN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
Хранить нужно тот же домен, с которым сравниваешь. Kookee нормализует адреса на входе, а googlemail.com — тот же почтовый ящик, что и gmail.com. Поэтому правило с googlemail.com не совпало бы ни с кем: выглядит верно, сохраняется без ошибок и молча отказывает всем. Привести домен к одному виду при создании правила — три строки кода, и они избавляют от обращения в поддержку, причину которого снаружи не найти.
Ссылка переживает своего автора
Админ создаёт ссылку в марте. В июне он переходит в другую команду и становится обычным участником, или уходит, и его удаляют, или его блокируют. А ссылка всё ещё лежит в канале.
Здесь есть два разумных решения. Проверять автора при каждом вступлении — читать его текущую роль и отказывать, если её больше нет, — или отзывать ссылку в момент самого события. Первое звучит правильнее, но работает хуже: каждое вступление требует лишнего запроса, и приходится отвечать на неудобные вопросы, например что значит ссылка владельца, когда проект перешёл к другому человеку.
Я выбрал второе. Если участника удаляют, понижают с владельца или админа или блокируют, все созданные им ссылки отзываются в той же транзакции базы данных, что и само удаление, понижение или блокировка. За этим стоит правило, которое легко сказать вслух: ссылка — продолжение полномочий того, кто её создал, и действует, пока они есть.
Места — вторая половина вопроса
Ссылка, которую любой может переслать, на тарифе с фиксированным числом мест — это ещё и способ потратить чужие деньги. Поэтому места проверяются при вступлении, а не только при отправке приглашения:
const seatsInUse = members + pendingInvitations;
if (seatsInUse + 1 > plan.members.maxPerProject) throw Conflict('MEMBER_LIMIT_REACHED');
Ожидающие приглашения легко забыть посчитать. Тогда на три места уходит десять приглашений, все получают приветственное письмо, а последние семеро при принятии видят ошибку — худший момент, чтобы сообщить человеку, что команда уже набрана.
Посчитать их дважды — обратная ошибка. Если человека уже пригласили по почте, а он вступил по ссылке, место за ним уже забронировано; если списать с него второе, система откажет во вступлении, с которым всё в порядке. Поэтому его собственное ожидающее приглашение в подсчёт не входит, а при вступлении закрывается.
Что я сделал
У владельцев и админов на странице команды проекта появился раздел Ссылки-приглашения. В нём перечислены все действующие ссылки: кого каждая пускает и когда истекает, а рядом — кнопки копирования и отзыва.

При создании ссылки нужно решить две вещи: с каких почтовых доменов по ней можно вступить и сколько она действует. Если оставить домены пустыми, вступить сможет любой адрес; если заполнить — только подтверждённые адреса на этих доменах. Срок действия — 7 дней, 30 дней или «Никогда», и если ничего не выбрать, будет 30 дней.

Тот, кто открывает ссылку, видит проект, в который вступает, и ограничение, если оно есть. Войти нужно под настоящим аккаунтом — гостевая сессия место занять не может, — и человек всегда попадает в проект участником, никогда админом.

Лимит, о котором узнаёшь, упёршись в него, — это баг-репорт
Места — лишь один из лимитов, которые тариф задаёт проекту, и для всех них верно одно: число, о котором узнаёшь, только упёршись в него, оборачивается злым письмом, а не переходом на старший тариф.
Поэтому теперь каждый проект показывает, что разрешает его тариф и сколько из этого уже занято: места, записи, API-ключи, категории, теги, типы записей и конфигурации. Каждый список предупреждает до того, как заполнится, а не после.

Если вы делаете ссылки-приглашения
Относитесь к ссылке как к пропуску на предъявителя, потому что это он и есть. Проектируйте её как общий пароль, а не как письмо.
Ограничьте её подтверждёнными доменами и сразу отказывайте неподтверждённым адресам. С неподтверждённым адресом правило домена становится декорацией.
Храните записи списка разрешённых доменов в ASCII и нормализуйте их так же, как адреса, с которыми их сравниваете.
Задайте срок действия по умолчанию, а «никогда» сделайте осознанным выбором, а не самым лёгким.
Выдавайте самую младшую роль, с которой можно работать. Ссылка не должна уметь создавать другие ссылки.
Отзывайте ссылку, когда её автор теряет полномочия, с которыми её создал.
Учитывайте ожидающие приглашения в лимите мест и считайте каждого человека один раз.
Показывайте все действующие ссылки там, куда человек заглянет, и делайте отзыв одним кликом.
В проектах Kookee теперь есть ссылки-приглашения, а каждый проект показывает лимиты своего тарифа. Как создать ссылку, рассказано в статье Как пригласить команду в проект, а что даёт каждый тариф — в статье Лимиты запросов, тарифы и обработка ошибок.