@organizapp/site-kit 0.0.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +171 -6
  2. package/dist/assertion/site-assertion.d.ts +35 -0
  3. package/dist/assertion/site-assertion.js +49 -0
  4. package/dist/chat/chat-reply.d.ts +22 -0
  5. package/dist/chat/chat-reply.js +1 -0
  6. package/dist/chat/send-chat-message.d.ts +32 -0
  7. package/dist/chat/send-chat-message.js +61 -0
  8. package/dist/chat/whatsapp-href.d.ts +7 -0
  9. package/dist/chat/whatsapp-href.js +10 -0
  10. package/dist/crypto/hmac.d.ts +5 -0
  11. package/dist/crypto/hmac.js +47 -0
  12. package/dist/form/index.d.ts +7 -0
  13. package/dist/form/index.js +7 -0
  14. package/dist/form/inquiry-form.d.ts +23 -0
  15. package/dist/form/inquiry-form.js +43 -0
  16. package/dist/form/site-chat.d.ts +29 -0
  17. package/dist/form/site-chat.js +42 -0
  18. package/dist/index.d.ts +16 -1
  19. package/dist/index.js +11 -4
  20. package/dist/inquiry/form-seal.d.ts +18 -0
  21. package/dist/inquiry/form-seal.js +34 -0
  22. package/dist/inquiry/form-state.d.ts +36 -0
  23. package/dist/inquiry/form-state.js +16 -0
  24. package/dist/inquiry/handle-inquiry-form.d.ts +21 -0
  25. package/dist/inquiry/handle-inquiry-form.js +55 -0
  26. package/dist/inquiry/idempotency-key.d.ts +2 -0
  27. package/dist/inquiry/idempotency-key.js +24 -0
  28. package/dist/inquiry/inquiry-fields.d.ts +25 -0
  29. package/dist/inquiry/inquiry-fields.js +1 -0
  30. package/dist/inquiry/marketing-opt-in.d.ts +11 -0
  31. package/dist/inquiry/marketing-opt-in.js +16 -0
  32. package/dist/inquiry/site-environment.d.ts +19 -0
  33. package/dist/inquiry/site-environment.js +44 -0
  34. package/dist/inquiry/submit-inquiry.d.ts +30 -0
  35. package/dist/inquiry/submit-inquiry.js +50 -0
  36. package/dist/inquiry/turnstile.d.ts +6 -0
  37. package/dist/inquiry/turnstile.js +25 -0
  38. package/dist/profile/get-site-profile.d.ts +24 -0
  39. package/dist/profile/get-site-profile.js +41 -0
  40. package/dist/profile/revalidate-handler.d.ts +19 -0
  41. package/dist/profile/revalidate-handler.js +27 -0
  42. package/dist/profile/site-profile.d.ts +26 -0
  43. package/dist/profile/site-profile.js +21 -0
  44. package/package.json +13 -3
package/dist/index.js CHANGED
@@ -1,6 +1,13 @@
1
1
  // @organizapp/site-kit
2
2
  //
