/** * Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y * USUARIOS (invitaciones) desde el CRM. * * Principio de seguridad: SIEMPRE se llama server-side reenviando el token del * usuario autenticado (Bearer). Es el Hub quien valida el permiso — * `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para * invitaciones. El CRM NO guarda secretos ni hace bypass de autorización. * * Endpoints del Hub (base = getHubBackendUrl()): * GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin) * POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin) * POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant) */ import { getHubBackendUrl } from '$lib/server/workspace-auth'; import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared'; const HUB_API_PREFIX = '/api/v1/hub'; function hubUrl(path: string): string { return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`; } export type WorkspaceTenant = { id: number; name: string; slug: string; display_name?: string | null; contact_name?: string | null; contact_email: string; status: string; is_self_hosted: boolean; has_license: boolean; created_at: string; }; export type WorkspaceInvite = { id: number; email: string; tenant_slug: string; role: string; invite_url: string; expires_at: string; created_at: string; }; export type HubResult = | { ok: true; data: T } | { ok: false; status: number; forbidden: boolean; error: string }; /** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */ async function toErrorResult(res: Response): Promise> { const body = await res.json().catch(() => null); return { ok: false, status: res.status, forbidden: isForbiddenStatus(res.status), error: hubErrorMessage(body, res.status) }; } const jsonHeaders = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }); /** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */ export async function listWorkspaceTenants( accessToken: string, fetch: typeof globalThis.fetch ): Promise> { const res = await fetch(hubUrl('/tenants'), { headers: { Authorization: `Bearer ${accessToken}` } }); if (!res.ok) return toErrorResult(res); const data = (await res.json()) as { tenants?: WorkspaceTenant[] }; return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] }; } export type CreateTenantInput = { name: string; slug: string; contact_email: string; contact_name?: string; contact_phone?: string; display_name?: string; is_self_hosted: boolean; app_url?: string; }; /** Crea una organización (tenant) y provisiona su realm en Keycloak. */ export async function createWorkspaceTenant( accessToken: string, fetch: typeof globalThis.fetch, input: CreateTenantInput ): Promise> { const res = await fetch(hubUrl('/tenants'), { method: 'POST', headers: jsonHeaders(accessToken), body: JSON.stringify(input) }); if (!res.ok) return toErrorResult(res); return { ok: true, data: (await res.json()) as WorkspaceTenant }; } export type CreateInviteInput = { email: string; tenant_slug: string; role: string; }; /** Genera una invitación de un solo uso para un usuario en un tenant. */ export async function createWorkspaceInvite( accessToken: string, fetch: typeof globalThis.fetch, input: CreateInviteInput ): Promise> { const res = await fetch(hubUrl('/invites'), { method: 'POST', headers: jsonHeaders(accessToken), body: JSON.stringify(input) }); if (!res.ok) return toErrorResult(res); return { ok: true, data: (await res.json()) as WorkspaceInvite }; }