@better-auth/infra 0.1.14 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/client.d.mts +2 -70
- package/dist/client.mjs +1 -87
- package/dist/dash-client-hJHp7l_X.d.mts +72 -0
- package/dist/index.d.mts +306 -334
- package/dist/index.mjs +280 -70
- package/dist/native.d.mts +18 -0
- package/dist/native.mjs +292 -0
- package/dist/pow-BUuN_EKw.mjs +131 -0
- package/package.json +28 -3
- package/dist/identification-DF2nvmng.mjs +0 -178
package/dist/native.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { a as identify, c as dashClient, n as encodePoWSolution, o as bytesToHex, r as solvePoWChallenge, t as decodePoWChallenge } from "./pow-BUuN_EKw.mjs";
|
|
2
|
+
import { env } from "@better-auth/core/env";
|
|
3
|
+
import { Dimensions, InteractionManager, PixelRatio, Platform } from "react-native";
|
|
4
|
+
//#region src/sentinel/native/components.ts
|
|
5
|
+
/**
|
|
6
|
+
* Default first-party component payload for React Native / Expo.
|
|
7
|
+
* Avoids browser-style entropy; sets `clientRuntime` for durable-kv bot scoring.
|
|
8
|
+
*/
|
|
9
|
+
async function defaultCollectNativeComponents() {
|
|
10
|
+
const windowDims = Dimensions.get("window");
|
|
11
|
+
const scale = PixelRatio.get();
|
|
12
|
+
let locale = "en";
|
|
13
|
+
let locales = ["en"];
|
|
14
|
+
try {
|
|
15
|
+
locale = Intl.DateTimeFormat().resolvedOptions().locale || locale;
|
|
16
|
+
locales = [locale];
|
|
17
|
+
} catch {}
|
|
18
|
+
const nav = globalThis.navigator;
|
|
19
|
+
if (nav?.languages?.length) locales = [...nav.languages];
|
|
20
|
+
let timezone = "";
|
|
21
|
+
let timezoneOffset = 0;
|
|
22
|
+
try {
|
|
23
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
|
24
|
+
timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
|
|
25
|
+
} catch {
|
|
26
|
+
timezone = "unknown";
|
|
27
|
+
}
|
|
28
|
+
const components = {
|
|
29
|
+
clientRuntime: "react-native",
|
|
30
|
+
platform: Platform.OS,
|
|
31
|
+
screenResolution: `${Math.round(windowDims.width)}x${Math.round(windowDims.height)}`,
|
|
32
|
+
pixelRatio: scale,
|
|
33
|
+
fontScale: windowDims.fontScale,
|
|
34
|
+
language: locales[0] ?? locale,
|
|
35
|
+
languages: locales,
|
|
36
|
+
locale,
|
|
37
|
+
timezone,
|
|
38
|
+
timezoneOffset,
|
|
39
|
+
touchSupport: true,
|
|
40
|
+
maxTouchPoints: Platform.OS === "ios" ? 5 : 10
|
|
41
|
+
};
|
|
42
|
+
if (nav?.userAgent) components.userAgent = nav.userAgent;
|
|
43
|
+
await applyOptionalExpoMetadata(components);
|
|
44
|
+
return components;
|
|
45
|
+
}
|
|
46
|
+
async function applyOptionalExpoMetadata(components) {
|
|
47
|
+
try {
|
|
48
|
+
const c = (await import("expo-constants")).default;
|
|
49
|
+
if (c?.expoConfig?.version) components.appVersion = c.expoConfig.version;
|
|
50
|
+
else if (c?.nativeAppVersion) components.appVersion = c.nativeAppVersion;
|
|
51
|
+
if (c?.expoConfig?.slug) components.expoSlug = c.expoConfig.slug;
|
|
52
|
+
} catch {}
|
|
53
|
+
try {
|
|
54
|
+
const d = (await import("expo-device")).default;
|
|
55
|
+
if (d?.modelName) components.deviceModel = d.modelName;
|
|
56
|
+
if (d?.osVersion) components.osVersion = d.osVersion;
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/sentinel/native/crypto.ts
|
|
61
|
+
/**
|
|
62
|
+
* Cryptographically secure random bytes for React Native, Node (e.g. Expo API routes),
|
|
63
|
+
* and other runtimes
|
|
64
|
+
*/
|
|
65
|
+
async function randomBytesAsync(length) {
|
|
66
|
+
const c = globalThis.crypto;
|
|
67
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
68
|
+
const bytes = new Uint8Array(length);
|
|
69
|
+
c.getRandomValues(bytes);
|
|
70
|
+
return bytes;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const { getRandomBytesAsync } = await import("expo-crypto");
|
|
74
|
+
return await getRandomBytesAsync(length);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error("[@better-auth/infra] No secure RNG available: use Web Crypto / Node, add `import \"react-native-get-random-values\"` at app entry (before other imports), or install `expo-crypto` with a native dev build.");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/sentinel/native/fingerprint.ts
|
|
81
|
+
async function randomVisitorId() {
|
|
82
|
+
return bytesToHex(await randomBytesAsync(10)).slice(0, 20);
|
|
83
|
+
}
|
|
84
|
+
async function generateRequestId() {
|
|
85
|
+
const h = bytesToHex(await randomBytesAsync(16));
|
|
86
|
+
return [
|
|
87
|
+
h.slice(0, 8),
|
|
88
|
+
h.slice(8, 12),
|
|
89
|
+
h.slice(12, 16),
|
|
90
|
+
h.slice(16, 20),
|
|
91
|
+
h.slice(20, 32)
|
|
92
|
+
].join("-");
|
|
93
|
+
}
|
|
94
|
+
/** Conservative confidence for first-party native payloads (not web-grade signals). */
|
|
95
|
+
const NATIVE_IDENTIFY_CONFIDENCE = .52;
|
|
96
|
+
/** Logical URL recorded with native identify payloads. */
|
|
97
|
+
const NATIVE_IDENTIFY_CONTEXT_URL = "react-native://identify";
|
|
98
|
+
function createNativeFingerprintRuntime(deps) {
|
|
99
|
+
let cached = null;
|
|
100
|
+
let pending = null;
|
|
101
|
+
let identifySent = false;
|
|
102
|
+
let identifyComplete = null;
|
|
103
|
+
let identifyCompleteResolve = null;
|
|
104
|
+
async function getFingerprint() {
|
|
105
|
+
if (cached != null) return cached;
|
|
106
|
+
if (pending != null) return pending;
|
|
107
|
+
pending = (async () => {
|
|
108
|
+
try {
|
|
109
|
+
const visitorId = await deps.getOrCreateVisitorId();
|
|
110
|
+
const components = await defaultCollectNativeComponents();
|
|
111
|
+
const result = {
|
|
112
|
+
visitorId,
|
|
113
|
+
requestId: await generateRequestId(),
|
|
114
|
+
confidence: NATIVE_IDENTIFY_CONFIDENCE,
|
|
115
|
+
components
|
|
116
|
+
};
|
|
117
|
+
cached = result;
|
|
118
|
+
return result;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.warn("[Sentinel native] Fingerprint generation failed:", error);
|
|
121
|
+
pending = null;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
return pending;
|
|
126
|
+
}
|
|
127
|
+
async function sendIdentify() {
|
|
128
|
+
if (identifySent) return;
|
|
129
|
+
const fingerprint = await getFingerprint();
|
|
130
|
+
if (!fingerprint) return;
|
|
131
|
+
identifySent = true;
|
|
132
|
+
identifyComplete = new Promise((resolve) => {
|
|
133
|
+
identifyCompleteResolve = resolve;
|
|
134
|
+
});
|
|
135
|
+
const payload = {
|
|
136
|
+
visitorId: fingerprint.visitorId,
|
|
137
|
+
requestId: fingerprint.requestId,
|
|
138
|
+
confidence: fingerprint.confidence,
|
|
139
|
+
components: fingerprint.components,
|
|
140
|
+
url: NATIVE_IDENTIFY_CONTEXT_URL,
|
|
141
|
+
incognito: false
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
await identify(deps.identifyUrl, payload);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.warn("[Sentinel native] Identify request failed:", error);
|
|
147
|
+
} finally {
|
|
148
|
+
identifyCompleteResolve?.();
|
|
149
|
+
identifyCompleteResolve = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function waitForIdentify(timeoutMs = 500) {
|
|
153
|
+
if (!identifyComplete) return;
|
|
154
|
+
await Promise.race([identifyComplete, new Promise((resolve) => setTimeout(resolve, timeoutMs))]);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
getFingerprint,
|
|
158
|
+
sendIdentify,
|
|
159
|
+
waitForIdentify
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const VISITOR_STORAGE_KEY = "better-auth.infra.sentinel.visitorId";
|
|
163
|
+
let memoryVisitorId = null;
|
|
164
|
+
/**
|
|
165
|
+
* Stable per-install visitor id. Prefer passing `storage` (e.g. secure storage).
|
|
166
|
+
* Otherwise tries `@react-native-async-storage/async-storage`, then falls back to an in-memory id (session only).
|
|
167
|
+
*/
|
|
168
|
+
async function getOrCreateVisitorId(storage) {
|
|
169
|
+
if (storage) {
|
|
170
|
+
const existing = await storage.getItem(VISITOR_STORAGE_KEY);
|
|
171
|
+
if (existing) return existing;
|
|
172
|
+
const id = await randomVisitorId();
|
|
173
|
+
await storage.setItem(VISITOR_STORAGE_KEY, id);
|
|
174
|
+
return id;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const { default: AsyncStorage } = await import("@react-native-async-storage/async-storage");
|
|
178
|
+
const existing = await AsyncStorage.getItem(VISITOR_STORAGE_KEY);
|
|
179
|
+
if (existing) return existing;
|
|
180
|
+
const id = await randomVisitorId();
|
|
181
|
+
await AsyncStorage.setItem(VISITOR_STORAGE_KEY, id);
|
|
182
|
+
return id;
|
|
183
|
+
} catch {
|
|
184
|
+
memoryVisitorId ??= await randomVisitorId();
|
|
185
|
+
return memoryVisitorId;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/sentinel/native/client.ts
|
|
190
|
+
const DEFAULT_IDENTIFY_URL = "https://kv.better-auth.com";
|
|
191
|
+
function scheduleIdentify(send) {
|
|
192
|
+
const run = () => {
|
|
193
|
+
try {
|
|
194
|
+
send();
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.warn("[Sentinel native] identify schedule error:", e);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
const im = InteractionManager;
|
|
201
|
+
if (im && typeof im.runAfterInteractions === "function") {
|
|
202
|
+
im.runAfterInteractions(run);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
} catch {}
|
|
206
|
+
if (typeof queueMicrotask === "function") queueMicrotask(run);
|
|
207
|
+
else setTimeout(run, 0);
|
|
208
|
+
}
|
|
209
|
+
const sentinelNativeClient = (options) => {
|
|
210
|
+
const autoSolve = options?.autoSolveChallenge !== false;
|
|
211
|
+
const runtime = createNativeFingerprintRuntime({
|
|
212
|
+
identifyUrl: options?.identifyUrl ?? env.BETTER_AUTH_KV_URL ?? DEFAULT_IDENTIFY_URL,
|
|
213
|
+
getOrCreateVisitorId: () => getOrCreateVisitorId(options?.storage)
|
|
214
|
+
});
|
|
215
|
+
scheduleIdentify(() => {
|
|
216
|
+
runtime.sendIdentify();
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
id: "sentinel",
|
|
220
|
+
fetchPlugins: [{
|
|
221
|
+
id: "sentinel-fingerprint",
|
|
222
|
+
name: "sentinel-fingerprint",
|
|
223
|
+
hooks: { async onRequest(context) {
|
|
224
|
+
await runtime.waitForIdentify(500);
|
|
225
|
+
const fingerprint = await runtime.getFingerprint();
|
|
226
|
+
if (!fingerprint) return context;
|
|
227
|
+
const headers = context.headers || new Headers();
|
|
228
|
+
if (headers instanceof Headers) {
|
|
229
|
+
headers.set("X-Visitor-Id", fingerprint.visitorId);
|
|
230
|
+
headers.set("X-Request-Id", fingerprint.requestId);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
...context,
|
|
234
|
+
headers
|
|
235
|
+
};
|
|
236
|
+
} }
|
|
237
|
+
}, {
|
|
238
|
+
id: "sentinel-pow-solver",
|
|
239
|
+
name: "sentinel-pow-solver",
|
|
240
|
+
hooks: {
|
|
241
|
+
async onResponse(context) {
|
|
242
|
+
if (context.response.status !== 423 || !autoSolve) return context;
|
|
243
|
+
const challengeHeader = context.response.headers.get("X-PoW-Challenge");
|
|
244
|
+
const reason = context.response.headers.get("X-PoW-Reason") || "";
|
|
245
|
+
if (!challengeHeader) return context;
|
|
246
|
+
options?.onChallengeReceived?.(reason);
|
|
247
|
+
const challenge = decodePoWChallenge(challengeHeader);
|
|
248
|
+
if (!challenge) return context;
|
|
249
|
+
try {
|
|
250
|
+
const startTime = Date.now();
|
|
251
|
+
const solution = await solvePoWChallenge(challenge);
|
|
252
|
+
const solveTime = Date.now() - startTime;
|
|
253
|
+
options?.onChallengeSolved?.(solveTime);
|
|
254
|
+
const originalUrl = context.response.url;
|
|
255
|
+
const fingerprint = await runtime.getFingerprint();
|
|
256
|
+
const retryHeaders = new Headers();
|
|
257
|
+
retryHeaders.set("X-PoW-Solution", encodePoWSolution(solution));
|
|
258
|
+
if (fingerprint) {
|
|
259
|
+
retryHeaders.set("X-Visitor-Id", fingerprint.visitorId);
|
|
260
|
+
retryHeaders.set("X-Request-Id", fingerprint.requestId);
|
|
261
|
+
}
|
|
262
|
+
retryHeaders.set("Content-Type", "application/json");
|
|
263
|
+
let body;
|
|
264
|
+
if (context.request?.body) body = context.request._originalBody;
|
|
265
|
+
const retryResponse = await fetch(originalUrl, {
|
|
266
|
+
method: context.request?.method || "POST",
|
|
267
|
+
headers: retryHeaders,
|
|
268
|
+
body
|
|
269
|
+
});
|
|
270
|
+
return {
|
|
271
|
+
...context,
|
|
272
|
+
response: retryResponse
|
|
273
|
+
};
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("[Sentinel native] Failed to solve PoW challenge:", err);
|
|
276
|
+
options?.onChallengeFailed?.(err instanceof Error ? err : new Error(String(err)));
|
|
277
|
+
return context;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
async onRequest(context) {
|
|
281
|
+
if (context.body) {
|
|
282
|
+
const ctx = context;
|
|
283
|
+
ctx._originalBody = context.body;
|
|
284
|
+
}
|
|
285
|
+
return context;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}]
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
//#endregion
|
|
292
|
+
export { dashClient, sentinelNativeClient };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import "./constants-DdWGfvz1.mjs";
|
|
2
|
+
//#region src/dash-client.ts
|
|
3
|
+
function resolveDashUserId(input, options) {
|
|
4
|
+
return input.userId || options?.resolveUserId?.({
|
|
5
|
+
userId: input.userId,
|
|
6
|
+
user: input.user,
|
|
7
|
+
session: input.session
|
|
8
|
+
}) || input.user?.id || input.session?.user?.id || void 0;
|
|
9
|
+
}
|
|
10
|
+
const dashClient = (options) => {
|
|
11
|
+
return {
|
|
12
|
+
id: "dash",
|
|
13
|
+
getActions: ($fetch) => ({ dash: { getAuditLogs: async (input = {}) => {
|
|
14
|
+
const userId = resolveDashUserId(input, options);
|
|
15
|
+
return $fetch("/events/audit-logs", {
|
|
16
|
+
method: "GET",
|
|
17
|
+
query: {
|
|
18
|
+
limit: input.limit,
|
|
19
|
+
offset: input.offset,
|
|
20
|
+
organizationId: input.organizationId,
|
|
21
|
+
identifier: input.identifier,
|
|
22
|
+
eventType: input.eventType,
|
|
23
|
+
userId
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
} } }),
|
|
27
|
+
pathMethods: { "/events/audit-logs": "GET" }
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/crypto.ts
|
|
32
|
+
function randomBytes(length) {
|
|
33
|
+
const bytes = new Uint8Array(length);
|
|
34
|
+
crypto.getRandomValues(bytes);
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
function bytesToHex(bytes) {
|
|
38
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
}
|
|
40
|
+
async function hash(message) {
|
|
41
|
+
const msgBuffer = new TextEncoder().encode(message);
|
|
42
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
43
|
+
return bytesToHex(new Uint8Array(hashBuffer));
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/abort-signal.ts
|
|
47
|
+
/**
|
|
48
|
+
* Returns an AbortSignal that aborts after `ms`.
|
|
49
|
+
* Uses `AbortSignal.timeout` when present; otherwise `AbortController` + `setTimeout`
|
|
50
|
+
* for React Native / older runtimes where `.timeout` is missing.
|
|
51
|
+
*/
|
|
52
|
+
function timeout(ms) {
|
|
53
|
+
if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
setTimeout(() => controller.abort(), ms);
|
|
56
|
+
return controller.signal;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/identification-client.ts
|
|
60
|
+
function generateRequestId() {
|
|
61
|
+
const hex = bytesToHex(randomBytes(16));
|
|
62
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
63
|
+
}
|
|
64
|
+
async function identify(baseURL, payload, signal) {
|
|
65
|
+
const base = baseURL.replace(/\/$/, "");
|
|
66
|
+
await fetch(`${base}/identify`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify(payload),
|
|
70
|
+
signal: signal ?? timeout(5e3)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/sentinel/pow.ts
|
|
75
|
+
function hasLeadingZeroBits(hash, bits) {
|
|
76
|
+
const fullHexChars = Math.floor(bits / 4);
|
|
77
|
+
const remainingBits = bits % 4;
|
|
78
|
+
for (let i = 0; i < fullHexChars; i++) if (hash[i] !== "0") return false;
|
|
79
|
+
if (remainingBits > 0 && fullHexChars < hash.length) {
|
|
80
|
+
if (parseInt(hash[fullHexChars], 16) > (1 << 4 - remainingBits) - 1) return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
async function solvePoWChallenge(challenge) {
|
|
85
|
+
const { nonce, difficulty } = challenge;
|
|
86
|
+
let counter = 0;
|
|
87
|
+
while (true) {
|
|
88
|
+
if (hasLeadingZeroBits(await hash(`${nonce}:${counter}`), difficulty)) return {
|
|
89
|
+
nonce,
|
|
90
|
+
counter
|
|
91
|
+
};
|
|
92
|
+
counter++;
|
|
93
|
+
if (counter % 1e3 === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
94
|
+
if (counter > 1e8) throw new Error("PoW challenge took too long to solve");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function decodeBase64ToUtf8(encoded) {
|
|
98
|
+
if (typeof globalThis.atob === "function") return globalThis.atob(encoded);
|
|
99
|
+
throw new Error("[Sentinel] Base64 decode requires atob (browser, Hermes, or Bun)");
|
|
100
|
+
}
|
|
101
|
+
function encodeUtf8ToBase64(str) {
|
|
102
|
+
if (typeof globalThis.btoa === "function") return globalThis.btoa(str);
|
|
103
|
+
const bytes = new TextEncoder().encode(str);
|
|
104
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
105
|
+
let out = "";
|
|
106
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
107
|
+
const b0 = bytes[i];
|
|
108
|
+
const b1 = bytes[i + 1] ?? 0;
|
|
109
|
+
const b2 = bytes[i + 2] ?? 0;
|
|
110
|
+
const triple = b0 << 16 | b1 << 8 | b2;
|
|
111
|
+
const pad = i + 2 >= bytes.length ? i + 1 >= bytes.length ? 2 : 1 : 0;
|
|
112
|
+
out += chars[triple >> 18 & 63];
|
|
113
|
+
out += chars[triple >> 12 & 63];
|
|
114
|
+
out += pad < 2 ? chars[triple >> 6 & 63] : "=";
|
|
115
|
+
out += pad < 1 ? chars[triple & 63] : "=";
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function decodePoWChallenge(encoded) {
|
|
120
|
+
try {
|
|
121
|
+
const decoded = decodeBase64ToUtf8(encoded);
|
|
122
|
+
return JSON.parse(decoded);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function encodePoWSolution(solution) {
|
|
128
|
+
return encodeUtf8ToBase64(JSON.stringify(solution));
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { identify as a, dashClient as c, generateRequestId as i, encodePoWSolution as n, bytesToHex as o, solvePoWChallenge as r, hash as s, decodePoWChallenge as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/infra",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Dashboard and analytics plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
"types": "./dist/email.d.mts",
|
|
31
31
|
"import": "./dist/email.mjs",
|
|
32
32
|
"default": "./dist/email.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./native": {
|
|
35
|
+
"types": "./dist/native.d.mts",
|
|
36
|
+
"import": "./dist/native.mjs",
|
|
37
|
+
"default": "./dist/native.mjs"
|
|
33
38
|
}
|
|
34
39
|
},
|
|
35
40
|
"files": [
|
|
@@ -62,8 +67,9 @@
|
|
|
62
67
|
"@types/bun": "latest",
|
|
63
68
|
"@types/node": "catalog:",
|
|
64
69
|
"better-auth": "catalog:",
|
|
70
|
+
"expo-crypto": "^14.0.2",
|
|
65
71
|
"happy-dom": "^20.8.9",
|
|
66
|
-
"msw": "^2.
|
|
72
|
+
"msw": "^2.13.0",
|
|
67
73
|
"tsdown": "^0.21.1",
|
|
68
74
|
"typescript": "catalog:",
|
|
69
75
|
"zod": "catalog:"
|
|
@@ -78,6 +84,25 @@
|
|
|
78
84
|
"better-auth": ">=1.4.0",
|
|
79
85
|
"zod": ">=4.1.12",
|
|
80
86
|
"@better-auth/core": ">=1.4.0",
|
|
81
|
-
"@better-auth/sso": ">=1.4.0"
|
|
87
|
+
"@better-auth/sso": ">=1.4.0",
|
|
88
|
+
"react-native": ">=0.74.0",
|
|
89
|
+
"@react-native-async-storage/async-storage": ">=1.21.0",
|
|
90
|
+
"expo-constants": ">=16.0.0",
|
|
91
|
+
"expo-crypto": ">=13.0.0",
|
|
92
|
+
"expo-device": ">=6.0.0"
|
|
93
|
+
},
|
|
94
|
+
"peerDependenciesMeta": {
|
|
95
|
+
"@react-native-async-storage/async-storage": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"expo-constants": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"expo-crypto": {
|
|
102
|
+
"optional": true
|
|
103
|
+
},
|
|
104
|
+
"expo-device": {
|
|
105
|
+
"optional": true
|
|
106
|
+
}
|
|
82
107
|
}
|
|
83
108
|
}
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { n as INFRA_KV_URL, r as KV_TIMEOUT_MS } from "./constants-DdWGfvz1.mjs";
|
|
2
|
-
import { logger } from "better-auth";
|
|
3
|
-
import { createAuthMiddleware } from "better-auth/api";
|
|
4
|
-
//#region src/crypto.ts
|
|
5
|
-
function randomBytes(length) {
|
|
6
|
-
const bytes = new Uint8Array(length);
|
|
7
|
-
crypto.getRandomValues(bytes);
|
|
8
|
-
return bytes;
|
|
9
|
-
}
|
|
10
|
-
function bytesToHex(bytes) {
|
|
11
|
-
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
12
|
-
}
|
|
13
|
-
async function hash(message) {
|
|
14
|
-
const msgBuffer = new TextEncoder().encode(message);
|
|
15
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
|
16
|
-
return bytesToHex(new Uint8Array(hashBuffer));
|
|
17
|
-
}
|
|
18
|
-
//#endregion
|
|
19
|
-
//#region src/identification.ts
|
|
20
|
-
/**
|
|
21
|
-
* Identification Service
|
|
22
|
-
*
|
|
23
|
-
* Fetches identification data from the durable-kv service
|
|
24
|
-
* when a request includes an X-Request-Id header.
|
|
25
|
-
*/
|
|
26
|
-
const IDENTIFICATION_COOKIE_NAME = "__infra-rid";
|
|
27
|
-
const identificationCache = /* @__PURE__ */ new Map();
|
|
28
|
-
const CACHE_TTL_MS = 6e4;
|
|
29
|
-
const CACHE_MAX_SIZE = 1e3;
|
|
30
|
-
let lastCleanup = Date.now();
|
|
31
|
-
function cleanupCache() {
|
|
32
|
-
const now = Date.now();
|
|
33
|
-
for (const [key, value] of identificationCache.entries()) if (now - value.timestamp > CACHE_TTL_MS) identificationCache.delete(key);
|
|
34
|
-
lastCleanup = now;
|
|
35
|
-
}
|
|
36
|
-
function maybeCleanup() {
|
|
37
|
-
if (Date.now() - lastCleanup > CACHE_TTL_MS || identificationCache.size > CACHE_MAX_SIZE) cleanupCache();
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Fetch identification data from durable-kv by requestId
|
|
41
|
-
*/
|
|
42
|
-
async function getIdentification(requestId, apiKey, kvUrl) {
|
|
43
|
-
maybeCleanup();
|
|
44
|
-
const cached = identificationCache.get(requestId);
|
|
45
|
-
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.data;
|
|
46
|
-
const baseUrl = kvUrl || INFRA_KV_URL;
|
|
47
|
-
const maxRetries = 3;
|
|
48
|
-
const retryDelays = [
|
|
49
|
-
50,
|
|
50
|
-
100,
|
|
51
|
-
200
|
|
52
|
-
];
|
|
53
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) try {
|
|
54
|
-
const response = await fetch(`${baseUrl}/identify/${requestId}`, {
|
|
55
|
-
method: "GET",
|
|
56
|
-
headers: { "x-api-key": apiKey },
|
|
57
|
-
signal: AbortSignal.timeout(KV_TIMEOUT_MS)
|
|
58
|
-
});
|
|
59
|
-
if (response.ok) {
|
|
60
|
-
const data = await response.json();
|
|
61
|
-
identificationCache.set(requestId, {
|
|
62
|
-
data,
|
|
63
|
-
timestamp: Date.now()
|
|
64
|
-
});
|
|
65
|
-
return data;
|
|
66
|
-
}
|
|
67
|
-
if (response.status === 404 && attempt < maxRetries) {
|
|
68
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
if (response.status !== 404) identificationCache.set(requestId, {
|
|
72
|
-
data: null,
|
|
73
|
-
timestamp: Date.now()
|
|
74
|
-
});
|
|
75
|
-
return null;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
if (attempt === maxRetries) {
|
|
78
|
-
logger.error("[Dash] Failed to fetch identification:", error);
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt] || 50));
|
|
82
|
-
}
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
function generateRequestId() {
|
|
86
|
-
const hex = bytesToHex(randomBytes(16));
|
|
87
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
88
|
-
}
|
|
89
|
-
async function identify(baseURL, payload, signal) {
|
|
90
|
-
const base = baseURL.replace(/\/$/, "");
|
|
91
|
-
await fetch(`${base}/identify`, {
|
|
92
|
-
method: "POST",
|
|
93
|
-
headers: { "Content-Type": "application/json" },
|
|
94
|
-
body: JSON.stringify(payload),
|
|
95
|
-
signal: signal ?? AbortSignal.timeout(5e3)
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Extract identification headers from a request
|
|
100
|
-
*/
|
|
101
|
-
function extractIdentificationHeaders(request) {
|
|
102
|
-
if (!request) return {
|
|
103
|
-
visitorId: null,
|
|
104
|
-
requestId: null
|
|
105
|
-
};
|
|
106
|
-
return {
|
|
107
|
-
visitorId: request.headers.get("X-Visitor-Id"),
|
|
108
|
-
requestId: request.headers.get("X-Request-Id")
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Early middleware that loads identification data
|
|
113
|
-
*/
|
|
114
|
-
function createIdentificationMiddleware(options) {
|
|
115
|
-
return createAuthMiddleware(async (ctx) => {
|
|
116
|
-
const { visitorId, requestId: headerRequestId } = extractIdentificationHeaders(ctx.request);
|
|
117
|
-
const requestId = headerRequestId ?? ctx.getCookie("__infra-rid") ?? null;
|
|
118
|
-
ctx.context.visitorId = visitorId;
|
|
119
|
-
ctx.context.requestId = requestId;
|
|
120
|
-
if (requestId) ctx.context.identification = ctx.context.identification ?? await getIdentification(requestId, options.apiKey, options.kvUrl) ?? null;
|
|
121
|
-
else ctx.context.identification = null;
|
|
122
|
-
const ipConfig = ctx.context.options?.advanced?.ipAddress;
|
|
123
|
-
if (ipConfig?.disableIpTracking === true) {
|
|
124
|
-
ctx.context.location = void 0;
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
const identification = ctx.context.identification;
|
|
128
|
-
if (requestId && identification) {
|
|
129
|
-
const loc = getLocation(identification);
|
|
130
|
-
ctx.context.location = {
|
|
131
|
-
ipAddress: identification.ip || void 0,
|
|
132
|
-
city: loc?.city || void 0,
|
|
133
|
-
country: loc?.country?.name || void 0,
|
|
134
|
-
countryCode: loc?.country?.code || void 0
|
|
135
|
-
};
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const ipAddress = getClientIpFromRequest(ctx.request, ipConfig?.ipAddressHeaders || null);
|
|
139
|
-
const countryCode = getCountryCodeFromRequest(ctx.request);
|
|
140
|
-
if (ipAddress || countryCode) {
|
|
141
|
-
ctx.context.location = {
|
|
142
|
-
ipAddress,
|
|
143
|
-
countryCode
|
|
144
|
-
};
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
ctx.context.location = void 0;
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Get the visitor's location
|
|
152
|
-
*/
|
|
153
|
-
function getLocation(identification) {
|
|
154
|
-
if (!identification) return null;
|
|
155
|
-
return identification.location;
|
|
156
|
-
}
|
|
157
|
-
function getClientIpFromRequest(request, ipAddressHeaders) {
|
|
158
|
-
if (!request) return void 0;
|
|
159
|
-
const headers = ipAddressHeaders?.length ? ipAddressHeaders : [
|
|
160
|
-
"cf-connecting-ip",
|
|
161
|
-
"x-forwarded-for",
|
|
162
|
-
"x-real-ip",
|
|
163
|
-
"x-vercel-forwarded-for"
|
|
164
|
-
];
|
|
165
|
-
for (const headerName of headers) {
|
|
166
|
-
const value = request.headers.get(headerName);
|
|
167
|
-
if (!value) continue;
|
|
168
|
-
const ip = value.split(",")[0]?.trim();
|
|
169
|
-
if (ip) return ip;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
function getCountryCodeFromRequest(request) {
|
|
173
|
-
if (!request) return void 0;
|
|
174
|
-
const cc = request.headers.get("cf-ipcountry") ?? request.headers.get("x-vercel-ip-country");
|
|
175
|
-
return cc ? cc.toUpperCase() : void 0;
|
|
176
|
-
}
|
|
177
|
-
//#endregion
|
|
178
|
-
export { hash as a, identify as i, createIdentificationMiddleware as n, generateRequestId as r, IDENTIFICATION_COOKIE_NAME as t };
|