@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.
@@ -0,0 +1,204 @@
1
+ import { randomId, signHmacHex, verifyHmacHex } from '../crypto';
2
+ import { DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION, } from '../types';
3
+ export { DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION };
4
+ export const DEFAULT_RECAPTCHA_MIN_SCORE = 0.5;
5
+ export const DEFAULT_CONTACT_MIN_AGE_MS = 2500;
6
+ export const DEFAULT_CONTACT_MAX_AGE_MS = 30 * 60 * 1000;
7
+ export const DEFAULT_CONTACT_MAX_PER_HOUR = 5;
8
+ export const DEFAULT_CONTACT_MAX_MESSAGE_CHARS = 4000;
9
+ /** Process-local buckets (good enough for single Node / Pages isolate). */
10
+ const rateBuckets = new Map();
11
+ export function resolveAntiSpamConfig(partial) {
12
+ const enabledEnv = process.env.URANO_SOCIAL_ANTISPAM;
13
+ const enabled = partial?.enabled ?? (enabledEnv == null ? true : !['0', 'false', 'off'].includes(enabledEnv.toLowerCase()));
14
+ const recaptchaSecret = partial?.recaptchaSecret
15
+ || process.env.URANO_SOCIAL_RECAPTCHA_SECRET
16
+ || process.env.RECAPTCHA_SECRET_KEY
17
+ || '';
18
+ const minScoreRaw = partial?.recaptchaMinScore
19
+ ?? (process.env.URANO_SOCIAL_RECAPTCHA_MIN_SCORE
20
+ ? Number(process.env.URANO_SOCIAL_RECAPTCHA_MIN_SCORE)
21
+ : DEFAULT_RECAPTCHA_MIN_SCORE);
22
+ const maxPerHourRaw = partial?.maxPerHour
23
+ ?? (process.env.URANO_SOCIAL_CONTACT_MAX_PER_HOUR
24
+ ? Number(process.env.URANO_SOCIAL_CONTACT_MAX_PER_HOUR)
25
+ : DEFAULT_CONTACT_MAX_PER_HOUR);
26
+ return {
27
+ enabled,
28
+ honeypotField: partial?.honeypotField || process.env.URANO_SOCIAL_HONEYPOT_FIELD || DEFAULT_HONEYPOT_FIELD,
29
+ minSubmitAgeMs: partial?.minSubmitAgeMs
30
+ ?? (process.env.URANO_SOCIAL_CONTACT_MIN_AGE_MS
31
+ ? Number(process.env.URANO_SOCIAL_CONTACT_MIN_AGE_MS)
32
+ : DEFAULT_CONTACT_MIN_AGE_MS),
33
+ maxSubmitAgeMs: partial?.maxSubmitAgeMs
34
+ ?? (process.env.URANO_SOCIAL_CONTACT_MAX_AGE_MS
35
+ ? Number(process.env.URANO_SOCIAL_CONTACT_MAX_AGE_MS)
36
+ : DEFAULT_CONTACT_MAX_AGE_MS),
37
+ maxPerHour: Number.isFinite(maxPerHourRaw) ? Math.max(1, maxPerHourRaw) : DEFAULT_CONTACT_MAX_PER_HOUR,
38
+ maxMessageChars: partial?.maxMessageChars
39
+ ?? (process.env.URANO_SOCIAL_CONTACT_MAX_MESSAGE_CHARS
40
+ ? Number(process.env.URANO_SOCIAL_CONTACT_MAX_MESSAGE_CHARS)
41
+ : DEFAULT_CONTACT_MAX_MESSAGE_CHARS),
42
+ recaptchaSecret: recaptchaSecret || undefined,
43
+ recaptchaMinScore: Number.isFinite(minScoreRaw) ? minScoreRaw : DEFAULT_RECAPTCHA_MIN_SCORE,
44
+ recaptchaAction: partial?.recaptchaAction
45
+ || process.env.URANO_SOCIAL_RECAPTCHA_ACTION
46
+ || DEFAULT_RECAPTCHA_ACTION,
47
+ requireRecaptcha: partial?.requireRecaptcha
48
+ ?? (recaptchaSecret
49
+ ? true
50
+ : ['1', 'true', 'on'].includes(String(process.env.URANO_SOCIAL_REQUIRE_RECAPTCHA || '').toLowerCase())),
51
+ };
52
+ }
53
+ export function issueContactTicket(secret, now = Date.now()) {
54
+ const nonce = randomId('ct');
55
+ const payload = `${now}.${nonce}`;
56
+ return `${payload}.${signHmacHex(payload, secret)}`;
57
+ }
58
+ export function verifyContactTicket(ticket, secret, opts, now = Date.now()) {
59
+ if (!ticket || typeof ticket !== 'string') {
60
+ return { ok: false, error: 'contact_ticket_required' };
61
+ }
62
+ const parts = ticket.split('.');
63
+ if (parts.length < 3)
64
+ return { ok: false, error: 'contact_ticket_invalid' };
65
+ const sig = parts.pop();
66
+ const payload = parts.join('.');
67
+ const [issuedRaw] = parts;
68
+ const issuedAt = Number(issuedRaw);
69
+ if (!Number.isFinite(issuedAt) || !verifyHmacHex(payload, sig, secret)) {
70
+ return { ok: false, error: 'contact_ticket_invalid' };
71
+ }
72
+ const age = now - issuedAt;
73
+ if (age < opts.minAgeMs)
74
+ return { ok: false, error: 'contact_too_fast' };
75
+ if (age > opts.maxAgeMs)
76
+ return { ok: false, error: 'contact_ticket_expired' };
77
+ return { ok: true, issuedAt };
78
+ }
79
+ export function checkContactRateLimit(key, maxPerHour, now = Date.now()) {
80
+ const bucket = rateBuckets.get(key);
81
+ if (!bucket || bucket.resetAt <= now) {
82
+ rateBuckets.set(key, { count: 1, resetAt: now + 60 * 60 * 1000 });
83
+ return { ok: true };
84
+ }
85
+ if (bucket.count >= maxPerHour) {
86
+ return {
87
+ ok: false,
88
+ error: 'contact_rate_limited',
89
+ retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
90
+ };
91
+ }
92
+ bucket.count += 1;
93
+ return { ok: true };
94
+ }
95
+ /** Test helper — clears in-memory rate buckets. */
96
+ export function resetContactRateLimits() {
97
+ rateBuckets.clear();
98
+ }
99
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
100
+ export function validateContactFields(input) {
101
+ const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : '';
102
+ const message = typeof input.message === 'string' ? input.message.trim() : '';
103
+ if (!email || !message)
104
+ return { ok: false, error: 'email_and_message_required' };
105
+ if (!EMAIL_RE.test(email) || email.length > 254)
106
+ return { ok: false, error: 'invalid_email' };
107
+ if (message.length > input.maxMessageChars)
108
+ return { ok: false, error: 'message_too_long' };
109
+ return { ok: true, email, message };
110
+ }
111
+ export async function verifyRecaptchaV3(opts) {
112
+ if (!opts.token)
113
+ return { ok: false, error: 'recaptcha_required' };
114
+ const body = new URLSearchParams();
115
+ body.set('secret', opts.secret);
116
+ body.set('response', opts.token);
117
+ if (opts.remoteIp)
118
+ body.set('remoteip', opts.remoteIp);
119
+ let data;
120
+ try {
121
+ const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
124
+ body,
125
+ });
126
+ data = await res.json();
127
+ }
128
+ catch {
129
+ return { ok: false, error: 'recaptcha_unreachable' };
130
+ }
131
+ if (!data?.success)
132
+ return { ok: false, error: 'recaptcha_failed' };
133
+ if (opts.expectedAction && data.action && data.action !== opts.expectedAction) {
134
+ return { ok: false, error: 'recaptcha_action_mismatch' };
135
+ }
136
+ const score = typeof data.score === 'number' ? data.score : 0;
137
+ if (score < opts.minScore)
138
+ return { ok: false, error: 'recaptcha_low_score' };
139
+ return { ok: true, score };
140
+ }
141
+ export function clientIpFromRequest(request) {
142
+ const forwarded = request.headers.get('cf-connecting-ip')
143
+ || request.headers.get('x-real-ip')
144
+ || request.headers.get('x-forwarded-for');
145
+ if (!forwarded)
146
+ return null;
147
+ return forwarded.split(',')[0]?.trim() || null;
148
+ }
149
+ /**
150
+ * Runs contact anti-spam gates. On honeypot hit returns softOk so the proxy
151
+ * can fake a success without calling the AI webhook.
152
+ */
153
+ export async function enforceContactAntiSpam(opts) {
154
+ const { body, request, visitorId, ticketSecret, config } = opts;
155
+ if (!config.enabled)
156
+ return { ok: true };
157
+ const honeypotField = config.honeypotField || DEFAULT_HONEYPOT_FIELD;
158
+ const honeypotValue = body[honeypotField];
159
+ if (typeof honeypotValue === 'string' && honeypotValue.trim() !== '') {
160
+ return { ok: false, softOk: true, fakeText: 'Gracias. Te responderemos pronto.' };
161
+ }
162
+ const fields = validateContactFields({
163
+ email: body.email,
164
+ message: body.message ?? body.text,
165
+ maxMessageChars: config.maxMessageChars || DEFAULT_CONTACT_MAX_MESSAGE_CHARS,
166
+ });
167
+ if (!fields.ok)
168
+ return { ok: false, status: 400, error: fields.error };
169
+ const ticket = typeof body.contactTicket === 'string'
170
+ ? body.contactTicket
171
+ : typeof body.ticket === 'string'
172
+ ? body.ticket
173
+ : undefined;
174
+ const ticketCheck = verifyContactTicket(ticket, ticketSecret, {
175
+ minAgeMs: config.minSubmitAgeMs ?? DEFAULT_CONTACT_MIN_AGE_MS,
176
+ maxAgeMs: config.maxSubmitAgeMs ?? DEFAULT_CONTACT_MAX_AGE_MS,
177
+ });
178
+ if (!ticketCheck.ok)
179
+ return { ok: false, status: 403, error: ticketCheck.error };
180
+ const ip = clientIpFromRequest(request);
181
+ const rateKey = `contact:${visitorId}:${ip || 'noip'}`;
182
+ const rate = checkContactRateLimit(rateKey, config.maxPerHour ?? DEFAULT_CONTACT_MAX_PER_HOUR);
183
+ if (!rate.ok) {
184
+ return { ok: false, status: 429, error: rate.error, retryAfterSec: rate.retryAfterSec };
185
+ }
186
+ const needsCaptcha = Boolean(config.recaptchaSecret) && config.requireRecaptcha !== false;
187
+ if (needsCaptcha) {
188
+ const token = typeof body.captchaToken === 'string'
189
+ ? body.captchaToken
190
+ : typeof body.recaptchaToken === 'string'
191
+ ? body.recaptchaToken
192
+ : '';
193
+ const captcha = await verifyRecaptchaV3({
194
+ secret: config.recaptchaSecret,
195
+ token,
196
+ minScore: config.recaptchaMinScore ?? DEFAULT_RECAPTCHA_MIN_SCORE,
197
+ expectedAction: config.recaptchaAction || DEFAULT_RECAPTCHA_ACTION,
198
+ remoteIp: ip,
199
+ });
200
+ if (!captcha.ok)
201
+ return { ok: false, status: 403, error: captcha.error };
202
+ }
203
+ return { ok: true };
204
+ }
@@ -1,3 +1,4 @@
1
1
  export { createSocialProxy, socialProxyRoute } from './proxy';
