Skip to content

Свой сервер и прокси

Remote-апп — это твой https-сервер, который отдаёт экраны (Stac JSON) и API-ответы. Клиент никогда не ходит к нему напрямую: весь трафик идёт через защищённый прокси платформы.

Нет своего хостинга?

Готовый пример с нуля — деплой бэкенда на Yandex Cloud (Cloud Functions + API Gateway).

Как ходит трафик

Приложение → защищённый прокси (JWT) → твой сервер (https); прокси проверяет авторизацию, rate limit, пиннинг хоста, редиректы, размер и таймаут ответа

Контракт твоего сервера

Экраны

На GET <origin><path> сервер отвечает телом-объектом — Stac JSON экрана. path — это путь, указанный как стартовый или запрошенный экшеном openPage.

GET https://my-app.example.com/        → JSON стартового экрана
GET https://my-app.example.com/stats   → JSON страницы /stats

API-вызовы

Экшен networkRequest с относительным url превращается в запрос к твоему origin с тем же методом, query и телом:

{ "actionType": "networkRequest", "url": "/api/vote", "method": "post", "body": {} }
→ POST https://my-app.example.com/api/vote

Кто пришёл: identity по разрешениям

Сессия и JWT пользователя не передаются никогда. Какие данные видит твой сервер, решает сам пользователь: при первом запуске он подтверждает запрошенные тобой разрешения (см. публикацию) и может изменить выбор в любой момент. Прокси шлёт только разрешённое:

ЗаголовокСкоупЗначение
X-MireaNinja-App-UserвсегдаПсевдонимный ID — стабилен для твоего аппа, но в разных аппах разный (нельзя сопоставить аудитории)
X-MireaNinja-ScopesвсегдаСписок выданных скоупов через запятую
X-MireaNinja-UseridentityРеальный UUID пользователя
X-MireaNinja-EmailemailУниверситетская почта
X-MireaNinja-NameprofileФИО, base64(utf-8)
X-MireaNinja-CourseprofileНомер курса
X-MireaNinja-GroupgroupУчебная группа, base64(utf-8)
X-MireaNinja-TimestampUnix-время подписи (сек)
X-MireaNinja-SignatureHMAC-SHA256(секрет, "<appUserId>.<timestamp>"), hex

Проектируй под отказ

Каждый скоуп пользователь может не выдать или отозвать позже — апп обязан работать без них. Скоупы, не заявленные при публикации, не будут переданы даже при желании пользователя.

Имя и группа могут содержать кириллицу, поэтому передаются как base64 от UTF-8: Buffer.from(value, "base64").toString("utf8").

Проверка подписи

Подпись доказывает, что запрос пришёл именно от прокси Mirea Ninja, а не от кого-то, кто подставил заголовки руками. Секрет выдаётся команде платформы по запросу (контакты — в приложении). Проверка на Node.js:

js
// express middleware
import crypto from "node:crypto";

function verifyNinja(req, res, next) {
  const user = req.header("X-MireaNinja-App-User");
  const ts = req.header("X-MireaNinja-Timestamp");
  const sig = req.header("X-MireaNinja-Signature");
  if (!user || !ts || !sig) return res.status(401).end();

  // окно в 5 минут против replay
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.status(401).end();
  }
  const expected = crypto
    .createHmac("sha256", process.env.NINJA_SECRET)
    .update(`${user}.${ts}`)
    .digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).end();
  }
  req.ninjaAppUserId = user; // псевдонимный ID — используй как ключ аккаунта
  next();
}

Нет секрета — нет проблемы

Если подпись тебе не нужна (например, апп без аккаунтов), просто игнорируй заголовки — они придут без Signature, пока секрет не настроен.

Жёсткие ограничения прокси

  • Только https; IP-адреса, localhost, *.local, *.internal и внутренние хосты платформы запрещены.
  • Редиректы не выполняются — ответ 3xx считается ошибкой.
  • Ответ обязан быть валидным JSON и не больше 512 КБ.
  • Таймаут запроса — 10 секунд.
  • Методы: GET, POST, PUT, PATCH, DELETE.
  • Rate limit: 180 запросов в минуту на пользователя (на все мини-аппы суммарно). При превышении — 429.
  • Путь не может выйти за пределы origin (никаких ..).
  • Query-параметры прокидываются с лимитом: ключ ≤ 64, значение ≤ 512 символов; длиннее — отбрасывается.

Картинки ходят напрямую

Виджет image загружает URL без прокси. Используй стабильный https-хостинг и не выкладывай через картинки чувствительные данные.

Чек-лист перед публикацией

  1. Сервер отвечает JSON-объектом на стартовый путь за < 10 c.
  2. Все ответы меньше 512 КБ (пагинация, а не «всё сразу»).
  3. Нет редиректов (включая http→https и слэш-редиректы).
  4. CDN/хостинг не отдаёт HTML-страницы ошибок на JSON-роуты.
  5. Если используешь identity — проверяешь подпись и timestamp.

Сервисные мини-аппы (внутренние)

Помимо hosted и remote есть третий вид источника — service. Это первопартийные аппы самой платформы (например, «Свободные аудитории»): у них нет внешнего origin и нет владельца-пользователя, а экраны отдаёт внутренняя edge-функция с префиксом miniapp-svc-<slug>.

Прокси по service-role вызывает внутреннюю edge-функцию miniapp-svc-<slug>: source_kind = 'service', вызов по slug без SSRF-проверок

  • Прокси узнаёт такой апп по source_kind = 'service' и зовёт функцию miniapp-svc-<slug> напрямую (доверенная внутренняя цель), передавая ей slug, path, kind и userId.
  • Функция авторизует вызов по service-role ключу — снаружи её не дёрнуть.
  • Завести сервисный апп может только платформа (миграцией): обычные пользователи не могут выставить source_kind = 'service'.

Для авторов сторонних аппов это закрытый путь — используйте hosted или remote.

Работает на Stac