Приложение с базой данных без бэкенда: собираем Layero Inbox
Data API работает в бета-версии и открыт пока не всем. Как получить доступ и чего ждать от страниц, написано в «Что такое Data API».
Пошаговый разбор приложения на Layero: Postgres, Data API, вход, файлы и уведомления. Около 30 минут.

Layero Inbox — виджет для сайта и кабинет к нему. Посетитель сайта сообщает о проблеме и прикладывает снимок экрана, оставляет заявку или записывается на свободное время. Владелец сайта видит всё во входящих, разбирает сообщения вместе с командой и получает уведомления в Telegram.
У приложения нет своего сервера: только Postgres, Data API поверх него и три статические страницы. Ниже — как это устроено и как повторить это в своём продукте.
Код: github.com/LayeroInfra/layero-inbox · Демо: inbox.layero.app
Что вы получите
- Базу с правами на уровне строк. Владелец сайта видит только свои сайты, пользователь из команды — только сайты, куда его пригласили, посетитель — ничего.
- Приём форм от посетителей без входа — через функции базы, которые проверяют всё, что приходит из браузера.
- Приватные файлы. Снимки экрана лежат в закрытом бакете и открываются по подписанным ссылкам.
- Вход в кабинет по почте и паролю или через Яндекс ID.
- HTTP-запросы из SQL — уведомления в Telegram отправляет сама база.
- Проверку прав одной командой — от имени посетителя и каждого пользователя.
Что понадобится
- аккаунт Layero и организация с общей базой (тариф Pro) или с выделенной базой;
- Node 20+;
- для уведомлений — бот от @BotFather (необязательно);
- для входа через Яндекс — приложение в Яндекс OAuth (необязательно).
Кто есть кто
| Кто это | Роль в базе | |
|---|---|---|
| Посетитель сайта | Человек на сайте, где стоит виджет; в систему не входит | anon |
| Пользователь | Вошёл в кабинет по почте или через Яндекс | authenticated |
| Владелец сайта | Пользователь, который добавил сайт в кабинет | authenticated + sites.owner_id |
| Команда сайта | Пользователи, которых владелец пригласил | authenticated + site_members |
Как это устроено
сайт с виджетом ─iframe─▶ виджет ──RPC──▶ Data API ──▶ Postgres
▲ │ RLS, функции, файлы
владелец, команда ──▶ кабинет ──REST/auth──┘ └── net.http_post ──▶ Telegram
Браузер обращается к Data API напрямую, с публичным ключом. Ключ сам по себе прав не даёт: что можно посетителю и каждому пользователю, решают политики RLS и функции в Postgres. Секретный ключ приложению не нужен.
Каждый шаг ниже можно сделать в панели Layero или в терминале — результат
одинаковый. Всё сразу делает scripts/setup.sh (шаг 8).
Шаг 1. Создайте базу и включите Data API
- Панель Layero
- CLI
«Базы данных» → «Создать базу». Одна общая база входит в тариф Pro. Откройте базу и включите три сервиса.

- Data API — таблицы и функции базы по HTTP; совместим с supabase-js.
- Авторизация — регистрация и вход. Появляются схема
authи функцияauth.uid(), на которой построены все политики. - Файлы — хранилище с бакетами.
npx layero@latest db create inbox --org my-team
npx layero@latest data enable --db inbox --org my-team --auth --storage
Сразу заведите бакет для снимков экрана.
- Панель Layero
- CLI
«Файлы» → «Добавить бакет».