2
2
  export { attachSocialWebSocket, buildWsEnvelope } from './websocket';
3
3
  export { resolveVisitorId, parseCookies, DEFAULT_COOKIE_NAME } from './visitorCookie';
4
+ export { resolveAntiSpamConfig, issueContactTicket, verifyContactTicket, verifyRecaptchaV3, enforceContactAntiSpam, resetContactRateLimits, DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION, } from './antiSpam';
@@ -1,13 +1,4 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_COOKIE_NAME = exports.parseCookies = exports.resolveVisitorId = exports.buildWsEnvelope = exports.attachSocialWebSocket = exports.socialProxyRoute = exports.createSocialProxy = void 0;
4
- var proxy_1 = require("./proxy");
5
- Object.defineProperty(exports, "createSocialProxy", { enumerable: true, get: function () { return proxy_1.createSocialProxy; } });
6
- Object.defineProperty(exports, "socialProxyRoute", { enumerable: true, get: function () { return proxy_1.socialProxyRoute; } });
7
- var websocket_1 = require("./websocket");
8
- Object.defineProperty(exports, "attachSocialWebSocket", { enumerable: true, get: function () { return websocket_1.attachSocialWebSocket; } });
9
- Object.defineProperty(exports, "buildWsEnvelope", { enumerable: true, get: function () { return websocket_1.buildWsEnvelope; } });
10
- var visitorCookie_1 = require("./visitorCookie");
11
- Object.defineProperty(exports, "resolveVisitorId", { enumerable: true, get: function () { return visitorCookie_1.resolveVisitorId; } });
12
- Object.defineProperty(exports, "parseCookies", { enumerable: true, get: function () { return visitorCookie_1.parseCookies; } });
13
- Object.defineProperty(exports, "DEFAULT_COOKIE_NAME", { enumerable: true, get: function () { return visitorCookie_1.DEFAULT_COOKIE_NAME; } });
1
+ export { createSocialProxy, socialProxyRoute } from './proxy';
2
+ export { attachSocialWebSocket, buildWsEnvelope } from './websocket';
3
+ export { resolveVisitorId, parseCookies, DEFAULT_COOKIE_NAME } from './visitorCookie';
4
+ export { resolveAntiSpamConfig, issueContactTicket, verifyContactTicket, verifyRecaptchaV3, enforceContactAntiSpam, resetContactRateLimits, DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION, } from './antiSpam';
@@ -1,9 +1,6 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createSocialProxy = createSocialProxy;
4
- exports.socialProxyRoute = socialProxyRoute;
5
- const crypto_1 = require("../crypto");
6
- const visitorCookie_1 = require("./visitorCookie");
1
+ import { signHmacHex } from '../crypto';
2
+ import { resolveVisitorId, DEFAULT_COOKIE_NAME } from './visitorCookie';
3
+ import { enforceContactAntiSpam, issueContactTicket, resolveAntiSpamConfig, } from './antiSpam';
7
4
  function envConfig(partial) {
8
5
  const webhookUrl = partial?.webhookUrl || process.env.URANO_SOCIAL_WEBHOOK_URL || '';
9
6
  const webhookSecret = partial?.webhookSecret || process.env.URANO_SOCIAL_WEBHOOK_SECRET || '';
@@ -15,9 +12,10 @@ function envConfig(partial) {
15
12
  webhookUrl: webhookUrl.replace(/\/$/, ''),
16
13
  webhookSecret,
17
14
  cookieSecret: partial?.cookieSecret || process.env.URANO_SOCIAL_COOKIE_SECRET || webhookSecret,
18
- cookieName: partial?.cookieName || visitorCookie_1.DEFAULT_COOKIE_NAME,
15
+ cookieName: partial?.cookieName || DEFAULT_COOKIE_NAME,
19
16
  cookieMaxAgeSec: partial?.cookieMaxAgeSec,
20
17
  defaultDisplayName: partial?.defaultDisplayName,
18
+ antiSpam: resolveAntiSpamConfig(partial?.antiSpam),
21
19
  };
22
20
  }