3
- // Vacío en W-0: existe para publicar la primera versión y dejar probada la
4
- // cadena (workflow, procedencia, plantilla). `getSiteProfile`, `<InquiryForm>`,
5
- // `submitInquiry` y `revalidateHandler` llegan con W-1 (docs/domain/website.md).
6
- export {};
3
+ // Lo del SERVIDOR del sitio: firma con `ORGANIZAPP_SITE_SECRET`, así que nunca
4
+ // se importa desde un Client Component. El formulario sale por
5
+ // `@organizapp/site-kit/form`. Uso y contrato en el README.
6
+ export { createFormSeal } from "./inquiry/form-seal.js";
7
+ export { handleInquiryForm } from "./inquiry/handle-inquiry-form.js";
8
+ export { MARKETING_OPT_IN_VERSION, marketingOptInText } from "./inquiry/marketing-opt-in.js";
9
+ export { submitInquiry } from "./inquiry/submit-inquiry.js";
10
+ export { clientIpFromHeaders, revalidationFromEnv, siteConnectionFromEnv } from "./inquiry/site-environment.js";
11
+ export { getSiteProfile } from "./profile/get-site-profile.js";
12
+ export { SITE_PROFILE_TAG, createRevalidateHandler } from "./profile/revalidate-handler.js";
13
+ export { CHAT_COOKIE, CHAT_COOKIE_OPTIONS, sendChatMessage } from "./chat/send-chat-message.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * El sello de hora del formulario (W16): el servidor del sitio firma cuándo
3
+ * dibujó el formulario, y al recibirlo mira cuánto pasó. Una persona no
4
+ * completa un formulario en menos de 2 segundos; un formulario de hace más de
5
+ * 24 horas se recarga antes de mandarse. La hora va firmada con el secreto del
6
+ * sitio: cambiarla a mano invalida el sello.
7
+ */
8
+ export declare const SEAL_MIN_AGE_MS = 2000;
9
+ export declare const SEAL_MAX_AGE_MS: number;
10
+ export type SealCheck = "ok" | "too_fast" | "expired" | "invalid";
11
+ export declare function createFormSeal(input: {
12
+ readonly secret: string;
13
+ readonly nowMs: number;
14
+ }): Promise<string>;
15
+ export declare function checkFormSeal(seal: string, input: {
16
+ readonly secret: string;
17
+ readonly nowMs: number;
18
+ }): Promise<SealCheck>;
@@ -0,0 +1,34 @@
1
+ import { hmacSign, hmacVerify } from "../crypto/hmac.js";
2
+ /**
3
+ * El sello de hora del formulario (W16): el servidor del sitio firma cuándo
4
+ * dibujó el formulario, y al recibirlo mira cuánto pasó. Una persona no
5
+ * completa un formulario en menos de 2 segundos; un formulario de hace más de
6
+ * 24 horas se recarga antes de mandarse. La hora va firmada con el secreto del
7
+ * sitio: cambiarla a mano invalida el sello.
8
+ */
9
+ export const SEAL_MIN_AGE_MS = 2_000;
10
+ export const SEAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
11
+ /** Lo que se tolera entre relojes de dos instancias del mismo sitio. */
12
+ const SKEW_MS = 5_000;
13
+ const PREFIX = "organizapp-form-seal:v1:";
14
+ export async function createFormSeal(input) {
15
+ const issuedAt = String(Math.trunc(input.nowMs));
16
+ return `${issuedAt}.${await hmacSign(input.secret, PREFIX + issuedAt)}`;
17
+ }
18
+ export async function checkFormSeal(seal, input) {
19
+ const parts = seal.split(".");
20
+ const [issuedAt = "", signature = ""] = parts;
21
+ if (parts.length !== 2 || !/^\d{1,15}$/u.test(issuedAt))
22
+ return "invalid";
23
+ // La firma primero: una hora que no firmó el sitio no dice nada.
24
+ if (!(await hmacVerify(input.secret, PREFIX + issuedAt, signature)))
25
+ return "invalid";
26
+ const age = input.nowMs - Number(issuedAt);
27
+ if (age < -SKEW_MS)
28
+ return "invalid";
29
+ if (age < SEAL_MIN_AGE_MS)
30
+ return "too_fast";
31
+ if (age > SEAL_MAX_AGE_MS)
32
+ return "expired";
33
+ return "ok";
34
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Lo que comparten el formulario (navegador) y su acción (servidor): los
3
+ * nombres de los campos y el estado que vuelve. Sin código de servidor: esto
4
+ * sí llega al bundle del navegador.
5
+ */
6
+ export declare const INQUIRY_FIELD: {
7
+ readonly name: "name";
8
+ readonly phone: "phone";
9
+ readonly email: "email";
10
+ readonly message: "message";
11
+ readonly marketingOptIn: "marketingOptIn";
12
+ readonly seal: "organizapp_seal";
13
+ /** El campo trampa. Un nombre que un bot quiere llenar y una persona no ve. */
14
+ readonly honeypot: "website";
15
+ readonly turnstile: "cf-turnstile-response";
16
+ };
17
+ /** Qué exige el sitio de los dos medios de contacto: lo mismo que eligió en el CRM. */
18
+ export type InquiryContactRequirement = "either" | "phone" | "email" | "both";
19
+ /**
20
+ * `sent` también es la respuesta a un envío que se descartó por bot: quien lo
21
+ * mandó no aprende qué lo delató.
22
+ */
23
+ export type InquiryFormState = {
24
+ readonly status: "idle";
25
+ } | {
26
+ readonly status: "sent";
27
+ } | {
28
+ readonly status: "invalid";
29
+ readonly field: string;
30
+ } | {
31
+ readonly status: "reload";
32
+ } | {
33
+ readonly status: "rate_limited";
34
+ } | {
35
+ readonly status: "failed";
36
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lo que comparten el formulario (navegador) y su acción (servidor): los
3
+ * nombres de los campos y el estado que vuelve. Sin código de servidor: esto
4
+ * sí llega al bundle del navegador.
5
+ */
6
+ export const INQUIRY_FIELD = {
7
+ name: "name",
8
+ phone: "phone",
9
+ email: "email",
10
+ message: "message",
11
+ marketingOptIn: "marketingOptIn",
12
+ seal: "organizapp_seal",
13
+ /** El campo trampa. Un nombre que un bot quiere llenar y una persona no ve. */
14
+ honeypot: "website",
15
+ turnstile: "cf-turnstile-response",
16
+ };
@@ -0,0 +1,21 @@
1
+ import { type InquiryFormState } from "./form-state.js";
2
+ import type { SiteConnection } from "./inquiry-fields.js";
3
+ /**
4
+ * La acción del formulario, para la Server Action del sitio. En orden: campo
5
+ * trampa, sello de hora, Turnstile si está prendido, y recién ahí la API. Lo
6
+ * que delata a un bot responde las mismas gracias que un envío bueno y no gasta
7
+ * el cupo del sitio; un sello vencido pide recargar, que es lo que le pasa a
8
+ * una persona que dejó la pestaña abierta.
9
+ */
10
+ export interface InquiryFormOptions {
11
+ readonly connection: SiteConnection;
12
+ /** `clientIpFromHeaders(await headers())` en Next. Sin IP no se firma. */
13
+ readonly clientIp: string | null;
14
+ readonly sourcePath: string;
15
+ readonly policyVersion: string;
16
+ /** Prende Turnstile. Ausente, el formulario no lo pide. */
17
+ readonly turnstileSecret?: string;
18
+ readonly nowMs?: number;
19
+ readonly fetch?: typeof fetch;
20
+ }
21
+ export declare function handleInquiryForm(data: FormData, options: InquiryFormOptions): Promise<InquiryFormState>;
@@ -0,0 +1,55 @@
1
+ import { checkFormSeal } from "./form-seal.js";
2
+ import { INQUIRY_FIELD } from "./form-state.js";
3
+ import { inquiryIdempotencyKey } from "./idempotency-key.js";
4
+ import { MARKETING_OPT_IN_VERSION } from "./marketing-opt-in.js";
5
+ import { submitInquiry } from "./submit-inquiry.js";
6
+ import { verifyTurnstile } from "./turnstile.js";
7
+ const SENT = { status: "sent" };
8
+ function text(data, name) {
9
+ const value = data.get(name);
10
+ return typeof value === "string" ? value.trim() : "";
11
+ }
12
+ function readFields(data, options) {
13
+ const phone = text(data, INQUIRY_FIELD.phone);
14
+ const email = text(data, INQUIRY_FIELD.email);
15
+ const optIn = data.get(INQUIRY_FIELD.marketingOptIn) !== null;
16
+ return {
17
+ name: text(data, INQUIRY_FIELD.name),
18
+ ...(phone === "" ? {} : { phone }),
19
+ ...(email === "" ? {} : { email }),
20
+ message: text(data, INQUIRY_FIELD.message),
21
+ sourcePath: options.sourcePath,
22
+ policyVersion: options.policyVersion,
23
+ marketingOptIn: optIn,
24
+ ...(optIn ? { marketingOptInVersion: MARKETING_OPT_IN_VERSION } : {}),
25
+ };
26
+ }
27
+ export async function handleInquiryForm(data, options) {
28
+ if (text(data, INQUIRY_FIELD.honeypot) !== "")
29
+ return SENT;
30
+ const seal = text(data, INQUIRY_FIELD.seal);
31
+ const nowMs = options.nowMs ?? Date.now();
32
+ const check = await checkFormSeal(seal, { secret: options.connection.siteSecret, nowMs });
33
+ if (check === "expired")
34
+ return { status: "reload" };
35
+ if (check !== "ok")
36
+ return SENT;
37
+ if (options.clientIp === null)
38
+ return { status: "failed" };
39
+ const fetcher = options.fetch === undefined ? {} : { fetch: options.fetch };
40
+ if (options.turnstileSecret !== undefined) {
41
+ const human = await verifyTurnstile({
42
+ token: text(data, INQUIRY_FIELD.turnstile), secret: options.turnstileSecret, clientIp: options.clientIp, ...fetcher,
43
+ });
44
+ if (!human)
45
+ return SENT;
46
+ }
47
+ const fields = readFields(data, options);
48
+ const idempotencyKey = await inquiryIdempotencyKey(options.connection.siteSecret, seal, fields);
49
+ const result = await submitInquiry({ fields, idempotencyKey, clientIp: options.clientIp }, options.connection, { ...fetcher, nowSeconds: Math.floor(nowMs / 1000) });
50
+ if (result.status === "sent" || result.status === "invalid")
51
+ return result;
52
+ if (result.status === "rate_limited")
53
+ return { status: "rate_limited" };
54
+ return { status: "failed" };
55
+ }
@@ -0,0 +1,2 @@
1
+ import type { InquiryFields } from "./inquiry-fields.js";
2
+ export declare function inquiryIdempotencyKey(secret: string, seal: string, fields: InquiryFields): Promise<string>;
@@ -0,0 +1,24 @@
1
+ import { hmacBytes } from "../crypto/hmac.js";
2
+ /**
3
+ * La clave de idempotencia de un envío, derivada de la carga de la página (el
4
+ * sello) y de lo escrito. Así se cumple lo que pide la API: la misma clave en
5
+ * cada reintento del MISMO envío (un doble clic, una red que se cortó) y otra si
6
+ * cambió el contenido —corregir el email después de un error es otro envío, y
7
+ * con la misma clave la API respondería conflicto—. Firmada con el secreto del
8
+ * sitio: nadie de afuera puede adivinar la clave de otra persona.
9
+ */
10
+ const PREFIX = "organizapp-inquiry-key:v1:";
11
+ function hex(bytes) {
12
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
13
+ }
14
+ export async function inquiryIdempotencyKey(secret, seal, fields) {
15
+ const content = JSON.stringify([
16
+ fields.name, fields.phone ?? "", fields.email ?? "", fields.message, fields.sourcePath, fields.policyVersion, fields.marketingOptIn,
17
+ ]);
18
+ const bytes = (await hmacBytes(secret, `${PREFIX}${seal}\n${content}`)).slice(0, 16);
19
+ // Forma de UUID versión 8 (la de los formatos propios) con la variante RFC.
20
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x80;
21
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
22
+ const text = hex(bytes);
23
+ return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
24
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lo que viaja a `POST /api/sites/v1/inquiries`, menos la clave. El contrato
3
+ * vive en `docs/domain/website/inquiries.md` de Organizapp: el cuerpo es
4
+ * estricto y un campo de más rechaza el pedido entero.
5
+ */
6
+ export interface InquiryFields {
7
+ readonly name: string;
8
+ /** Lo que no se llenó no se manda. */
9
+ readonly phone?: string;
10
+ readonly email?: string;
11
+ readonly message: string;
12
+ /** La ruta de la página del formulario, con `/` adelante y sin query. */
13
+ readonly sourcePath: string;
14
+ /** La versión de la política de privacidad que vio quien escribe. */
15
+ readonly policyVersion: string;
16
+ readonly marketingOptIn: boolean;
17
+ /** La versión del texto de novedades que vio. Solo con `marketingOptIn: true`. */
18
+ readonly marketingOptInVersion?: string;
19
+ }
20
+ /** Con qué sitio se habla: las tres variables del proyecto del sitio. */
21
+ export interface SiteConnection {
22
+ readonly apiUrl: string;
23
+ readonly siteId: string;
24
+ readonly siteSecret: string;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * El texto de la casilla de novedades. Lo fija el kit y no cada sitio, porque
3
+ * es la prueba del consentimiento: el CRM guarda la versión que se aceptó
4
+ * (W11), y un texto que cambia sin cambiar de versión dejaría una prueba falsa.
5
+ * La política de opt-in de Meta pide que diga por dónde llegan los mensajes y
6
+ * quién los manda; la casilla arranca sin marcar.
7
+ *
8
+ * Cambiar el texto es cambiar la versión, en el mismo commit.
9
+ */
10
+ export declare const MARKETING_OPT_IN_VERSION = "wa-novedades-v1";
11
+ export declare function marketingOptInText(businessName: string): string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * El texto de la casilla de novedades. Lo fija el kit y no cada sitio, porque
3
+ * es la prueba del consentimiento: el CRM guarda la versión que se aceptó
4
+ * (W11), y un texto que cambia sin cambiar de versión dejaría una prueba falsa.
5
+ * La política de opt-in de Meta pide que diga por dónde llegan los mensajes y
6
+ * quién los manda; la casilla arranca sin marcar.
7
+ *
8
+ * Cambiar el texto es cambiar la versión, en el mismo commit.
9
+ */
10
+ export const MARKETING_OPT_IN_VERSION = "wa-novedades-v1";
11
+ export function marketingOptInText(businessName) {
12
+ const name = businessName.trim();
13
+ if (name === "")
14
+ throw new Error("businessName: el texto de novedades tiene que nombrar al negocio");
15
+ return `Quiero recibir novedades de ${name} por WhatsApp.`;
16
+ }
@@ -0,0 +1,19 @@
1
+ import type { RevalidationConnection } from "../profile/revalidate-handler.js";
2
+ import type { SiteConnection } from "./inquiry-fields.js";
3
+ /**
4
+ * Las variables del proyecto del sitio. Ninguna lleva `NEXT_PUBLIC_`: no pueden
5
+ * llegar al navegador. Una que falta falla con su nombre, nunca con un valor.
6
+ */
7
+ type Environment = Readonly<Record<string, string | undefined>>;
8
+ export declare function siteConnectionFromEnv(env?: Environment): SiteConnection;
9
+ /** Para la ruta del aviso: el secreto de revalidación, que el panel muestra junto con el de la API. */
10
+ export declare function revalidationFromEnv(env?: Environment): RevalidationConnection;
11
+ /**
12
+ * La IP de quien escribe, para el límite por persona de la API. En Vercel la
13
+ * primera de `x-forwarded-for` la pone la plataforma, no el navegador. Detrás
14
+ * de otro proxy, el sitio la saca él y se la pasa a `handleInquiryForm`.
15
+ */
16
+ export declare function clientIpFromHeaders(headers: {
17
+ get(name: string): string | null;
18
+ }): string | null;
19
+ export {};
@@ -0,0 +1,44 @@
1
+ function required(env, name) {
2
+ const value = env[name]?.trim() ?? "";
3
+ if (value === "")
4
+ throw new Error(`site-kit: falta la variable ${name}`);
5
+ return value;
6
+ }
7
+ /** La API va por https: el secreto no viaja, pero el cuerpo lleva datos de una persona. */
8
+ function apiUrl(value) {
9
+ let url;
10
+ try {
11
+ url = new URL(value);
12
+ }
13
+ catch {
14
+ throw new Error("site-kit: ORGANIZAPP_API_URL no es una URL");
15
+ }
16
+ const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
17
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) {
18
+ throw new Error("site-kit: ORGANIZAPP_API_URL tiene que ir por https");
19
+ }
20
+ return value;
21
+ }
22
+ export function siteConnectionFromEnv(env = process.env) {
23
+ return {
24
+ apiUrl: apiUrl(required(env, "ORGANIZAPP_API_URL")),
25
+ siteId: required(env, "ORGANIZAPP_SITE_ID"),
26
+ siteSecret: required(env, "ORGANIZAPP_SITE_SECRET"),
27
+ };
28
+ }
29
+ /** Para la ruta del aviso: el secreto de revalidación, que el panel muestra junto con el de la API. */
30
+ export function revalidationFromEnv(env = process.env) {
31
+ return { siteId: required(env, "ORGANIZAPP_SITE_ID"), revalidationSecret: required(env, "ORGANIZAPP_REVALIDATION_SECRET") };
32
+ }
33
+ /**
34
+ * La IP de quien escribe, para el límite por persona de la API. En Vercel la
35
+ * primera de `x-forwarded-for` la pone la plataforma, no el navegador. Detrás
36
+ * de otro proxy, el sitio la saca él y se la pasa a `handleInquiryForm`.
37
+ */
38
+ export function clientIpFromHeaders(headers) {
39
+ const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "";
40
+ if (forwarded !== "")
41
+ return forwarded;
42
+ const real = headers.get("x-real-ip")?.trim() ?? "";
43
+ return real === "" ? null : real;
44
+ }
@@ -0,0 +1,30 @@
1
+ import type { InquiryFields, SiteConnection } from "./inquiry-fields.js";
2
+ /**
3
+ * Manda una consulta a Organizapp. Corre en el SERVIDOR del sitio, nunca en el
4
+ * navegador: firma con `ORGANIZAPP_SITE_SECRET`. La clave de idempotencia la
5
+ * trae quien llama y se repite en cada reintento: una red cortada a mitad de
6
+ * camino no dice si la consulta llegó, y repetir con la misma clave no duplica.
7
+ *
8
+ * Las respuestas de la API se traducen a cuatro desenlaces sin detalle
9
+ * interno: lo que se muestra a quien escribe lo decide el formulario.
10
+ */
11
+ export type SubmitInquiryResult = {
12
+ readonly status: "sent";
13
+ } | {
14
+ readonly status: "invalid";
15
+ readonly field: string;
16
+ } | {
17
+ readonly status: "rate_limited";
18
+ readonly retryAfterSeconds: number | null;
19
+ } | {
20
+ readonly status: "failed";
21
+ readonly reason: "unauthorized" | "conflict" | "unavailable" | "network" | "unexpected";
22
+ };
23
+ export declare function submitInquiry(input: {
24
+ readonly fields: InquiryFields;
25
+ readonly idempotencyKey: string;
26
+ readonly clientIp: string;
27
+ }, connection: SiteConnection, deps?: {
28
+ readonly fetch?: typeof fetch;
29
+ readonly nowSeconds?: number;
30
+ }): Promise<SubmitInquiryResult>;
@@ -0,0 +1,50 @@
1
+ import { ASSERTION_HEADER, SITE_HEADER, signSiteAssertion } from "../assertion/site-assertion.js";
2
+ const TIMEOUT_MS = 10_000;
3
+ const PATH = "/api/sites/v1/inquiries";
4
+ async function invalidField(response) {
5
+ try {
6
+ const body = (await response.json());
7
+ return typeof body.field === "string" ? body.field : "body";
8
+ }
9
+ catch {
10
+ return "body";
11
+ }
12
+ }
13
+ function retryAfter(response) {
14
+ const seconds = Number(response.headers.get("retry-after"));
15
+ return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : null;
16
+ }
17
+ export async function submitInquiry(input, connection, deps = {}) {
18
+ const assertion = await signSiteAssertion({
19
+ secret: connection.siteSecret,
20
+ siteId: connection.siteId,
21
+ clientIp: input.clientIp,
22
+ nowSeconds: deps.nowSeconds ?? Math.floor(Date.now() / 1000),
23
+ });
24
+ let response;
25
+ try {
26
+ response = await (deps.fetch ?? fetch)(`${connection.apiUrl.replace(/\/+$/u, "")}${PATH}`, {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json", [SITE_HEADER]: connection.siteId, [ASSERTION_HEADER]: assertion },
29
+ body: JSON.stringify({ idempotencyKey: input.idempotencyKey, ...input.fields }),
30
+ signal: AbortSignal.timeout(TIMEOUT_MS),
31
+ cache: "no-store",
32
+ });
33
+ }
34
+ catch {
35
+ return { status: "failed", reason: "network" };
36
+ }
37
+ if (response.status === 201 || response.status === 200)
38
+ return { status: "sent" };
39
+ if (response.status === 422)
40
+ return { status: "invalid", field: await invalidField(response) };
41
+ if (response.status === 429)
42
+ return { status: "rate_limited", retryAfterSeconds: retryAfter(response) };
43
+ if (response.status === 401)
44
+ return { status: "failed", reason: "unauthorized" };
45
+ if (response.status === 409)
46
+ return { status: "failed", reason: "conflict" };
47
+ if (response.status === 503)
48
+ return { status: "failed", reason: "unavailable" };
49
+ return { status: "failed", reason: "unexpected" };
50
+ }
@@ -0,0 +1,6 @@
1
+ export declare function verifyTurnstile(input: {
2
+ readonly token: string;
3
+ readonly secret: string;
4
+ readonly clientIp: string;
5
+ readonly fetch?: typeof fetch;
6
+ }): Promise<boolean>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Turnstile de Cloudflare, apagado salvo que el sitio lo prenda (W16): se usa
3
+ * solo si aparece spam que el campo trampa y el sello no paran. Cualquier cosa
4
+ * que no sea un `success: true` explícito es un rechazo.
5
+ */
6
+ const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
7
+ const TIMEOUT_MS = 5_000;
8
+ export async function verifyTurnstile(input) {
9
+ if (input.token === "")
10
+ return false;
11
+ try {
12
+ const response = await (input.fetch ?? fetch)(SITEVERIFY, {
13
+ method: "POST",
14
+ body: new URLSearchParams({ secret: input.secret, response: input.token, remoteip: input.clientIp }),
15
+ signal: AbortSignal.timeout(TIMEOUT_MS),
16
+ });
17
+ if (!response.ok)
18
+ return false;
19
+ const body = (await response.json());
20
+ return body.success === true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
@@ -0,0 +1,24 @@
1
+ import type { SiteConnection } from "../inquiry/inquiry-fields.js";
2
+ import { type SiteProfile } from "./site-profile.js";
3
+ /**
4
+ * Lee los datos de la web del negocio. Corre en el SERVIDOR del sitio: firma con
5
+ * `ORGANIZAPP_SITE_SECRET`.
6
+ *
7
+ * **Sin caché propia, a propósito**: la firma cambia en cada pedido, y la caché de
8
+ * `fetch` de Next usa los headers en su clave, así que nunca acertaría. El sitio
9
+ * cachea el RESULTADO con la etiqueta `SITE_PROFILE_TAG` (`unstable_cache` o
10
+ * `"use cache"`), y `createRevalidateHandler` la invalida cuando el CRM avisa.
11
+ */
12
+ export type SiteProfileResult = {
13
+ readonly status: "ok";
14
+ readonly profile: SiteProfile;
15
+ } | {
16
+ readonly status: "not_set";
17
+ } | {
18
+ readonly status: "failed";
19
+ readonly reason: "unauthorized" | "rate_limited" | "unavailable" | "network" | "unexpected";
20
+ };
21
+ export declare function getSiteProfile(connection: SiteConnection, deps?: {
22
+ readonly fetch?: typeof fetch;
23
+ readonly nowSeconds?: number;
24
+ }): Promise<SiteProfileResult>;
@@ -0,0 +1,41 @@
1
+ import { ASSERTION_HEADER, SITE_HEADER, signSiteAssertion } from "../assertion/site-assertion.js";
2
+ import { readSiteProfile } from "./site-profile.js";
3
+ const TIMEOUT_MS = 10_000;
4
+ const PATH = "/api/sites/v1/profile";
5
+ export async function getSiteProfile(connection, deps = {}) {
6
+ const assertion = await signSiteAssertion({
7
+ secret: connection.siteSecret,
8
+ siteId: connection.siteId,
9
+ clientIp: "",
10
+ audience: "website:profile",
11
+ nowSeconds: deps.nowSeconds ?? Math.floor(Date.now() / 1000),
12
+ });
13
+ let response;
14
+ try {
15
+ response = await (deps.fetch ?? fetch)(`${connection.apiUrl.replace(/\/+$/u, "")}${PATH}`, {
16
+ headers: { [SITE_HEADER]: connection.siteId, [ASSERTION_HEADER]: assertion },
17
+ signal: AbortSignal.timeout(TIMEOUT_MS),
18
+ cache: "no-store",
19
+ });
20
+ }
21
+ catch {
22
+ return { status: "failed", reason: "network" };
23
+ }
24
+ if (response.status === 404)
25
+ return { status: "not_set" };
26
+ if (response.status === 401)
27
+ return { status: "failed", reason: "unauthorized" };
28
+ if (response.status === 429)
29
+ return { status: "failed", reason: "rate_limited" };
30
+ if (response.status === 503)
31
+ return { status: "failed", reason: "unavailable" };
32
+ if (response.status !== 200)
33
+ return { status: "failed", reason: "unexpected" };
34
+ try {
35
+ const profile = readSiteProfile((await response.json()).profile);
36
+ return profile === null ? { status: "failed", reason: "unexpected" } : { status: "ok", profile };
37
+ }
38
+ catch {
39
+ return { status: "failed", reason: "unexpected" };
40
+ }
41
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * La ruta `POST /api/organizapp/revalidate` del sitio: el CRM avisa que
3
+ * cambiaron los datos, y el sitio invalida lo que cacheó con `SITE_PROFILE_TAG`.
4
+ *
5
+ * El aviso viene firmado con el secreto de REVALIDACIÓN del sitio, distinto del
6
+ * de la API: quien tenga uno no puede usar el otro. Un aviso falso no cuesta
7
+ * más que una relectura, pero igual se rechaza. `revalidate` la trae el sitio
8
+ * (`revalidateTag` de `next/cache`): el kit no importa Next.
9
+ */
10
+ export declare const SITE_PROFILE_TAG = "organizapp:site-profile";
11
+ export interface RevalidationConnection {
12
+ readonly siteId: string;
13
+ readonly revalidationSecret: string;
14
+ }
15
+ export declare function createRevalidateHandler(options: {
16
+ readonly connection: RevalidationConnection;
17
+ readonly revalidate: (tag: string) => void | Promise<void>;
18
+ readonly nowSeconds?: () => number;
19
+ }): (request: Request) => Promise<Response>;
@@ -0,0 +1,27 @@
1
+ import { ASSERTION_HEADER, SITE_HEADER, verifySiteAssertion } from "../assertion/site-assertion.js";
2
+ /**
3
+ * La ruta `POST /api/organizapp/revalidate` del sitio: el CRM avisa que
4
+ * cambiaron los datos, y el sitio invalida lo que cacheó con `SITE_PROFILE_TAG`.
5
+ *
6
+ * El aviso viene firmado con el secreto de REVALIDACIÓN del sitio, distinto del
7
+ * de la API: quien tenga uno no puede usar el otro. Un aviso falso no cuesta
8
+ * más que una relectura, pero igual se rechaza. `revalidate` la trae el sitio
9
+ * (`revalidateTag` de `next/cache`): el kit no importa Next.
10
+ */
11
+ export const SITE_PROFILE_TAG = "organizapp:site-profile";
12
+ export function createRevalidateHandler(options) {
13
+ const now = options.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
14
+ return async (request) => {
15
+ if (request.method !== "POST")
16
+ return new Response(null, { status: 405, headers: { allow: "POST" } });
17
+ const site = request.headers.get(SITE_HEADER);
18
+ const assertion = request.headers.get(ASSERTION_HEADER);
19
+ const valid = site === options.connection.siteId && assertion !== null && await verifySiteAssertion(assertion, {
20
+ secret: options.connection.revalidationSecret, audience: "website:revalidate", siteId: options.connection.siteId, nowSeconds: now(),
21
+ });
22
+ if (!valid)
23
+ return new Response(null, { status: 401 });
24
+ await options.revalidate(SITE_PROFILE_TAG);
25
+ return new Response(null, { status: 204 });
26
+ };
27
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Los datos de la web que devuelve `GET /api/sites/v1/profile`. El contrato
3
+ * vive en `docs/domain/website.md` de Organizapp («Los datos de la web»).
4
+ */
5
+ export interface SiteProfile {
6
+ readonly displayName: string;
7
+ readonly tagline: string | null;
8
+ /** Solo dígitos, con código de país (`5491140302370`): el sitio decide cómo mostrarlos. */
9
+ readonly phones: readonly string[];
10
+ readonly emails: readonly string[];
11
+ /** El número del botón «Escribinos por WhatsApp», en la misma forma: `https://wa.me/<número>`. */
12
+ readonly whatsapp: string | null;
13
+ readonly address: string | null;
14
+ readonly mapUrl: string | null;
15
+ /** Por día (`mon` … `sun`), tramos `HH:MM` ordenados; un día ausente es cerrado. */
16
+ readonly hours: Readonly<Record<string, readonly {
17
+ readonly open: string;
18
+ readonly close: string;
19
+ }[]>>;
20
+ /** La zona en la que está escrito el horario. */
21
+ readonly timeZone: string;
22
+ readonly socialLinks: readonly string[];
23
+ readonly updatedAt: string;
24
+ }
25
+ /** Lo que no tiene la forma del contrato no llega al sitio: es `null`. */
26
+ export declare function readSiteProfile(value: unknown): SiteProfile | null;
@@ -0,0 +1,21 @@
1
+ const isText = (value) => typeof value === "string";
2
+ const isTextOrNull = (value) => value === null || typeof value === "string";
3
+ const isTextList = (value) => Array.isArray(value) && value.every(isText);
4
+ const isRange = (value) => typeof value === "object" && value !== null
5
+ && isText(value["open"]) && isText(value["close"]);
6
+ const isHours = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
7
+ && Object.values(value).every((ranges) => Array.isArray(ranges) && ranges.every(isRange));
8
+ /** Lo que no tiene la forma del contrato no llega al sitio: es `null`. */
9
+ export function readSiteProfile(value) {
10
+ if (typeof value !== "object" || value === null)
11
+ return null;
12
+ const s = value;
13
+ const { displayName, tagline, phones, emails, whatsapp, address, mapUrl, hours, timeZone, socialLinks, updatedAt } = s;
14
+ if (!isText(displayName) || !isTextOrNull(tagline) || !isTextList(phones) || !isTextList(emails))
15
+ return null;
16
+ if (!isTextOrNull(whatsapp) || !isTextOrNull(address) || !isTextOrNull(mapUrl) || !isHours(hours))
17
+ return null;
18
+ if (!isText(timeZone) || !isTextList(socialLinks) || !isText(updatedAt))
19
+ return null;
20
+ return { displayName, tagline, phones, emails, whatsapp, address, mapUrl, hours, timeZone, socialLinks, updatedAt };
21
+ }