Тестовая страница встраивания в CRM (postMessage auth + проверка в Supabase)

This commit is contained in:
crm-embed-test
2026-10-13 12:36:09 +00:00
parent d5f8b9cbb4
commit a92286d413
8 changed files with 281 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.git
deploy.json
*.md

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
deploy.json
node_modules/

22
CLAUDE.md Normal file
View File

@@ -0,0 +1,22 @@
# crm-embed-test
Тестовая страница встраивания сайтов в CRM Tvorymo (авторизация CRM передаётся в iframe через postMessage).
**Полная спецификация — `SPEC.md` в этой папке. Начинай с неё.**
## Окружение
- Код: Gitea `gitea.tes.gd`, репозиторий `admin/crm-embed-test` — env `GIT_URL`, `GIT_USER`, `GIT_KEY` (репо создан пустым).
- Деплой: Coolify на сервере storage — env `COOLIFY_URL`, `COOLIFY_KEY` (через профиль «Storage coolify»).
Деплоить через Coolify v4 REST API (скилл `deploying-to-coolify-via-api`), fire-and-forget.
- Домен страницы: `https://test-crm.tes.gd` (HTTPS обязателен; DNS и домен на том же сервере).
- CRM: `https://test.units.tvorymo.com`. Supabase: `https://yjxbkcqsquwbsyoonfkn.supabase.co`.
- Publishable-ключ Supabase пришлёт Лана (секретарь владельца) напрямую — запиши его сюда, в раздел ниже (ключ публичный по дизайну).
## Supabase publishable key
`sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR` (для `https://yjxbkcqsquwbsyoonfkn.supabase.co`, получен от Ланы 2026-10-13)
## Правила
- Токены CRM/Supabase не логировать (ни браузер, ни сервер), сессия только в памяти.
- Никогда не печатать `GIT_KEY` / `COOLIFY_KEY`.

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY server.js ./
COPY public ./public
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
USER node
CMD ["node", "server.js"]

View File

@@ -1,2 +1,12 @@
# crm-embed-test # crm-embed-test
Тестовая страница встраивания в CRM Tvorymo: получает авторизацию CRM через `postMessage`
(`crm:ready` → `crm:auth`, `crm:refresh`), проверяет `access_token` на сервере через
Supabase `GET /auth/v1/user` (+ `exp`, `aud = authenticated`) и показывает имя/email/id.
- Node 22, без зависимостей: `server.js` + `public/index.html`.
- Заголовок `Content-Security-Policy: frame-ancestors *`, без `X-Frame-Options`.
- Env: `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `PORT` (по умолчанию 3000).
- Деплой: Coolify (Dockerfile), домен `https://test-crm.tes.gd`.
Подробности — `SPEC.md`.

40
SPEC.md Normal file
View File

@@ -0,0 +1,40 @@
---
добавлено: 2026-10-13
---
# Спецификация. Тестовая страница встраивания CRM
Агент: `crm-embed-test` (создаёт админ). Код в Gitea `gitea.tes.gd`, хостинг на нашем сервере (Coolify).
## Цель
Проверить задачу Олега в CRM «Передавати авторизацію CRM сайтам в iframe через postMessage» (`mv5mmtt7kdk16z`, проект Tvorymo shotam): страница, открытая в iframe из боковой панели CRM, узнаёт пользователя CRM.
## Адреса
- Страница: `https://test-crm.tes.gd` (HTTPS обязателен).
- CRM: `https://test.units.tvorymo.com`.
- Supabase: `https://yjxbkcqsquwbsyoonfkn.supabase.co`.
## Протокол (со стороны страницы)
1. После загрузки: `window.parent.postMessage({ type: "crm:ready", version: 1 }, "*")`.
2. Принимать `crm:auth` `{ access_token, expires_at, user: { id, email, name } }` от любого origin (решение владельца для теста). Origin отправителя показывать на странице.
3. `access_token` отправить на сервер страницы; сервер проверяет его через `GET /auth/v1/user` (заголовки `apikey: <publishable key>`, `Authorization: Bearer <token>`) и сверяет `exp`, `aud = "authenticated"`.
4. Имя и email брать из ответа Supabase, не из `user` в сообщении (имя из `user_metadata`; если пусто, показать «имя не задано в Supabase» и email).
5. Кнопка «Обновить токен»: отправляет `{ type: "crm:refresh" }`, CRM отвечает новым `crm:auth`; страница показывает, что токен сменился (время `expires_at`, без самого токена).
6. Выход (`crm:logout`) не проверяем.
## Требования
- Заголовок `Content-Security-Policy: frame-ancestors *` (встраивание откуда угодно, для теста), без `X-Frame-Options`.
- Токен не логировать ни в браузере, ни на сервере. Сессию держать в памяти.
- Publishable-ключ Supabase агент получает от Ланы и записывает в свой CLAUDE.md (ключ публичный по дизайну).
- Без открытого токена в ответе страницы: показываем только имя, email, id, origin, `expires_at`.
## Готово, когда
- Страница в iframe CRM показывает имя и email пользователя из Supabase.
- После «Обновить токен» приходит новый токен и страница это показывает.
- Без iframe (открыта напрямую) страница пишет «жду авторизацию от CRM».
Ссылку в боковую панель CRM добавляет владелец.

