← Назад

Создаём неустаревающий Slack-инвайт

Зачем я сделала этот сервис

Slack-приглашения на бесплатном тарифе имеют срок жизни. Через 30 дней ссылка устаревает — и люди, которые переходят по ней позже, видят ошибку. Это создаёт две проблемы:

  • Старые ссылки умирают — их приходится отслеживать и вручную менять. Если они упоминаются в нескольких местах, то это неудобно.
  • Люди жалуются — они не могут зайти в Slack, потому что попали на неактуальный инвайт.

Мне хотелось, чтобы была одна стабильная ссылка вроде slack.myproject.com, которая всегда ведёт на актуальный инвайт. И чтобы обновлять его было можно без пересборки деплоя или ручной правки.

До того, как я собрала этот сервис, ссылку можно было менять только в репозитории на GitHub. Это было неудобно, поскольку за ссылку отвечает отдел маркетинга, а не разработчики. Они были вынуждены обращаться к кому-то из команды, чтобы обновить ссылку. Это создавало задержки и неудобства.

В результате моей работы, описанной в этой статье, получился маленький, но надёжный сервис, который решает задачу. Он состоит из нескольких частей: публичной страницы, админки, API, системы валидации и мониторинга. В этой статье я расскажу, как он устроен и как собрать такой же.

Его можно легко адаптировать для других задач, не только для инвайтов в Slack.


Как развернуть проект

Прежде чем перейти к коду, посмотрим, какие технологии используются и как всё запустить локально.

Проект построен на:

  • Next.js 15 с App Router;
  • Vercel Edge Functions для редиректов и API;
  • Vercel Edge Config для хранения данных;
  • Auth0 для авторизации;
  • Tailwind CSS для стилизации.

Чтобы запустить проект локально:

  1. Создайте новый проект с помощью create-next-app на Next.js
npx create-next-app@latest slack-invite-service --typescript --app
cd slack-invite-service
  1. Установите нужные пакеты:
pnpm add next-auth @auth0/nextjs-auth0 tailwindcss postcss autoprefixer
pnpm add -D typescript @types/node @types/react

Если планируете использовать Edge Config SDK, также установите нужный пакет:

pnpm add @vercel/edge-config
  1. Добавьте .env.local со всеми переменными (будут перечислены по ходу статьи).
  2. Запустите локальный сервер:
pnpm dev

Теперь можно переходить по http://localhost:3000 и увидеть редирект (если ссылка настроена) или заглушку.


Структура проекта

Вот как выглядит структура файлов, отвечающих за логику сервиса:

app/
├── api/
│   ├── invite/route.ts              // GET и POST для работы со ссылкой
│   ├── invite/validate/route.ts     // POST для ручной валидации
│   ├── cron/check-invite/route.ts   // Ежедневная проверка ссылки
│   └── auth/[...auth0]/route.ts     // Обработка входа/выхода через Auth0

├── admin/page.tsx                   // Админка: просмотр и обновление ссылки
├── page.tsx                         // Публичная страница с редиректом
├── middleware.ts                    // Обработка редиректов и ошибок авторизации

lib/
├── invite-utils.ts                 // Чтение, запись и валидация ссылок
├── slack-notifications.ts          // Уведомления в Slack
├── auth-utils.ts                   // Обёртки над сессией

Архитектура: из чего состоит сервис

Вот схема, как работает этот сервис:

┌──────────────┐       ┌──────────────────────┐
│ Пользователь │─────▶│  slack.myproject.com  │
└──────────────┘       └──────────────────────┘


                         [ Edge Function ]

                ┌─────────────┴──────────────┐
                │        Проверка ссылки     │
                │     (валидна и активна?)   │
                └─────────────┬──────────────┘

                 ┌────────────▼────────────┐
                 │  Slack-инвайт доступен? │
                 └────────────┬────────────┘
                              │ Да

                    [ Редирект на ссылку ]


                     [ Пользователь в Slack ]


                              └──────────────┐

                    ┌────────────────────────────────────┐
                    │ Cron-задача проверяет ссылку       │
                    │ (срок действия, HEAD-запрос)       │
                    └────────────────────────────────────┘

┌───────────────────────────────────────┐
│                  Админка              │
│    - Просмотр текущей ссылки          │
│    - Добавление новой                 │
│    - Ручная проверка                  │
└───────────────────────────────────────┘


