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