elysium-attribution 1.0.0 → 1.0.2
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/README.md +365 -0
- package/index.d.ts +213 -0
- package/package.json +34 -6
- package/src/config/index.js +45 -0
- package/src/config/schema.js +85 -0
- package/src/dom/buttonDetector.js +128 -0
- package/src/gateways/GatewayRegistry.js +75 -0
- package/src/gateways/GenericGateway.js +12 -0
- package/src/gateways/PayPalGateway.js +83 -0
- package/src/gateways/PaymentGateway.js +173 -0
- package/src/gateways/RazorpayGateway.js +88 -0
- package/src/gateways/StripeGateway.js +89 -0
- package/src/gateways/index.js +6 -0
- package/src/index.js +151 -0
- package/src/signals/params.js +68 -0
- package/src/signals/referrer.js +106 -0
- package/src/signals/session.js +134 -0
- package/src/signals/touchEngine.js +211 -0
- package/src/signals/transport.js +64 -0
- package/src/signals/visitor.js +82 -0
- package/src/tracking/params.js +68 -0
- package/src/tracking/referrer.js +106 -0
- package/src/tracking/session.js +134 -0
- package/src/tracking/touchTracker.js +202 -0
- package/src/tracking/transport.js +60 -0
- package/src/tracking/visitor.js +82 -0
- package/src/utils/attributionInit.js +56 -3
- package/src/utils/cli.js +69 -17
- package/src/utils/logger.js +31 -0
- package/src/utils/storage.js +110 -0
- package/test/buttonDetector.test.js +26 -0
- package/test/config.test.js +35 -0
- package/test/gateways.test.js +112 -0
- package/test/params.test.js +51 -0
- package/test/referrer.test.js +41 -0
- package/test/session.test.js +112 -0
- package/test/visitor.test.js +35 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { getVisitorId, setVisitorId } from './visitor.js';
|
|
2
|
+
import { extractMarketingParams } from './params.js';
|
|
3
|
+
import { parseReferrer } from './referrer.js';
|
|
4
|
+
import { shouldSendTouch, saveSuccessfulTouch, readTouchHistory } from './session.js';
|
|
5
|
+
import { request } from './transport.js';
|
|
6
|
+
import { getConfig } from '../config/index.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Construct full touch payload with parameters, referrer, and visitor ID
|
|
11
|
+
*/
|
|
12
|
+
export function buildPayload(customData = {}) {
|
|
13
|
+
const config = getConfig();
|
|
14
|
+
const visitorId = getVisitorId();
|
|
15
|
+
const { utm, clickIds, cookies, custom } = extractMarketingParams();
|
|
16
|
+
const referrerInfo = parseReferrer();
|
|
17
|
+
|
|
18
|
+
// If UTM source/medium missing, infer from organic/social referrer
|
|
19
|
+
const source = utm.source || referrerInfo.detectedSource || null;
|
|
20
|
+
const medium = utm.medium || referrerInfo.detectedMedium || null;
|
|
21
|
+
|
|
22
|
+
const currentHref = typeof window !== 'undefined' ? window.location.href : '';
|
|
23
|
+
const currentTitle = typeof document !== 'undefined' ? document.title : '';
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
visitorId,
|
|
27
|
+
touchedAt: new Date().toISOString(),
|
|
28
|
+
source,
|
|
29
|
+
medium,
|
|
30
|
+
campaign: utm.campaign,
|
|
31
|
+
term: utm.term,
|
|
32
|
+
content: utm.content,
|
|
33
|
+
referrer: referrerInfo.referrer,
|
|
34
|
+
landingPage: currentHref,
|
|
35
|
+
pageTitle: currentTitle,
|
|
36
|
+
channel: referrerInfo.channel,
|
|
37
|
+
...clickIds,
|
|
38
|
+
...cookies,
|
|
39
|
+
custom: { ...custom, ...customData },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Record a marketing touchpoint
|
|
45
|
+
*/
|
|
46
|
+
export async function recordTouch(options = {}) {
|
|
47
|
+
const config = getConfig();
|
|
48
|
+
|
|
49
|
+
if (!config.apiKey) {
|
|
50
|
+
logger.warn('Tracking disabled: apiKey is not configured.');
|
|
51
|
+
return { ok: false, error: 'apiKey missing' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Path regex check if configured (e.g. for product pages only)
|
|
55
|
+
if (config.productPathRegex && typeof window !== 'undefined') {
|
|
56
|
+
const regex =
|
|
57
|
+
config.productPathRegex instanceof RegExp
|
|
58
|
+
? config.productPathRegex
|
|
59
|
+
: new RegExp(config.productPathRegex);
|
|
60
|
+
if (!regex.test(window.location.pathname)) {
|
|
61
|
+
logger.debug('Skipping touch: Path did not match productPathRegex.');
|
|
62
|
+
return { ok: false, skipped: true, reason: 'path_mismatch' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const payload = buildPayload(options.customData);
|
|
67
|
+
const history = readTouchHistory();
|
|
68
|
+
|
|
69
|
+
if (!options.force && !shouldSendTouch(payload, history)) {
|
|
70
|
+
logger.debug('Touch not sent: Duplicate or redundant in active session.');
|
|
71
|
+
return { ok: false, skipped: true, reason: 'deduplicated' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
logger.info('Sending touch payload:', payload);
|
|
75
|
+
|
|
76
|
+
const res = await request('/v1/touches', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
body: payload,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
logger.error('Failed to record touch:', res.error);
|
|
83
|
+
return res;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const responseData = res.data || {
|
|
87
|
+
id: `local_${Date.now()}`,
|
|
88
|
+
visitorId: payload.visitorId,
|
|
89
|
+
channel: payload.channel || 'direct',
|
|
90
|
+
touchedAt: payload.touchedAt,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
saveSuccessfulTouch({
|
|
94
|
+
payload,
|
|
95
|
+
response: responseData,
|
|
96
|
+
recordedAt: Date.now(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
logger.info('Touch recorded successfully:', responseData);
|
|
100
|
+
|
|
101
|
+
if (typeof window !== 'undefined') {
|
|
102
|
+
window.dispatchEvent(
|
|
103
|
+
new CustomEvent('elysium:touch_recorded', {
|
|
104
|
+
detail: { payload, response: responseData },
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { ok: true, data: responseData };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Backward-compatible product touch tracker from existing touch-tracking.ts
|
|
114
|
+
*/
|
|
115
|
+
export async function recordProductTouch() {
|
|
116
|
+
const config = getConfig();
|
|
117
|
+
// By default, if productPathRegex is not configured, preserve existing /product/ check
|
|
118
|
+
const pathRegex = config.productPathRegex || /^\/product\/[^/]+\/?$/;
|
|
119
|
+
if (typeof window !== 'undefined' && !pathRegex.test(window.location.pathname)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
return recordTouch();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Identify a visitor with their email and optional user traits
|
|
127
|
+
*/
|
|
128
|
+
export async function identifyVisitor(email, traits = {}) {
|
|
129
|
+
const config = getConfig();
|
|
130
|
+
|
|
131
|
+
if (!config.apiKey) {
|
|
132
|
+
logger.warn('Visitor identification failed: apiKey is not configured.');
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!email || typeof email !== 'string') {
|
|
137
|
+
logger.error('identifyVisitor requires a valid email string.');
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const visitorId = getVisitorId();
|
|
142
|
+
const payload = {
|
|
143
|
+
visitorId,
|
|
144
|
+
email,
|
|
145
|
+
traits,
|
|
146
|
+
identifiedAt: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const res = await request('/v1/identify', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
body: payload,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
logger.error('Identify request failed:', res.error);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Update canonical visitorId if returned by backend customer profile
|
|
160
|
+
if (res.data && res.data.canonicalVisitorId) {
|
|
161
|
+
setVisitorId(res.data.canonicalVisitorId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
logger.info('Visitor identified successfully:', res.data);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Request checkout attribution session token
|
|
170
|
+
*/
|
|
171
|
+
export async function createCheckoutSession() {
|
|
172
|
+
const config = getConfig();
|
|
173
|
+
|
|
174
|
+
if (!config.apiKey) {
|
|
175
|
+
throw new Error('Checkout session was not created: apiKey is not configured.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const visitorId = getVisitorId();
|
|
179
|
+
const res = await request('/v1/checkout-sessions', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
body: { visitorId },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw new Error(res.error || 'Failed to create checkout session');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const result = res.data;
|
|
189
|
+
if (!result || typeof result !== 'object' || !result.checkoutSessionId || !result.token) {
|
|
190
|
+
throw new Error('The checkout session endpoint returned an invalid response structure.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
checkoutSessionId: result.checkoutSessionId,
|
|
195
|
+
token: result.token,
|
|
196
|
+
visitorId: result.visitorId || visitorId,
|
|
197
|
+
visitorRecordId: result.visitorRecordId || '',
|
|
198
|
+
touchIds: Array.isArray(result.touchIds) ? result.touchIds : [],
|
|
199
|
+
touchCount: typeof result.touchCount === 'number' ? result.touchCount : 0,
|
|
200
|
+
expiresAt: result.expiresAt || new Date(Date.now() + 3600000).toISOString(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getConfig } from '../config/index.js';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export async function request(path, options = {}) {
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
const url = `${config.apiBaseUrl}${path}`;
|
|
7
|
+
|
|
8
|
+
const headers = {
|
|
9
|
+
'Content-Type': 'application/json',
|
|
10
|
+
...(config.apiKey ? { 'X-Api-Key': config.apiKey, 'Authorization': `Bearer ${config.apiKey}` } : {}),
|
|
11
|
+
...options.headers,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const timeoutMs = options.timeout ?? 8000;
|
|
15
|
+
let controller = null;
|
|
16
|
+
let timeoutId = null;
|
|
17
|
+
|
|
18
|
+
if (typeof AbortController !== 'undefined') {
|
|
19
|
+
controller = new AbortController();
|
|
20
|
+
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, {
|
|
25
|
+
method: options.method || 'POST',
|
|
26
|
+
headers,
|
|
27
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
28
|
+
keepalive: options.keepalive ?? true,
|
|
29
|
+
signal: controller ? controller.signal : undefined,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
33
|
+
|
|
34
|
+
const contentType = response.headers.get('content-type') || '';
|
|
35
|
+
let data = null;
|
|
36
|
+
if (contentType.includes('application/json')) {
|
|
37
|
+
data = await response.json().catch(() => null);
|
|
38
|
+
} else {
|
|
39
|
+
data = await response.text().catch(() => null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const errorMsg =
|
|
44
|
+
(data && typeof data === 'object' && (data.message || data.error)) ||
|
|
45
|
+
`Request to ${path} failed with HTTP status ${response.status}`;
|
|
46
|
+
return { ok: false, status: response.status, error: errorMsg, data };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { ok: true, status: response.status, data };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
52
|
+
const isAbort = error.name === 'AbortError';
|
|
53
|
+
const message = isAbort
|
|
54
|
+
? `Request to ${path} timed out after ${timeoutMs}ms`
|
|
55
|
+
: `Network error connecting to ${url}: ${error.message}`;
|
|
56
|
+
|
|
57
|
+
logger.error(message);
|
|
58
|
+
return { ok: false, status: 0, error: message, data: null };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { safeLocalStorage, getCookie, setCookie } from '../utils/storage.js';
|
|
2
|
+
import { getConfig } from '../config/index.js';
|
|
3
|
+
import { logger } from '../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export const VISITOR_ID_KEY = 'aid_vid';
|
|
6
|
+
let memoryVisitorId = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate standard UUIDv4 with crypto fallback
|
|
10
|
+
*/
|
|
11
|
+
export function generateUUID() {
|
|
12
|
+
if (typeof crypto !== 'undefined') {
|
|
13
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
14
|
+
return crypto.randomUUID();
|
|
15
|
+
}
|
|
16
|
+
if (typeof crypto.getRandomValues === 'function') {
|
|
17
|
+
const buffer = new Uint8Array(16);
|
|
18
|
+
crypto.getRandomValues(buffer);
|
|
19
|
+
buffer[6] = (buffer[6] & 0x0f) | 0x40;
|
|
20
|
+
buffer[8] = (buffer[8] & 0x3f) | 0x80;
|
|
21
|
+
const hex = Array.from(buffer, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
|
|
27
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
|
|
28
|
+
const value = Number.parseInt(randomHex(), 16);
|
|
29
|
+
return (character === 'x' ? value : (value & 0x3) | 0x8).toString(16);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Retrieve or create persistent visitor ID.
|
|
35
|
+
* Keeps cookie and localStorage synchronized to prevent identity loss across ITP/subdomains.
|
|
36
|
+
*/
|
|
37
|
+
export function getVisitorId() {
|
|
38
|
+
if (memoryVisitorId) {
|
|
39
|
+
return memoryVisitorId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 1. Try LocalStorage
|
|
43
|
+
let visitorId = safeLocalStorage.getItem(VISITOR_ID_KEY);
|
|
44
|
+
|
|
45
|
+
// 2. Try Cookie if not in LocalStorage
|
|
46
|
+
if (!visitorId) {
|
|
47
|
+
visitorId = getCookie(VISITOR_ID_KEY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Generate new if missing
|
|
51
|
+
if (!visitorId) {
|
|
52
|
+
visitorId = generateUUID();
|
|
53
|
+
logger.debug('Generated new visitor ID:', visitorId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4. Synchronize back to both storages
|
|
57
|
+
const config = getConfig();
|
|
58
|
+
safeLocalStorage.setItem(VISITOR_ID_KEY, visitorId);
|
|
59
|
+
setCookie(VISITOR_ID_KEY, visitorId, {
|
|
60
|
+
maxAge: 63072000, // 2 years
|
|
61
|
+
path: '/',
|
|
62
|
+
domain: config.cookieDomain,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
memoryVisitorId = visitorId;
|
|
66
|
+
return visitorId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Set custom or backend-canonicalized visitor ID
|
|
71
|
+
*/
|
|
72
|
+
export function setVisitorId(newId) {
|
|
73
|
+
if (!newId || typeof newId !== 'string') return;
|
|
74
|
+
memoryVisitorId = newId;
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
safeLocalStorage.setItem(VISITOR_ID_KEY, newId);
|
|
77
|
+
setCookie(VISITOR_ID_KEY, newId, {
|
|
78
|
+
maxAge: 63072000,
|
|
79
|
+
path: '/',
|
|
80
|
+
domain: config.cookieDomain,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -1,3 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
|
|
2
|
+
const API_BASE_URL =
|
|
3
|
+
"https://asbTemp-production.up.railway.app/".replace(/\/+$/, "");
|
|
4
|
+
|
|
5
|
+
export function initAttribution(apikey) {
|
|
6
|
+
if (!apikey || typeof apikey !== "string") {
|
|
7
|
+
throw new Error("A valid API key is required.");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof window === "undefined") return;
|
|
11
|
+
|
|
12
|
+
const visitorKey = "aid_vid";
|
|
13
|
+
|
|
14
|
+
let visitorId = localStorage.getItem(visitorKey);
|
|
15
|
+
|
|
16
|
+
if (!visitorId) {
|
|
17
|
+
visitorId = crypto.randomUUID();
|
|
18
|
+
localStorage.setItem(visitorKey, visitorId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const params = new URLSearchParams(location.search);
|
|
22
|
+
|
|
23
|
+
const hasMarketingParams = [
|
|
24
|
+
"utm_source", "utm_medium", "utm_campaign",
|
|
25
|
+
"gclid", "fbclid", "ttclid", "msclkid"
|
|
26
|
+
].some(key => params.has(key));
|
|
27
|
+
|
|
28
|
+
if (!hasMarketingParams) return;
|
|
29
|
+
|
|
30
|
+
const payload = {
|
|
31
|
+
visitorId,
|
|
32
|
+
touchedAt: new Date().toISOString(),
|
|
33
|
+
source: params.get("utm_source"),
|
|
34
|
+
medium: params.get("utm_medium"),
|
|
35
|
+
campaign: params.get("utm_campaign"),
|
|
36
|
+
term: params.get("utm_term"),
|
|
37
|
+
content: params.get("utm_content"),
|
|
38
|
+
referrer: document.referrer || null,
|
|
39
|
+
landingPage: location.href,
|
|
40
|
+
pageTitle: document.title,
|
|
41
|
+
gclid: params.get("gclid"),
|
|
42
|
+
fbclid: params.get("fbclid"),
|
|
43
|
+
ttclid: params.get("ttclid"),
|
|
44
|
+
msclkid: params.get("msclkid")
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return fetch(`${API_BASE_URL}/v1/touches`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"authorization": apikey
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify(payload),
|
|
54
|
+
keepalive: true
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/utils/cli.js
CHANGED
|
@@ -1,17 +1,69 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const configPath = 'elysium.config.js';
|
|
7
|
+
|
|
8
|
+
if (fs.existsSync(configPath)) {
|
|
9
|
+
console.log(`[ElysiumSDK] Configuration file already exists at ${path.resolve(configPath)}`);
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const template = `/**
|
|
14
|
+
* ElysiumSDK Configuration File
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT SECURITY NOTICE:
|
|
17
|
+
* This configuration is loaded on the client side (browser).
|
|
18
|
+
* ONLY include PUBLISHABLE keys here. NEVER expose secret keys
|
|
19
|
+
* (e.g., Stripe secret key "sk_live_...", Razorpay key secret, or private tokens).
|
|
20
|
+
* Secret keys must remain strictly on your backend server.
|
|
21
|
+
*/
|
|
22
|
+
export default {
|
|
23
|
+
// Public attribution API Key provided by Elysium
|
|
24
|
+
apiKey: "YOUR_ELYSIUM_PUBLIC_API_KEY",
|
|
25
|
+
|
|
26
|
+
// Attribution API Backend endpoint
|
|
27
|
+
apiBaseUrl: "https://asbTemp-production.up.railway.app",
|
|
28
|
+
|
|
29
|
+
// Enable verbose debugging logs in browser console
|
|
30
|
+
debug: false,
|
|
31
|
+
|
|
32
|
+
// Session inactivity timeout in milliseconds (default: 30 minutes)
|
|
33
|
+
sessionWindowMs: 30 * 60 * 1000,
|
|
34
|
+
|
|
35
|
+
// CSS selector or class for detecting checkout/payment buttons
|
|
36
|
+
buttonSelector: ".elysium-payment-button",
|
|
37
|
+
|
|
38
|
+
// Automatically record touch on page load
|
|
39
|
+
autoTrack: true,
|
|
40
|
+
|
|
41
|
+
// Optional: restrict touch tracking to specific path regex (e.g. product pages only)
|
|
42
|
+
// productPathRegex: null,
|
|
43
|
+
|
|
44
|
+
// Domain for cross-subdomain cookie tracking (e.g., ".yourstore.com")
|
|
45
|
+
cookieDomain: undefined,
|
|
46
|
+
|
|
47
|
+
// Additional custom query parameters to capture and attribute
|
|
48
|
+
customParams: [],
|
|
49
|
+
|
|
50
|
+
// Supported payment gateways configuration (PUBLIC keys only)
|
|
51
|
+
gateways: {
|
|
52
|
+
stripe: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
publishableKey: "pk_test_...", // Stripe Publishable Key (NOT secret key)
|
|
55
|
+
},
|
|
56
|
+
razorpay: {
|
|
57
|
+
enabled: true,
|
|
58
|
+
keyId: "rzp_test_...", // Razorpay Key ID (NOT key secret)
|
|
59
|
+
},
|
|
60
|
+
paypal: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
clientId: "YOUR_PAYPAL_CLIENT_ID", // PayPal Client ID (NOT client secret)
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
fs.writeFileSync(configPath, template, 'utf8');
|
|
69
|
+
console.log(`[ElysiumSDK] Created template configuration file at ${configPath}`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe logger for ElysiumSDK
|
|
3
|
+
*/
|
|
4
|
+
export class Logger {
|
|
5
|
+
constructor(getDebug = () => false) {
|
|
6
|
+
this.getDebug = getDebug;
|
|
7
|
+
this.prefix = '[ElysiumSDK]';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
debug(...args) {
|
|
11
|
+
if (this.getDebug()) {
|
|
12
|
+
console.debug(this.prefix, ...args);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
info(...args) {
|
|
17
|
+
if (this.getDebug()) {
|
|
18
|
+
console.info(this.prefix, ...args);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
warn(...args) {
|
|
23
|
+
console.warn(this.prefix, ...args);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
error(...args) {
|
|
27
|
+
console.error(this.prefix, ...args);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilient cross-browser storage utilities with in-memory fallbacks
|
|
3
|
+
* and Safari ITP-aware cookie handling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class MemoryStorage {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.store = new Map();
|
|
9
|
+
}
|
|
10
|
+
getItem(key) {
|
|
11
|
+
return this.store.has(key) ? this.store.get(key) : null;
|
|
12
|
+
}
|
|
13
|
+
setItem(key, value) {
|
|
14
|
+
this.store.set(key, String(value));
|
|
15
|
+
}
|
|
16
|
+
removeItem(key) {
|
|
17
|
+
this.store.delete(key);
|
|
18
|
+
}
|
|
19
|
+
clear() {
|
|
20
|
+
this.store.clear();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const memoryFallback = new MemoryStorage();
|
|
25
|
+
|
|
26
|
+
export function isLocalStorageAvailable() {
|
|
27
|
+
try {
|
|
28
|
+
if (typeof window === 'undefined' || !window.localStorage) return false;
|
|
29
|
+
const testKey = '__elysium_storage_test__';
|
|
30
|
+
window.localStorage.setItem(testKey, '1');
|
|
31
|
+
window.localStorage.removeItem(testKey);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const safeLocalStorage = {
|
|
39
|
+
getItem(key) {
|
|
40
|
+
try {
|
|
41
|
+
if (isLocalStorageAvailable()) {
|
|
42
|
+
return window.localStorage.getItem(key);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fallback below
|
|
46
|
+
}
|
|
47
|
+
return memoryFallback.getItem(key);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
setItem(key, value) {
|
|
51
|
+
try {
|
|
52
|
+
if (isLocalStorageAvailable()) {
|
|
53
|
+
window.localStorage.setItem(key, value);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Storage quota exceeded or disabled
|
|
58
|
+
}
|
|
59
|
+
memoryFallback.setItem(key, value);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
removeItem(key) {
|
|
63
|
+
try {
|
|
64
|
+
if (isLocalStorageAvailable()) {
|
|
65
|
+
window.localStorage.removeItem(key);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Fallback
|
|
69
|
+
}
|
|
70
|
+
memoryFallback.removeItem(key);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Safely parse cookie string regardless of spacing
|
|
76
|
+
*/
|
|
77
|
+
export function getCookie(name) {
|
|
78
|
+
if (typeof document === 'undefined') return null;
|
|
79
|
+
try {
|
|
80
|
+
const escapedName = name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
|
81
|
+
const regex = new RegExp(`(?:^|;)\\s*${escapedName}=([^;]*)`);
|
|
82
|
+
const match = document.cookie.match(regex);
|
|
83
|
+
if (!match) return null;
|
|
84
|
+
return decodeURIComponent(match[1]);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Set a cookie safely with max-age, path, sameSite, and optional domain
|
|
92
|
+
*/
|
|
93
|
+
export function setCookie(name, value, options = {}) {
|
|
94
|
+
if (typeof document === 'undefined') return;
|
|
95
|
+
try {
|
|
96
|
+
const maxAge = options.maxAge ?? 63072000; // 2 years default
|
|
97
|
+
const path = options.path ?? '/';
|
|
98
|
+
const sameSite = options.sameSite ?? 'Lax';
|
|
99
|
+
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
|
100
|
+
const secure = options.secure ?? isHttps;
|
|
101
|
+
|
|
102
|
+
let cookieStr = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; max-age=${maxAge}; path=${path}; SameSite=${sameSite}`;
|
|
103
|
+
if (secure) cookieStr += '; Secure';
|
|
104
|
+
if (options.domain) cookieStr += `; domain=${options.domain}`;
|
|
105
|
+
|
|
106
|
+
document.cookie = cookieStr;
|
|
107
|
+
} catch {
|
|
108
|
+
// In restricted iframe or sandbox
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isPaymentButton } from '../src/dom/buttonDetector.js';
|
|
4
|
+
|
|
5
|
+
describe('Payment Button Detector Module', () => {
|
|
6
|
+
it('detects button matching configured selector via closest()', () => {
|
|
7
|
+
const mockButton = {
|
|
8
|
+
tagName: 'BUTTON',
|
|
9
|
+
className: 'elysium-payment-button custom-style',
|
|
10
|
+
closest: (sel) => {
|
|
11
|
+
if (sel.includes('.elysium-payment-button')) return mockButton;
|
|
12
|
+
return null;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const mockChildSpan = {
|
|
17
|
+
tagName: 'SPAN',
|
|
18
|
+
innerText: 'Pay Now',
|
|
19
|
+
closest: (sel) => mockButton.closest(sel),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
assert.equal(isPaymentButton(mockButton, '.elysium-payment-button'), mockButton);
|
|
23
|
+
assert.equal(isPaymentButton(mockChildSpan, '.elysium-payment-button'), mockButton);
|
|
24
|
+
assert.equal(isPaymentButton(mockButton, '.non-existent-button'), null);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { validateAndSanitizeConfig } from '../src/config/schema.js';
|
|
4
|
+
import { configManager } from '../src/config/index.js';
|
|
5
|
+
|
|
6
|
+
describe('Configuration & Security Sanitization Module', () => {
|
|
7
|
+
it('loads default configuration', () => {
|
|
8
|
+
const config = validateAndSanitizeConfig({});
|
|
9
|
+
assert.equal(config.apiBaseUrl, 'https://asbTemp-production.up.railway.app');
|
|
10
|
+
assert.equal(config.buttonSelector, '.elysium-payment-button');
|
|
11
|
+
assert.equal(config.sessionWindowMs, 1800000);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('removes trailing slashes from apiBaseUrl', () => {
|
|
15
|
+
const config = validateAndSanitizeConfig({
|
|
16
|
+
apiBaseUrl: 'https://api.elysium.dev///',
|
|
17
|
+
});
|
|
18
|
+
assert.equal(config.apiBaseUrl, 'https://api.elysium.dev');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('strips client-exposed secret keys to prevent security leaks', () => {
|
|
22
|
+
const unsafeConfig = {
|
|
23
|
+
apiKey: 'pk_live_safe_123',
|
|
24
|
+
secretKey: 'sk_live_DANGEROUS_SECRET_NEVER_EXPOSE',
|
|
25
|
+
apiSecret: 'SUPER_SECRET',
|
|
26
|
+
clientSecret: 'ANOTHER_SECRET',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const sanitized = validateAndSanitizeConfig(unsafeConfig);
|
|
30
|
+
assert.equal(sanitized.apiKey, 'pk_live_safe_123');
|
|
31
|
+
assert.equal(sanitized.secretKey, undefined);
|
|
32
|
+
assert.equal(sanitized.apiSecret, undefined);
|
|
33
|
+
assert.equal(sanitized.clientSecret, undefined);
|
|
34
|
+
});
|
|
35
|
+
});
|