┌─────────────────────────────┐
│ Auth0 авторизация по имейлу │
└─────────────────────────────┘


  ┌─────────────────────────────┐
  │ Edge Config (хранилище URL) │
  └─────────────────────────────┘

А вот компоненты внутри:

1. Публичная страница (/)

На эту страницу попадает пользователь, переходя по ссылке slack.myproject.com. Она делает следующее:

  • проверяет, есть ли активная ссылка;
  • валидирует, не устарела ли она и работает ли вообще;
  • если всё хорошо — делает редирект;
  • если что-то пошло не так — показывает заглушку с сообщением.

2. Админка (/admin)

Сюда могут зайти только авторизованные пользователи. В админке можно:

  • посмотреть текущую ссылку;
  • проверить, сколько дней ей осталось;
  • заменить на новую.

Авторизация работает через Auth0 и ограничена по домену. Я расскажу об этом подробнее в следующем разделе.

3. API

Вся работа с данными идёт через API:

  • GET /api/invite — получить текущую ссылку;
  • POST /api/invite — сохранить новую ссылку (с проверкой валидности);
  • POST /api/invite/validate — вручную проверить текущую ссылку.

4. Хранение данных

Все данные — текущая ссылка, дата её создания, флаг активности — хранятся в Vercel Edge Config. Это почти как key-value база с мгновенным доступом по всему миру. Для маленького сервиса — идеальное решение.

5. Валидация и срок жизни

Каждая ссылка живёт максимум 30 дней. Сервис автоматически проверяет:

  • валидный ли формат (например, содержит join.slack.com);
  • доступна ли ссылка по HEAD-запросу;
  • не истёк ли срок жизни.

Если что-то пошло не так — она деактивируется. Флаг isActive становится false, и пользователь видит сообщение о том, что ссылка устарела.

6. Slack-уведомления

Сервис отправляет уведомления в Slack, чтобы команда была в курсе событий: при обновлении ссылки или её деактивации. Подробнее об этом — в разделе ниже.

7. Cron-задача

Каждое утро (или по другому расписанию) запускается cron-роут, который проверяет:

  • сколько дней осталось у ссылки;
  • не сломалась ли она;
  • если что-то не так — деактивирует и присылает уведомление.

Аутентификация и защита доступа

Чтобы никто случайный не зашёл в админку и не заменил ссылку на фишинговую, я добавила авторизацию через Auth0.

Зачем нужен Auth0

  • Безопасный вход через имейл.
  • Можно ограничить доступ по домену (например, только @mycompany.com).
  • Простая интеграция с Next.js.

Я использовала Auth0 SDK для Next.js и настроила логин через кнопку Log in, которая перенаправляет пользователя на форму входа.

Как создать приложение в Auth0

  1. Зарегистрируйтесь на https://auth0.com/ и создайте новый tenant (например, myproject.eu.auth0.com).
  2. В разделе Applications нажмите Create application.
  3. Назовите приложение, выберите Regular Web Application.
  4. В настройках Settings укажите:
Allowed Callback URLs:
http://localhost:3000/api/auth/callback
https://slack.myproject.com/api/auth/callback

Allowed Logout URLs:
http://localhost:3000/admin
https://slack.myproject.com/admin
  1. Скопируйте Client ID и Client Secret — они понадобятся в .env.

  2. Создайте переменные окружения:

AUTH0_SECRET=...  # можно сгенерировать с помощью openssl
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=https://your-tenant.auth0.com
AUTH0_CLIENT_ID=...
AUTH0_CLIENT_SECRET=...

Как ограничить доступ по домену

Ранее в Auth0 использовались Rules, но сейчас актуальный способ — через Actions:

  1. Перейдите в Actions → Library и нажмите Create Action → Build from scratch.
  2. Назовите экшен, например RestrictDomain.
  3. В коде вставьте следующее:
/**
 * @param {Event} event - Details about the user and the context of the login.
 * @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
 */
exports.onExecutePostLogin = async (event, api) => {
  const email = event.user.email || '';

  if (!email.endsWith('@yourdomain.com')) {
    api.access.deny('Access denied: only corporate emails are allowed.');
  }
};

Замените @yourdomain.com на ваш корпоративный домен.

  1. Нажмите Deploy, чтобы задеплоить экшен.
  2. Перейдите в Actions → Triggers → Login (Post Login).
  3. Перетащите ваш экшен из правой панели в основное поле и нажмите Apply.

Теперь только пользователи с имейлами определённого домена смогут получить доступ к админке.

Обработка ошибок входа

