@uranotools/urano-social 0.0.0-stage → 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.
Files changed (41) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +212 -2
  4. package/dist/client/createUranoChat.d.ts +16 -0
  5. package/dist/client/createUranoChat.js +223 -0
  6. package/dist/client/index.d.ts +6 -0
  7. package/dist/client/index.js +4 -0
  8. package/dist/client/recaptcha.d.ts +14 -0
  9. package/dist/client/recaptcha.js +36 -0
  10. package/dist/client/sse.d.ts +3 -0
  11. package/dist/client/sse.js +104 -0
  12. package/dist/client/submitContactForm.d.ts +61 -0
  13. package/dist/client/submitContactForm.js +133 -0
  14. package/dist/crypto.d.ts +5 -0
  15. package/dist/crypto.js +35 -0
  16. package/dist/flavors/UranoSocialChatElement.d.ts +13 -0
  17. package/dist/flavors/UranoSocialChatElement.js +173 -0
  18. package/dist/flavors/UranoSocialContactFormElement.d.ts +9 -0
  19. package/dist/flavors/UranoSocialContactFormElement.js +103 -0
  20. package/dist/flavors/styles.css +257 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +10 -0
  23. package/dist/react/UranoSocialChat.d.ts +38 -0
  24. package/dist/react/UranoSocialChat.js +81 -0
  25. package/dist/react/UranoSocialContactForm.d.ts +19 -0
  26. package/dist/react/UranoSocialContactForm.js +78 -0
  27. package/dist/react/index.d.ts +4 -0
  28. package/dist/react/index.js +2 -0
  29. package/dist/server/antiSpam.d.ts +76 -0
  30. package/dist/server/antiSpam.js +204 -0
  31. package/dist/server/index.d.ts +4 -0
  32. package/dist/server/index.js +4 -0
  33. package/dist/server/proxy.d.ts +16 -0
  34. package/dist/server/proxy.js +253 -0
  35. package/dist/server/visitorCookie.d.ts +11 -0
  36. package/dist/server/visitorCookie.js +38 -0
  37. package/dist/server/websocket.d.ts +21 -0
  38. package/dist/server/websocket.js +126 -0
  39. package/dist/types.d.ts +94 -0
  40. package/dist/types.js +3 -0
  41. package/package.json +92 -4
