Тестовая страница встраивания в CRM (postMessage auth + проверка в Supabase)
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
deploy.json
|
||||||
|
*.md
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
deploy.json
|
||||||
|
node_modules/
|
||||||
22
CLAUDE.md
Normal file
22
CLAUDE.md
Normal 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
8
Dockerfile
Normal 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"]
|
||||||
10
README.md
10
README.md
@@ -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
40
SPEC.md
Normal 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
107
public/index.html
Normal 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
89
server.js
Normal 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}`));
|
||||||
Reference in New Issue
Block a user