Если при входе возникла ошибка (например, отказ в доступе), сервис перенаправляет пользователя обратно на /admin, добавляя в URL параметры error и error_description. Интерфейс показывает соответствующее сообщение:

Доступ запрещён. Используйте корпоративный имейл.

Хранение данных: зачем Vercel Edge Config

Когда я искала способ хранить одну ссылку и метаданные к ней, мне не хотелось разворачивать полноценную базу данных ради пары полей. Решение должно было быть:

  • простым и бесплатным;
  • с минимальной задержкой;
  • работать хорошо в edge-функциях на Vercel.

Именно поэтому я выбрала Vercel Edge Config — это key-value хранилище, которое доступно глобально и почти мгновенно. Оно идеально подходит для хранения небольших конфигурационных данных.

Что именно я храню

interface InviteData {
  url: string; // Ссылка на Slack-инвайт
  createdAt: string; // Когда была создана
  isActive: boolean; // Флаг активности
}

Эта структура записывается в Edge Config под одним ключом — например, current_invite.

Как я читаю данные

При чтении я сначала пробую использовать SDK от Vercel. Если он не сработал (такое бывает в edge-среде), перехожу на резервный план — REST API:

const data = await get<InviteData>(INVITE_KEY); // через SDK

Если SDK не отдал результат, выполняется fetch-запрос напрямую по URL https://api.vercel.com/v1/edge-config/..., с авторизацией через токен.

Если и это не сработает, я возвращаю безопасный fallback:

{
  url: '',
  createdAt: new Date().toISOString(),
  isActive: false
}

Как я записываю данные

Для записи я использую PATCH-запрос через REST API. В теле запроса отправляется массив с операцией upsert, которая заменяет или создаёт значение по ключу:

{
  items: [
    {
      operation: 'upsert',
      key: 'current_invite',
      value: inviteData,
    },
  ];
}

Безопасность

Для всех REST-запросов я использую API Token от Vercel, который хранится в .env. Этот токен даёт доступ только к Edge Config и не затрагивает остальные части проекта.

Валидация Slack-ссылки и срок действия

Когда пользователь сохраняет новую ссылку в админке, важно убедиться, что она действительно рабочая. Для этого сервис проводит несколько проверок:

1. Проверка формата

Slack-инвайты бывают нескольких видов, но все они содержат поддомены вроде join.slack.com или slack.com/signup. Поэтому сначала я проверяю, что ссылка вообще выглядит как Slack-инвайт:

if (!url.includes('join.slack.com') && !url.includes('slack.com/signup')) {
  return false;
}

2. Проверка доступности (HEAD-запрос)

Даже если ссылка выглядит правильно, она может быть уже недействительной. Поэтому я отправляю HEAD-запрос с таймаутом 10 секунд:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);

const res = await fetch(url, {
  method: 'HEAD',
  signal: controller.signal,
  headers: { 'User-Agent': 'SlackInviteValidator/1.0' },
});

clearTimeout(timeout);
return res.ok;

Если HEAD-запрос вернул 2xx или 3xx — значит ссылка ещё активна.

3. Проверка срока действия

Slack-инвайты живут 30 дней. Я сохраняю дату создания и считаю разницу:

const created = new Date(invite.createdAt);
const now = new Date();
const daysPassed = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);

const isExpired = daysPassed > 30;

Если ссылка устарела — она автоматически помечается как неактивная.

Когда и как происходит валидация

  • При добавлении новой ссылки — если формат неправильный или HEAD-запрос не проходит, она не сохраняется.
  • При каждом заходе на главную страницу — если ссылка есть, она валидируется и при необходимости деактивируется. Это важно, потому что Slack-инвайты могут протухнуть не только по времени (30 дней), но и по количеству использований (обычно это 400 вступлений). Поэтому HEAD-запрос на каждый заход — дополнительная гарантия.
  • В админке — можно запустить проверку вручную.
  • Через cron-задачу — каждый день ссылка перепроверяется автоматически.

Slack-уведомления

Сервис отправляет уведомления в Slack, чтобы не пропустить важные события:

  • Новая ссылка сохранена — кто-то обновил инвайт. Важно чтобы команда знала, что ссылка изменилась.
  • Ссылка устарела и была деактивирована — по итогам cron-проверки или при переходе пользователя по неактивной ссылке.

Для этого используется Slack Incoming Webhook.

