@uranotools/urano-social 0.2.0 → 0.3.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.
package/.env.example CHANGED
@@ -4,3 +4,19 @@ URANO_SOCIAL_WEBHOOK_SECRET=replace-with-the-same-WEBHOOK_SECRET-from-the-plugin
4
4
 
5
5
  # Optional: separate secret for signing the visitor cookie (defaults to WEBHOOK_SECRET).
6
6
  # URANO_SOCIAL_COOKIE_SECRET=
7
+
8
+ # --- Contact anti-spam (on by default) ---
9
+ # Ticket + honeypot + rate limit always run for type=contact.
10
+ # URANO_SOCIAL_ANTISPAM=true
11
+ # URANO_SOCIAL_CONTACT_MAX_PER_HOUR=5
12
+ # URANO_SOCIAL_CONTACT_MIN_AGE_MS=2500
13
+
14
+ # Optional Google reCAPTCHA v3 — paste secret here; site key goes in the frontend.
15
+ # https://www.google.com/recaptcha/admin
16
+ # URANO_SOCIAL_RECAPTCHA_SECRET=
17
+ # URANO_SOCIAL_RECAPTCHA_MIN_SCORE=0.5
18
+ # URANO_SOCIAL_RECAPTCHA_ACTION=contact
19
+ #
20
+ # Frontend (public):
21
+ # NEXT_PUBLIC_URANO_SOCIAL_RECAPTCHA_SITE_KEY=
22
+ # VITE_URANO_SOCIAL_RECAPTCHA_SITE_KEY=
package/README.md CHANGED
@@ -1,33 +1,75 @@
1
1
  # @uranotools/urano-social
2
2
 
3
- Chat de atención al cliente para el webhook MCP de **SalesAgentConnector**.
3
+ **Chat de soporte y formularios de contacto** para tu sitio, conectados a un agente Urano (Desktop o Cloud) — sin exponer secretos al navegador.
4
4
 
