From a92286d413ca3f228a01b02ba270f16934fb4d48 Mon Sep 17 00:00:00 2001 From: crm-embed-test Date: Tue, 13 Oct 2026 12:36:09 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20?= =?UTF-8?q?=D0=B2=D1=81=D1=82=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=20CRM=20(postMessage=20auth=20+=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=B2=20Supabase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 ++ .gitignore | 2 + CLAUDE.md | 22 ++++++++++ Dockerfile | 8 ++++ README.md | 10 +++++ SPEC.md | 40 +++++++++++++++++ public/index.html | 107 ++++++++++++++++++++++++++++++++++++++++++++++ server.js | 89 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 281 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 SPEC.md create mode 100644 public/index.html create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ed4fd1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +deploy.json +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4af4cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +deploy.json +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2d044fb --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ff0a59 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 1757fd4..434cf35 100644 --- a/README.md +++ b/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`. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..47c5071 --- /dev/null +++ b/SPEC.md @@ -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: `, `Authorization: Bearer `) и сверяет `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 добавляет владелец. diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2ddfa36 --- /dev/null +++ b/public/index.html @@ -0,0 +1,107 @@ + + + + + +CRM embed test + + + +

Тест встраивания в CRM Tvorymo

+
жду авторизацию от CRM
+ + +
    + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..8ccf5bd --- /dev/null +++ b/server.js @@ -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}`));