Как настроить

  1. Перейдите на https://api.slack.com/apps и создайте новое приложение.
  2. В разделе Incoming Webhooks включите функциональность.
  3. Создайте webhook-URL для нужного канала (например, #alerts).
  4. Скопируйте URL и сохраните в .env.local:
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

Где и как вызываются уведомления

Создайте файл lib/slack-notifications.ts со вспомогательными функциями:

export const SlackNotifications = {
  async linkUpdated(url: string, email: string) {
    await fetch(process.env.SLACK_WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `🔗 Ссылка обновлена пользователем ${email}: ${url}`,
      }),
    });
  },

  async linkInvalid(url: string) {
    await fetch(process.env.SLACK_WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `⚠️ Slack-инвайт недействителен и был отключён: ${url}`,
      }),
    });
  },
};
  • linkUpdated вызывается в POST /api/invite, когда пользователь сохраняет новую ссылку;
  • linkInvalid вызывается в cron-задаче, если ссылка устарела или перестала работать.

Ручная проверка ссылки (POST /api/invite/validate) уведомление не отправляет — это просто утилита для админки.


Интерфейс и страницы

Сервис использует три ключевые страницы и промежуточный слой:


Публичная страница

Когда пользователь переходит по https://slack.myproject.com/, он попадает на эту страницу. Она делает следующее:

  • получает ссылку через GET /api/invite;
  • проверяет, активна ли она;
  • валидирует HEAD-запросом;
  • если всё хорошо — делает redirect();
  • если что-то пошло не так — рендерит компонент-заглушку с сообщением.
// app/page.tsx
import { redirect } from 'next/navigation';
import { readInviteData, validateSlackInviteLink } from '@/lib/invite-utils';

export default async function HomePage() {
  const invite = await readInviteData();

  if (invite?.url && invite.isActive) {
    const isValid = await validateSlackInviteLink(invite.url);
    if (isValid) {
      redirect(invite.url);
    }
  }

  return (
    <main className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
      <h1 className="text-2xl font-bold">Ссылка недоступна</h1>
      <p className="mt-2">
        Пожалуйста, свяжитесь с администратором сообщества.
      </p>
    </main>
  );
}

Админка

Страница для управления ссылкой. Доступна только после входа через Auth0. Показывает текущую ссылку, оставшиеся дни и форму замены:

// app/admin/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useUser } from '@auth0/nextjs-auth0/client';

export default function AdminPage() {
  const { user, error, isLoading } = useUser();
  const [data, setData] = useState<any>(null);

  useEffect(() => {
    fetch('/api/invite')
      .then((res) => res.json())
      .then(setData);
  }, []);

  if (isLoading) return <p>Загрузка...</p>;
  if (error) return <p>Ошибка: {error.message}</p>;
  if (!user) return <a href="/api/auth/login">Войти</a>;

  return (
    <main className="p-4 max-w-xl mx-auto">
      <h1 className="text-xl font-bold mb-2">Текущая ссылка</h1>
      <pre className="p-2 bg-gray-100 rounded">{data?.url || 'Нет ссылки'}</pre>
      <form className="mt-4">
        {/* тут можно реализовать отправку новой ссылки */}
      </form>
      <a className="text-sm underline mt-4 block" href="/api/auth/logout">
        Выйти
      </a>
    </main>
  );
}

Middleware (middleware.ts)

Используется для проверки авторизации в админке. Если пользователь не авторизован — его редиректит на страницу входа:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith('/admin')) {
    const loggedIn = req.cookies.has('appSession');
    if (!loggedIn) {
      return NextResponse.redirect(new URL('/api/auth/login', req.url));
    }
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/admin'],
};

API: работа с ссылками

Сервис использует отдельные маршруты в app/api для получения, записи и валидации Slack-инвайтов. Ниже — пошаговая реализация каждого из них.

GET /api/invite

Возвращает текущую ссылку (если она есть).

// app/api/invite/route.ts
import { readInviteData } from '@/lib/invite-utils';
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    const invite = await readInviteData();
    return NextResponse.json(invite);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to read invite data' },
      { status: 500 }
    );
  }
}

Этот роут не требует авторизации и используется как публичный эндпоинт внутри системы.


POST /api/invite

Добавляет новую ссылку. Работает только для авторизованных пользователей.

Добавьте в тот же файл app/api/invite/route.ts второй хендлер:

// app/api/invite/route.ts
import { getSession } from '@auth0/nextjs-auth0';
import { writeInviteData, validateSlackInviteLink } from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';