23
21
  function json(status, body, headers) {
@@ -41,8 +39,9 @@ async function readJson(request) {
41
39
  * Creates a site-local proxy that signs webhook traffic with the server secret.
42
40
  * The browser never receives WEBHOOK_SECRET.
43
41
  */
44
- function createSocialProxy(partial) {
42
+ export function createSocialProxy(partial) {
45
43
  const config = envConfig(partial);
44
+ const antiSpam = resolveAntiSpamConfig(config.antiSpam);
46
45
  async function handle(request) {
47
46
  const url = new URL(request.url);
48
47
  const method = request.method.toUpperCase();
@@ -57,7 +56,7 @@ function createSocialProxy(partial) {
57
56
  },
58
57
  });
59
58
  }
60
- const visitor = (0, visitorCookie_1.resolveVisitorId)({
59
+ const visitor = resolveVisitorId({
61
60
  cookieHeader: request.headers.get('cookie'),
62
61
  cookieName: config.cookieName,
63
62
  cookieSecret: config.cookieSecret,
@@ -65,7 +64,20 @@ function createSocialProxy(partial) {
65
64
  const responseHeaders = new Headers();
66
65
  if (visitor.setCookie)
67
66
  responseHeaders.append('Set-Cookie', visitor.setCookie);
68
- if (method === 'GET' && !url.searchParams.get('mode') && !url.searchParams.get('payload')) {
67
+ const action = url.searchParams.get('action');
68
+ if (method === 'GET' && action === 'contact-ticket') {
69
+ const ticket = issueContactTicket(config.cookieSecret || config.webhookSecret);
70
+ return json(200, {
71
+ ok: true,
72
+ ticket,
73
+ minAgeMs: antiSpam.minSubmitAgeMs,
74
+ maxAgeMs: antiSpam.maxSubmitAgeMs,
75
+ honeypotField: antiSpam.honeypotField,
76
+ recaptchaRequired: Boolean(antiSpam.recaptchaSecret) && antiSpam.requireRecaptcha !== false,
77
+ recaptchaAction: antiSpam.recaptchaAction,
78
+ }, responseHeaders);
79
+ }
80
+ if (method === 'GET' && !url.searchParams.get('mode') && !url.searchParams.get('payload') && !action) {
69
81
  try {
70
82
  const discovery = await fetch(config.webhookUrl, { method: 'GET' });
71
83
  const body = await discovery.json().catch(() => ({ ok: discovery.ok }));
@@ -74,6 +86,15 @@ function createSocialProxy(partial) {
74
86
  ok: true,
75
87
  proxy: true,
76
88
  visitorId: visitor.visitorId,
89
+ antiSpam: {
90
+ enabled: antiSpam.enabled !== false,
91
+ honeypotField: antiSpam.honeypotField,
92
+ ticket: true,
93
+ rateLimitPerHour: antiSpam.maxPerHour,
94
+ recaptcha: Boolean(antiSpam.recaptchaSecret),
95
+ recaptchaRequired: Boolean(antiSpam.recaptchaSecret) && antiSpam.requireRecaptcha !== false,
96
+ recaptchaAction: antiSpam.recaptchaAction,
97
+ },
77
98
  upstream: body,
78
99
  }), { status: 200, headers: responseHeaders });
79
100
  }
@@ -98,6 +119,31 @@ function createSocialProxy(partial) {
98
119
  || clientBody.mode
99
120
  || ((request.headers.get('Accept') || '').includes('text/event-stream') ? 'sse' : 'wait'));
100
121
  const isContact = ['contact', 'email', 'form'].includes(String(clientBody.type || clientBody.channel || '').toLowerCase());
122
+ if (isContact) {
123
+ const gate = await enforceContactAntiSpam({
124
+ body: clientBody,
125
+ request,
126
+ visitorId: visitor.visitorId,
127
+ ticketSecret: config.cookieSecret || config.webhookSecret,
128
+ config: antiSpam,
129
+ });
130
+ if (!gate.ok) {
131
+ if ('softOk' in gate && gate.softOk) {
132
+ return json(200, {
133
+ ok: true,
134
+ channel: 'contact',
135
+ delivered: 'honeypot',
136
+ text: gate.fakeText,
137
+ }, responseHeaders);
138
+ }
139
+ if ('status' in gate) {
140
+ if (gate.retryAfterSec)
141
+ responseHeaders.set('Retry-After', String(gate.retryAfterSec));
142
+ return json(gate.status, { error: gate.error, retryAfterSec: gate.retryAfterSec }, responseHeaders);
143
+ }
144
+ return json(403, { error: 'contact_rejected' }, responseHeaders);
145
+ }
146
+ }
101
147
  const outbound = isContact
102
148
  ? { type: 'contact' }
103
149
  : {
@@ -146,7 +192,7 @@ function createSocialProxy(partial) {
146
192
  delete clientBody.visitorId;
147
193
  delete clientBody.from;
148
194
  const rawBody = JSON.stringify(outbound);
149
- const signature = (0, crypto_1.signHmacHex)(rawBody, config.webhookSecret);
195
+ const signature = signHmacHex(rawBody, config.webhookSecret);
150
196
  const idempotency = request.headers.get('Idempotency-Key')
151
197
  || (typeof outbound.idempotencyKey === 'string' ? outbound.idempotencyKey : undefined);
152
198
  const upstreamHeaders = {
@@ -197,7 +243,7 @@ function createSocialProxy(partial) {
197
243
  return { handle };
198
244
  }
199
245
  /** Convenience for Next.js App Router route handlers. */
200
- function socialProxyRoute(partial) {
246
+ export function socialProxyRoute(partial) {
201
247
  const proxy = createSocialProxy(partial);
202
248
  return {
203
249
  GET: (request) => proxy.handle(request),
@@ -1,11 +1,6 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_COOKIE_NAME = void 0;
4
- exports.parseCookies = parseCookies;
5
- exports.resolveVisitorId = resolveVisitorId;
6
- const crypto_1 = require("../crypto");
7
- exports.DEFAULT_COOKIE_NAME = 'urano_social_vid';
8
- function parseCookies(header) {
1
+ import { randomId, signCookieValue, verifyCookieValue } from '../crypto';
2
+ export const DEFAULT_COOKIE_NAME = 'urano_social_vid';
3
+ export function parseCookies(header) {
9
4
  const out = {};
10
5
  if (!header)
11
6
  return out;
@@ -20,17 +15,17 @@ function parseCookies(header) {
20
15
  }
21
16
  return out;
22
17
  }
23
- function resolveVisitorId(opts) {
24
- const name = opts.cookieName || exports.DEFAULT_COOKIE_NAME;
18
+ export function resolveVisitorId(opts) {
19
+ const name = opts.cookieName || DEFAULT_COOKIE_NAME;
25
20
  const cookies = parseCookies(opts.cookieHeader);
26
21
  const existing = cookies[name];
27
22
  if (existing) {
28
- const verified = (0, crypto_1.verifyCookieValue)(existing, opts.cookieSecret);
23
+ const verified = verifyCookieValue(existing, opts.cookieSecret);
29
24
  if (verified)
30
25
  return { visitorId: verified, isNew: false };
31
26
  }
32
- const visitorId = (0, crypto_1.randomId)('vis');
33
- const signed = (0, crypto_1.signCookieValue)(visitorId, opts.cookieSecret);
27
+ const visitorId = randomId('vis');
28
+ const signed = signCookieValue(visitorId, opts.cookieSecret);
34
29
  const maxAge = 60 * 60 * 24 * 365;
35
30
  const setCookie = [
36
31
  `${name}=${encodeURIComponent(signed)}`,
@@ -1,19 +1,15 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.attachSocialWebSocket = attachSocialWebSocket;
4
- exports.buildWsEnvelope = buildWsEnvelope;
5
- const crypto_1 = require("../crypto");
6
- const visitorCookie_1 = require("./visitorCookie");
1
+ import { signHmacHex } from '../crypto';
2
+ import { resolveVisitorId, DEFAULT_COOKIE_NAME } from './visitorCookie';
7
3
  /**
8
4
  * Attaches an optional WebSocket bridge to a Node HTTP server.
9
5
  * Next App Router cannot upgrade sockets in a route handler — use SSE there.
10
6
  * Astro Node / custom servers can call this after `http.createServer`.
11
7
  */
12
- function attachSocialWebSocket(server, partial) {
8
+ export function attachSocialWebSocket(server, partial) {
13
9
  const webhookUrl = (partial?.webhookUrl || process.env.URANO_SOCIAL_WEBHOOK_URL || '').replace(/\/$/, '');
14
10
  const webhookSecret = partial?.webhookSecret || process.env.URANO_SOCIAL_WEBHOOK_SECRET || '';
15
11
  const cookieSecret = partial?.cookieSecret || process.env.URANO_SOCIAL_COOKIE_SECRET || webhookSecret;
16
- const cookieName = partial?.cookieName || visitorCookie_1.DEFAULT_COOKIE_NAME;
12
+ const cookieName = partial?.cookieName || DEFAULT_COOKIE_NAME;
17
13
  const path = partial?.path || '/api/urano-social/ws';
18
14
  if (!webhookUrl || !webhookSecret) {
19
15
  throw new Error('attachSocialWebSocket requires URANO_SOCIAL_WEBHOOK_URL and URANO_SOCIAL_WEBHOOK_SECRET');
@@ -23,7 +19,7 @@ function attachSocialWebSocket(server, partial) {
23
19
  const url = new URL(req.url || '/', 'http://localhost');
24
20
  if (url.pathname !== path)
25
21
  return;
26
- const visitor = (0, visitorCookie_1.resolveVisitorId)({
22
+ const visitor = resolveVisitorId({
27
23
  cookieHeader: req.headers.cookie,
28
24
  cookieName,
29
25
  cookieSecret,
@@ -68,7 +64,7 @@ function attachSocialWebSocket(server, partial) {
68
64
  turnId: msg.turnId,
69
65
  };
70
66
  const payloadStr = JSON.stringify(payloadObj);
71
- const signature = (0, crypto_1.signHmacHex)(payloadStr, webhookSecret);
67
+ const signature = signHmacHex(payloadStr, webhookSecret);
72
68
  upstream.send(JSON.stringify({
73
69
  payload: payloadStr,
74
70
  headers: {
@@ -117,12 +113,12 @@ function attachSocialWebSocket(server, partial) {
117
113
  return () => server.off('upgrade', onUpgrade);
118
114
  }
119
115
  /** Exported for tests — builds the WS envelope the upstream expects. */
120
- function buildWsEnvelope(payload, secret) {
116
+ export function buildWsEnvelope(payload, secret) {
121
117
  const payloadStr = JSON.stringify(payload);
122
118
  return {
123
119
  payload: payloadStr,
124
120
  headers: {
125
- 'x-urano-signature': (0, crypto_1.signHmacHex)(payloadStr, secret),
121
+ 'x-urano-signature': signHmacHex(payloadStr, secret),
126
122
  'Content-Type': 'application/json',
127
123
  'X-Urano-Protocol-Version': '1',
128
124
  },
package/dist/types.d.ts CHANGED
@@ -1,5 +1,33 @@
1
1
  export type SocialTransport = 'sse' | 'wait' | 'ws' | 'async';
2
2
  export type WebhookAction = 'status' | 'events' | 'cancel';
3
+ /** Shared honeypot field name (safe to import from browser bundles). */
4
+ export declare const DEFAULT_HONEYPOT_FIELD = "website";
5
+ export declare const DEFAULT_RECAPTCHA_ACTION = "contact";
6
+ export interface ContactAntiSpamConfig {
7
+ /** Master switch for contact gates. Default true. */
8
+ enabled?: boolean;
9
+ /** Hidden field name bots fill. Default `website`. */
10
+ honeypotField?: string;
11
+ /** Minimum ms between issuing a contact ticket and accepting submit. Default 2500. */
12
+ minSubmitAgeMs?: number;
13
+ /** Maximum ticket lifetime. Default 30 minutes. */
14
+ maxSubmitAgeMs?: number;
15
+ /** Max contact submits per visitor+IP per hour. Default 5. */
16
+ maxPerHour?: number;
17
+ /** Max message length. Default 4000. */
18
+ maxMessageChars?: number;
19
+ /** Google reCAPTCHA v3 secret (server-only). Env: URANO_SOCIAL_RECAPTCHA_SECRET */
20
+ recaptchaSecret?: string;
21
+ /** Minimum v3 score. Default 0.5. */
22
+ recaptchaMinScore?: number;
23
+ /** Expected grecaptcha action. Default `contact`. */
24
+ recaptchaAction?: string;
25
+ /**
26
+ * When a recaptcha secret is set, require a token (default true).
27
+ * Set false to verify only when the client sends a token.
28
+ */
29
+ requireRecaptcha?: boolean;
30
+ }
3
31
  export interface SocialProxyConfig {
4
32
  webhookUrl: string;
5
33
  webhookSecret: string;
@@ -9,6 +37,8 @@ export interface SocialProxyConfig {
9
37
  cookieMaxAgeSec?: number;
10
38
  /** Default display name forwarded as displayName when the client omits one. */
11
39
  defaultDisplayName?: string;
40
+ /** Contact-form anti-spam (honeypot, ticket, rate limit, optional reCAPTCHA v3). */
41
+ antiSpam?: ContactAntiSpamConfig;
12
42
  }
13
43
  export interface SocialClientMessage {
14
44
  id: string;
package/dist/types.js CHANGED
@@ -1,2 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
1
+ /** Shared honeypot field name (safe to import from browser bundles). */
2
+ export const DEFAULT_HONEYPOT_FIELD = 'website';
3
+ export const DEFAULT_RECAPTCHA_ACTION = 'contact';
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@uranotools/urano-social",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Customer-support chat widget for SalesAgentConnector MCP webhooks (SSE / WebSocket / wait)",
5
+ "type": "module",
5
6
  "main": "dist/index.js",
6
7
  "types": "dist/index.d.ts",
7
8
  "scripts": {