diff --git a/supabase/functions/caterium-create-employee/index.ts b/supabase/functions/caterium-create-employee/index.ts index 9c57489..1fe91e7 100644 --- a/supabase/functions/caterium-create-employee/index.ts +++ b/supabase/functions/caterium-create-employee/index.ts @@ -1,13 +1,36 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; -import { createClient } from "npm:@supabase/supabase-js@2"; +import { createClient } from "npm:@supabase/supabase-js@2.116.0"; -const cors = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Content-Type": "application/json", -}; -const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: cors }); +const PROD_ORIGINS = new Set([ + "https://app.caterium.ru", + "https://caterium.ru", + "https://www.caterium.ru", +]); +const WORKERS_DEV_ORIGIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev$/i; +const LOCAL_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/i; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const ALLOWED_ROLES = new Set(["owner", "admin", "manager", "operator", "viewer"]); + +function allowedOrigin(origin: string) { + if (!origin) return true; + return PROD_ORIGINS.has(origin) || WORKERS_DEV_ORIGIN.test(origin) || LOCAL_ORIGIN.test(origin); +} + +function corsHeaders(req: Request) { + const origin = req.headers.get("Origin") || ""; + const headers: Record = { + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Content-Type": "application/json", + "Vary": "Origin", + }; + if (origin && allowedOrigin(origin)) headers["Access-Control-Allow-Origin"] = origin; + return headers; +} + +const reply = (req: Request, body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: corsHeaders(req) }); type PrepData = { status?: string; @@ -17,26 +40,55 @@ type PrepData = { role?: string; }; +function publicError(error: unknown) { + const message = error instanceof Error ? error.message : String(error || ""); + if (/access denied|permission|not allowed/i.test(message)) return { status: 403, error: "Недостаточно прав для добавления сотрудника.", code: "forbidden" }; + if (/subscription|blocked|plan/i.test(message)) return { status: 403, error: "Добавление сотрудника недоступно для текущего тарифа.", code: "plan_restricted" }; + if (/limit|maximum|max_members/i.test(message)) return { status: 409, error: "Достигнут лимит сотрудников для текущего тарифа.", code: "member_limit" }; + if (/already registered|already exists|duplicate/i.test(message)) return { status: 409, error: "Аккаунт с этим email уже существует.", code: "account_exists" }; + return { status: 500, error: "Не удалось добавить сотрудника. Повторите попытку.", code: "employee_create_failed" }; +} + Deno.serve(async (req: Request) => { - if (req.method === "OPTIONS") return new Response("ok", { headers: cors }); - if (req.method !== "POST") return reply({ error: "Method not allowed" }, 405); + const origin = req.headers.get("Origin") || ""; + if (origin && !allowedOrigin(origin)) return reply(req, { error: "Origin is not allowed", code: "origin_not_allowed" }, 403); + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders(req) }); + if (req.method !== "POST") return reply(req, { error: "Method not allowed", code: "method_not_allowed" }, 405); + try { const auth = req.headers.get("Authorization") || ""; + if (!/^Bearer\s+\S+/i.test(auth)) return reply(req, { error: "Требуется авторизация.", code: "unauthorized" }, 401); + const url = Deno.env.get("SUPABASE_URL") || ""; const anon = Deno.env.get("SUPABASE_ANON_KEY") || ""; const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; - if (!url || !anon || !serviceKey) return reply({ error: "Server auth configuration is incomplete" }, 500); + if (!url || !anon || !serviceKey) { + console.error("[caterium-create-employee] incomplete server auth configuration"); + return reply(req, { error: "Сервис временно недоступен.", code: "server_configuration" }, 503); + } + + let body: Record; + try { + body = await req.json(); + } catch (_) { + return reply(req, { error: "Некорректный формат запроса.", code: "invalid_json" }, 400); + } + + const workspaceId = String(body.workspace_id || "").trim(); + const email = String(body.email || "").trim().toLowerCase(); + const displayName = String(body.display_name || "").trim(); + const role = String(body.role || "manager").trim().toLowerCase(); + + if (!UUID_RE.test(workspaceId)) return reply(req, { error: "Некорректная компания.", code: "invalid_workspace" }, 400); + if (!EMAIL_RE.test(email) || email.length > 254) return reply(req, { error: "Введите корректный email.", code: "invalid_email" }, 400); + if (displayName.length > 120) return reply(req, { error: "Имя сотрудника слишком длинное.", code: "invalid_display_name" }, 400); + if (!ALLOWED_ROLES.has(role)) return reply(req, { error: "Некорректная роль сотрудника.", code: "invalid_role" }, 400); const caller = createClient(url, anon, { global: { headers: { Authorization: auth } }, auth: { persistSession: false, autoRefreshToken: false }, }); const service = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } }); - const body = await req.json(); - const workspaceId = String(body.workspace_id || ""); - const email = String(body.email || "").trim().toLowerCase(); - const displayName = String(body.display_name || "").trim(); - const role = String(body.role || "manager"); const prepare = async () => { const result = await caller.rpc("sun_employee_prepare_v28", { @@ -57,7 +109,7 @@ Deno.serve(async (req: Request) => { p_role: role, }); if (fin.error) throw new Error(fin.error.message); - return reply({ + return reply(req, { ...fin.data, created, temporary_password: temporaryPassword, @@ -69,7 +121,7 @@ Deno.serve(async (req: Request) => { const prepStatus = String(prep.status || ""); if (prepStatus === "already_member") { - return reply({ + return reply(req, { status: "already_member", user_id: prep.user_id, email: prep.email || email, @@ -83,13 +135,11 @@ Deno.serve(async (req: Request) => { if (prepStatus === "existing") { const existingUserId = String(prep.user_id || ""); - if (!existingUserId) return reply({ error: "Existing employee account has no user id" }, 409); + if (!UUID_RE.test(existingUserId)) throw new Error("Existing employee account has no valid user id"); return await finalize(existingUserId, false, null); } - if (prepStatus !== "new") { - return reply({ error: `Unexpected employee prepare status: ${prepStatus || "empty"}` }, 409); - } + if (prepStatus !== "new") throw new Error(`Unexpected employee prepare status: ${prepStatus || "empty"}`); const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; const bytes = crypto.getRandomValues(new Uint8Array(12)); @@ -109,12 +159,10 @@ Deno.serve(async (req: Request) => { }); if (created.error || !created.data.user) { - // A concurrent request or a previous partial failure may have created Auth already. - // Re-read server state and finalize the existing account instead of leaving the workspace half-configured. const retryPrep = await prepare(); const retryStatus = String(retryPrep.status || ""); if (retryStatus === "already_member") { - return reply({ + return reply(req, { status: "already_member", user_id: retryPrep.user_id, email: retryPrep.email || email, @@ -125,14 +173,14 @@ Deno.serve(async (req: Request) => { must_change_password: false, }); } - if (retryStatus === "existing" && retryPrep.user_id) { - return await finalize(String(retryPrep.user_id), false, null); - } - return reply({ error: created.error?.message || "Create failed" }, 400); + if (retryStatus === "existing" && retryPrep.user_id) return await finalize(String(retryPrep.user_id), false, null); + throw new Error(created.error?.message || "Employee auth creation failed"); } return await finalize(created.data.user.id, true, password); - } catch (e) { - return reply({ error: e instanceof Error ? e.message : String(e) }, 500); + } catch (error) { + console.error("[caterium-create-employee]", error); + const safe = publicError(error); + return reply(req, { error: safe.error, code: safe.code }, safe.status); } });