zubbl-sdk 1.1.14 → 1.1.16

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.
@@ -1,241 +1,710 @@
1
- import axios from 'axios';
1
+ import crypto from 'crypto';
2
2
 
3
+ // src/core/config.ts
4
+ // -----------------------------------------------------------
5
+ // Universal environment detection + SDK configuration
6
+ // -----------------------------------------------------------
7
+ const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined";
8
+ const isNode = typeof process !== "undefined" &&
9
+ !!process.versions?.node &&
10
+ !isBrowser;
11
+ const isWorker = typeof self !== "undefined" &&
12
+ typeof self.importScripts === "function" &&
13
+ !isNode;
3
14
  let config = {
4
- apiKey: null,
5
- tenantId: null,
6
- appId: null,
7
- baseUrl: "https://api.zubbl.com/api",
8
- injectWorkerHeaders: false,
9
- workerSecret: null,
15
+ apiKey: null,
16
+ tenantId: null,
17
+ appId: null,
18
+ baseUrl: "https://api.zubbl.com/api",
19
+ injectWorkerHeaders: false,
20
+ workerSecret: null,
10
21
  };
11
-
12
- // ---- Telemetry State ----
13
- let sampleRatio = 0.1; // default 1 in 10
14
- let endpointBuffer = [];
15
- let debounceTimer = null;
16
-
17
- function shouldSample() {
18
- return Math.random() < sampleRatio;
22
+ // -----------------------------------------------------------
23
+ // Browser safety check
24
+ // -----------------------------------------------------------
25
+ function assertBrowserSafety(config) {
26
+ if (isBrowser || isWorker) {
27
+ const key = config.apiKey ?? "";
28
+ // Simple rule: if the key looks like a secret (sk_ or very long)
29
+ const looksSecret = key.startsWith("sk_") || key.length > 40;
30
+ if (looksSecret) {
31
+ throw new Error("[Zubbl SDK] Secret or server key detected in browser environment. " +
32
+ "Use a public SDK key instead (prefix pk_).");
33
+ }
34
+ }
19
35
  }
20
-
21
- function setSamplingRatio(ratio) {
22
- if (ratio < 0 || ratio > 1) throw new Error("Sampling ratio must be between 0 and 1");
23
- sampleRatio = ratio;
36
+ // -----------------------------------------------------------
37
+ // Public API
38
+ // -----------------------------------------------------------
39
+ function init$1(partial) {
40
+ config = { ...config, ...partial };
41
+ assertBrowserSafety(config); // 🔒 check that key is safe in browser
24
42
  }
25
-
26
- function emitEndpoint(payload) {
27
- if (!shouldSample()) return;
28
- const enriched = { ...payload, ts: Date.now(), source: "sdk" };
29
- endpointBuffer.push(enriched);
30
- console.log("[Zubbl SDK][Telemetry] Queued endpoint:", enriched);
31
-
32
- if (!debounceTimer) {
33
- debounceTimer = setTimeout(flushEndpointBuffer, 200);
34
- }
43
+ function getConfig() {
44
+ return config;
45
+ }
46
+ // -----------------------------------------------------------
47
+ // Header builder (used by HTTP layer)
48
+ // -----------------------------------------------------------
49
+ function buildHeaders(extra = {}) {
50
+ if (!config.apiKey || !config.tenantId || !config.appId) {
51
+ throw new Error("[Zubbl SDK] Not initialized – call init() first.");
52
+ }
53
+ return {
54
+ Authorization: `Bearer ${config.apiKey}`,
55
+ "X-Tenant-Id": config.tenantId,
56
+ "X-App-Id": config.appId,
57
+ "Content-Type": "application/json",
58
+ ...extra,
59
+ };
35
60
  }
36
61
 
37
- async function flushEndpointBuffer() {
38
- if (endpointBuffer.length === 0) {
39
- debounceTimer = null;
40
- return;
41
- }
42
- const batch = [...endpointBuffer];
43
- endpointBuffer = [];
44
- debounceTimer = null;
45
-
46
- const telemetryUrl =
47
- process.env.ZUBBL_TELEMETRY_URL || `${config.baseUrl.replace(/\/$/, "")}/sdk/log`;
48
-
49
- const headers = {
50
- "Content-Type": "application/json",
51
- Authorization: `Bearer ${config.apiKey}`,
52
- "X-Tenant-Id": config.tenantId,
53
- "X-App-Id": config.appId,
54
- };
55
-
56
- console.log("[Zubbl SDK][Telemetry] Flushing batch:", {
57
- url: telemetryUrl,
58
- count: batch.length,
59
- sampleRatio,
60
- payload: batch,
61
- headers,
62
- });
63
-
64
- try {
65
- const resp = await fetch(telemetryUrl, {
66
- method: "POST",
67
- headers,
68
- body: JSON.stringify(batch),
69
- });
62
+ // src/core/context.ts
63
+ // -----------------------------------------------------------
64
+ // Adaptive Context Engine — Geo + Stability Enrichment + Persistence
65
+ // -----------------------------------------------------------
66
+ const MAX_HISTORY = 100;
67
+ const CACHE_KEY = "zubbl-stability";
68
+ let state = loadStability() ?? {
69
+ routes: [],
70
+ geo: "unknown",
71
+ stability: 1.0,
72
+ lastGeoChange: Date.now(),
73
+ };
74
+ // -----------------------------------------------------------
75
+ // 💾 Persistence Helpers
76
+ // -----------------------------------------------------------
77
+ function loadStability() {
78
+ if (!isBrowser || typeof localStorage === "undefined")
79
+ return null;
80
+ try {
81
+ const raw = localStorage.getItem(CACHE_KEY);
82
+ if (!raw)
83
+ return null;
84
+ const parsed = JSON.parse(raw);
85
+ if (!parsed.geo || !parsed.stability)
86
+ return null;
87
+ return { ...parsed, routes: [] };
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ function saveStability() {
94
+ if (!isBrowser || typeof localStorage === "undefined")
95
+ return;
96
+ try {
97
+ const payload = {
98
+ geo: state.geo,
99
+ stability: state.stability,
100
+ lastGeoChange: state.lastGeoChange,
101
+ };
102
+ localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
103
+ }
104
+ catch {
105
+ /* ignore */
106
+ }
107
+ }
108
+ // -----------------------------------------------------------
109
+ // 🌍 Geo Detection (Node, Browser, Worker)
110
+ // -----------------------------------------------------------
111
+ function detectGeo() {
112
+ try {
113
+ if (isBrowser && typeof window !== "undefined" && window.navigator?.language) {
114
+ return window.navigator.language.split("-")[1] || "unknown";
115
+ }
116
+ if (isWorker && typeof self !== "undefined" && self.navigator?.language) {
117
+ return self.navigator.language.split("-")[1] || "unknown";
118
+ }
119
+ if (isNode && typeof process !== "undefined") {
120
+ const envGeo = process.env.ZUBBL_GEO || process.env.GEO;
121
+ if (envGeo && /^[A-Z]{2}$/i.test(envGeo))
122
+ return envGeo.toUpperCase();
123
+ return "GB";
124
+ }
125
+ return "unknown";
126
+ }
127
+ catch (err) {
128
+ console.warn("[Zubbl SDK][Geo Detect] Failed:", err);
129
+ return "unknown";
130
+ }
131
+ }
132
+ // -----------------------------------------------------------
133
+ // 🧭 Update Geo & Stability (with persistence)
134
+ // -----------------------------------------------------------
135
+ function updateGeo(geoHint) {
136
+ const newGeo = geoHint || detectGeo();
137
+ if (newGeo !== state.geo) {
138
+ const now = Date.now();
139
+ const elapsed = (now - state.lastGeoChange) / 1000;
140
+ state.stability = Math.max(0.5, Math.min(1.0, elapsed / 3600));
141
+ state.geo = newGeo;
142
+ state.lastGeoChange = now;
143
+ saveStability();
144
+ }
145
+ else {
146
+ state.stability = Math.min(1.0, state.stability + 0.01);
147
+ saveStability();
148
+ }
149
+ }
150
+ // -----------------------------------------------------------
151
+ // 🧮 Record Context Samples
152
+ // -----------------------------------------------------------
153
+ function recordContextSample(path, method, status, latency) {
154
+ state.routes.push({ path, method, ts: Date.now(), status, latency });
155
+ if (state.routes.length > MAX_HISTORY)
156
+ state.routes.shift();
157
+ updateGeo(); // auto-refresh geo info
158
+ }
159
+ // -----------------------------------------------------------
160
+ // 📦 Get Adaptive Headers
161
+ // -----------------------------------------------------------
162
+ function getAdaptiveHeaders() {
163
+ const currentGeo = detectGeo();
164
+ updateGeo(currentGeo);
165
+ const summary = btoa(JSON.stringify({
166
+ env: isNode ? "node" : isBrowser ? "browser" : "worker",
167
+ routeCount: state.routes.length,
168
+ lastGeo: state.geo,
169
+ avgLatency: state.routes.reduce((a, b) => a + b.latency, 0) /
170
+ Math.max(1, state.routes.length),
171
+ }));
172
+ return {
173
+ "X-Zubbl-Context-Summary": summary,
174
+ "X-Zubbl-Geo-Stability": `${state.geo}/${state.stability.toFixed(2)}`,
175
+ };
176
+ }
177
+ // -----------------------------------------------------------
178
+ // 🌍 Initialize Geo Context Immediately
179
+ // -----------------------------------------------------------
180
+ updateGeo(process.env.ZUBBL_GEO);
70
181
 
71
- const text = await resp.text();
72
- console.log("[Zubbl SDK][Telemetry] Response:", {
73
- status: resp.status,
74
- body: text,
182
+ // -----------------------------------------------------------
183
+ // 🔐 Secure Signing & Nonce Generator
184
+ // -----------------------------------------------------------
185
+ // -----------------------------------------------------------
186
+ // Generate UUIDv4 Nonce
187
+ // -----------------------------------------------------------
188
+ function generateNonce() {
189
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
190
+ const r = (Math.random() * 16) | 0;
191
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
192
+ return v.toString(16);
75
193
  });
76
- } catch (err) {
77
- console.error("[Zubbl SDK][Telemetry] Failed to send batch:", err);
78
- }
194
+ }
195
+ // -----------------------------------------------------------
196
+ // Compute SHA256 HMAC signature
197
+ // -----------------------------------------------------------
198
+ async function signPayload(payload, secret) {
199
+ if (isNode) {
200
+ // ✅ Node.js native HMAC
201
+ return crypto.createHmac("sha256", secret).update(payload).digest("hex");
202
+ }
203
+ // ✅ Browser / Worker using WebCrypto
204
+ if (typeof window !== "undefined" && window.crypto?.subtle) {
205
+ const key = await window.crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
206
+ const sig = await window.crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
207
+ return Array.from(new Uint8Array(sig))
208
+ .map(b => b.toString(16).padStart(2, "0"))
209
+ .join("");
210
+ }
211
+ throw new Error("No crypto implementation available in this environment");
212
+ }
213
+ // -----------------------------------------------------------
214
+ // Attach signing headers to a request
215
+ // -----------------------------------------------------------
216
+ async function attachSigningHeaders(method, url, body, secretKey) {
217
+ const nonce = generateNonce();
218
+ // Compute canonical string (deterministic)
219
+ const bodyHash = body
220
+ ? crypto.createHash("sha256").update(body).digest("hex")
221
+ : "";
222
+ const canonical = `${method.toUpperCase()}\n${url}\n${nonce}\n${bodyHash}`;
223
+ // ⚙️ Always include nonce even if secret is missing
224
+ if (!secretKey) {
225
+ return { "X-Zubbl-Nonce": nonce };
226
+ }
227
+ const signature = await signPayload(canonical, secretKey);
228
+ return {
229
+ "X-Zubbl-Nonce": nonce,
230
+ "X-Zubbl-Signature": signature,
231
+ };
79
232
  }
80
233
 
81
- async function zubblFetch(input, init = {}, context = {}) {
82
- const start = performance.now();
83
- let response;
84
- try {
85
- response = await fetch(input, init);
86
- return response;
87
- } finally {
88
- const latency_ms = performance.now() - start;
234
+ // src/core/http.ts
235
+ // -----------------------------------------------------------
236
+ // Isomorphic HTTP transport for Node, Browser, and Workers
237
+ // Includes adaptive context, telemetry, signing & safety timeout
238
+ // -----------------------------------------------------------
239
+ async function httpRequest(url, options = {}) {
240
+ // 🔧 Default timeout lowered for SDK-12 hardening
241
+ const { timeoutMs = 250, ...fetchOpts } = options;
242
+ // ---------------------------------------------------------
243
+ // Polyfill fetch for Node
244
+ // ---------------------------------------------------------
245
+ let fetchImpl;
246
+ if (isNode) {
247
+ const { fetch } = await import('undici');
248
+ // @ts-expect-error Node native fetch lacks duplex definition in undici types
249
+ fetchImpl = fetch;
250
+ }
251
+ else if (isBrowser || isWorker) {
252
+ fetchImpl = fetch;
253
+ }
254
+ else {
255
+ console.warn("[Zubbl SDK][HTTP] Unsupported runtime environment");
256
+ return null;
257
+ }
258
+ const controller = new AbortController();
259
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
260
+ const start = performance.now();
89
261
  try {
90
- const url = new URL(input, window.location.origin);
91
- emitEndpoint({
92
- tenant_id: context.tenant_id || config.tenantId,
93
- app_id: context.app_id || config.appId,
94
- external_user_id: context.external_user_id,
95
- method: init.method || "GET",
96
- path: url.pathname, // fixed to "path"
97
- status: response?.status ?? 0,
98
- latency_ms,
99
- });
100
- } catch (e) {
101
- console.warn("[Zubbl SDK] Failed to record endpoint telemetry", e);
102
- }
103
- }
262
+ const config = getConfig();
263
+ const bodyText = fetchOpts.body && typeof fetchOpts.body === "string"
264
+ ? fetchOpts.body
265
+ : fetchOpts.body
266
+ ? JSON.stringify(fetchOpts.body)
267
+ : "";
268
+ // ---------------------------------------------------------
269
+ // 🔐 Signing headers (adds X-Zubbl-Nonce and X-Zubbl-Signature)
270
+ // ---------------------------------------------------------
271
+ const signingHeaders = await attachSigningHeaders(fetchOpts.method ?? "GET", url, bodyText, config.workerSecret ?? null);
272
+ // ---------------------------------------------------------
273
+ // 🧠 Adaptive context headers
274
+ // ---------------------------------------------------------
275
+ const adaptiveHeaders = getAdaptiveHeaders();
276
+ // ✅ Merge headers cleanly — final order matters
277
+ fetchOpts.headers = {
278
+ ...(fetchOpts.headers || {}),
279
+ ...adaptiveHeaders,
280
+ ...signingHeaders,
281
+ };
282
+ // ---------------------------------------------------------
283
+ // Perform request
284
+ // ---------------------------------------------------------
285
+ const response = await fetchImpl(url, { ...fetchOpts, signal: controller.signal });
286
+ const latencyMs = performance.now() - start;
287
+ const status = response?.status ?? 0;
288
+ // -----------------------------------------------------
289
+ // Telemetry hook — record runtime discovery + metrics
290
+ // -----------------------------------------------------
291
+ await Promise.resolve().then(function () { return telemetry; }).then(({ recordRequestTelemetry, logRuntimeDiscovery }) => {
292
+ logRuntimeDiscovery(url);
293
+ recordRequestTelemetry(url, fetchOpts.method ?? "GET", status, latencyMs);
294
+ });
295
+ // -----------------------------------------------------
296
+ // Adaptive context sample recording
297
+ // -----------------------------------------------------
298
+ recordContextSample(url, fetchOpts.method ?? "GET", status, latencyMs);
299
+ // -----------------------------------------------------
300
+ // Error handling / Soft fail behaviour
301
+ // -----------------------------------------------------
302
+ if (!response.ok) {
303
+ const text = await response.text().catch(() => "");
304
+ console.warn(`[Zubbl SDK][HTTP] Non-OK response ${status}: ${text}`);
305
+ return response; // still return so host can inspect
306
+ }
307
+ return response;
308
+ }
309
+ catch (err) {
310
+ const latencyMs = performance.now() - start;
311
+ const reason = err?.name === "AbortError"
312
+ ? `timeout (${timeoutMs}ms)`
313
+ : err?.message || "unknown network error";
314
+ console.warn(`[Zubbl SDK][HTTP] Request failed: ${reason}`, { url, latencyMs });
315
+ return null; // 🧩 Never throw to host app
316
+ }
317
+ finally {
318
+ clearTimeout(timeout);
319
+ }
104
320
  }
105
321
 
106
- /**
107
- * Initialize SDK with API credentials.
108
- */
109
- function init({
110
- apiKey,
111
- tenantId,
112
- appId,
113
- baseUrl,
114
- injectWorkerHeaders = false,
115
- workerSecret = null,
116
- }) {
117
- if (!apiKey || !tenantId || !appId) {
118
- throw new Error("apiKey, tenantId, and appId are required");
119
- }
120
- config = {
121
- apiKey,
122
- tenantId,
123
- appId,
124
- baseUrl: baseUrl || "https://api.zubbl.com/api",
125
- injectWorkerHeaders,
126
- workerSecret,
127
- };
322
+ // src/core/storage.ts
323
+ // ------------------------------------------------------
324
+ // Node (in-memory) implementation
325
+ // ------------------------------------------------------
326
+ class NodeStorage {
327
+ constructor() {
328
+ this.store = new Map();
329
+ }
330
+ get(key) {
331
+ const entry = this.store.get(key);
332
+ if (!entry)
333
+ return null;
334
+ if (entry.expiresAt && entry.expiresAt < Date.now()) {
335
+ this.store.delete(key);
336
+ return null;
337
+ }
338
+ return entry.value;
339
+ }
340
+ set(key, value, ttlSeconds) {
341
+ const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined;
342
+ this.store.set(key, { value, expiresAt });
343
+ }
344
+ remove(key) {
345
+ this.store.delete(key);
346
+ }
128
347
  }
129
-
130
- /**
131
- * Identify a user by email (and optional name).
132
- * Backend will return or generate a valid UUID for external_user_id.
133
- */
134
- async function identifyUser({ email, name }) {
135
- if (!config.apiKey || !config.tenantId || !config.appId) {
136
- throw new Error("Zubbl SDK not initialized");
137
- }
138
- if (!email) throw new Error("email is required");
139
-
140
- const headers = {
141
- Authorization: `Bearer ${config.apiKey}`,
142
- "X-Tenant-Id": config.tenantId,
143
- "X-App-Id": config.appId,
144
- "Content-Type": "application/json",
145
- };
146
- if (config.injectWorkerHeaders && config.workerSecret) {
147
- headers["X-Zubbl-Worker-Secret"] = config.workerSecret;
148
- headers["X-Zubbl-Internal-Call"] = "true";
149
- }
150
-
151
- const response = await axios.post(
152
- `${config.baseUrl}/sdk/identify`,
153
- { email, name },
154
- { headers }
155
- );
156
-
157
- const { external_user_id } = response.data;
158
- if (!external_user_id) {
159
- throw new Error("Backend did not return external_user_id");
160
- }
161
-
162
- // ✅ Ensure UUID format
163
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
164
- if (!uuidRegex.test(external_user_id)) {
165
- throw new Error(`Invalid external_user_id received: ${external_user_id}`);
166
- }
167
-
168
- return response.data;
348
+ // ------------------------------------------------------
349
+ // Browser (localStorage) implementation
350
+ // ------------------------------------------------------
351
+ class BrowserStorage {
352
+ constructor() {
353
+ this.prefix = "zubbl_sdk_";
354
+ }
355
+ get(key) {
356
+ try {
357
+ const raw = localStorage.getItem(this.prefix + key);
358
+ if (!raw)
359
+ return null;
360
+ const { value, expiresAt } = JSON.parse(raw);
361
+ if (expiresAt && expiresAt < Date.now()) {
362
+ localStorage.removeItem(this.prefix + key);
363
+ return null;
364
+ }
365
+ return value;
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ set(key, value, ttlSeconds) {
372
+ try {
373
+ const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined;
374
+ localStorage.setItem(this.prefix + key, JSON.stringify({ value, expiresAt }));
375
+ }
376
+ catch (err) {
377
+ // localStorage quota exceeded → silently ignore
378
+ console.warn("[Zubbl SDK][Storage] Failed to persist item:", err);
379
+ }
380
+ }
381
+ remove(key) {
382
+ localStorage.removeItem(this.prefix + key);
383
+ }
169
384
  }
170
-
171
- /**
172
- * Fetch effective tiles for a given user.
173
- * Requires valid external_user_id (UUID).
174
- */
175
- async function getTiles({ external_user_id, app_id = null }) {
176
- if (!config.apiKey || !config.tenantId || !config.appId) {
177
- throw new Error("Zubbl SDK not initialized");
178
- }
179
- if (!external_user_id) throw new Error("external_user_id is required");
180
-
181
- // Validate UUID format
182
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
183
- if (!uuidRegex.test(external_user_id)) {
184
- throw new Error(`Invalid external_user_id: ${external_user_id}. Must be UUID.`);
185
- }
186
-
187
- const headers = {
188
- Authorization: `Bearer ${config.apiKey}`,
189
- "X-Tenant-Id": config.tenantId,
190
- "X-App-Id": config.appId,
191
- "X-External-User-Id": external_user_id,
192
- };
193
- if (config.injectWorkerHeaders && config.workerSecret) {
194
- headers["X-Zubbl-Worker-Secret"] = config.workerSecret;
195
- headers["X-Zubbl-Internal-Call"] = "true";
196
- }
197
-
198
- const params = new URLSearchParams({ external_user_id });
199
- params.set("app_id", app_id || config.appId);
200
-
201
- const response = await axios.get(
202
- `${config.baseUrl.replace(/\/$/, "")}/tiles/effective?${params.toString()}`,
203
- { headers }
204
- );
205
- return response.data;
385
+ // ------------------------------------------------------
386
+ // Factory
387
+ // ------------------------------------------------------
388
+ function createStorage() {
389
+ if (isNode)
390
+ return new NodeStorage();
391
+ if (isBrowser)
392
+ return new BrowserStorage();
393
+ // Fallback: safe no-op storage for tests / fake environments
394
+ return {
395
+ get: () => null,
396
+ set: () => { },
397
+ remove: () => { },
398
+ };
206
399
  }
207
400
 
208
- async function enforce({ external_user_id, app_id = null }) {
209
- if (!config.apiKey || !config.tenantId || !config.appId) {
210
- throw new Error("Zubbl SDK not initialized");
211
- }
212
- if (!external_user_id) throw new Error("external_user_id is required");
401
+ // -----------------------------------------------------------
402
+ // 🌍 Zubbl Geo Engine Multi-Provider Fallback + 15min Cache
403
+ // -----------------------------------------------------------
404
+ // Fully typed, aligns with Zubbl GEO Enforcement Policy
405
+ // -----------------------------------------------------------
406
+ const GEO_CACHE_KEY = "zubbl-geo-cache";
407
+ const GEO_TTL_MS = 15 * 60 * 1000; // 15 minutes
408
+ // -----------------------------------------------------------
409
+ // 🗃 Cache Helpers
410
+ // -----------------------------------------------------------
411
+ function loadGeoCache() {
412
+ try {
413
+ if (!isBrowser || typeof localStorage === "undefined")
414
+ return null;
415
+ const raw = localStorage.getItem(GEO_CACHE_KEY);
416
+ if (!raw)
417
+ return null;
418
+ const parsed = JSON.parse(raw);
419
+ if (!parsed.data?.ip || Date.now() - parsed.timestamp > GEO_TTL_MS)
420
+ return null;
421
+ return parsed.data;
422
+ }
423
+ catch {
424
+ return null;
425
+ }
426
+ }
427
+ function saveGeoCache(data) {
428
+ try {
429
+ if (!isBrowser || typeof localStorage === "undefined")
430
+ return;
431
+ localStorage.setItem(GEO_CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() }));
432
+ }
433
+ catch { }
434
+ }
435
+ // -----------------------------------------------------------
436
+ // 🌐 Provider Wrappers
437
+ // -----------------------------------------------------------
438
+ async function fetchJSON(url, timeoutMs = 3000) {
439
+ const controller = new AbortController();
440
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
441
+ try {
442
+ const resp = await fetch(url, { signal: controller.signal });
443
+ clearTimeout(timeout);
444
+ if (!resp.ok)
445
+ throw new Error(`HTTP ${resp.status}`);
446
+ return await resp.json();
447
+ }
448
+ catch (err) {
449
+ clearTimeout(timeout);
450
+ throw err;
451
+ }
452
+ }
453
+ async function providerIpInfo() {
454
+ const d = await fetchJSON("https://ipinfo.io/json?token=public");
455
+ return {
456
+ ip: d.ip || null,
457
+ country: d.country || "unknown",
458
+ asn: d.org?.replace(/^AS/, "") || null,
459
+ privacy: { vpn: false, hosting: false },
460
+ };
461
+ }
462
+ async function providerIpApi() {
463
+ const d = await fetchJSON("https://ipapi.co/json/");
464
+ return {
465
+ ip: d.ip || null,
466
+ country: d.country_code || "unknown",
467
+ asn: d.asn?.replace(/^AS/, "") || null,
468
+ privacy: { vpn: false, hosting: false },
469
+ };
470
+ }
471
+ async function providerGeoJs() {
472
+ const d = await fetchJSON("https://get.geojs.io/v1/ip/geo.json");
473
+ return {
474
+ ip: d.ip || null,
475
+ country: d.country_code || "unknown",
476
+ asn: null,
477
+ privacy: { vpn: false, hosting: false },
478
+ };
479
+ }
480
+ // -----------------------------------------------------------
481
+ // 🧠 Fallback Resolver
482
+ // -----------------------------------------------------------
483
+ async function getGeoInfo() {
484
+ const cached = loadGeoCache();
485
+ if (cached)
486
+ return cached;
487
+ const providers = [providerIpInfo, providerIpApi, providerGeoJs];
488
+ for (const fn of providers) {
489
+ try {
490
+ const result = await fn();
491
+ if (result?.country && result.country !== "unknown") {
492
+ saveGeoCache(result);
493
+ return result;
494
+ }
495
+ }
496
+ catch (err) {
497
+ console.warn("[Zubbl SDK][Geo] Provider failed:", fn.name, "-", err.message);
498
+ }
499
+ }
500
+ const fallback = {
501
+ ip: null,
502
+ country: "unknown",
503
+ asn: null,
504
+ privacy: { vpn: false, hosting: false },
505
+ };
506
+ saveGeoCache(fallback);
507
+ return fallback;
508
+ }
509
+ async function getGeoHeaders() {
510
+ const geo = await getGeoInfo();
511
+ return {
512
+ "X-Zubbl-Geo-Country": geo.country || "unknown",
513
+ "X-Zubbl-ASN": geo.asn || "unknown",
514
+ "X-Zubbl-IP": geo.ip || "",
515
+ "X-Zubbl-Privacy-VPN": String(geo.privacy?.vpn ?? false),
516
+ "X-Zubbl-Privacy-Hosting": String(geo.privacy?.hosting ?? false),
517
+ };
518
+ }
213
519
 
214
- const headers = {
215
- Authorization: `Bearer ${config.apiKey}`,
216
- "X-Tenant-Id": config.tenantId,
217
- "X-App-Id": app_id || config.appId,
218
- "X-External-User-Id": external_user_id,
219
- "Content-Type": "application/json",
220
- };
221
- if (config.injectWorkerHeaders && config.workerSecret) {
222
- headers["X-Zubbl-Worker-Secret"] = config.workerSecret;
223
- headers["X-Zubbl-Internal-Call"] = "true";
224
- }
520
+ // ---------------------------------------------------------
521
+ // Zubbl SDK — Unified Bootstrap (Adaptive + GEO Aware)
522
+ // ---------------------------------------------------------
523
+ // ---------------------------------------------------------
524
+ // Initialization
525
+ // ---------------------------------------------------------
526
+ async function init({ apiKey, tenantId, appId, baseUrl = "https://api.zubbl.com/api", injectWorkerHeaders = false, workerSecret = null, }) {
527
+ // Initialize core configuration
528
+ init$1({ apiKey, tenantId, appId, baseUrl, injectWorkerHeaders, workerSecret });
529
+ // Prime GEO context cache
530
+ try {
531
+ const geo = await getGeoInfo();
532
+ console.log(`[Zubbl SDK] 🌍 GEO detected: ${geo.country} (ASN ${geo.asn || "?"})`);
533
+ }
534
+ catch (err) {
535
+ console.warn("[Zubbl SDK] GEO init failed:", err?.message);
536
+ }
537
+ }
538
+ // ---------------------------------------------------------
539
+ // Identify User
540
+ // ---------------------------------------------------------
541
+ async function identifyUser({ email, name }) {
542
+ const config = getConfig();
543
+ if (!config.apiKey || !config.tenantId || !config.appId) {
544
+ throw new Error("Zubbl SDK not initialized");
545
+ }
546
+ const adaptiveHeaders = getAdaptiveHeaders();
547
+ const geoHeaders = await getGeoHeaders();
548
+ const headers = buildHeaders({
549
+ "Content-Type": "application/json",
550
+ ...adaptiveHeaders,
551
+ ...geoHeaders,
552
+ });
553
+ const body = JSON.stringify({ email, name });
554
+ const resp = await httpRequest(`${config.baseUrl}/sdk/identify`, {
555
+ method: "POST",
556
+ headers,
557
+ body,
558
+ });
559
+ const data = await resp.json().catch(() => ({}));
560
+ if (!data.external_user_id) {
561
+ console.warn("[Zubbl SDK] identifyUser: missing external_user_id");
562
+ }
563
+ return data;
564
+ }
565
+ // ---------------------------------------------------------
566
+ // Fetch Effective Tiles
567
+ // ---------------------------------------------------------
568
+ async function getTiles({ external_user_id, app_id = null, }) {
569
+ const config = getConfig();
570
+ if (!config.apiKey || !config.tenantId || !config.appId) {
571
+ throw new Error("Zubbl SDK not initialized");
572
+ }
573
+ if (!external_user_id)
574
+ throw new Error("external_user_id is required");
575
+ const adaptiveHeaders = getAdaptiveHeaders();
576
+ const geoHeaders = await getGeoHeaders();
577
+ const headers = buildHeaders({
578
+ "X-External-User-Id": external_user_id,
579
+ ...adaptiveHeaders,
580
+ ...geoHeaders,
581
+ });
582
+ const params = new URLSearchParams({ external_user_id });
583
+ params.set("app_id", app_id || config.appId);
584
+ const resp = await httpRequest(`${config.baseUrl.replace(/\/$/, "")}/tiles/effective?${params.toString()}`, { method: "GET", headers });
585
+ return resp.json();
586
+ }
587
+ // ---------------------------------------------------------
588
+ // Policy Enforcement
589
+ // ---------------------------------------------------------
590
+ async function enforce({ external_user_id, app_id = null, }) {
591
+ const config = getConfig();
592
+ if (!config.apiKey || !config.tenantId || !config.appId) {
593
+ throw new Error("Zubbl SDK not initialized");
594
+ }
595
+ const adaptiveHeaders = getAdaptiveHeaders();
596
+ const geoHeaders = await getGeoHeaders();
597
+ const headers = buildHeaders({
598
+ "X-External-User-Id": external_user_id,
599
+ ...adaptiveHeaders,
600
+ ...geoHeaders,
601
+ });
602
+ const resp = await httpRequest(`${config.baseUrl.replace(/\/$/, "")}/sdk/test-enforcement`, { method: "POST", headers });
603
+ const json = await resp.json().catch(() => ({}));
604
+ return { decision: "allow", status: resp.status, data: json };
605
+ }
606
+ // ---------------------------------------------------------
607
+ // Exposed Helpers
608
+ // ---------------------------------------------------------
609
+ const storage = createStorage();
225
610
 
226
- try {
227
- const resp = await axios.post(
228
- `${config.baseUrl.replace(/\/$/, "")}/sdk/test-enforcement`,
229
- {},
230
- { headers }
231
- );
232
- return { decision: "allow", status: resp.status, data: resp.data };
233
- } catch (e) {
234
- if (e.response && e.response.status === 429) {
235
- return { decision: "block", status: 429, data: e.response.data };
236
- }
237
- throw e;
238
- }
611
+ // -----------------------------------------------------------
612
+ // Zubbl Telemetry Core (Endpoint Discovery)
613
+ // -----------------------------------------------------------
614
+ // Local telemetry buffer
615
+ let sampleRatio = 0.1; // 1 in 10 by default
616
+ let buffer = [];
617
+ let debounceTimer = null;
618
+ function shouldSample() {
619
+ return Math.random() < sampleRatio;
239
620
  }
621
+ // -----------------------------------------------------------
622
+ // Emit telemetry events
623
+ // -----------------------------------------------------------
624
+ function emitEndpoint(payload) {
625
+ if (!shouldSample())
626
+ return;
627
+ const enriched = {
628
+ ...payload,
629
+ ts: Date.now(),
630
+ source: isBrowser ? "browser" : isNode ? "node" : "worker",
631
+ };
632
+ buffer.push(enriched);
633
+ if (!debounceTimer) {
634
+ debounceTimer = setTimeout(flushBuffer, 200);
635
+ }
636
+ }
637
+ // -----------------------------------------------------------
638
+ // Flush logic (batch send)
639
+ // -----------------------------------------------------------
640
+ async function flushBuffer() {
641
+ if (buffer.length === 0) {
642
+ debounceTimer = null;
643
+ return;
644
+ }
645
+ const batch = [...buffer];
646
+ buffer = [];
647
+ debounceTimer = null;
648
+ const config = getConfig();
649
+ const url = `${config.baseUrl.replace(/\/$/, "")}/sdk/log`;
650
+ const headers = {
651
+ "Content-Type": "application/json",
652
+ Authorization: `Bearer ${config.apiKey}`,
653
+ "X-Tenant-Id": config.tenantId ?? "",
654
+ "X-App-Id": config.appId ?? "",
655
+ };
656
+ try {
657
+ const resp = await fetch(url, {
658
+ method: "POST",
659
+ headers,
660
+ body: JSON.stringify(batch),
661
+ });
662
+ const text = await resp.text();
663
+ console.log("[Zubbl SDK][Telemetry] Flushed", batch.length, "events →", resp.status, text);
664
+ }
665
+ catch (err) {
666
+ console.warn("[Zubbl SDK][Telemetry] Failed to send batch:", err.message);
667
+ }
668
+ }
669
+ // -----------------------------------------------------------
670
+ // Helper for HTTP wrapping
671
+ // -----------------------------------------------------------
672
+ async function recordRequestTelemetry(url, method, status, latency, extra = {}) {
673
+ try {
674
+ const path = new URL(url).pathname;
675
+ emitEndpoint({
676
+ path,
677
+ method,
678
+ status,
679
+ latency,
680
+ ...extra,
681
+ });
682
+ }
683
+ catch (err) {
684
+ console.warn("[Zubbl SDK][Telemetry] Failed to record telemetry", err);
685
+ }
686
+ }
687
+ // -----------------------------------------------------------
688
+ // Runtime Endpoint Discovery
689
+ // -----------------------------------------------------------
690
+ let hasLoggedDiscovery = false;
691
+ function logRuntimeDiscovery(url) {
692
+ if (hasLoggedDiscovery)
693
+ return; // only once per runtime
694
+ hasLoggedDiscovery = true;
695
+ const runtime = isNode ? "Node" : isBrowser ? "Browser" : isWorker ? "Worker" : "Unknown";
696
+ const time = new Date().toISOString();
697
+ console.log(`[Zubbl SDK][Discovery] Runtime: ${runtime}`);
698
+ console.log(`[Zubbl SDK][Discovery] Timestamp: ${time}`);
699
+ console.log(`[Zubbl SDK][Discovery] Initial endpoint: ${url}`);
700
+ }
701
+
702
+ var telemetry = /*#__PURE__*/Object.freeze({
703
+ __proto__: null,
704
+ emitEndpoint: emitEndpoint,
705
+ logRuntimeDiscovery: logRuntimeDiscovery,
706
+ recordRequestTelemetry: recordRequestTelemetry
707
+ });
240
708
 
241
- export { emitEndpoint, enforce, getTiles, identifyUser, init, setSamplingRatio, zubblFetch };
709
+ export { enforce, getConfig, getTiles, httpRequest, identifyUser, init, storage };
710
+ //# sourceMappingURL=zubbl-sdk.esm.js.map