@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 +16 -0
- package/README.md +122 -102
- package/dist/client/createUranoChat.js +4 -7
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.js +4 -10
- package/dist/client/recaptcha.d.ts +14 -0
- package/dist/client/recaptcha.js +36 -0
- package/dist/client/sse.js +2 -6
- package/dist/client/submitContactForm.d.ts +33 -2
- package/dist/client/submitContactForm.js +74 -6
- package/dist/crypto.js +6 -46
- package/dist/flavors/UranoSocialChatElement.js +4 -9
- package/dist/flavors/UranoSocialContactFormElement.js +17 -12
- package/dist/index.d.ts +5 -3
- package/dist/index.js +10 -26
- package/dist/react/UranoSocialChat.js +25 -62
- package/dist/react/UranoSocialContactForm.d.ts +4 -0
- package/dist/react/UranoSocialContactForm.js +46 -16
- package/dist/react/index.js +2 -8
- package/dist/server/antiSpam.d.ts +76 -0
- package/dist/server/antiSpam.js +204 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +4 -13
- package/dist/server/proxy.js +58 -12
- package/dist/server/visitorCookie.js +8 -13
- package/dist/server/websocket.js +8 -12
- package/dist/types.d.ts +30 -0
- package/dist/types.js +3 -2
- package/package.json +2 -1
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
|
|
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
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@uranotools/urano-social)
|
|
6
|
+
[](./LICENSE)
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
10
|
+
---
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
+
URANO_SOCIAL_WEBHOOK_SECRET=…
|
|
24
62
|
```
|
|
25
63
|
|
|
26
|
-
|
|
64
|
+
No uses prefijos `NEXT_PUBLIC_` / `VITE_` / `PUBLIC_` para el secreto.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Arranque en 3 pasos
|
|
27
69
|
|
|
28
|
-
###
|
|
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
|
-
//
|
|
87
|
+
// return proxy.handle(request)
|
|
45
88
|
```
|
|
46
89
|
|
|
47
|
-
###
|
|
90
|
+
### 2. Widget de chat
|
|
48
91
|
|
|
49
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
|
|
140
|
+
await submitContactForm({
|
|
141
|
+
email: 'ana@empresa.com',
|
|
142
|
+
name: 'Ana',
|
|
143
|
+
message: 'Quiero una demo',
|
|
144
|
+
});
|
|
108
145
|
```
|
|
109
146
|
|
|
110
|
-
|
|
147
|
+
---
|
|
111
148
|
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
+
---
|
|
125
160
|
|
|
126
|
-
|
|
161
|
+
## Anti-spam en formularios
|
|
127
162
|
|
|
128
|
-
|
|
129
|
-
import http from 'http';
|
|
130
|
-
import { attachSocialWebSocket } from '@uranotools/urano-social/server';
|
|
163
|
+
Incluido de fábrica:
|
|
131
164
|
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
169
|
+
Opcional — **reCAPTCHA v3** (solo pegar keys):
|
|
137
170
|
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
npm test
|
|
142
|
-
npm run build
|
|
143
|
-
```
|
|
171
|
+
```env
|
|
172
|
+
# Servidor
|
|
173
|
+
URANO_SOCIAL_RECAPTCHA_SECRET=tu_secret_de_google
|
|
144
174
|
|
|
145
|
-
|
|
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
|
-
|
|
180
|
+
Con el secret configurado, el proxy exige y verifica el token **antes** del webhook.
|
|
148
181
|
|
|
149
|
-
|
|
182
|
+
---
|
|
150
183
|
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
npm install @uranotools/urano-social
|
|
167
|
-
```
|
|
191
|
+
---
|
|
168
192
|
|
|
169
|
-
|
|
193
|
+
## Ejemplos
|
|
170
194
|
|
|
171
|
-
|
|
195
|
+
- [`examples/next`](examples/next) — formulario de contacto + proxy
|
|
196
|
+
- [`examples/astro`](examples/astro) — misma idea en Astro
|
|
172
197
|
|
|
173
198
|
```bash
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
+
MIT © Urano Open Source
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
199
|
+
return readSseStream(response, controlOpts.signal);
|
|
203
200
|
}
|
|
204
201
|
return fetch(`${endpoint}?mode=wait`, {
|
|
205
202
|
method: 'POST',
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/client/index.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
}
|
package/dist/client/sse.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
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))}`;
|