- «Публичный» выключен: снимки открываются только по подписанным ссылкам.
- До 5 МБ и только JPEG или PNG — остальное бакет не примет.
npx layero@latest data buckets create screenshots --private --max 5MB \
--types image/jpeg,image/png --db inbox --org my-team
Включайте авторизацию до миграций: политики ссылаются на auth.uid(), и без
авторизации миграция упадёт на первой же политике.
Шаг 2. Опишите таблицы и закройте их политиками
Четыре таблицы: сайты, команда сайта, приглашения и сообщения. Главное правило — посетителю таблицы закрыты целиком:
alter table public.reports enable row level security;
revoke all on public.sites, public.site_members, public.invites, public.reports from anon;
Команда сайта читает его сообщения и может менять только статус — это право на уровне колонки:
revoke insert, update on public.reports from authenticated;
grant update (status) on public.reports to authenticated;
create policy reports_read on public.reports for select to authenticated
using (inbox_private.is_member(site_id));
Проверку, состоит ли пользователь в команде сайта, вынесите в функцию с
security definer. Если политика sites обращается к site_members
напрямую, а политика site_members — к sites, Postgres уйдёт в бесконечную
рекурсию:
create function inbox_private.is_member(p_site uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from public.site_members m
where m.site_id = p_site and m.user_id = auth.uid());
$$;
- Панель Layero
- CLI
SQL-редактор: вставьте текст миграции целиком и выполните его.
Файлы выполняйте по порядку, от 0001 до 0004.