5
- El secreto HMAC vive solo en el servidor del sitio. El navegador habla con `/api/urano-social`; el proxy firma y reenvía a Urano (Desktop o Cloud) por SSE, wait o WebSocket.
5
+ [![npm](https://img.shields.io/npm/v/@uranotools/urano-social.svg)](https://www.npmjs.com/package/@uranotools/urano-social)
6
+ [![license](https://img.shields.io/npm/l/@uranotools/urano-social.svg)](./LICENSE)
6
7
 
7
- ## Arranque rápido
8
+ Tu visitante habla con un widget o envía un formulario. Tu servidor firma la petición. El agente responde en tiempo real (chat) o por correo (contacto).
8
9
 
9
- ### 1. Plugin + agente
10
+ ---
10
11
 
11
- 1. Instala SalesAgentConnector **1.4.0+** en Urano Desktop / Cloud.
12
- 2. Vault: `WEBHOOK_SECRET` + `TARGET_AGENT` (agente lanzado con el módulo activo).
13
- 3. URL local:
12
+ ## Por qué usarlo
13
+
14
+ | Ventaja | Qué significa |
15
+ |---------|----------------|
16
+ | **Secreto fuera del browser** | El `WEBHOOK_SECRET` vive solo en tu servidor. El front solo llama a `/api/urano-social`. |
17
+ | **Tu agente, tu marca** | Conecta el SalesAgentConnector de Urano; el tono y las herramientas los defines tú. |
18
+ | **Chat + contacto** | Un mismo proxy sirve burbuja de chat (SSE / wait / WebSocket) y formularios SMTP. |
19
+ | **Anti-spam de verdad** | Honeypot, ticket firmado y rate limit incluidos. reCAPTCHA v3 opcional — pegas las keys y listo. |
20
+ | **UI a tu medida** | Flavors listos (`floating`, `sidebar`) o headless / slots para tu diseño. |
21
+ | **Next, Astro, Vite…** | Proxy Web-standard; funciona en App Router, edge/Node y Cloudflare Pages Functions. |
22
+
23
+ Sin esto, un formulario “a pelo” apuntando a un agente es un blanco fácil para quemar tokens de IA. El proxy corta bots **antes** de llamar al webhook.
24
+
25
+ ---
26
+
27
+ ## Instalar
28
+
29
+ ```bash
30
+ npm install @uranotools/urano-social
31
+ ```
32
+
33
+ Requisitos:
34
+
35
+ 1. **Urano Desktop o Cloud** con el plugin **SalesAgentConnector** (1.4.0+).
36
+ 2. En el vault del plugin: `WEBHOOK_SECRET`, `TARGET_AGENT` (y SMTP + `ENABLE_EMAIL_CONTACT=true` si usas formularios).
37
+ 3. Un endpoint en tu sitio que use el proxy de este paquete.
38
+
39
+ ---
40
+
41
+ ## Cómo encaja
14
42
 
15
43
  ```
16
- http://127.0.0.1:6274/v1/webhook/mcp_SalesAgentConnector/default
44
+ Visitante → tu sitio (/api/urano-social) → firma HMAC → webhook Urano → agente
45
+ ↑
46
+ cookie HttpOnly
47
+ anti-spam (contacto)
17
48
  ```
18
49
 
19
- ### 2. Variables de entorno (servidor)
50
+ El browser **nunca** recibe el secreto. Tú configuras dos variables de servidor:
51
+
52
+ ```env
53
+ URANO_SOCIAL_WEBHOOK_URL=https://tu-host/v1/webhook/mcp_SalesAgentConnector/default
54
+ URANO_SOCIAL_WEBHOOK_SECRET=el-mismo-del-vault
55
+ ```
56
+
57
+ Local (Desktop):
20
58
 
21
59
  ```env
22
60
  URANO_SOCIAL_WEBHOOK_URL=http://127.0.0.1:6274/v1/webhook/mcp_SalesAgentConnector/default
23
- URANO_SOCIAL_WEBHOOK_SECRET=el-mismo-secreto-del-vault
61
+ URANO_SOCIAL_WEBHOOK_SECRET=…
24
62
  ```
25
63
 
26
- Nunca uses prefijos `NEXT_PUBLIC_` / `PUBLIC_` para el secreto.
64
+ No uses prefijos `NEXT_PUBLIC_` / `VITE_` / `PUBLIC_` para el secreto.
65
+
66
+ ---
67
+
68
+ ## Arranque en 3 pasos
27
69
 
28
- ### 3. Proxy en tu app
70
+ ### 1. Proxy en tu app
29
71
 
30
- **Next.js App Router**
72
+ **Next.js (App Router)**
31
73
 
32
74
  ```ts
33
75
  // app/api/urano-social/route.ts
@@ -36,17 +78,18 @@ import { socialProxyRoute } from '@uranotools/urano-social/server';
36
78
  export const { GET, POST, OPTIONS } = socialProxyRoute();
37
79
  ```
38
80
 
39
- **Astro / Node**
81
+ **Astro / Node / edge**
40
82
 
41
83
  ```ts
42
84
  import { createSocialProxy } from '@uranotools/urano-social/server';
85
+
43
86
  const proxy = createSocialProxy();
44
- // en tu handler: return proxy.handle(request)
87
+ // return proxy.handle(request)
45
88
  ```
46
89
 
47
- ### 4. Widget
90
+ ### 2. Widget de chat
48
91
 
49
- **Custom element (Astro / HTML / TS)**
92
+ **HTML / Astro (custom element)**
50
93
 
51
94
  ```html
52
95
  <script type="module">
@@ -56,138 +99,115 @@ const proxy = createSocialProxy();
56
99
  <urano-social-chat flavor="floating" title="Soporte"></urano-social-chat>
57
100
  ```
58
101
 
59
- Slots: `header`, `messages`, `composer`, `launcher`, `empty`.
60
-
61
- **React / Next**
102
+ **React**
62
103
 
63
104
  ```tsx
64
105
  import { UranoSocialChat } from '@uranotools/urano-social/react';
65
106
  import '@uranotools/urano-social/flavors.css';
66
107
 
67
108
  export function SupportWidget() {
68
- return (
69
- <UranoSocialChat
70
- flavor="floating"
71
- renderMessage={(m) => <div data-role={m.role}>{m.content}</div>}
72
- />
73
- );
109
+ return <UranoSocialChat flavor="floating" title="Soporte" />;
74
110
  }
75
111
  ```
76
112
 
77
- **Headless**
113
+ **Headless** (tu propia UI)
78
114
 
79
115
  ```ts
80
116
  import { createUranoChat } from '@uranotools/urano-social/client';
81
117
 
82
118
  const chat = createUranoChat({ endpoint: '/api/urano-social' });
83
- await chat.send('Hola');
119
+ await chat.send('Hola, necesito ayuda');
84
120
  ```
85
121
 
86
- ## Formularios de contacto (GET + SMTP)
87
-
88
- Mismo proxy y secreto. El widget envía `type=contact` por **GET** `?mode=wait&payload=…`. El plugin responde con el agente y manda el correo por SMTP.
122
+ ### 3. Formulario de contacto (opcional)
89
123
 
90
- Requisito en Vault del plugin: `ENABLE_EMAIL_CONTACT=true` + SMTP.
91
-
92
- ```html
93
- <script type="module">
94
- import { registerUranoSocialContactForm } from '@uranotools/urano-social';
95
- registerUranoSocialContactForm();
96
- </script>
97
- <urano-social-contact title="Escríbenos"></urano-social-contact>
98
- ```
124
+ El agente recibe el mensaje y puede responder por SMTP.
99
125
 
100
126
  ```tsx
101
127
  import { UranoSocialContactForm } from '@uranotools/urano-social/react';
102
- <UranoSocialContactForm title="Escríbenos" />
128
+
129
+ <UranoSocialContactForm
130
+ title="Escríbenos"
131
+ recaptchaSiteKey={import.meta.env.VITE_URANO_SOCIAL_RECAPTCHA_SITE_KEY}
132
+ />
103
133
  ```
104
134
 
135
+ O solo la API:
136
+
105
137
  ```ts
106
138
  import { submitContactForm } from '@uranotools/urano-social/client';
107
- await submitContactForm({ email: 'a@b.co', name: 'Ana', message: 'Hola' });
139
+
140
+ await submitContactForm({
141
+ email: 'ana@empresa.com',
142
+ name: 'Ana',
143
+ message: 'Quiero una demo',
144
+ });
108
145
  ```
109
146
 
110
- ## Flavors
147
+ ---
111
148
 
112
- | Flavor | Uso |
113
- |--------|-----|
114
- | `floating` | Burbuja fija + panel |
115
- | `sidebar` | Columna lateral |
116
- | `headless` | Solo controlador / React con UI propia |
149
+ ## Flavors de chat
117
150
 
118
- ## Seguridad
151
+ | Flavor | Ideal para |
152
+ |--------|------------|
153
+ | `floating` | Burbuja fija en la esquina (soporte clásico) |
154
+ | `sidebar` | Panel lateral en product / docs |
155
+ | `headless` | Diseño 100 % tuyo; solo el controlador |
119
156
 
120
- - Cookie HttpOnly firmada con el `visitorId` (el browser no elige la identidad CRM).
121
- - Firma `x-urano-signature` (HMAC-SHA256 hex) solo en el proxy.
122
- - Controles `status` / `events` / `cancel` e `Idempotency-Key` para reanudar turnos.
157
+ Slots del custom element: `header`, `messages`, `composer`, `launcher`, `empty`.
123
158
 
124
- ## WebSocket
159
+ ---
125
160
 
126
- Next App Router usa SSE por defecto. En Astro Node o un `http.Server` propio:
161
+ ## Anti-spam en formularios
127
162
 
128
- ```ts
129
- import http from 'http';
130
- import { attachSocialWebSocket } from '@uranotools/urano-social/server';
163
+ Incluido de fábrica:
131
164
 
132
- const server = http.createServer(app);
133
- attachSocialWebSocket(server); // path /api/urano-social/ws
134
- ```
165
+ - **Honeypot** — si un bot llena el campo oculto, responde 200 falso y **no** llama a la IA
166
+ - **Ticket firmado** — edad mínima / máxima entre abrir el form y enviar
167
+ - **Rate limit** — por defecto 5 envíos / hora por visitante + IP
135
168
 
136
- ## Desarrollo
169
+ Opcional — **reCAPTCHA v3** (solo pegar keys):
137
170
 
138
- ```bash
139
- cd UranoDesktop-demos/urano-social
140
- npm install
141
- npm test
142
- npm run build
143
- ```
171
+ ```env
172
+ # Servidor
173
+ URANO_SOCIAL_RECAPTCHA_SECRET=tu_secret_de_google
144
174
 
145
- Ejemplos: [`examples/next`](examples/next), [`examples/astro`](examples/astro).
175
+ # Front (público)
176
+ VITE_URANO_SOCIAL_RECAPTCHA_SITE_KEY=tu_site_key
177
+ # o NEXT_PUBLIC_URANO_SOCIAL_RECAPTCHA_SITE_KEY=
178
+ ```
146
179
 
147
- ## Publicar en npm (`@uranotools/urano-social`)
180
+ Con el secret configurado, el proxy exige y verifica el token **antes** del webhook.
148
181
 
149
- Igual que `@uranotools/urano-guard`: scope de la org **uranotools**, acceso **public**.
182
+ ---
150
183
 
151
- ```bash
152
- # 1. Cuenta con permiso en la org (una sola vez)
153
- npm login
154
- # Si el scope es nuevo en la org: npm org invite <user> --org=uranotools
155
-
156
- # 2. Desde esta carpeta
157
- npm test
158
- npm run build
159
- npm publish --access public
160
- # o: npm run publish:npm
161
- ```
184
+ ## Seguridad (resumen)
162
185
 
163
- Tras publicar:
186
+ - Cookie **HttpOnly** firmada: el visitante no elige su `visitorId` CRM
187
+ - Firma **HMAC** (`x-urano-signature`) solo en el proxy
188
+ - Controles de turno: `status` / `events` / `cancel` + `Idempotency-Key`
189
+ - Contacto filtrado antes de gastar tokens del agente
164
190
 
165
- ```bash
166
- npm install @uranotools/urano-social
167
- ```
191
+ ---
168
192
 
169
- Subidas siguientes: sube `version` en `package.json` (`0.1.1`, `0.2.0`, …) y vuelve a `npm publish`.
193
+ ## Ejemplos
170
194
 
171
- Dry-run sin subir nada:
195
+ - [`examples/next`](examples/next) — formulario de contacto + proxy
196
+ - [`examples/astro`](examples/astro) — misma idea en Astro
172
197
 
173
198
  ```bash
174
- npm publish --dry-run
199
+ cd examples/next
200
+ cp .env.example .env # pon URL + secreto del plugin
201
+ npm install
202
+ npm run dev # http://localhost:3011
175
203
  ```
176
204
 
177
- ### GitHub Actions (igual que urano-guard)
178
-
179
- 1. Secret del repo: **`NPM_TOKEN`** (Settings → Secrets and variables → Actions).
180
- Debe ser un [Automation token](https://www.npmjs.com/settings/~/tokens) con permiso de publish en la org `uranotools`.
181
- 2. Workflow: [`.github/workflows/publish.yml`](.github/workflows/publish.yml) — corre en **Release** o manualmente (`workflow_dispatch`).
182
- 3. Publicar una versión:
205
+ ---
183
206
 
184
- ```bash
185
- # sube version en package.json, commit, push
186
- git tag v0.2.0
187
- git push origin v0.2.0
188
- # Luego en GitHub → Releases → Create release from tag v0.2.0
189
- ```
207
+ ## Enlaces
190
208
 
191
- El job hace `npm ci` → typecheck → test → build → `npm publish --access public`.
209
+ - npm: [npmjs.com/package/@uranotools/urano-social](https://www.npmjs.com/package/@uranotools/urano-social)
210
+ - UranoAI: [uranoai.com](https://uranoai.com)
211
+ - Org: [github.com/uranotools](https://github.com/uranotools)
192
212
 
193
- Nota: `--provenance` solo funciona si el repo de GitHub es **público**. Con repo privado se publica sin provenance.
213
+ MIT © Urano Open Source
@@ -1,7 +1,4 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createUranoChat = createUranoChat;
4
- const sse_1 = require("./sse");
1
+ import { readSseStream } from './sse';
5
2
  function newMessageId(prefix) {
6
3
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
7
4
  }
@@ -9,7 +6,7 @@ function newMessageId(prefix) {
9
6
  * Headless chat controller. Talks only to the site proxy (`/api/urano-social`).
10
7
  * Never receives or sends the webhook secret.
11
8
  */
12
- function createUranoChat(options = {}) {
9
+ export function createUranoChat(options = {}) {
13
10
  const endpoint = (options.endpoint || '/api/urano-social').replace(/\/$/, '');
14
11
  let sessionId = options.sessionId ?? null;
15
12
  let displayName = options.displayName || '';
@@ -85,7 +82,7 @@ function createUranoChat(options = {}) {
85
82
  }
86
83
  const contentType = response.headers.get('Content-Type') || '';
87
84
  if (contentType.includes('text/event-stream')) {
88
- for await (const evt of (0, sse_1.readSseStream)(response, signal)) {
85
+ for await (const evt of readSseStream(response, signal)) {
89
86
  applyEvent(evt, assistant);
90
87
  }
91
88
  }
@@ -199,7 +196,7 @@ function createUranoChat(options = {}) {
199
196
  body: JSON.stringify(body),
200
197
  signal: controlOpts.signal,
201
198
  });
202
- return (0, sse_1.readSseStream)(response, controlOpts.signal);
199
+ return readSseStream(response, controlOpts.signal);
203
200
  }
204
201
  return fetch(`${endpoint}?mode=wait`, {
205
202
  method: 'POST',
@@ -1,5 +1,6 @@
1
1
  export { createUranoChat } from './createUranoChat';
2
2
  export type { UranoChat } from './createUranoChat';
3
3
  export { readSseStream, parseSseChunk } from './sse';
4
- export { submitContactForm } from './submitContactForm';
5
- export type { ContactFormPayload, ContactFormResult, SubmitContactFormOptions } from './submitContactForm';
4
+ export { submitContactForm, fetchContactTicket } from './submitContactForm';
5
+ export type { ContactFormPayload, ContactFormResult, ContactTicketResponse, SubmitContactFormOptions, } from './submitContactForm';
6
+ export { loadRecaptchaV3, executeRecaptchaV3 } from './recaptcha';
@@ -1,10 +1,4 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.submitContactForm = exports.parseSseChunk = exports.readSseStream = exports.createUranoChat = void 0;
4
- var createUranoChat_1 = require("./createUranoChat");
5
- Object.defineProperty(exports, "createUranoChat", { enumerable: true, get: function () { return createUranoChat_1.createUranoChat; } });
6
- var sse_1 = require("./sse");
7
- Object.defineProperty(exports, "readSseStream", { enumerable: true, get: function () { return sse_1.readSseStream; } });
8
- Object.defineProperty(exports, "parseSseChunk", { enumerable: true, get: function () { return sse_1.parseSseChunk; } });
9
- var submitContactForm_1 = require("./submitContactForm");
10
- Object.defineProperty(exports, "submitContactForm", { enumerable: true, get: function () { return submitContactForm_1.submitContactForm; } });
1
+ export { createUranoChat } from './createUranoChat';
2
+ export { readSseStream, parseSseChunk } from './sse';
3
+ export { submitContactForm, fetchContactTicket } from './submitContactForm';
4
+ export { loadRecaptchaV3, executeRecaptchaV3 } from './recaptcha';
@@ -0,0 +1,14 @@
1
+ declare global {
2
+ interface Window {
3
+ grecaptcha?: {
4
+ ready: (cb: () => void) => void;
5
+ execute: (siteKey: string, opts: {
6
+ action: string;
7
+ }) => Promise<string>;
8
+ };
9
+ }
10
+ }
11
+ /** Loads the reCAPTCHA v3 script once (browser only). */
12
+ export declare function loadRecaptchaV3(siteKey: string): Promise<void>;
13
+ /** Executes reCAPTCHA v3 and returns a short-lived token for the proxy. */
14
+ export declare function executeRecaptchaV3(siteKey: string, action?: string): Promise<string>;
@@ -0,0 +1,36 @@
1
+ const SCRIPT_ID = 'uranotools-recaptcha-v3';
2
+ /** Loads the reCAPTCHA v3 script once (browser only). */
3
+ export function loadRecaptchaV3(siteKey) {
4
+ if (typeof window === 'undefined') {
5
+ return Promise.reject(new Error('recaptcha_browser_only'));
6
+ }
7
+ if (!siteKey)
8
+ return Promise.reject(new Error('recaptcha_site_key_required'));
9
+ if (window.grecaptcha) {
10
+ return new Promise((resolve) => window.grecaptcha.ready(() => resolve()));
11
+ }
12
+ const existing = document.getElementById(SCRIPT_ID);
13
+ if (existing) {
14
+ return new Promise((resolve, reject) => {
15
+ existing.addEventListener('load', () => window.grecaptcha.ready(() => resolve()));
16
+ existing.addEventListener('error', () => reject(new Error('recaptcha_script_failed')));
17
+ });
18
+ }
19
+ return new Promise((resolve, reject) => {
20
+ const script = document.createElement('script');
21
+ script.id = SCRIPT_ID;
22
+ script.src = `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
23
+ script.async = true;
24
+ script.onload = () => window.grecaptcha.ready(() => resolve());
25
+ script.onerror = () => reject(new Error('recaptcha_script_failed'));
26
+ document.head.appendChild(script);
27
+ });
28
+ }
29
+ /** Executes reCAPTCHA v3 and returns a short-lived token for the proxy. */
30
+ export async function executeRecaptchaV3(siteKey, action = 'contact') {
31
+ await loadRecaptchaV3(siteKey);
32
+ const token = await window.grecaptcha.execute(siteKey, { action });
33
+ if (!token)
34
+ throw new Error('recaptcha_empty_token');
35
+ return token;
36
+ }
@@ -1,8 +1,4 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.readSseStream = readSseStream;
4
- exports.parseSseChunk = parseSseChunk;
5
- async function* readSseStream(response, signal) {
1
+ export async function* readSseStream(response, signal) {
6
2
  if (!response.body) {
7
3
  throw new Error('missing_response_body');
8
4
  }
@@ -73,7 +69,7 @@ async function* readSseStream(response, signal) {
73
69
  catch { /* ignore */ }
74
70
  }
75
71
  }
76
- function parseSseChunk(chunk) {
72
+ export function parseSseChunk(chunk) {
77
73
  const events = [];
78
74
  let eventType = 'message';
79
75
  let dataLines = [];
@@ -5,6 +5,15 @@ export interface ContactFormPayload {
5
5
  subject?: string;
6
6
  phone?: string;
7
7
  company?: string;
8
+ /**
9
+ * Honeypot value — must stay empty. Bots that fill it are silently dropped
10
+ * by the proxy (fake success, no AI call).
11
+ */
12
+ website?: string;
13
+ /** Signed ticket from GET ?action=contact-ticket (auto-fetched when omitted). */
14
+ contactTicket?: string;
15
+ /** reCAPTCHA v3 token (or pass `recaptchaSiteKey` in options to auto-execute). */
16
+ captchaToken?: string;
8
17
  }
9
18
  export interface ContactFormResult {
10
19
  ok: boolean;
@@ -16,15 +25,37 @@ export interface ContactFormResult {
16
25
  status: number;
17
26
  raw?: unknown;
18
27
  }
28
+ export interface ContactTicketResponse {
29
+ ticket: string;
30
+ minAgeMs: number;
31
+ maxAgeMs: number;
32
+ honeypotField: string;
33
+ recaptchaRequired: boolean;
34
+ recaptchaAction: string;
35
+ }
19
36
  export interface SubmitContactFormOptions {
20
37
  /** Site proxy endpoint. Default `/api/urano-social`. */
21
38
  endpoint?: string;
22
- /** Prefer GET ?mode=wait&payload=… (default true). */
39
+ /**
40
+ * Prefer GET ?mode=wait&payload=…
41
+ * Default **false** (POST) so tickets / captcha tokens stay out of URLs and logs.
42
+ */
23
43
  useGet?: boolean;
24
44
  signal?: AbortSignal;
45
+ /** When set, loads grecaptcha and executes action `contact` before submit. */
46
+ recaptchaSiteKey?: string;
47
+ /** Override reCAPTCHA action (must match server). Default `contact`. */
48
+ recaptchaAction?: string;
49
+ /** Skip auto ticket fetch (tests / custom flows). */
50
+ skipTicket?: boolean;
25
51
  }
52
+ /** Prefetch a signed contact ticket (call on form mount). */
53
+ export declare function fetchContactTicket(endpoint?: string, signal?: AbortSignal): Promise<ContactTicketResponse>;
26
54
  /**
27
55
  * Submits a contact form through the site proxy.
28
- * Default transport is GET + mode=wait (signed upstream by the server).
56
+ * Default transport is POST + mode=wait (signed upstream by the server).
57
+ *
58
+ * Prefer calling `fetchContactTicket` when the form mounts so humans don't
59
+ * wait the min-age delay at submit time.
29
60
  */
30
61
  export declare function submitContactForm(input: ContactFormPayload, options?: SubmitContactFormOptions): Promise<ContactFormResult>;
@@ -1,17 +1,82 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.submitContactForm = submitContactForm;
1
+ import { executeRecaptchaV3 } from './recaptcha';
2
+ function sleep(ms, signal) {
3
+ if (ms <= 0)
4
+ return Promise.resolve();
5
+ return new Promise((resolve, reject) => {
6
+ const timer = setTimeout(resolve, ms);
7
+ signal?.addEventListener('abort', () => {
8
+ clearTimeout(timer);
9
+ reject(new DOMException('Aborted', 'AbortError'));
10
+ }, { once: true });
11
+ });
12
+ }
13
+ /** Prefetch a signed contact ticket (call on form mount). */
14
+ export async function fetchContactTicket(endpoint = '/api/urano-social', signal) {
15
+ const base = endpoint.replace(/\/$/, '');
16
+ const res = await fetch(`${base}?action=contact-ticket`, {
17
+ method: 'GET',
18
+ credentials: 'include',
19
+ signal,
20
+ headers: { Accept: 'application/json' },
21
+ });
22
+ const raw = await res.json().catch(() => ({}));
23
+ if (!res.ok || !raw?.ticket) {
24
+ throw new Error(String(raw?.error || 'contact_ticket_failed'));
25
+ }
26
+ const minAgeMs = Number(raw.minAgeMs);
27
+ const maxAgeMs = Number(raw.maxAgeMs);
28
+ return {
29
+ ticket: String(raw.ticket),
30
+ minAgeMs: Number.isFinite(minAgeMs) ? minAgeMs : 2500,
31
+ maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : 30 * 60 * 1000,
32
+ honeypotField: String(raw.honeypotField || 'website'),
33
+ recaptchaRequired: Boolean(raw.recaptchaRequired),
34
+ recaptchaAction: String(raw.recaptchaAction || 'contact'),
35
+ };
36
+ }
4
37
  /**
5
38
  * Submits a contact form through the site proxy.
6
- * Default transport is GET + mode=wait (signed upstream by the server).
39
+ * Default transport is POST + mode=wait (signed upstream by the server).
40
+ *
41
+ * Prefer calling `fetchContactTicket` when the form mounts so humans don't
42
+ * wait the min-age delay at submit time.
7
43
  */
8
- async function submitContactForm(input, options = {}) {
44
+ export async function submitContactForm(input, options = {}) {
9
45
  const endpoint = (options.endpoint || '/api/urano-social').replace(/\/$/, '');
10
46
  const email = String(input.email || '').trim();
11
47
  const message = String(input.message || '').trim();
12
48
  if (!email || !message) {
13
49
  return { ok: false, status: 400, error: 'email_and_message_required' };
14
50
  }
51
+ let contactTicket = input.contactTicket;
52
+ if (!contactTicket && !options.skipTicket) {
53
+ try {
54
+ const issued = await fetchContactTicket(endpoint, options.signal);
55
+ contactTicket = issued.ticket;
56
+ // Ticket was just minted — wait min age before POST (bot / headless path).
57
+ await sleep(issued.minAgeMs, options.signal);
58
+ }
59
+ catch (error) {
60
+ return {
61
+ ok: false,
62
+ status: 403,
63
+ error: error?.message || 'contact_ticket_failed',
64
+ };
65
+ }
66
+ }
67
+ let captchaToken = input.captchaToken;
68
+ if (!captchaToken && options.recaptchaSiteKey) {
69
+ try {
70
+ captchaToken = await executeRecaptchaV3(options.recaptchaSiteKey, options.recaptchaAction || 'contact');
71
+ }
72
+ catch (error) {
73
+ return {
74
+ ok: false,
75
+ status: 403,
76
+ error: error?.message || 'recaptcha_failed',
77
+ };
78
+ }
79
+ }
15
80
  const payload = {
16
81
  type: 'contact',
17
82
  email,
@@ -20,8 +85,11 @@ async function submitContactForm(input, options = {}) {
20
85
  message,
21
86
  phone: input.phone || undefined,
22
87
  company: input.company || undefined,
88
+ website: input.website ?? '',
89
+ contactTicket,
90
+ captchaToken: captchaToken || undefined,
23
91
  };
24
- const useGet = options.useGet !== false;
92
+ const useGet = options.useGet === true;
25
93
  let response;
26
94
  if (useGet) {
27
95
  const url = `${endpoint}?mode=wait&payload=${encodeURIComponent(JSON.stringify(payload))}`;