Тестовая страница встраивания в 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 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