- «Выполнить» или ⌘/Ctrl + Enter. Роль — «Владелец»: она создаёт таблицы, функции и политики.
- Текст миграции.
for f in db/migrations/*.sql; do
npx layero@latest db sql inbox --org my-team --file "$f"
done
Что получилось, видно на экране «Политики»:

- RLS включён: строку, к которой не подходит ни одна политика, не увидит никто.
- Три политики: читать и менять статус может команда сайта, удалять — только владелец.
Полная схема — 0001_schema.sql.
Шаг 3. Примите форму от посетителя через функцию
Посетитель не входит в систему, но должен отправить форму. Писать в таблицу
ему нельзя — иначе он запишет что угодно и куда угодно. Поэтому форму принимает
функция в схеме api, которую Data API публикует как
POST /rest/v1/rpc/submit_form:
create function api.submit_form(site_key text, form text, message text default null, …)
returns jsonb language plpgsql security definer set search_path = public as $$
begin
-- ключ сайта существует, форма включена, не больше 30 сообщений в минуту,
-- у заявки есть контакт и согласие, слот записи свободен…
insert into public.reports (…) values (…);
return jsonb_build_object('id', v_id, 'upload_path', v_path);
end $$;
grant execute on function api.submit_form(…) to anon, authenticated;
- Панель Layero
- CLI
Data API → «Методы»: что и кому база отдаёт по HTTP.

- Функции кабинета — только для вошедших пользователей. Права внутри проверяет функция.
site_formиsubmit_formдоступны любому посетителю — так работает виджет.
npx layero@latest data methods --db inbox --org my-team
Гонку за слот разрешает сама база: если два посетителя одновременно записываются на одно время, второго остановит уникальный индекс.
create unique index reports_booking_slot on public.reports (site_id, booking_at)
where kind = 'booking' and status <> 'rejected';
Так это выглядит для посетителя. Слот 12:00 в среду уже занят, поэтому в списке его нет.

Все функции — 0002_forms.sql.
Шаг 4. Разрешите загрузку файла ровно в одно место
Снимок экрана загружает посетитель, но только по пути, который выдала
submit_form, в течение 10 минут и только один раз:
create policy screenshots_visitor_upload on storage.objects
for insert to anon, authenticated
with check (bucket_id = 'screenshots'
and inbox_private.screenshot_upload_allowed(name));

Виджет делает снимок до того, как откроет форму, поэтому сам на него не попадает.
В панели: «Файлы» → «Политики».

- Команда сайта открывает снимки, владелец может их удалить.
- Посетитель загружает свой снимок и получает подписанную ссылку на минуту, чтобы Telegram мог скачать фото.
В кабинете снимки открываются по подписанным ссылкам:
const { data } = await db.storage.from("screenshots").createSignedUrl(path, 600);
Политики — 0003_storage.sql.
Шаг 5. Отправьте уведомление прямо из базы
Сохраните токен бота как секрет базы.
- Панель Layero
- CLI
Data API → «Доступ» → «Секреты» → «Добавить секрет».

- Имя секрета. В SQL оно пишется как
{{secrets.TELEGRAM_BOT_TOKEN}}. - Токен от @BotFather. После сохранения его не покажет ни панель, ни журнал.
npx layero@latest data secrets set TELEGRAM_BOT_TOKEN --from-env TELEGRAM_BOT_TOKEN --db inbox
Значение подставляется в запрос только в момент отправки:
create function inbox_private.tg_send_text(p_chat text, p_text text) returns void
language sql as $$
select net.http_post(
url := 'https://api.telegram.org/bot{{secrets.TELEGRAM_BOT_TOKEN}}/sendMessage',
body := jsonb_build_object('chat_id', p_chat, 'text', p_text));
$$;
net.http_post ставит запрос в очередь и отправляет после коммита: если
вставка откатится, уведомление не уйдёт. Триггер на reports вызывает эту
функцию для каждого нового сообщения. Снимок уходит фотографией: Telegram
скачивает его сам по подписанной ссылке.
Подключение без номера чата. Входящих вебхуков у Data API нет, поэтому
кабинет открывает t.me/<бот>?start=<код> и, пока владелец сайта на странице,
раз в две секунды спрашивает базу, не пришёл ли этот код в getUpdates.

- Подключённый чат и выбор сообщений, которые в него приходят.
- Одна кнопка: откроется бот, нажмите «Старт» — и чат подключён.
Подробности — в notifications.md.
Шаг 6. Соберите кабинет на supabase-js
Data API совместим с supabase-js, поэтому кабинет — обычный клиент.
- Панель Layero
- CLI
Data API → «Подключение».

- Адрес и публичный ключ. Если проект подключён к базе, Layero сам подставит их в сборку.
- Клиент — обычный supabase-js.
Для локальной разработки:
npx layero@latest data env --write # запишет .env.local
import { createClient } from "@supabase/supabase-js";
const db = createClient(import.meta.env.VITE_LAYERO_DATA_URL,
import.meta.env.VITE_LAYERO_DATA_KEY);
const { data } = await db.from("reports").select("*")
.eq("site_id", siteId).order("created_at", { ascending: false });
Фильтр site_id здесь для удобства, а не для защиты: чужих строк запрос не
вернёт, даже если фильтр убрать.

- Статусы. Команда сайта может менять только статус сообщения.
- Снимок экрана из приватного бакета, по подписанной ссылке на 10 минут.
Вход через Яндекс. Почта с паролем работает сразу. Яндекс ID подключается только в панели: «Авторизация» → «Способы входа» → «Яндекс».

- Этот адрес впишите в приложение на oauth.yandex.ru, а Client ID и секрет оттуда — сюда.
- Адреса, на которые можно вернуть пользователя после входа.
В клиенте — одна строка:
await db.auth.signInWithOAuth({ provider: "yandex", options: { redirectTo: APP_URL } });
Имя отправителя в письмах для входа:
npx layero@latest data auth sender "Layero Inbox" --db inbox
Шаг 7. Встройте виджет через iframe
Data API по умолчанию принимает запросы только с адресов ваших проектов. Для
кабинета это то, что нужно. Но виджет работает на чужих сайтах, поэтому
embed.js к базе не обращается: он рисует кнопку, делает снимок экрана и
открывает форму в iframe с адреса виджета. Запросы к базе уходят оттуда.
- Панель Layero
- CLI
Data API → «Доступ» → «С каких сайтов доступен Data API».

«Любой сайт» выключен: чужие сайты в базу не обращаются, для них виджет открывает iframe. Адреса виджета и кабинета, а также окружений их веток Layero добавляет сам.
npx layero@latest data origins list --db inbox
Ключ сайта st_… кабинет показывает, когда владелец добавляет сайт. На сайт
ставится одна строка, кнопки — по желанию:
<script src="https://inbox-widget.layero.app/embed.js" data-site="st_…" async></script>
<button data-inbox="booking">Записаться</button>
Так публичный ключ и логика формы не попадают в контекст чужой страницы.
Шаг 8. Выложите и проверьте права
Шаги 1–7 и выкладку двух проектов делает один скрипт:
LAYERO_ORG=my-team APP_ADDRESS=my-inbox ./scripts/setup.sh
В нашем прогоне база, схема и секреты заняли меньше минуты, сборка виджета — две, лендинга с кабинетом — пять. Итого около восьми минут.
Лендинг тоже принимает формы; ключ сайта для них задаётся переменной проекта.
- Панель Layero
- CLI
Проект → «Переменные окружения».

- Свои переменные: адрес виджета, имя бота, ключ сайта. После изменения проект нужно пересобрать.
- Переменные с пометкой «ставит платформа» появляются сами, когда проект подключён к базе: адрес базы, адрес Data API и публичный ключ.
npx layero@latest env set VITE_INBOX_SITE_KEY=st_… && npx layero@latest deploy --yes
Остаётся проверить права. layero data probe выполняет запрос через настоящий
шлюз от имени посетителя или конкретного пользователя и откатывает запись:
OWNER_EMAIL=… OTHER_EMAIL=… MEMBER_EMAIL=… ./scripts/check-access.sh
Посетитель сайта (публичный ключ)
✓ не читает сообщения 401
✓ не пишет в таблицу напрямую 401
✓ не видит список сайтов 401
Владелец сайта
✓ видит свой сайт 200:1
✓ меняет статус 200:1
✓ не меняет текст сообщения 403
Пользователь из другой команды
✓ не видит чужой сайт 200:0
✓ не видит чужое сообщение по id 200:0
✓ не меняет чужое сообщение 200:0
✓ не удаляет чужой сайт 200:0
✓ не добавляет себя в чужую команду 403
✓ не приглашает себя в чужую команду 403
✓ не создаёт сайт от имени владельца 403
✓ не видит снимки чужого сайта 200:0
Пользователь из команды сайта
✓ видит сайт 200:1
✓ меняет статус 200:1
✓ не удаляет сообщение — только владелец 200:0
✓ не меняет настройки сайта 200:0
Итог: 18 ✓, 0 ✗
200:0 — запрос прошёл, но RLS скрыл все строки: для этого пользователя их
нет. 401 — посетителю таблицы закрыты целиком. 403 — у роли нет права на
действие.
Подводные камни
| Что случилось | Почему | Как обойти |
|---|---|---|
Миграция падает на auth.uid() | Авторизация не включена | Включить её до миграций |
| Аргумент функции называется так же, как колонка, и фильтр всегда истинен | where site_key = site_key сравнивает колонку с собой | #variable_conflict use_column и полное имя submit_form.site_key |
| Политики двух таблиц зациклились | RLS одной таблицы читает другую и наоборот | Проверки членства — функции security definer |
| Чужой сайт не может обращаться к Data API | CORS пускает только адреса проектов | iframe со своего адреса |
| Подключению Telegram нужен входящий вебхук | Data API не принимает входящие запросы | Опрос getUpdates, пока владелец сайта на странице |
Перед запуском в продакшен
- Проверяйте права скриптом после каждой миграции.
- Смотрите, что видит каждая роль: Data API → «Методы» или
npx layero@latest data methods --db inbox. - Задайте имя отправителя писем:
npx layero@latest data auth sender "Ваш продукт". - Если владельцев сайтов станет много, перенесите проект на выделенную базу.
Что дальше
- Возьмите репозиторий за основу: замените формы на свои, схема и скрипты останутся.
- Прочитайте о Data API в документации Layero.
- Разберитесь в архитектуре Layero Inbox.