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.
@@ -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
- export const initAttribution = () => {
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
- const configPath = 'elysium.config.js';
6
-
7
- if(fs.existsSync(configPath)){
8
- console.log("The config already exists");
9
- process.exit(0);
10
- }
11
- fs.writeFileSync(
12
- configPath,
13
- `export default {
14
- apiKey: "",
15
- debug: true,
16
- };\n`
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
+ });