107
public/index.html Normal file
View File

@@ -0,0 +1,107 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRM embed test</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 16px; color: #222; }
h1 { font-size: 18px; margin: 0 0 12px; }
#status { padding: 8px 12px; border-radius: 6px; background: #fff4d6; }
#status.ok { background: #e3f6e3; } #status.err { background: #fde2e2; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; }
dt { color: #666; } dd { margin: 0; word-break: break-all; }
button { margin-top: 12px; padding: 6px 14px; }
#log { margin-top: 16px; font-size: 13px; color: #555; padding-left: 18px; }
</style>
</head>
<body>
<h1>Тест встраивания в CRM Tvorymo</h1>
<div id="status">жду авторизацию от CRM</div>
<dl id="info" hidden>
<dt>Имя</dt><dd id="name"></dd>
<dt>Email</dt><dd id="email"></dd>
<dt>ID</dt><dd id="uid"></dd>
<dt>Origin отправителя</dt><dd id="origin"></dd>
<dt>expires_at</dt><dd id="expires"></dd>
<dt>Получено токенов</dt><dd id="count">0</dd>
</dl>
<button id="refresh" hidden>Обновить токен</button>
<ol id="log"></ol>
<script>
(() => {
// Сессия только в памяти, токен не выводится и не логируется.
let session = null; // { token, expiresAt }
let crmSource = null, crmOrigin = null, count = 0;
const $ = (id) => document.getElementById(id);
const inIframe = window.parent !== window;
function setStatus(text, cls) { const s = $("status"); s.textContent = text; s.className = cls || ""; }
function log(text) {
const li = document.createElement("li");
li.textContent = new Date().toLocaleTimeString() + " — " + text;
$("log").prepend(li);
}
function fmtTime(v) {
if (v == null) return "—";
const n = Number(v);
const d = new Date(Number.isFinite(n) ? (n < 1e12 ? n * 1000 : n) : v);
return isNaN(d) ? String(v) : d.toLocaleString() + " (" + d.toISOString() + ")";
}
async function onAuth(data, ev) {
crmSource = ev.source; crmOrigin = ev.origin;
$("origin").textContent = ev.origin;
const token = data.access_token;
if (typeof token !== "string" || !token) { setStatus("crm:auth без access_token", "err"); return; }
const changed = session && session.token !== token;
const prevExp = session && session.expiresAt;
session = { token, expiresAt: data.expires_at };
count++;
setStatus("проверяю токен в Supabase…");
try {
const r = await fetch("/api/verify", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access_token: token }),
});
const res = await r.json();
if (!res.ok) { setStatus("Токен не принят: " + res.error, "err"); log("crm:auth от " + ev.origin + " — отклонён"); return; }
$("name").textContent = res.name || "имя не задано в Supabase";
$("email").textContent = res.email || "—";
$("uid").textContent = res.id;
$("expires").textContent = fmtTime(data.expires_at != null ? data.expires_at : res.exp);
$("count").textContent = String(count);
$("info").hidden = false; $("refresh").hidden = false;
setStatus(changed ? "Токен сменился, пользователь подтверждён Supabase" : "Пользователь подтверждён Supabase", "ok");
log("crm:auth от " + ev.origin + (changed
? " — новый токен, expires_at: " + fmtTime(prevExp) + " → " + fmtTime(data.expires_at)
: session && count > 1 ? " — тот же токен" : " — принят"));
} catch (e) {
setStatus("Ошибка проверки токена", "err");
}
}
window.addEventListener("message", (ev) => {
const data = ev.data;
if (!data || typeof data !== "object") return;
// Для теста принимаем от любого origin (решение владельца).
if (data.type === "crm:auth") onAuth(data, ev);
});
$("refresh").addEventListener("click", () => {
const target = crmSource || window.parent;
target.postMessage({ type: "crm:refresh" }, crmOrigin || "*");
setStatus("запросил новый токен (crm:refresh)…");
log("отправлен crm:refresh → " + (crmOrigin || "*"));
});
if (inIframe) {
window.parent.postMessage({ type: "crm:ready", version: 1 }, "*");
log("отправлен crm:ready");
} else {
log("страница открыта не в iframe");
}
})();
</script>
</body>
</html>