@@ -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
+ }
@@ -0,0 +1,4 @@
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';
@@ -0,0 +1,4 @@
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';
@@ -0,0 +1,16 @@
1
+ import type { SocialProxyConfig } from '../types';
2
+ export interface SocialProxyHandlers {
3
+ /** Fetch/Web-standard handler for App Router / Astro / edge-compatible hosts. */
4
+ handle: (request: Request) => Promise<Response>;
5
+ }
6
+ /**
7
+ * Creates a site-local proxy that signs webhook traffic with the server secret.
8
+ * The browser never receives WEBHOOK_SECRET.
9
+ */
10
+ export declare function createSocialProxy(partial?: Partial<SocialProxyConfig>): SocialProxyHandlers;
11
+ /** Convenience for Next.js App Router route handlers. */
12
+ export declare function socialProxyRoute(partial?: Partial<SocialProxyConfig>): {
13
+ GET: (request: Request) => Promise<Response>;
14
+ POST: (request: Request) => Promise<Response>;
15
+ OPTIONS: (request: Request) => Promise<Response>;
16
+ };
@@ -0,0 +1,253 @@
1
+ import { signHmacHex } from '../crypto';
2
+ import { resolveVisitorId, DEFAULT_COOKIE_NAME } from './visitorCookie';
3
+ import { enforceContactAntiSpam, issueContactTicket, resolveAntiSpamConfig, } from './antiSpam';
4
+ function envConfig(partial) {
5
+ const webhookUrl = partial?.webhookUrl || process.env.URANO_SOCIAL_WEBHOOK_URL || '';
6
+ const webhookSecret = partial?.webhookSecret || process.env.URANO_SOCIAL_WEBHOOK_SECRET || '';
7
+ if (!webhookUrl)
8
+ throw new Error('URANO_SOCIAL_WEBHOOK_URL is required');
9
+ if (!webhookSecret)
10
+ throw new Error('URANO_SOCIAL_WEBHOOK_SECRET is required');
11
+ return {
12
+ webhookUrl: webhookUrl.replace(/\/$/, ''),
13
+ webhookSecret,
14
+ cookieSecret: partial?.cookieSecret || process.env.URANO_SOCIAL_COOKIE_SECRET || webhookSecret,
15
+ cookieName: partial?.cookieName || DEFAULT_COOKIE_NAME,
16
+ cookieMaxAgeSec: partial?.cookieMaxAgeSec,
17
+ defaultDisplayName: partial?.defaultDisplayName,
18
+ antiSpam: resolveAntiSpamConfig(partial?.antiSpam),
19
+ };
20
+ }
21
+ function json(status, body, headers) {
22
+ return new Response(JSON.stringify(body), {
23
+ status,
24
+ headers: { 'Content-Type': 'application/json', ...(headers || {}) },
25
+ });
26
+ }
27
+ async function readJson(request) {
28
+ const text = await request.text();
29
+ if (!text)
30
+ return {};
31
+ try {
32
+ return JSON.parse(text);
33
+ }
34
+ catch {
35
+ throw new Error('invalid_json');
36
+ }
37
+ }
38
+ /**
39
+ * Creates a site-local proxy that signs webhook traffic with the server secret.
40
+ * The browser never receives WEBHOOK_SECRET.
41
+ */
42
+ export function createSocialProxy(partial) {
43
+ const config = envConfig(partial);
44
+ const antiSpam = resolveAntiSpamConfig(config.antiSpam);
45
+ async function handle(request) {
46
+ const url = new URL(request.url);
47
+ const method = request.method.toUpperCase();
48
+ if (method === 'OPTIONS') {
49
+ return new Response(null, {
50
+ status: 204,
51
+ headers: {
52
+ 'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
53
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
54
+ 'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key, Last-Event-ID, Accept',
55
+ 'Access-Control-Allow-Credentials': 'true',
56
+ },
57
+ });
58
+ }
59
+ const visitor = resolveVisitorId({
60
+ cookieHeader: request.headers.get('cookie'),
61
+ cookieName: config.cookieName,
62
+ cookieSecret: config.cookieSecret,
63
+ });
64
+ const responseHeaders = new Headers();
65
+ if (visitor.setCookie)
66
+ responseHeaders.append('Set-Cookie', visitor.setCookie);
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) {
81
+ try {
82
+ const discovery = await fetch(config.webhookUrl, { method: 'GET' });
83
+ const body = await discovery.json().catch(() => ({ ok: discovery.ok }));
84
+ responseHeaders.set('Content-Type', 'application/json');
85
+ return new Response(JSON.stringify({
86
+ ok: true,
87
+ proxy: true,
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
+ },
98
+ upstream: body,
99
+ }), { status: 200, headers: responseHeaders });
100
+ }
101
+ catch (error) {
102
+ return json(502, { error: 'upstream_unreachable', message: error?.message }, responseHeaders);
103
+ }
104
+ }
105
+ let clientBody = {};
106
+ try {
107
+ if (method === 'GET') {
108
+ const payloadParam = url.searchParams.get('payload');
109
+ clientBody = payloadParam ? JSON.parse(payloadParam) : {};
110
+ }
111
+ else {
112
+ clientBody = await readJson(request);
113
+ }
114
+ }
115
+ catch {
116
+ return json(400, { error: 'invalid_json' }, responseHeaders);
117
+ }
118
+ const mode = String(url.searchParams.get('mode')
119
+ || clientBody.mode
120
+ || ((request.headers.get('Accept') || '').includes('text/event-stream') ? 'sse' : 'wait'));
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
+ }
147
+ const outbound = isContact
148
+ ? { type: 'contact' }
149
+ : {
150
+ visitorId: visitor.visitorId,
151
+ from: visitor.visitorId,
152
+ };
153
+ if (isContact) {
154
+ if (typeof clientBody.email === 'string')
155
+ outbound.email = clientBody.email.trim().toLowerCase();
156
+ if (typeof clientBody.name === 'string')
157
+ outbound.name = clientBody.name;
158
+ if (typeof clientBody.subject === 'string')
159
+ outbound.subject = clientBody.subject;
160
+ if (typeof clientBody.message === 'string')
161
+ outbound.message = clientBody.message;
162
+ else if (typeof clientBody.text === 'string')
163
+ outbound.message = clientBody.text;
164
+ if (typeof clientBody.phone === 'string')
165
+ outbound.phone = clientBody.phone;
166
+ if (typeof clientBody.company === 'string')
167
+ outbound.company = clientBody.company;
168
+ // Correlate without trusting client visitor identity for CRM email channel.
169
+ outbound.visitorId = visitor.visitorId;
170
+ }
171
+ else {
172
+ if (typeof clientBody.text === 'string')
173
+ outbound.text = clientBody.text;
174
+ if (typeof clientBody.sessionId === 'string')
175
+ outbound.sessionId = clientBody.sessionId;
176
+ if (typeof clientBody.SessionId === 'string')
177
+ outbound.sessionId = clientBody.SessionId;
178
+ if (typeof clientBody.displayName === 'string')
179
+ outbound.displayName = clientBody.displayName;
180
+ else if (config.defaultDisplayName)
181
+ outbound.displayName = config.defaultDisplayName;
182
+ if (clientBody.webhookAction)
183
+ outbound.webhookAction = clientBody.webhookAction;
184
+ if (clientBody.turnId)
185
+ outbound.turnId = clientBody.turnId;
186
+ if (clientBody.after)
187
+ outbound.after = clientBody.after;
188
+ if (clientBody.idempotencyKey)
189
+ outbound.idempotencyKey = clientBody.idempotencyKey;
190
+ }
191
+ // Never trust a visitorId / from supplied by the browser for chat identity.
192
+ delete clientBody.visitorId;
193
+ delete clientBody.from;
194
+ const rawBody = JSON.stringify(outbound);
195
+ const signature = signHmacHex(rawBody, config.webhookSecret);
196
+ const idempotency = request.headers.get('Idempotency-Key')
197
+ || (typeof outbound.idempotencyKey === 'string' ? outbound.idempotencyKey : undefined);
198
+ const upstreamHeaders = {
199
+ 'Content-Type': 'application/json',
200
+ 'x-urano-signature': signature,
201
+ 'X-Urano-Protocol-Version': '1',
202
+ };
203
+ if (idempotency)
204
+ upstreamHeaders['Idempotency-Key'] = idempotency;
205
+ const lastEventId = request.headers.get('Last-Event-ID');
206
+ if (lastEventId)
207
+ upstreamHeaders['Last-Event-ID'] = lastEventId;
208
+ let upstreamUrl = `${config.webhookUrl}?mode=${encodeURIComponent(mode === 'auto' ? 'sse' : mode)}`;
209
+ let upstreamInit;
210
+ if (method === 'GET') {
211
+ upstreamUrl += `&payload=${encodeURIComponent(rawBody)}`;
212
+ upstreamInit = { method: 'GET', headers: upstreamHeaders };
213
+ }
214
+ else {
215
+ upstreamInit = { method: 'POST', headers: upstreamHeaders, body: rawBody };
216
+ }
217
+ if (mode === 'sse' || request.headers.get('Accept')?.includes('text/event-stream')) {
218
+ upstreamHeaders['Accept'] = 'text/event-stream';
219
+ }
220
+ try {
221
+ const upstream = await fetch(upstreamUrl, upstreamInit);
222
+ upstream.headers.forEach((value, key) => {
223
+ const lower = key.toLowerCase();
224
+ if (['transfer-encoding', 'content-encoding', 'content-length', 'set-cookie'].includes(lower))
225
+ return;
226
+ responseHeaders.set(key, value);
227
+ });
228
+ responseHeaders.set('X-Urano-Social-Visitor', visitor.visitorId);
229
+ responseHeaders.set('Cache-Control', 'no-cache, no-transform');
230
+ if (mode === 'sse') {
231
+ responseHeaders.set('Content-Type', 'text/event-stream; charset=utf-8');
232
+ responseHeaders.set('X-Accel-Buffering', 'no');
233
+ }
234
+ return new Response(upstream.body, {
235
+ status: upstream.status,
236
+ headers: responseHeaders,
237
+ });
238
+ }
239
+ catch (error) {
240
+ return json(502, { error: 'upstream_unreachable', message: error?.message }, responseHeaders);
241
+ }
242
+ }
243
+ return { handle };
244
+ }
245
+ /** Convenience for Next.js App Router route handlers. */
246
+ export function socialProxyRoute(partial) {
247
+ const proxy = createSocialProxy(partial);
248
+ return {
249
+ GET: (request) => proxy.handle(request),
250
+ POST: (request) => proxy.handle(request),
251
+ OPTIONS: (request) => proxy.handle(request),
252
+ };
253
+ }
@@ -0,0 +1,11 @@
1
+ export declare const DEFAULT_COOKIE_NAME = "urano_social_vid";
2
+ export declare function parseCookies(header: string | null | undefined): Record<string, string>;
3
+ export declare function resolveVisitorId(opts: {
4
+ cookieHeader?: string | null;
5
+ cookieName?: string;
6
+ cookieSecret: string;
7
+ }): {
8
+ visitorId: string;
9
+ isNew: boolean;
10
+ setCookie?: string;
11
+ };
@@ -0,0 +1,38 @@
1
+ import { randomId, signCookieValue, verifyCookieValue } from '../crypto';
2
+ export const DEFAULT_COOKIE_NAME = 'urano_social_vid';
3
+ export function parseCookies(header) {
4
+ const out = {};
5
+ if (!header)
6
+ return out;
7
+ for (const part of header.split(';')) {
8
+ const idx = part.indexOf('=');
9
+ if (idx < 0)
10
+ continue;
11
+ const key = part.slice(0, idx).trim();
12
+ const value = part.slice(idx + 1).trim();
13
+ if (key)
14
+ out[key] = decodeURIComponent(value);
15
+ }
16
+ return out;
17
+ }
18
+ export function resolveVisitorId(opts) {
19
+ const name = opts.cookieName || DEFAULT_COOKIE_NAME;
20
+ const cookies = parseCookies(opts.cookieHeader);
21
+ const existing = cookies[name];
22
+ if (existing) {
23
+ const verified = verifyCookieValue(existing, opts.cookieSecret);
24
+ if (verified)
25
+ return { visitorId: verified, isNew: false };
26
+ }
27
+ const visitorId = randomId('vis');
28
+ const signed = signCookieValue(visitorId, opts.cookieSecret);
29
+ const maxAge = 60 * 60 * 24 * 365;
30
+ const setCookie = [
31
+ `${name}=${encodeURIComponent(signed)}`,
32
+ 'Path=/',
33
+ `Max-Age=${maxAge}`,
34
+ 'HttpOnly',
35
+ 'SameSite=Lax',
36
+ ].join('; ');
37
+ return { visitorId, isNew: true, setCookie };
38
+ }
@@ -0,0 +1,21 @@
1
+ import * as http from 'http';
2
+ import type { SocialProxyConfig } from '../types';
3
+ export interface AttachSocialWebSocketOptions extends Partial<SocialProxyConfig> {
4
+ /** Path on the site server that accepts WS upgrades, default `/api/urano-social/ws`. */
5
+ path?: string;
6
+ }
7
+ /**
8
+ * Attaches an optional WebSocket bridge to a Node HTTP server.
9
+ * Next App Router cannot upgrade sockets in a route handler — use SSE there.
10
+ * Astro Node / custom servers can call this after `http.createServer`.
11
+ */
12
+ export declare function attachSocialWebSocket(server: http.Server, partial?: AttachSocialWebSocketOptions): () => void;
13
+ /** Exported for tests — builds the WS envelope the upstream expects. */
14
+ export declare function buildWsEnvelope(payload: Record<string, unknown>, secret: string): {
15
+ payload: string;
16
+ headers: {
17
+ 'x-urano-signature': string;
18
+ 'Content-Type': string;
19
+ 'X-Urano-Protocol-Version': string;
20
+ };
21
+ };