first init

This commit is contained in:
2026-08-14 00:13:56 +05:00
commit 7ae997f99e
14 changed files with 6310 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=3s --start-period=3s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
+20
View File
@@ -0,0 +1,20 @@
# OVE API documentation
Static public API reference served at `https://docs.ove.rs`.
Regenerate the machine-readable catalog and OpenAPI documents:
```sh
python3 scripts/generate.py
```
Verify that every public route in the Messenger and Crypto Gateway source is
documented:
```sh
python3 scripts/verify.py
```
The verifier intentionally excludes Messenger `/internal/*` routes and the
Crypto Gateway browser console. Hosted checkout endpoints remain part of the
public reference.
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|json|txt)$ {
expires 5m;
add_header Cache-Control "public, max-age=300";
try_files $uri =404;
}
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options DENY always;
}
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
(() => {
"use strict";
const list = document.querySelector("#endpoint-list");
const template = document.querySelector("#endpoint-template");
const search = document.querySelector("#search");
const filters = document.querySelector("#service-filters");
const count = document.querySelector("#endpoint-count");
const empty = document.querySelector("#empty");
const sidebar = document.querySelector("#sidebar");
const menu = document.querySelector("#menu");
let manifest;
let activeService = "all";
const escapeHtml = (value) => String(value)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
function curlFor(service, endpoint) {
const url = `${service.base_url}${endpoint.path.replaceAll(/{([^}]+)}/g, "<$1>")}`;
const lines = [`curl -X ${endpoint.method} '${url}'`];
if (endpoint.auth === "bearer" || endpoint.auth === "admin") {
lines.push(" -H 'Authorization: Bearer $TOKEN'");
} else if (endpoint.auth === "hmac") {
lines.push(" -H 'X-Api-Key: $API_KEY'",
" -H 'X-Api-Timestamp: $TIMESTAMP'",
" -H 'X-Api-Nonce: $UUID'",
" -H 'X-Api-Signature: $SIGNATURE'");
if (endpoint.method !== "GET") lines.push(" -H 'Idempotency-Key: $UUID'");
}
if (endpoint.body) {
lines.push(" -H 'Content-Type: application/json'",
` -d '${JSON.stringify(endpoint.body)}'`);
}
return lines.join(" \\\n");
}
function endpointId(service, endpoint) {
return `${service.id}-${endpoint.method.toLowerCase()}-${endpoint.path
.replace(/[{}]/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
}
function render() {
const query = search.value.trim().toLowerCase();
list.replaceChildren();
let shown = 0;
manifest.services.forEach((service) => {
service.endpoints.forEach((endpoint) => {
const haystack = `${service.name} ${endpoint.method} ${endpoint.path} ${endpoint.title} ${endpoint.description} ${endpoint.group}`.toLowerCase();
if ((activeService !== "all" && activeService !== service.id) || !haystack.includes(query)) return;
shown += 1;
const node = template.content.firstElementChild.cloneNode(true);
node.id = endpointId(service, endpoint);
node.dataset.service = service.id;
const summary = node.querySelector(".endpoint-summary");
const method = node.querySelector(".method");
method.textContent = endpoint.method;
method.classList.add(endpoint.method);
node.querySelector(".path").textContent = endpoint.path;
node.querySelector(".endpoint-title").textContent = endpoint.title;
node.querySelector(".auth-badge").textContent = endpoint.auth_label;
node.querySelector(".endpoint-description").textContent = endpoint.description;
node.querySelector(".endpoint-meta").innerHTML =
`<code>${escapeHtml(service.base_url)}</code><code>${escapeHtml(endpoint.group)}</code>`;
const curl = curlFor(service, endpoint);
node.querySelector(".endpoint-example").innerHTML =
`<div class="code-card"><div class="code-head"><span>cURL</span><button class="copy">Копировать</button></div><pre><code>${escapeHtml(curl)}</code></pre></div>`;
summary.addEventListener("click", () => {
const open = node.classList.toggle("open");
summary.setAttribute("aria-expanded", String(open));
if (open) history.replaceState(null, "", `#${node.id}`);
});
node.querySelector(".copy").addEventListener("click", (event) => copyText(event.currentTarget, curl));
list.append(node);
});
});
count.textContent = `${shown} / ${manifest.total_endpoints}`;
empty.hidden = shown !== 0;
}
function copyText(button, text) {
navigator.clipboard.writeText(text).then(() => {
const old = button.textContent;
button.textContent = "Скопировано";
window.setTimeout(() => { button.textContent = old; }, 1300);
});
}
function makeFilters() {
const choices = [["all", "Все"], ...manifest.services.map((service) => [service.id, service.name])];
choices.forEach(([id, label]) => {
const button = document.createElement("button");
button.className = `filter${id === "all" ? " active" : ""}`;
button.textContent = label;
button.addEventListener("click", () => {
activeService = id;
filters.querySelectorAll(".filter").forEach((item) => item.classList.toggle("active", item === button));
render();
});
filters.append(button);
});
}
document.querySelectorAll("[data-copy-target]").forEach((button) => {
button.addEventListener("click", () => copyText(button, document.querySelector(`#${button.dataset.copyTarget}`).innerText));
});
search.addEventListener("input", render);
document.addEventListener("keydown", (event) => {
if (event.key === "/" && document.activeElement !== search) {
event.preventDefault();
search.focus();
}
if (event.key === "Escape") sidebar.classList.remove("open");
});
menu.addEventListener("click", () => {
const open = sidebar.classList.toggle("open");
menu.setAttribute("aria-expanded", String(open));
});
sidebar.querySelectorAll("a").forEach((link) => link.addEventListener("click", () => sidebar.classList.remove("open")));
fetch("/api-manifest.json")
.then((response) => {
if (!response.ok) throw new Error(`manifest ${response.status}`);
return response.json();
})
.then((data) => {
manifest = data;
makeFilters();
render();
if (location.hash.startsWith("#messenger-") || location.hash.startsWith("#gateway-")) {
const target = document.querySelector(location.hash);
if (target) {
target.classList.add("open");
target.querySelector(".endpoint-summary").setAttribute("aria-expanded", "true");
target.scrollIntoView();
}
}
})
.catch(() => {
empty.hidden = false;
empty.textContent = "Не удалось загрузить API catalog.";
});
})();
+209
View File
@@ -0,0 +1,209 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Публичная документация API сервисов OVE">
<title>OVE API Reference</title>
<link rel="stylesheet" href="/styles.css">
<script src="/app.js" defer></script>
</head>
<body>
<div class="noise" aria-hidden="true"></div>
<aside class="sidebar" id="sidebar">
<a class="brand" href="#top" aria-label="OVE API">
<span class="brand-mark">O</span>
<span><strong>OVE</strong><small>API reference</small></span>
</a>
<nav aria-label="Основная навигация">
<a href="#overview">Обзор</a>
<a href="#quickstart">Быстрый старт</a>
<a href="#auth">Авторизация</a>
<a href="#messenger">Messenger API</a>
<a href="#bots">Bot API</a>
<a href="#oauth">OAuth / OIDC</a>
<a href="#gateway">Crypto Gateway</a>
<a href="#donations">Donations + OBS</a>
<a href="#webhooks">Webhooks</a>
<a href="#errors">Ошибки</a>
</nav>
<div class="sidebar-foot">
<span class="status-dot"></span> Production polygon
<a href="/openapi/messenger.json" download>Messenger OpenAPI ↗</a>
<a href="/openapi/gateway.json" download>Gateway OpenAPI ↗</a>
<a href="/openapi/donations.json" download>Donations OpenAPI ↗</a>
</div>
</aside>
<main id="top">
<header class="topbar">
<button class="menu" id="menu" aria-label="Открыть меню" aria-expanded="false"></button>
<label class="search">
<span></span>
<input id="search" type="search" placeholder="Поиск endpoint, пути или метода…" autocomplete="off">
<kbd>/</kbd>
</label>
<a class="spec-link" href="/openapi/gateway.json">OpenAPI</a>
</header>
<section class="hero" id="overview">
<div class="eyebrow">PUBLIC API · OVE.RS</div>
<h1>Один вход.<br><em>Все сервисы OVE.</em></h1>
<p class="lead">Документация публичных HTTP API Messenger, Crypto Gateway и Donation Service: авторизация, сообщения, боты, платежи, OBS alerts, webhooks и операторские методы.</p>
<div class="hero-actions">
<a class="button primary" href="#quickstart">Начать интеграцию</a>
<a class="button secondary" href="#endpoints">Все endpoints</a>
</div>
<div class="service-strip">
<article><span class="service-icon messenger">M</span><div><b>Messenger</b><code>https://ms.ove.rs</code></div><i>REST · OAuth · WS</i></article>
<article><span class="service-icon gateway">C</span><div><b>Crypto Gateway</b><code>https://cr.ove.rs</code></div><i>REST · HMAC</i></article>
<article><span class="service-icon donations">D</span><div><b>Donations</b><code>https://do.ove.rs</code></div><i>REST · SSE · OBS</i></article>
</div>
</section>
<section class="doc-section" id="quickstart">
<div class="section-label">01 — Quick start</div>
<h2>Первый запрос</h2>
<p>Все ответы API — JSON, если endpoint явно не обозначен как HTML, WebSocket или файл. Для Messenger получите токен через email-код, затем передавайте его в заголовке Bearer.</p>
<div class="code-card">
<div class="code-head"><span>Email login</span><button class="copy" data-copy-target="quick-code">Копировать</button></div>
<pre id="quick-code"><code>curl -X POST https://ms.ove.rs/auth/email/start \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com"}'
curl -X POST https://ms.ove.rs/auth/email/verify \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","code":"123456"}'</code></pre>
</div>
<div class="callout"><strong>HTTPS в тестовом полигоне.</strong> Сертификаты выпущены локальным CA OVE. Добавьте CA в доверенные на устройстве; не отключайте проверку TLS в production-клиенте.</div>
</section>
<section class="doc-section" id="auth">
<div class="section-label">02 — Security</div>
<h2>Три схемы авторизации</h2>
<div class="auth-grid">
<article><span>01</span><h3>Messenger Bearer</h3><p>Пользовательский или bot token передается как <code>Authorization: Bearer &lt;token&gt;</code>.</p></article>
<article><span>02</span><h3>Gateway HMAC</h3><p>Каждый merchant-запрос подписывается ключом, timestamp и одноразовым UUID nonce.</p></article>
<article><span>03</span><h3>Operator Bearer</h3><p><code>/admin/v1/*</code> принимает отдельный секрет <code>GATEWAY_ADMIN_TOKEN</code>.</p></article>
</div>
<h3>Каноническая строка Gateway</h3>
<pre><code>timestamp + "\n" +
nonce + "\n" +
HTTP_METHOD + "\n" +
path_and_query + "\n" +
hex(sha256(raw_request_body))</code></pre>
<p>Результат подпишите HMAC-SHA256 и отправьте lowercase hex в <code>X-Api-Signature</code>. Также обязательны <code>X-Api-Key</code>, <code>X-Api-Timestamp</code>, <code>X-Api-Nonce</code>; для изменяющих запросов — <code>Idempotency-Key</code>.</p>
</section>
<section class="doc-section" id="messenger">
<div class="section-label">03 — Messenger</div>
<h2>Клиенты, сообщения и real-time</h2>
<p>HTTP API покрывает аккаунты, чаты, E2E-ключи, файлы, голосовые сессии, Dastars и очередь обновлений. Native Android транспорт MST5 работает на <code>ms.ove.rs:8080</code>: после защищённого handshake он передаёт CBOR-команды и мультиплексированные ответы внутри зашифрованного потока.</p>
<div class="facts">
<div><b>Updates</b><span>long polling + ACK</span></div>
<div><b>Voice</b><span>WebSocket ticket</span></div>
<div><b>E2E</b><span>не зависит от username</span></div>
<div><b>Files</b><span>multipart upload</span></div>
</div>
</section>
<section class="doc-section" id="bots">
<div class="section-label">04 — Bots</div>
<h2>Bot API</h2>
<p>Создайте бота через проверенного <code>botfather</code> или <code>POST /bots</code>. Bot token использует ту же Bearer-схему. Обновления хранятся до подтверждения через <code>POST /updates/ack</code>.</p>
<div class="two-col">
<div><h3>Кнопки</h3><p><code>url</code>, <code>callback</code> и <code>pay_dsr</code>; не более 12 кнопок, текст до 64 символов.</p></div>
<div><h3>Реакции</h3><p>Бот получает callback для обычных и платных реакций. Пакет платной реакции может содержать <code>amount &gt; 1</code>.</p></div>
</div>
</section>
<section class="doc-section" id="oauth">
<div class="section-label">05 — Identity</div>
<h2>OAuth 2.0 Device Flow + OIDC</h2>
<p>Сервис запрашивает device code у Messenger, показывает QR или user code, а пользователь подтверждает вход в Android-клиенте. Токен обменивается только после решения пользователя.</p>
<div class="flow"><span>1 · device_authorization</span><b></b><span>2 · QR / code</span><b></b><span>3 · approve</span><b></b><span>4 · token</span></div>
</section>
<section class="doc-section" id="gateway">
<div class="section-label">06 — Payments</div>
<h2>Crypto Gateway</h2>
<p>Создавайте invoice и withdrawal, сверяйте баланс и принимайте подписанные callbacks. Денежные величины всегда передаются строками в атомарных единицах — без float.</p>
<div class="asset-table" role="table" aria-label="Поддерживаемые активы">
<div class="thead"><span>Asset</span><span>Network</span><span>Decimals</span></div>
<div><b>BTC</b><span>bitcoin</span><code>8</code></div>
<div><b>LTC</b><span>litecoin</span><code>8</code></div>
<div><b>TON</b><span>ton</span><code>9</code></div>
<div><b>TRX</b><span>tron</span><code>6</code></div>
<div><b>USDT</b><span>ton / tron</span><code>6</code></div>
</div>
<div class="callout"><strong>Hosted checkout.</strong> URL из ответа invoice открывается как <code>/pay/{token}</code>; JSON-статус доступен в <code>/pay/{token}/status</code>. Таймер оплаты синхронизирован с серверным временем.</div>
</section>
<section class="doc-section" id="webhooks">
<div class="section-label">07 — Events</div>
<h2>Подпись callbacks</h2>
<pre><code>signature = hex(HMAC-SHA256(
webhook_secret,
timestamp + "." + event_id + "." + raw_body
))</code></pre>
<p>Проверяйте <code>X-Gateway-Timestamp</code>, <code>X-Gateway-Event-Id</code> и <code>X-Gateway-Signature</code>. Доставка at-least-once повторяется до 24 часов — дедуплицируйте события по <code>event_id</code>.</p>
</section>
<section class="doc-section" id="donations">
<div class="section-label">08 — Donations</div>
<h2>Страница доната и OBS Browser Source</h2>
<p>Стример входит через Messenger, получает публичный адрес <code>do.ove.rs/u/{slug}</code> и секретную ссылку виджета. Доноры авторизоваться не обязаны: Donation Service создаёт invoice в Crypto Gateway и показывает его hosted checkout.</p>
<div class="flow"><span>1 · public donation</span><b></b><span>2 · Gateway checkout</span><b></b><span>3 · signed webhook</span><b></b><span>4 · OBS SSE alert</span></div>
<div class="callout"><strong>Вывод средств.</strong> Каждый запрос резервирует доступный баланс и отдельно подтверждается свайпом в Messenger. В окне подтверждения показаны актив, сумма, сеть и сокращённый адрес.</div>
</section>
<section class="doc-section endpoints-section" id="endpoints">
<div class="section-label">09 — Reference</div>
<h2>Все endpoints</h2>
<div class="endpoint-toolbar">
<div id="service-filters" class="filters"></div>
<span id="endpoint-count"></span>
</div>
<div id="endpoint-list" class="endpoint-list" aria-live="polite"></div>
<div id="empty" class="empty" hidden>Ничего не найдено. Попробуйте другой запрос.</div>
</section>
<section class="doc-section" id="errors">
<div class="section-label">10 — Errors</div>
<h2>Ошибки и повторные запросы</h2>
<div class="error-grid">
<div><code>400</code><span>Неверные поля или состояние</span></div>
<div><code>401</code><span>Нет или неверна авторизация</span></div>
<div><code>403</code><span>Недостаточно прав</span></div>
<div><code>404</code><span>Объект не найден</span></div>
<div><code>409</code><span>Конфликт / повтор</span></div>
<div><code>429</code><span>Превышен лимит</span></div>
<div><code>5xx</code><span>Временная ошибка сервиса</span></div>
</div>
<p>Для безопасного повтора merchant POST используйте тот же <code>Idempotency-Key</code>. Не повторяйте запрос с новым ключом, пока результат предыдущего неизвестен.</p>
</section>
<footer>
<span>OVE API · public contract</span>
<span>Internal wallet gRPC и системные bot nodes не публикуются.</span>
</footer>
</main>
<template id="endpoint-template">
<article class="endpoint">
<button class="endpoint-summary" type="button" aria-expanded="false">
<span class="method"></span>
<code class="path"></code>
<span class="endpoint-title"></span>
<span class="auth-badge"></span>
<span class="chevron"></span>
</button>
<div class="endpoint-body">
<p class="endpoint-description"></p>
<div class="endpoint-meta"></div>
<div class="endpoint-example"></div>
</div>
</article>
</template>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
# OVE public API
Canonical human documentation: https://docs.ove.rs/
Messenger OpenAPI: https://docs.ove.rs/openapi/messenger.json
Crypto Gateway OpenAPI: https://docs.ove.rs/openapi/gateway.json
Donation Service OpenAPI: https://docs.ove.rs/openapi/donations.json
Public services:
- Messenger: https://ms.ove.rs
- Crypto Gateway: https://cr.ove.rs
- Donation Service: https://do.ove.rs
Messenger uses Bearer tokens for users and bots. Crypto Gateway merchant API
uses request-specific HMAC-SHA256 signatures. Gateway operator API uses a
Bearer admin token. Amounts in Crypto Gateway are decimal strings in atomic
units.
+660
View File
@@ -0,0 +1,660 @@
{
"openapi": "3.1.0",
"info": {
"title": "OVE Donations API",
"version": "1.0.0",
"description": "OVE donation pages, Messenger login, Crypto Gateway payments and OBS alerts."
},
"servers": [
{
"url": "https://do.ove.rs"
}
],
"paths": {
"/health": {
"get": {
"tags": [
"Сервис"
],
"summary": "Состояние Gateway",
"description": "Состояние Gateway. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_health",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
}
}
},
"/api/assets": {
"get": {
"tags": [
"Сервис"
],
"summary": "Активы для донатов",
"description": "Активы для донатов. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_assets",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
}
}
},
"/api/auth/start": {
"post": {
"tags": [
"Авторизация"
],
"summary": "Вход стримера через Messenger",
"description": "Вход стримера через Messenger. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "post_api_auth_start",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"email": "streamer@example.com"
}
}
}
}
}
},
"/api/auth/poll/{flow}": {
"get": {
"tags": [
"Авторизация"
],
"summary": "Статус входа стримера",
"description": "Статус входа стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_auth_poll_flow",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"parameters": [
{
"name": "flow",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/logout": {
"post": {
"tags": [
"Авторизация"
],
"summary": "Выход из Donation Service",
"description": "Выход из Donation Service. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "post_api_logout",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
}
},
"/api/me": {
"get": {
"tags": [
"Стример"
],
"summary": "Панель стримера",
"description": "Панель стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_me",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
}
},
"/api/profile": {
"patch": {
"tags": [
"Стример"
],
"summary": "Настройки страницы и OBS",
"description": "Настройки страницы и OBS. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "patch_api_profile",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"display_name": "My channel",
"slug": "my-channel",
"accent_color": "#b9f541",
"duration_seconds": 8,
"show_donor_name": true,
"show_message": true
}
}
}
}
}
},
"/api/sound": {
"put": {
"tags": [
"OBS"
],
"summary": "Загрузка звука",
"description": "Загрузка звука. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "put_api_sound",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
},
"delete": {
"tags": [
"OBS"
],
"summary": "Удаление звука",
"description": "Удаление звука. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "delete_api_sound",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
}
},
"/api/widget/rotate": {
"post": {
"tags": [
"OBS"
],
"summary": "Ротация секретной OBS-ссылки",
"description": "Ротация секретной OBS-ссылки. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "post_api_widget_rotate",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
}
},
"/api/widget/test": {
"post": {
"tags": [
"OBS"
],
"summary": "Тестовый OBS alert",
"description": "Тестовый OBS alert. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "post_api_widget_test",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
}
},
"/api/widget/{token}/config": {
"get": {
"tags": [
"OBS"
],
"summary": "Конфигурация OBS-виджета",
"description": "Конфигурация OBS-виджета. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_widget_token_config",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/widget/{token}/events": {
"get": {
"tags": [
"OBS"
],
"summary": "SSE-события OBS-виджета",
"description": "Долгоживущий SSE-поток для OBS Browser Source. Виджет сам ставит alerts в очередь.",
"operationId": "get_api_widget_token_events",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/public/creators/{slug}": {
"get": {
"tags": [
"Донаты"
],
"summary": "Публичная страница стримера",
"description": "Публичная страница стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_public_creators_slug",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/public/donations": {
"post": {
"tags": [
"Донаты"
],
"summary": "Создание доната",
"description": "Создает Gateway invoice с комиссией 5%. cover_fee=false удерживает комиссию из суммы; cover_fee=true точно увеличивает платеж так, чтобы стример получил введенную сумму после комиссии. Все расчеты выполняются в atomic без float.",
"operationId": "post_api_public_donations",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"slug": "my-channel",
"donor_name": "Anonymous",
"message": "Great stream!",
"asset": "USDT",
"network": "tron",
"amount": "25.5",
"cover_fee": true
}
}
}
}
}
},
"/api/public/donations/{token}/status": {
"get": {
"tags": [
"Донаты"
],
"summary": "Статус доната",
"description": "Статус доната. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_public_donations_token_status",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/withdrawals": {
"post": {
"tags": [
"Вывод"
],
"summary": "Запрос вывода",
"description": "Резервирует баланс и запускает новый Messenger Device Flow с описанием суммы и адреса.",
"operationId": "post_api_withdrawals",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"asset": "USDT",
"network": "tron",
"amount": "10",
"address": "TExampleRecipientAddress"
}
}
}
}
}
},
"/api/withdrawals/{flow}/poll": {
"get": {
"tags": [
"Вывод"
],
"summary": "Подтверждение вывода через Messenger",
"description": "После свайпа тем же Messenger-пользователем идемпотентно отправляет withdrawal в Gateway.",
"operationId": "get_api_withdrawals_flow_poll",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
],
"parameters": [
{
"name": "flow",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/admin/status": {
"get": {
"tags": [
"Оператор"
],
"summary": "Статус Donation Service",
"description": "Статус Donation Service. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "get_api_admin_status",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
]
}
},
"/api/admin/creators/{creator_id}/blocked": {
"patch": {
"tags": [
"Оператор"
],
"summary": "Блокировка страницы стримера",
"description": "Блокировка страницы стримера. Возвращает JSON-ответ или стандартную JSON-ошибку.",
"operationId": "patch_api_admin_creators_creator_id_blocked",
"responses": {
"200": {
"description": "Успешный ответ"
},
"400": {
"description": "Ошибка запроса"
},
"401": {
"description": "Ошибка авторизации"
}
},
"security": [
{
"sessionCookie": []
}
],
"parameters": [
{
"name": "creator_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"blocked": true
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"sessionCookie": {
"type": "apiKey",
"in": "cookie",
"name": "ove_donations_session",
"description": "HttpOnly session; changing requests also require X-CSRF-Token."
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
},
"required": [
"error"
]
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+137
View File
@@ -0,0 +1,137 @@
:root {
color-scheme: dark;
--bg: #0b0d10;
--panel: #111419;
--panel-2: #171b21;
--line: #262c34;
--muted: #929ba8;
--text: #f3f5f7;
--lime: #b9f541;
--cyan: #61ddff;
--violet: #a78bfa;
--orange: #ffb86b;
--sidebar: 260px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 88px; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.65 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body::before {
content: ""; position: fixed; inset: 0; pointer-events: none;
background: radial-gradient(circle at 80% 5%, rgba(97,221,255,.07), transparent 28%),
radial-gradient(circle at 30% 40%, rgba(185,245,65,.035), transparent 25%);
}
.noise { position: fixed; inset: 0; opacity: .025; pointer-events: none; z-index: 10;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
a { color: inherit; }
code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
code { font-size: .9em; }
.sidebar {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--sidebar);
display: flex; flex-direction: column; padding: 26px 20px;
background: rgba(12,14,17,.96); border-right: 1px solid var(--line); backdrop-filter: blur(18px);
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; margin-bottom: 38px; }
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px;
background: var(--lime); color: #0a0d08; font-weight: 900; font-size: 20px; box-shadow: 0 0 30px rgba(185,245,65,.15); }
.brand strong { display: block; letter-spacing: .08em; }
.brand small { display: block; color: var(--muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
nav { display: grid; gap: 3px; }
nav a { padding: 8px 11px; border-radius: 8px; color: #aab2bd; text-decoration: none; font-size: 13px; }
nav a:hover, nav a.active { color: var(--text); background: var(--panel-2); }
.sidebar-foot { margin-top: auto; display: grid; gap: 8px; color: var(--muted); font-size: 11px; }
.sidebar-foot a { text-decoration: none; color: #c5cbd3; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); display: inline-block; box-shadow: 0 0 10px var(--lime); }
main { margin-left: var(--sidebar); min-width: 0; }
.topbar { height: 66px; position: sticky; top: 0; z-index: 15; display: flex; align-items: center; gap: 18px;
padding: 0 max(32px, calc((100vw - var(--sidebar) - 980px) / 2));
border-bottom: 1px solid rgba(38,44,52,.75); background: rgba(11,13,16,.82); backdrop-filter: blur(20px); }
.menu { display: none; background: none; color: white; border: 0; font-size: 20px; }
.search { flex: 1; max-width: 620px; height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px;
border: 1px solid var(--line); border-radius: 9px; background: #111419; color: var(--muted); }
.search:focus-within { border-color: #53606e; }
.search input { width: 100%; border: 0; outline: 0; color: var(--text); background: transparent; font: inherit; font-size: 13px; }
kbd { border: 1px solid #343b45; border-radius: 5px; padding: 0 6px; font: 11px/19px inherit; color: var(--muted); }
.spec-link { margin-left: auto; color: var(--lime); text-decoration: none; font-size: 12px; letter-spacing: .05em; text-transform: uppercase; }
.hero, .doc-section, footer { width: min(980px, calc(100% - 64px)); margin-inline: auto; }
.hero { min-height: 690px; display: flex; flex-direction: column; justify-content: center; padding: 100px 0 72px; }
.eyebrow, .section-label { color: var(--lime); font: 700 11px/1.3 "SFMono-Regular", Consolas, monospace; letter-spacing: .14em; text-transform: uppercase; }
h1 { margin: 24px 0 20px; font-size: clamp(50px, 7vw, 88px); line-height: .98; letter-spacing: -.065em; font-weight: 720; }
h1 em { color: var(--muted); font-style: normal; }
.lead { max-width: 670px; margin: 0; color: #b6bec8; font-size: 19px; line-height: 1.7; }
.hero-actions { display: flex; gap: 12px; margin: 32px 0 66px; }
.button { padding: 11px 17px; border-radius: 9px; text-decoration: none; font-weight: 650; font-size: 13px; }
.button.primary { background: var(--lime); color: #10140a; }
.button.secondary { border: 1px solid var(--line); background: var(--panel); }
.service-strip { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.service-strip article { display: flex; align-items: center; gap: 13px; padding: 18px; background: rgba(17,20,25,.7); }
.service-strip article + article { border-left: 1px solid var(--line); }
.service-icon { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; font-weight: 800; color: #0b0d10; }
.service-icon.messenger { background: var(--cyan); }.service-icon.gateway { background: var(--violet); }
.service-strip b, .service-strip code { display: block; }
.service-strip code { color: var(--muted); font-size: 11px; }
.service-strip i { margin-left: auto; color: var(--muted); font: normal 10px/1.2 monospace; }
.doc-section { padding: 100px 0; border-top: 1px solid var(--line); }
h2 { margin: 14px 0 20px; font-size: clamp(32px, 4vw, 49px); line-height: 1.1; letter-spacing: -.045em; }
h3 { margin: 28px 0 10px; font-size: 17px; }
p { color: #b2bac5; max-width: 760px; }
pre { position: relative; overflow-x: auto; margin: 22px 0; padding: 22px; border: 1px solid var(--line); border-radius: 12px;
background: #0d1014; color: #dce4ec; font-size: 12px; line-height: 1.75; }
.code-card { border: 1px solid var(--line); border-radius: 13px; overflow: hidden; margin-top: 28px; background: #0d1014; }
.code-card pre { border: 0; border-radius: 0; margin: 0; }
.code-head { display: flex; justify-content: space-between; align-items: center; padding: 9px 13px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; }
.copy { border: 0; color: var(--lime); background: none; cursor: pointer; font: inherit; }
.callout { margin-top: 24px; padding: 18px 20px; border-left: 3px solid var(--lime); background: rgba(185,245,65,.055); color: #abb4bf; }
.callout strong { color: var(--text); }
.auth-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 32px; }
.auth-grid article { min-height: 190px; padding: 20px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
.auth-grid article > span { color: var(--lime); font: 11px monospace; }.auth-grid h3 { margin-top: 32px; }.auth-grid p { font-size: 13px; }
.facts { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 34px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.facts div { padding: 18px; }.facts div + div { border-left: 1px solid var(--line); }.facts b,.facts span { display:block; }.facts span { color:var(--muted);font-size:11px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }.two-col > div { padding: 0 20px 12px; border-left: 1px solid var(--line); }
.flow { display: flex; align-items: center; gap: 12px; margin-top: 30px; overflow-x: auto; padding-bottom: 8px; }
.flow span { flex: 0 0 auto; padding: 12px 15px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); font: 12px monospace; }.flow b { color: var(--lime); }
.asset-table { max-width: 670px; margin-top: 28px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.asset-table > div { display: grid; grid-template-columns: 1fr 2fr 1fr; padding: 10px 15px; border-top: 1px solid var(--line); }
.asset-table > div:first-child { border: 0; }.asset-table .thead { color: var(--muted); background: var(--panel); font-size: 11px; text-transform: uppercase; }
.asset-table code { color: var(--lime); }
.endpoint-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin: 28px 0 14px; }
.filters { display: flex; gap: 7px; flex-wrap: wrap; }
.filter { border: 1px solid var(--line); border-radius: 99px; background: var(--panel); color: #b7bec7; padding: 6px 11px; cursor: pointer; font: 11px inherit; }
.filter.active { background: var(--text); color: var(--bg); border-color: var(--text); }
#endpoint-count { color: var(--muted); font: 11px monospace; white-space: nowrap; }
.endpoint-list { display: grid; gap: 7px; }
.endpoint { border: 1px solid var(--line); border-radius: 11px; background: rgba(17,20,25,.74); overflow: hidden; scroll-margin-top: 80px; }
.endpoint-summary { width: 100%; min-height: 57px; display: grid; grid-template-columns: 54px minmax(175px, 1.25fr) 1fr auto 20px; align-items: center; gap: 12px;
border: 0; padding: 9px 14px; background: none; color: var(--text); text-align: left; cursor: pointer; }
.method { width: 48px; padding: 3px 0; border-radius: 5px; text-align:center; font: 800 10px monospace; }
.method.GET { color: var(--cyan); background: rgba(97,221,255,.09); }.method.POST { color: var(--lime); background: rgba(185,245,65,.09); }.method.DELETE { color: #ff718a; background: rgba(255,113,138,.09); }
.path { overflow-wrap: anywhere; color: #dce2e9; font-size: 12px; }
.endpoint-title { color: var(--muted); font-size: 12px; }
.auth-badge { border: 1px solid #343b45; border-radius: 99px; padding: 2px 8px; color: var(--muted); font: 9px monospace; text-transform: uppercase; }
.chevron { color: var(--muted); transition: transform .2s; }.endpoint.open .chevron { transform: rotate(180deg); }
.endpoint-body { display: none; padding: 0 18px 18px 80px; border-top: 1px solid var(--line); }.endpoint.open .endpoint-body { display: block; }
.endpoint-meta { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0; }.endpoint-meta code { padding: 4px 8px; background:#0d1014; border-radius:5px;color:#aeb7c2; }
.endpoint-example pre { margin-bottom: 0; }.empty { padding: 50px; text-align:center; color:var(--muted); }
.error-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; overflow:hidden; margin: 25px 0; border:1px solid var(--line); border-radius:12px; background:var(--line); }
.error-grid div { display:flex; gap:15px; padding:12px 15px; background:var(--panel); }.error-grid code{color:var(--orange)}.error-grid span{color:var(--muted)}
footer { display: flex; justify-content: space-between; gap: 20px; padding: 40px 0 60px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
@media (max-width: 900px) {
:root { --sidebar: 0px; }
.sidebar { width: 260px; transform: translateX(-100%); transition: transform .25s; box-shadow: 20px 0 50px rgba(0,0,0,.4); }
.sidebar.open { transform: translateX(0); }
.menu { display:block; }.topbar{padding:0 20px}.hero,.doc-section,footer{width:min(100% - 38px,760px)}
.auth-grid{grid-template-columns:1fr}.facts{grid-template-columns:1fr 1fr}.facts div:nth-child(3){border-left:0;border-top:1px solid var(--line)}.facts div:nth-child(4){border-top:1px solid var(--line)}
}
@media (max-width: 620px) {
.hero{min-height:auto;padding-top:75px}.service-strip{grid-template-columns:1fr}.service-strip article+article{border-left:0;border-top:1px solid var(--line)}
.two-col{grid-template-columns:1fr}.endpoint-summary{grid-template-columns:48px 1fr 18px}.endpoint-title,.auth-badge{display:none}.endpoint-body{padding-left:18px}
.facts{grid-template-columns:1fr}.facts div+div{border-left:0;border-top:1px solid var(--line)}.error-grid{grid-template-columns:1fr}.spec-link,kbd{display:none}
footer{flex-direction:column}h1{font-size:48px}.lead{font-size:16px}
}
@media (prefers-reduced-motion: reduce) { html{scroll-behavior:auto}*{transition:none!important} }
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""Generate the browser catalog and OpenAPI documents from one endpoint list."""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PUBLIC = ROOT / "public"
OPENAPI = PUBLIC / "openapi"
def title(path: str, method: str) -> str:
names = {
"/register": "Регистрация аккаунта",
"/login": "Вход по паролю",
"/auth/email/start": "Запрос email-кода",
"/auth/email/verify": "Подтверждение email-кода",
"/.well-known/openid-configuration": "OIDC Discovery",
"/.well-known/oauth-authorization-server": "OAuth Server Metadata",
"/oauth/jwks.json": "Публичные ключи OIDC",
"/oauth/device_authorization": "Запуск Device Flow",
"/oauth/device": "Страница подтверждения Device Flow",
"/oauth/token": "Обмен device code на токены",
"/oauth/userinfo": "Профиль OIDC",
"/oauth/device/request": "Данные запроса входа",
"/oauth/device/decision": "Решение пользователя",
"/me": "Текущий пользователь",
"/account/delete": "Удаление аккаунта",
"/username": "Установка username",
"/name": "Изменение отображаемого имени",
"/profiles/description": "Описание профиля, бота или чата",
"/privacy": "Настройки приватности",
"/contacts": "Список контактов",
"/contacts/add": "Добавление контакта",
"/contacts/delete": "Удаление контакта",
"/groups": "Создание группы",
"/channels": "Создание канала",
"/chats/title": "Изменение названия чата",
"/channels/username": "Username канала",
"/channels/comments/settings": "Настройки комментариев канала",
"/channels/comments/send": "Комментарий к публикации",
"/channels/comments": "Комментарии к публикации",
"/chats/members/add": "Добавление участника",
"/chats/members/remove": "Удаление участника",
"/cloud-password": "Облачный пароль",
"/cloud-password/reset": "Сброс облачного пароля",
"/sessions": "Активные сессии",
"/sessions/revoke": "Завершение сессии",
"/sessions/revoke-others": "Завершение других сессий",
"/bots": "Создание бота",
"/bots/token/reset": "Перевыпуск bot token",
"/e2e/key": "Публичный E2E-ключ" if method == "GET" else "Публикация E2E-ключа",
"/e2e/backup": "Резервная копия E2E" if method == "GET" else "Сохранение E2E-копии",
"/e2e/reset": "Сброс E2E-ключей",
"/wallet": "Баланс Dastars",
"/wallet/send": "Перевод Dastars",
"/wallet/history": "История Dastars",
"/call": "Создание звонка",
"/voice-ticket": "Билет голосовой сессии",
"/voice/participants": "Участники голосовой сессии",
"/voice": "Голосовой WebSocket",
"/send": "Отправка текстового сообщения",
"/edit": "Редактирование сообщения",
"/callback": "Callback кнопки",
"/reactions": "Emoji-реакция",
"/reactions/paid": "Платная реакция Dastars",
"/read": "Отметка о прочтении",
"/delete": "Удаление сообщения",
"/favorite": "Избранное сообщение",
"/media/quote": "Расчет стоимости медиа",
"/messages/prepare": "Подготовка сообщения с медиа",
"/messages/commit": "Публикация сообщения с медиа",
"/messages/cancel": "Отмена сообщения с медиа",
"/forward": "Пересылка сообщения с медиа",
"/file/ticket": "Билет скачивания файла",
"/health/live": "Проверка процесса Messenger",
"/health/ready": "Готовность Messenger",
"/metrics": "Метрики Messenger",
"/nodes/status": "Статус сервисных узлов",
"/chats": "Список чатов",
"/chats/delete": "Удаление чата",
"/users/ban": "Блокировка пользователя",
"/users/unban": "Разблокировка пользователя",
"/history": "История сообщений",
"/updates": "Очередь обновлений",
"/updates/ack": "Подтверждение обновлений",
"/file/{id}": "Скачивание файла",
"/health": "Состояние Gateway",
"/v1/assets": "Доступные активы",
"/v1/invoices": "Список invoices" if method == "GET" else "Создание invoice",
"/v1/invoices/{invoice_id}": "Получение invoice",
"/v1/withdrawals": "Список withdrawals" if method == "GET" else "Создание withdrawal",
"/v1/withdrawals/{withdrawal_id}": "Получение withdrawal",
"/v1/balances": "Merchant-балансы",
"/v1/api-keys": "Список API-ключей",
"/admin/v1/status": "Операторский статус",
"/admin/v1/callbacks": "Инциденты callbacks",
"/admin/v1/consolidations": "Инциденты консолидации",
"/admin/v1/callbacks/{callback_id}/retry": "Повтор callback",
"/admin/v1/merchants": "Создание merchant",
"/admin/v1/fee-policies": "Глобальная fee policy",
"/admin/v1/merchants/{merchant_id}/api-keys": "Выпуск merchant API-ключа",
"/admin/v1/merchants/{merchant_id}/api-keys/{api_key_id}": "Отзыв merchant API-ключа",
"/admin/v1/merchants/{merchant_id}/policies": "Merchant policy",
"/admin/v1/withdrawals/{withdrawal_id}/resolve": "Решение manual review",
"/admin/v1/consolidations/{consolidation_id}/retry": "Повтор консолидации",
"/pay/{token}": "Hosted checkout",
"/pay/{token}/status": "Статус hosted checkout",
"/api/assets": "Активы для донатов",
"/api/auth/start": "Вход стримера через Messenger",
"/api/auth/poll/{flow}": "Статус входа стримера",
"/api/logout": "Выход из Donation Service",
"/api/me": "Панель стримера",
"/api/profile": "Настройки страницы и OBS",
"/api/sound": "Удаление звука" if method == "DELETE" else "Загрузка звука",
"/api/widget/rotate": "Ротация секретной OBS-ссылки",
"/api/widget/test": "Тестовый OBS alert",
"/api/widget/{token}/config": "Конфигурация OBS-виджета",
"/api/widget/{token}/events": "SSE-события OBS-виджета",
"/api/public/creators/{slug}": "Публичная страница стримера",
"/api/public/donations": "Создание доната",
"/api/public/donations/{token}/status": "Статус доната",
"/api/withdrawals": "Запрос вывода",
"/api/withdrawals/{flow}/poll": "Подтверждение вывода через Messenger",
"/api/admin/status": "Статус Donation Service",
"/api/admin/creators/{creator_id}/blocked": "Блокировка страницы стримера",
}
return names.get(path, f"{method} {path}")
MESSENGER = [
("POST", "/register", "Аккаунт", "public"),
("POST", "/login", "Аккаунт", "public"),
("POST", "/auth/email/start", "Аккаунт", "public"),
("POST", "/auth/email/verify", "Аккаунт", "public"),
("GET", "/.well-known/openid-configuration", "OAuth", "public"),
("GET", "/.well-known/oauth-authorization-server", "OAuth", "public"),
("GET", "/oauth/jwks.json", "OAuth", "public"),
("POST", "/oauth/device_authorization", "OAuth", "oauth_client"),
("GET", "/oauth/device", "OAuth", "public"),
("POST", "/oauth/token", "OAuth", "oauth_client"),
("GET", "/oauth/userinfo", "OAuth", "bearer"),
("GET", "/oauth/device/request", "OAuth", "bearer"),
("POST", "/oauth/device/decision", "OAuth", "bearer"),
("GET", "/me", "Аккаунт", "bearer"),
("POST", "/account/delete", "Аккаунт", "bearer"),
("POST", "/username", "Аккаунт", "bearer"),
("POST", "/name", "Аккаунт", "bearer"),
("POST", "/profiles/description", "Аккаунт", "bearer"),
("POST", "/privacy", "Аккаунт", "bearer"),
("GET", "/contacts", "Контакты", "bearer"),
("POST", "/contacts/add", "Контакты", "bearer"),
("POST", "/contacts/delete", "Контакты", "bearer"),
("POST", "/groups", "Чаты", "bearer"),
("POST", "/channels", "Чаты", "bearer"),
("POST", "/chats/title", "Чаты", "bearer"),
("POST", "/channels/username", "Чаты", "bearer"),
("POST", "/channels/comments/settings", "Чаты", "bearer"),
("POST", "/channels/comments/send", "Чаты", "bearer"),
("GET", "/channels/comments", "Чаты", "bearer"),
("POST", "/chats/members/add", "Чаты", "bearer"),
("POST", "/chats/members/remove", "Чаты", "bearer"),
("POST", "/cloud-password", "Безопасность", "bearer"),
("POST", "/cloud-password/reset", "Безопасность", "bearer"),
("GET", "/sessions", "Безопасность", "bearer"),
("POST", "/sessions/revoke", "Безопасность", "bearer"),
("POST", "/sessions/revoke-others", "Безопасность", "bearer"),
("POST", "/bots", "Боты", "bearer"),
("POST", "/bots/token/reset", "Боты", "bearer"),
("POST", "/e2e/key", "E2E", "bearer"),
("GET", "/e2e/key", "E2E", "bearer"),
("POST", "/e2e/backup", "E2E", "bearer"),
("GET", "/e2e/backup", "E2E", "bearer"),
("POST", "/e2e/reset", "E2E", "bearer"),
("GET", "/wallet", "Dastars", "bearer"),
("POST", "/wallet/send", "Dastars", "bearer"),
("GET", "/wallet/history", "Dastars", "bearer"),
("POST", "/call", "Звонки", "bearer"),
("POST", "/voice-ticket", "Звонки", "bearer"),
("GET", "/voice/participants", "Звонки", "bearer"),
("GET", "/voice", "Звонки", "bearer"),
("POST", "/send", "Сообщения", "bearer"),
("POST", "/edit", "Сообщения", "bearer"),
("POST", "/callback", "Боты", "bearer"),
("POST", "/reactions", "Реакции", "bearer"),
("POST", "/reactions/paid", "Реакции", "bearer"),
("POST", "/read", "Сообщения", "bearer"),
("POST", "/delete", "Сообщения", "bearer"),
("POST", "/favorite", "Сообщения", "bearer"),
("POST", "/media/quote", "Файлы", "bearer"),
("POST", "/messages/prepare", "Файлы", "bearer"),
("POST", "/messages/commit", "Файлы", "bearer"),
("POST", "/messages/cancel", "Файлы", "bearer"),
("POST", "/forward", "Файлы", "bearer"),
("GET", "/file/ticket", "Файлы", "bearer"),
("GET", "/nodes/status", "Сервис", "bearer"),
("GET", "/chats", "Чаты", "bearer"),
("POST", "/chats/delete", "Чаты", "bearer"),
("POST", "/users/ban", "Модерация", "bearer"),
("POST", "/users/unban", "Модерация", "bearer"),
("GET", "/history", "Сообщения", "bearer"),
("GET", "/updates", "Updates", "bearer"),
("POST", "/updates/ack", "Updates", "bearer"),
("GET", "/file/{id}", "Файлы", "bearer"),
("GET", "/health/live", "Сервис", "public"),
("GET", "/health/ready", "Сервис", "public"),
("GET", "/metrics", "Сервис", "public"),
]
GATEWAY = [
("GET", "/health", "Сервис", "public"),
("GET", "/v1/assets", "Сервис", "public"),
("GET", "/v1/invoices", "Invoices", "hmac"),
("POST", "/v1/invoices", "Invoices", "hmac"),
("GET", "/v1/invoices/{invoice_id}", "Invoices", "hmac"),
("GET", "/v1/withdrawals", "Withdrawals", "hmac"),
("POST", "/v1/withdrawals", "Withdrawals", "hmac"),
("GET", "/v1/withdrawals/{withdrawal_id}", "Withdrawals", "hmac"),
("GET", "/v1/balances", "Баланс", "hmac"),
("GET", "/v1/api-keys", "Ключи", "hmac"),
("GET", "/admin/v1/status", "Operator", "admin"),
("GET", "/admin/v1/callbacks", "Operator", "admin"),
("GET", "/admin/v1/consolidations", "Operator", "admin"),
("POST", "/admin/v1/callbacks/{callback_id}/retry", "Operator", "admin"),
("POST", "/admin/v1/merchants", "Operator", "admin"),
("POST", "/admin/v1/fee-policies", "Operator", "admin"),
("POST", "/admin/v1/merchants/{merchant_id}/api-keys", "Operator", "admin"),
("DELETE", "/admin/v1/merchants/{merchant_id}/api-keys/{api_key_id}", "Operator", "admin"),
("POST", "/admin/v1/merchants/{merchant_id}/policies", "Operator", "admin"),
("POST", "/admin/v1/withdrawals/{withdrawal_id}/resolve", "Operator", "admin"),
("POST", "/admin/v1/consolidations/{consolidation_id}/retry", "Operator", "admin"),
("GET", "/pay/{token}", "Checkout", "public"),
("GET", "/pay/{token}/status", "Checkout", "public"),
]
DONATIONS = [
("GET", "/health", "Сервис", "public"),
("GET", "/api/assets", "Сервис", "public"),
("POST", "/api/auth/start", "Авторизация", "public"),
("GET", "/api/auth/poll/{flow}", "Авторизация", "public"),
("POST", "/api/logout", "Авторизация", "session"),
("GET", "/api/me", "Стример", "session"),
("PATCH", "/api/profile", "Стример", "session"),
("PUT", "/api/sound", "OBS", "session"),
("DELETE", "/api/sound", "OBS", "session"),
("POST", "/api/widget/rotate", "OBS", "session"),
("POST", "/api/widget/test", "OBS", "session"),
("GET", "/api/widget/{token}/config", "OBS", "widget"),
("GET", "/api/widget/{token}/events", "OBS", "widget"),
("GET", "/api/public/creators/{slug}", "Донаты", "public"),
("POST", "/api/public/donations", "Донаты", "public"),
("GET", "/api/public/donations/{token}/status", "Донаты", "public"),
("POST", "/api/withdrawals", "Вывод", "session"),
("GET", "/api/withdrawals/{flow}/poll", "Вывод", "session"),
("GET", "/api/admin/status", "Оператор", "session"),
("PATCH", "/api/admin/creators/{creator_id}/blocked", "Оператор", "session"),
]
AUTH_LABELS = {
"public": "public",
"bearer": "Bearer",
"oauth_client": "OAuth client",
"hmac": "HMAC",
"admin": "Admin Bearer",
"session": "Secure session",
"widget": "Widget token",
}
BODIES = {
("messenger", "POST", "/register"): {"email": "you@example.com", "password": "correct horse battery staple"},
("messenger", "POST", "/login"): {"email": "you@example.com", "password": "correct horse battery staple"},
("messenger", "POST", "/auth/email/start"): {"email": "you@example.com"},
("messenger", "POST", "/auth/email/verify"): {"email": "you@example.com", "code": "123456"},
("messenger", "POST", "/send"): {"to": 42, "text": "Привет!", "client_message_id": "0190f6d4-example"},
("messenger", "POST", "/media/quote"): {"media": [{"client_id": "attachment-1", "name": "photo.jpg", "mime": "image/jpeg", "size": 524288}]},
("messenger", "POST", "/messages/prepare"): {"to": 42, "text": "Caption", "client_message_id": "0190f6d4-media", "media": [{"client_id": "attachment-1", "name": "photo.jpg", "mime": "image/jpeg", "size": 524288}]},
("messenger", "POST", "/messages/commit"): {"operation_id": "message-op-1-0190f6d4-media"},
("messenger", "POST", "/messages/cancel"): {"operation_id": "message-op-1-0190f6d4-media"},
("messenger", "POST", "/forward"): {"message_id": 1001, "to": 42, "client_message_id": "0190f6d4-forward"},
("messenger", "POST", "/edit"): {"chat_id": 42, "message_id": 1001, "text": "Исправленный текст"},
("messenger", "POST", "/reactions"): {"chat_id": 42, "message_id": 1001, "emoji": "🔥"},
("messenger", "POST", "/reactions/paid"): {"chat_id": 42, "message_id": 1001, "amount": 5},
("messenger", "POST", "/updates/ack"): {"update_id": 2048},
("messenger", "POST", "/bots"): {"username": "shop_helper_bot", "name": "Shop helper"},
("gateway", "POST", "/v1/invoices"): {
"merchant_order_id": "order-123", "asset": "USDT", "network": "tron",
"amount_atomic": "25000000", "expires_in_seconds": 1800,
"description": "Order 123", "success_url": "https://merchant.example/orders/123",
"metadata": {"customer": "42"},
},
("gateway", "POST", "/v1/withdrawals"): {
"asset": "USDT", "network": "tron", "amount_atomic": "10000000",
"address": "T...", "fee_mode": "additional", "metadata": {"payout": "p-123"},
},
("gateway", "POST", "/admin/v1/merchants"): {
"name": "Example shop", "callback_url": "https://merchant.example/payments",
"callback_hosts": ["merchant.example"],
},
("gateway", "POST", "/admin/v1/fee-policies"): {
"asset": "USDT", "network": "tron", "platform_fee_basis_points": 500,
"platform_fee_cap_atomic": "10000000",
},
("donations", "POST", "/api/auth/start"): {"email": "streamer@example.com"},
("donations", "PATCH", "/api/profile"): {
"display_name": "My channel", "slug": "my-channel", "accent_color": "#b9f541",
"duration_seconds": 8, "show_donor_name": True, "show_message": True,
},
("donations", "POST", "/api/public/donations"): {
"slug": "my-channel", "donor_name": "Anonymous", "message": "Great stream!",
"asset": "USDT", "network": "tron", "amount": "25.5", "cover_fee": True,
},
("donations", "POST", "/api/withdrawals"): {
"asset": "USDT", "network": "tron", "amount": "10",
"address": "TExampleRecipientAddress",
},
("donations", "PATCH", "/api/admin/creators/{creator_id}/blocked"): {"blocked": True},
}
DESCRIPTIONS = {
"/oauth/device_authorization": "Создает device_code, user_code и verification URI для универсального входа через Messenger.",
"/oauth/token": "Обменивает подтвержденный device_code на OIDC access/id token. Во время ожидания возвращает authorization_pending.",
"/send": "Отправляет сообщение без медиа. Для клиентской очереди используйте стабильный client_message_id.",
"/media/quote": "Рассчитывает единую стоимость новых вложений: сумма байтов округляется вверх до MiB один раз на сообщение.",
"/messages/prepare": "Атомарно резервирует оплату и выдает до 10 upload tickets. Текст и media[] опциональны по отдельности, но сообщение не может быть пустым.",
"/messages/commit": "Проверяет все загруженные вложения, одним платежом списывает DSR и публикует или редактирует одно сообщение.",
"/messages/cancel": "Удаляет незавершенные загрузки и отменяет резерв оплаты.",
"/updates": "Long polling событий клиента или бота. Bot updates долговечны и остаются до явного ACK.",
"/reactions/paid": "Переводит указанное количество Dastars автору сообщения; для бота — его владельцу. amount может быть больше 1.",
"/voice": "Переключает соединение на WebSocket после получения voice ticket.",
"/v1/invoices": "Создает invoice или возвращает историю с cursor pagination, в зависимости от HTTP-метода.",
"/v1/withdrawals": "Создает withdrawal или возвращает историю. Для token assets разрешен только fee_mode=additional.",
"/v1/assets": "Возвращает только пары asset/network, для которых wallet seed готов к работе.",
"/pay/{token}": "Публичная HTML-страница оплаты с QR, серверно-синхронизированным таймером и live status.",
"/pay/{token}/status": "JSON-состояние checkout для polling; включает серверное время и срок действия.",
"/api/public/donations": "Создает Gateway invoice с комиссией 5%. cover_fee=false удерживает комиссию из суммы; cover_fee=true точно увеличивает платеж так, чтобы стример получил введенную сумму после комиссии. Все расчеты выполняются в atomic без float.",
"/api/widget/{token}/events": "Долгоживущий SSE-поток для OBS Browser Source. Виджет сам ставит alerts в очередь.",
"/api/withdrawals": "Резервирует баланс и запускает новый Messenger Device Flow с описанием суммы и адреса.",
"/api/withdrawals/{flow}/poll": "После свайпа тем же Messenger-пользователем идемпотентно отправляет withdrawal в Gateway.",
}
def endpoint(service_id: str, row: tuple[str, str, str, str]) -> dict:
method, path, group, auth = row
default = f"{title(path, method)}. Возвращает JSON-ответ или стандартную JSON-ошибку."
item = {
"method": method,
"path": path,
"group": group,
"auth": auth,
"auth_label": AUTH_LABELS[auth],
"title": title(path, method),
"description": DESCRIPTIONS.get(path, default),
}
body = BODIES.get((service_id, method, path))
if body is not None:
item["body"] = body
return item
def openapi(service: dict) -> dict:
paths: dict[str, dict] = {}
for item in service["endpoints"]:
operation: dict = {
"tags": [item["group"]],
"summary": item["title"],
"description": item["description"],
"operationId": (
item["method"].lower() + "_" +
item["path"].strip("/").replace("/", "_").replace("{", "").replace("}", "")
).replace("-", "_").replace(".", "_") or "root",
"responses": {
"200": {"description": "Успешный ответ"},
"400": {"description": "Ошибка запроса"},
"401": {"description": "Ошибка авторизации"},
},
}
if item["auth"] == "bearer":
operation["security"] = [{"bearerAuth": []}]
elif item["auth"] == "admin":
operation["security"] = [{"adminBearer": []}]
elif item["auth"] == "hmac":
operation["security"] = [{"merchantHmac": []}]
operation["parameters"] = [
{"name": "X-Api-Timestamp", "in": "header", "required": True, "schema": {"type": "integer"}},
{"name": "X-Api-Nonce", "in": "header", "required": True, "schema": {"type": "string", "format": "uuid"}},
{"name": "X-Api-Signature", "in": "header", "required": True, "schema": {"type": "string"}},
]
elif item["auth"] == "oauth_client":
operation["security"] = [{"oauthClient": []}]
elif item["auth"] == "session":
operation["security"] = [{"sessionCookie": []}]
parameters = operation.setdefault("parameters", [])
for parameter in (part[1:-1] for part in item["path"].split("/") if part.startswith("{")):
parameters.append({
"name": parameter, "in": "path", "required": True,
"schema": {"type": "string"},
})
if not parameters:
operation.pop("parameters", None)
if item.get("body") is not None:
operation["requestBody"] = {
"required": True,
"content": {"application/json": {"schema": {"type": "object"}, "example": item["body"]}},
}
paths.setdefault(item["path"], {})[item["method"].lower()] = operation
schemes = {
"bearerAuth": {"type": "http", "scheme": "bearer"},
"oauthClient": {
"type": "http",
"scheme": "basic",
"description": (
"OAuth client_id/client_secret. Device Authorization Grant не имеет "
"отдельного стандартного flow в OpenAPI 3.1; discovery доступен в "
"/.well-known/oauth-authorization-server."
),
},
"merchantHmac": {"type": "apiKey", "in": "header", "name": "X-Api-Key",
"description": "Дополнительно обязательна HMAC-SHA256 подпись запроса."},
"adminBearer": {"type": "http", "scheme": "bearer", "description": "Operator admin token"},
"sessionCookie": {"type": "apiKey", "in": "cookie", "name": "ove_donations_session",
"description": "HttpOnly session; changing requests also require X-CSRF-Token."},
}
scheme_auth = {
"bearerAuth": "bearer",
"oauthClient": "oauth_client",
"merchantHmac": "hmac",
"adminBearer": "admin",
"sessionCookie": "session",
}
active_auth = {item["auth"] for item in service["endpoints"]}
return {
"openapi": "3.1.0",
"info": {
"title": f"OVE {service['name']} API",
"version": "1.0.0",
"description": service["description"],
},
"servers": [{"url": service["base_url"]}],
"paths": paths,
"components": {
"securitySchemes": {
key: value for key, value in schemes.items()
if scheme_auth[key] in active_auth
},
"schemas": {
"Error": {
"type": "object",
"properties": {"error": {"type": "string"}},
"required": ["error"],
}
},
},
}
def main() -> None:
services = [
{
"id": "messenger",
"name": "Messenger",
"base_url": "https://ms.ove.rs",
"description": "OVE Messenger user, bot, OAuth, files, calls and realtime updates API.",
"endpoints": [endpoint("messenger", row) for row in MESSENGER],
},
{
"id": "gateway",
"name": "Crypto Gateway",
"base_url": "https://cr.ove.rs",
"description": "OVE multi-merchant cryptocurrency payments, checkout and operator API.",
"endpoints": [endpoint("gateway", row) for row in GATEWAY],
},
{
"id": "donations",
"name": "Donations",
"base_url": "https://do.ove.rs",
"description": "OVE donation pages, Messenger login, Crypto Gateway payments and OBS alerts.",
"endpoints": [endpoint("donations", row) for row in DONATIONS],
},
]
manifest = {
"version": 1,
"generated_from": [
"micro-chat/micromsg/src/app_routes.rs",
"crypto-gateway/crates/gateway-api/src/main.rs",
"crypto-gateway/crates/gateway-api/src/web.rs",
"donations/src/main.rs",
],
"total_endpoints": sum(len(service["endpoints"]) for service in services),
"services": services,
}
OPENAPI.mkdir(parents=True, exist_ok=True)
(PUBLIC / "api-manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
for service in services:
(OPENAPI / f"{service['id']}.json").write_text(
json.dumps(openapi(service), ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
gateway_spec = OPENAPI / "gateway.json"
if not gateway_spec.exists():
(OPENAPI / "gateway.json").write_text(
(OPENAPI / "crypto-gateway.json").read_text(encoding="utf-8"), encoding="utf-8"
)
(OPENAPI / "crypto-gateway.json").unlink()
print(f"generated {manifest['total_endpoints']} endpoints")
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Fail when a public service route is missing from docs or OpenAPI."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
DOCS = Path(__file__).resolve().parents[1]
REPO = DOCS.parent
PUBLIC = DOCS / "public"
def messenger_routes() -> set[tuple[str, str]]:
source = (REPO / "micro-chat/micromsg/src/app_routes.rs").read_text(encoding="utf-8")
routes = set(re.findall(r'\("([A-Z]+)", "([^"]+)"\)\s*=>', source))
routes = {(method, path) for method, path in routes if not path.startswith("/internal/")}
if 'path.starts_with("/file/")' in source:
routes.add(("GET", "/file/{id}"))
return routes
def gateway_routes() -> set[tuple[str, str]]:
source = (REPO / "crypto-gateway/crates/gateway-api/src/main.rs").read_text(encoding="utf-8")
routes: set[tuple[str, str]] = set()
cursor = 0
while (start := source.find(".route(", cursor)) != -1:
depth = 0
end = start + len(".route(")
while end < len(source):
if source[end] == "(":
depth += 1
elif source[end] == ")":
if depth == 0:
break
depth -= 1
end += 1
block = source[start:end + 1]
match = re.search(r'\.route\(\s*"([^"]+)"\s*,(.*)\)$', block, re.S)
cursor = end + 1
if not match:
continue
path, handlers = match.groups()
if path.startswith("/v1/") or path.startswith("/admin/v1/") or path == "/health":
for method in re.findall(r'\b(get|post|delete)\s*\(', handlers):
routes.add((method.upper(), path))
web = (REPO / "crypto-gateway/crates/gateway-api/src/web.rs").read_text(encoding="utf-8")
for path in ("/pay/{token}", "/pay/{token}/status"):
if f'.route("{path}", get(' not in web:
raise AssertionError(f"hosted checkout route disappeared: {path}")
routes.add(("GET", path))
return routes
def donations_routes() -> set[tuple[str, str]]:
source = (REPO / "donations/src/main.rs").read_text(encoding="utf-8")
routes: set[tuple[str, str]] = set()
cursor = 0
while (start := source.find(".route(", cursor)) != -1:
depth = 0
end = start + len(".route(")
while end < len(source):
if source[end] == "(":
depth += 1
elif source[end] == ")":
if depth == 0:
break
depth -= 1
end += 1
block = source[start:end + 1]
match = re.search(r'\.route\(\s*"([^"]+)"\s*,(.*)\)$', block, re.S)
cursor = end + 1
if not match:
continue
path, handlers = match.groups()
if path == "/health" or path.startswith("/api/"):
for method in re.findall(r'\b(get|post|delete|put|patch)\s*\(', handlers):
routes.add((method.upper(), path))
for method in re.findall(r'\.(delete|put|patch)\s*\(', handlers):
routes.add((method.upper(), path))
return routes
def main() -> int:
manifest = json.loads((PUBLIC / "api-manifest.json").read_text(encoding="utf-8"))
documented = {
service["id"]: {(item["method"], item["path"]) for item in service["endpoints"]}
for service in manifest["services"]
}
source = {
"messenger": messenger_routes(),
"gateway": gateway_routes(),
"donations": donations_routes(),
}
failed = False
for service_id, actual in source.items():
missing = actual - documented[service_id]
stale = documented[service_id] - actual
if missing:
print(f"{service_id}: undocumented routes: {sorted(missing)}", file=sys.stderr)
failed = True
if stale:
print(f"{service_id}: stale routes: {sorted(stale)}", file=sys.stderr)
failed = True
spec = json.loads((PUBLIC / "openapi" / f"{service_id}.json").read_text(encoding="utf-8"))
in_spec = {(method.upper(), path) for path, operations in spec["paths"].items()
for method in operations if method.upper() in {"GET", "POST", "DELETE", "PUT", "PATCH"}}
if in_spec != documented[service_id]:
print(f"{service_id}: OpenAPI mismatch", file=sys.stderr)
failed = True
counted = sum(len(routes) for routes in documented.values())
if counted != manifest["total_endpoints"]:
print(f"manifest total is {manifest['total_endpoints']}, counted {counted}", file=sys.stderr)
failed = True
if failed:
return 1
print(f"verified {counted} public endpoints across {len(source)} services")
return 0
if __name__ == "__main__":
raise SystemExit(main())