/** * ¿Tiene el token permiso de ESCRITURA sobre calendarios y servicios? * * Sin crear nada. El truco: se manda un POST deliberadamente incompleto y se * mira QUÉ error vuelve. * * 401 «not authorized for this scope» → falta el permiso * 400 / 422 sobre campos → el permiso está, lo que falla es el cuerpo * * Distinguir esas dos cosas es justo lo que hace falta para saber si se puede * planificar la escritura de citas al calendario del CRM, y no cuesta un solo * registro basura en la subcuenta del cliente. * * De paso lee las cabeceras X-RateLimit-*, que hoy no lee nadie: el intervalo de * 650 ms del cliente es una estimación observada, no una cuota conocida. * * node scripts/run-tsx.mjs platform/scripts/crm-spike-permisos.ts */ import { requireEnv, loadEnv } from "../lib/env.ts"; loadEnv(); const BASE = process.env.CRM_BASE_URL || "https://services.leadconnectorhq.com"; const LOC = requireEnv("CRM_LOCATION_ID"); const TOKEN = requireEnv("CRM_TOKEN"); async function crudo( method: string, path: string, version: string, body?: unknown ): Promise<{ status: number; texto: string; headers: Record }> { const res = await fetch(`${BASE}${path}`, { method, headers: { authorization: `Bearer ${TOKEN}`, version, accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const headers: Record = {}; res.headers.forEach((v, k) => { if (k.toLowerCase().startsWith("x-ratelimit")) headers[k] = v; }); return { status: res.status, texto: await res.text(), headers }; } function veredicto(status: number, texto: string): string { if (status === 401 && /not authorized for this scope/i.test(texto)) { return "✖ FALTA EL PERMISO"; } if (status === 401) return "✖ 401 (token rechazado o sin permiso — ambiguo)"; if (status === 400 || status === 422) return "✔ EL PERMISO ESTÁ (rechaza por el cuerpo, no por el token)"; if (status >= 200 && status < 300) return "⚠ ACEPTÓ LA PETICIÓN — revisa si creó algo"; return `? ${status}`; } async function main() { console.log(`Subcuenta ${LOC}\n`); console.log("── ¿calendars/events.write? (POST incompleto a propósito)"); { // Falta `startTime`, que es obligatorio. Si el permiso está, la API se queja // del campo; si no está, se queja del token antes de mirar el cuerpo. const r = await crudo("POST", "/calendars/events/appointments", "v3", { locationId: LOC, }); console.log(` ${veredicto(r.status, r.texto)}`); console.log(` ${r.status} · ${r.texto.slice(0, 320)}`); } console.log("\n── ¿calendars.write? (POST incompleto al catálogo de servicios)"); { // Faltan `name`, `slug` y `staff[]`, todos obligatorios. const r = await crudo("POST", "/calendars/services/catalog", "v3", { locationId: LOC, }); console.log(` ${veredicto(r.status, r.texto)}`); console.log(` ${r.status} · ${r.texto.slice(0, 320)}`); } console.log("\n── ¿users.readonly? (confirma el hallazgo 14)"); { const r = await crudo("GET", `/users/?locationId=${LOC}`, "2021-07-28"); let n = 0; try { n = JSON.parse(r.texto || "{}")?.users?.length ?? 0; } catch { n = 0; } console.log( r.status === 200 ? ` ✔ RESPONDE 200 con ${n} usuarios — el hallazgo 14 (401) ha quedado obsoleto` : ` ✖ ${r.status} · ${r.texto.slice(0, 160)}` ); if (r.status === 200 && n) { const us = JSON.parse(r.texto).users.slice(0, 8); for (const u of us) console.log(` ${u.id} ${u.name ?? ""}`); } } console.log("\n── Citas de un contacto: GET /contacts/{id}/appointments"); { const b = await crudo("POST", "/contacts/search", "2021-07-28", { locationId: LOC, pageLimit: 1, }); let id: string | undefined; try { id = JSON.parse(b.texto || "{}")?.contacts?.[0]?.id; } catch { id = undefined; } if (!id) { console.log(" (no se pudo obtener un contacto de prueba)"); } else { const r = await crudo("GET", `/contacts/${id}/appointments`, "2021-07-28"); console.log(` contacto ${id} → ${r.status} · ${r.texto.slice(0, 200)}`); } } console.log("\n── Cabeceras de límite de tasa (nadie las lee hoy)"); { const r = await crudo("GET", `/locations/${LOC}`, "2021-07-28"); const hs = Object.entries(r.headers); if (!hs.length) { console.log(" la respuesta no trae ninguna cabecera X-RateLimit-*"); } else { for (const [k, v] of hs) console.log(` ${k}: ${v}`); const max = Number(r.headers["x-ratelimit-max"]); const ventana = Number(r.headers["x-ratelimit-interval-milliseconds"]); if (max && ventana) { console.log( ` → cuota real: ${max} peticiones / ${ventana} ms = 1 cada ${Math.ceil(ventana / max)} ms` ); console.log(` → el cliente usa 650 ms; margen sin aprovechar: ${(650 / (ventana / max)).toFixed(1)}×`); } } } } main().catch((e) => { console.error("Se detuvo:", e?.message ?? e); process.exit(1); });