export async function POST(request: Request) {
  try {
    const { user } = await getSession();
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const { url } = await request.json();
    const isValid = await validateSlackInviteLink(url);

    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid Slack invitation URL' },
        { status: 400 }
      );
    }

    const inviteData = {
      url: url.trim(),
      createdAt: new Date().toISOString(),
      isActive: true,
    };

    await writeInviteData(inviteData);
    await SlackNotifications.linkUpdated(url, user.email);

    return NextResponse.json(inviteData);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to save invite data' },
      { status: 500 }
    );
  }
}

Здесь:

  • сначала проверяется сессия;
  • затем валидируется ссылка;
  • если всё в порядке, ссылка сохраняется и отправляется уведомление в Slack.

POST /api/invite/validate

Позволяет вручную проверить текущую ссылку. Если ссылка уже недействительна, она будет деактивирована. Этот эндпоинт не отправляет уведомление в Slack — он предназначен только для проверки из админки.

// app/api/invite/validate/route.ts
import { getSession } from '@auth0/nextjs-auth0';
import {
  readInviteData,
  validateSlackInviteLink,
  getDaysLeft,
  writeInviteData,
} from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
import { NextResponse } from 'next/server';

export async function POST() {
  try {
    const { user } = await getSession();
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const invite = await readInviteData();
    if (!invite.url) {
      return NextResponse.json(
        { error: 'No invitation URL found' },
        { status: 404 }
      );
    }

    const isValid = await validateSlackInviteLink(invite.url);

    if (!isValid) {
      const updatedInvite = { ...invite, isActive: false };
      await writeInviteData(updatedInvite);

      return NextResponse.json(
        { error: 'Link is no longer valid and has been deactivated' },
        { status: 400 }
      );
    }

    return NextResponse.json({
      message: 'Link is valid and active',
      daysLeft: getDaysLeft(invite.createdAt),
    });
  } catch (error) {
    return NextResponse.json({ error: 'Validation failed' }, { status: 500 });
  }
}

Этот эндпоинт удобно вызывать из админки, чтобы проверить: не протухла ли ссылка до истечения 30 дней.


GET /api/cron/check-invite

Этот роут вызывается по расписанию (например, через встроенные cron-задачи Vercel) и проверяет текущую ссылку:

  • сколько дней осталось до её истечения;
  • активна ли она вообще;
  • если всё плохо — отключает ссылку и шлёт уведомление в Slack.
import {
  readInviteData,
  validateSlackInviteLink,
  getDaysLeft,
  writeInviteData,
} from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    const invite = await readInviteData();
    if (!invite.url || !invite.isActive) {
      return NextResponse.json({ message: 'No active invite to check' });
    }

    const isValid = await validateSlackInviteLink(invite.url);
    const daysLeft = getDaysLeft(invite.createdAt);

    if (!isValid || daysLeft <= 0) {
      await writeInviteData({ ...invite, isActive: false });
      await SlackNotifications.linkInvalid(invite.url);

      return NextResponse.json({ message: 'Link deactivated' });
    }

    return NextResponse.json({ message: 'Link still valid', daysLeft });
  } catch (error) {
    return NextResponse.json({ error: 'Cron check failed' }, { status: 500 });
  }
}

Если вы размещаете проект на Vercel, используйте встроенные Scheduled Functions, добавив следующий блок в vercel.json:

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/check-invite",
      "schedule": "0 9 * * *"
    }
  ]
}

Это запустит проверку каждый день в 9 утра по UTC. Если вы размещаетесь вне Vercel, настройте cron-задачу в GitHub Actions или используйте внешние планировщики.


Заключение

Мы построили простой, но мощный сервис, который решает распространенную проблему устаревания Slack-ссылок, используя современные облачные технологии. Ключевые преимущества этой архитектуры:

  • Соответствие политикам Slack: Работает только с официальными ссылками-инвайтами.
  • Глобальная производительность: Vercel Edge Functions и Edge Config обеспечивают мгновенный доступ по всему миру.
  • Безопасность: Аутентификация через Auth0 с ограничением по домену обеспечивает высокий уровень безопасности админки.
  • Надежность: Автоматический мониторинг через cron-задачи и уведомления в Slack помогают быстро реагировать на проблемы.
  • Минимальное обслуживание: Сервис требует минимального ручного вмешательства, в основном для обновления Slack-ссылки раз в 30 дней.
  • Адаптивность: Этот сервис легко адаптируется для других команд или проектов, требуя лишь изменения доменных ограничений в Auth0 и URL-адреса Slack Webhook.