89
server.js Normal file
View File

@@ -0,0 +1,89 @@
// Тестовая страница встраивания в CRM Tvorymo.
// Токены не логируются; сервер ничего не хранит, кроме конфигурации.
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = Number(process.env.PORT || 3000);
const SUPABASE_URL = (process.env.SUPABASE_URL || "").replace(/\/$/, "");
const SUPABASE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY || "";
const INDEX = fs.readFileSync(path.join(__dirname, "public", "index.html"));
const BASE_HEADERS = {
"Content-Security-Policy": "frame-ancestors *",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
};
function send(res, status, body, type) {
res.writeHead(status, { ...BASE_HEADERS, "Content-Type": type });
res.end(body);
}
const json = (res, status, obj) => send(res, status, JSON.stringify(obj), "application/json; charset=utf-8");
function decodeJwtPayload(token) {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("not a JWT");
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
}
function readBody(req, limit = 16384) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on("data", (c) => {
size += c.length;
if (size > limit) { reject(new Error("too large")); req.destroy(); return; }
chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
async function verify(req, res) {
let token;
try {
token = JSON.parse(await readBody(req)).access_token;
} catch {
return json(res, 400, { ok: false, error: "некорректный запрос" });
}
if (typeof token !== "string" || !token) return json(res, 400, { ok: false, error: "нет access_token" });
let claims;
try {
claims = decodeJwtPayload(token);
} catch {
return json(res, 401, { ok: false, error: "токен не является JWT" });
}
const now = Math.floor(Date.now() / 1000);
if (typeof claims.exp !== "number" || claims.exp <= now) return json(res, 401, { ok: false, error: "токен истёк (exp)" });
if (claims.aud !== "authenticated") return json(res, 401, { ok: false, error: "aud ≠ authenticated" });
let r;
try {
r = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },
});
} catch {
return json(res, 502, { ok: false, error: "Supabase недоступен" });
}
if (!r.ok) return json(res, 401, { ok: false, error: `Supabase отклонил токен (HTTP ${r.status})` });
const user = await r.json();
if (user.aud !== "authenticated") return json(res, 401, { ok: false, error: "aud пользователя ≠ authenticated" });
const md = user.user_metadata || {};
const name = md.name || md.full_name || md.display_name || null;
json(res, 200, { ok: true, id: user.id, email: user.email || null, name, exp: claims.exp });
}
http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if ((req.method === "GET" || req.method === "HEAD") && (url.pathname === "/" || url.pathname === "/index.html"))
return send(res, 200, INDEX, "text/html; charset=utf-8");
if (req.method === "GET" && url.pathname === "/healthz") return send(res, 200, "ok", "text/plain");
if (req.method === "POST" && url.pathname === "/api/verify")
return verify(req, res).catch(() => json(res, 500, { ok: false, error: "ошибка сервера" }));
send(res, 404, "not found", "text/plain");
}).listen(PORT, () => console.log(`listening on ${PORT}`));