@frak-labs/core-sdk 0.1.0 → 0.1.1-beta.4dfea079
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 +58 -0
- package/cdn/bundle.js +3 -8
- package/dist/actions.cjs +1 -1
- package/dist/actions.d.cts +3 -1400
- package/dist/actions.d.ts +3 -1400
- package/dist/actions.js +1 -1
- package/dist/bundle.cjs +1 -13
- package/dist/bundle.d.cts +4 -1927
- package/dist/bundle.d.ts +4 -1927
- package/dist/bundle.js +1 -13
- package/dist/computeLegacyProductId-CscYhyUi.d.cts +525 -0
- package/dist/computeLegacyProductId-WbD1gXV9.d.ts +525 -0
- package/dist/index.cjs +1 -13
- package/dist/index.d.cts +3 -1269
- package/dist/index.d.ts +3 -1269
- package/dist/index.js +1 -13
- package/dist/openSso-CC1-loUk.d.cts +1019 -0
- package/dist/openSso-tkqaDQLV.d.ts +1019 -0
- package/dist/setupClient-BjIbK6XJ.cjs +13 -0
- package/dist/setupClient-D_HId3e2.js +13 -0
- package/dist/siweAuthenticate-B_Z2OZmj.cjs +1 -0
- package/dist/siweAuthenticate-CQ4OfPuA.js +1 -0
- package/dist/siweAuthenticate-CR4Dpji6.d.cts +467 -0
- package/dist/siweAuthenticate-udoruuy9.d.ts +467 -0
- package/dist/trackEvent-CGIryq5h.cjs +1 -0
- package/dist/trackEvent-YfUh4jrx.js +1 -0
- package/package.json +24 -30
- package/src/actions/displayEmbeddedWallet.test.ts +194 -0
- package/src/actions/displayEmbeddedWallet.ts +20 -0
- package/src/actions/displayModal.test.ts +388 -0
- package/src/actions/displayModal.ts +120 -0
- package/src/actions/getMerchantInformation.test.ts +116 -0
- package/src/actions/getMerchantInformation.ts +9 -0
- package/src/actions/index.ts +29 -0
- package/src/actions/openSso.ts +116 -0
- package/src/actions/prepareSso.test.ts +223 -0
- package/src/actions/prepareSso.ts +48 -0
- package/src/actions/referral/processReferral.test.ts +248 -0
- package/src/actions/referral/processReferral.ts +232 -0
- package/src/actions/referral/referralInteraction.test.ts +147 -0
- package/src/actions/referral/referralInteraction.ts +52 -0
- package/src/actions/sendInteraction.ts +24 -0
- package/src/actions/trackPurchaseStatus.test.ts +287 -0
- package/src/actions/trackPurchaseStatus.ts +56 -0
- package/src/actions/watchWalletStatus.test.ts +372 -0
- package/src/actions/watchWalletStatus.ts +93 -0
- package/src/actions/wrapper/modalBuilder.test.ts +239 -0
- package/src/actions/wrapper/modalBuilder.ts +203 -0
- package/src/actions/wrapper/sendTransaction.test.ts +164 -0
- package/src/actions/wrapper/sendTransaction.ts +62 -0
- package/src/actions/wrapper/siweAuthenticate.test.ts +290 -0
- package/src/actions/wrapper/siweAuthenticate.ts +94 -0
- package/src/bundle.ts +2 -0
- package/src/clients/DebugInfo.test.ts +418 -0
- package/src/clients/DebugInfo.ts +182 -0
- package/src/clients/createIFrameFrakClient.ts +289 -0
- package/src/clients/index.ts +3 -0
- package/src/clients/setupClient.test.ts +343 -0
- package/src/clients/setupClient.ts +73 -0
- package/src/clients/transports/iframeLifecycleManager.test.ts +558 -0
- package/src/clients/transports/iframeLifecycleManager.ts +174 -0
- package/src/constants/interactionTypes.ts +15 -0
- package/src/constants/locales.ts +14 -0
- package/src/index.ts +110 -0
- package/src/types/client.ts +14 -0
- package/src/types/compression.ts +22 -0
- package/src/types/config.ts +117 -0
- package/src/types/context.ts +13 -0
- package/src/types/index.ts +75 -0
- package/src/types/lifecycle/client.ts +69 -0
- package/src/types/lifecycle/iframe.ts +41 -0
- package/src/types/lifecycle/index.ts +2 -0
- package/src/types/rpc/displayModal.ts +82 -0
- package/src/types/rpc/embedded/index.ts +68 -0
- package/src/types/rpc/embedded/loggedIn.ts +55 -0
- package/src/types/rpc/embedded/loggedOut.ts +28 -0
- package/src/types/rpc/interaction.ts +30 -0
- package/src/types/rpc/merchantInformation.ts +77 -0
- package/src/types/rpc/modal/final.ts +46 -0
- package/src/types/rpc/modal/generic.ts +46 -0
- package/src/types/rpc/modal/index.ts +16 -0
- package/src/types/rpc/modal/login.ts +36 -0
- package/src/types/rpc/modal/siweAuthenticate.ts +37 -0
- package/src/types/rpc/modal/transaction.ts +33 -0
- package/src/types/rpc/sso.ts +80 -0
- package/src/types/rpc/walletStatus.ts +29 -0
- package/src/types/rpc.ts +146 -0
- package/src/types/tracking.ts +60 -0
- package/src/types/transport.ts +34 -0
- package/src/utils/FrakContext.test.ts +407 -0
- package/src/utils/FrakContext.ts +158 -0
- package/src/utils/backendUrl.test.ts +83 -0
- package/src/utils/backendUrl.ts +62 -0
- package/src/utils/clientId.test.ts +41 -0
- package/src/utils/clientId.ts +40 -0
- package/src/utils/compression/b64.test.ts +181 -0
- package/src/utils/compression/b64.ts +29 -0
- package/src/utils/compression/compress.test.ts +123 -0
- package/src/utils/compression/compress.ts +11 -0
- package/src/utils/compression/decompress.test.ts +149 -0
- package/src/utils/compression/decompress.ts +11 -0
- package/src/utils/compression/index.ts +3 -0
- package/src/utils/computeLegacyProductId.ts +11 -0
- package/src/utils/constants.test.ts +23 -0
- package/src/utils/constants.ts +14 -0
- package/src/utils/deepLinkWithFallback.test.ts +243 -0
- package/src/utils/deepLinkWithFallback.ts +97 -0
- package/src/utils/formatAmount.test.ts +113 -0
- package/src/utils/formatAmount.ts +18 -0
- package/src/utils/getCurrencyAmountKey.test.ts +44 -0
- package/src/utils/getCurrencyAmountKey.ts +15 -0
- package/src/utils/getSupportedCurrency.test.ts +51 -0
- package/src/utils/getSupportedCurrency.ts +14 -0
- package/src/utils/getSupportedLocale.test.ts +64 -0
- package/src/utils/getSupportedLocale.ts +16 -0
- package/src/utils/iframeHelper.test.ts +450 -0
- package/src/utils/iframeHelper.ts +147 -0
- package/src/utils/index.ts +36 -0
- package/src/utils/merchantId.test.ts +564 -0
- package/src/utils/merchantId.ts +122 -0
- package/src/utils/sso.ts +126 -0
- package/src/utils/ssoUrlListener.test.ts +252 -0
- package/src/utils/ssoUrlListener.ts +60 -0
- package/src/utils/trackEvent.test.ts +180 -0
- package/src/utils/trackEvent.ts +31 -0
- package/cdn/bundle.js.LICENSE.txt +0 -10
- package/dist/interactions.cjs +0 -1
- package/dist/interactions.d.cts +0 -182
- package/dist/interactions.d.ts +0 -182
- package/dist/interactions.js +0 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type Address, bytesToHex, hexToBytes } from "viem";
|
|
2
|
+
import type { FrakContext } from "../types";
|
|
3
|
+
import { base64urlDecode, base64urlEncode } from "./compression/b64";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The context key
|
|
7
|
+
*/
|
|
8
|
+
const contextKey = "fCtx";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compress the current Frak context
|
|
12
|
+
* @param context - The context to be compressed
|
|
13
|
+
* @returns A compressed string containing the Frak context
|
|
14
|
+
*/
|
|
15
|
+
function compress(context?: Partial<FrakContext>): string | undefined {
|
|
16
|
+
if (!context?.r) return;
|
|
17
|
+
try {
|
|
18
|
+
const bytes = hexToBytes(context.r);
|
|
19
|
+
return base64urlEncode(bytes);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
console.error("Error compressing Frak context", { e, context });
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decompress the given Frak context
|
|
28
|
+
* @param context - The raw context to be decompressed into a `FrakContext`
|
|
29
|
+
* @returns The decompressed Frak context, or undefined if it fails
|
|
30
|
+
*/
|
|
31
|
+
function decompress(context?: string): FrakContext | undefined {
|
|
32
|
+
if (!context || context.length === 0) return;
|
|
33
|
+
try {
|
|
34
|
+
const bytes = base64urlDecode(context);
|
|
35
|
+
return { r: bytesToHex(bytes, { size: 20 }) as Address };
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error("Error decompressing Frak context", { e, context });
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse the current URL into a Frak Context
|
|
44
|
+
* @param args
|
|
45
|
+
* @param args.url - The url to parse
|
|
46
|
+
* @returns The parsed Frak context
|
|
47
|
+
*/
|
|
48
|
+
function parse({ url }: { url: string }) {
|
|
49
|
+
if (!url) return null;
|
|
50
|
+
|
|
51
|
+
// Check if the url contain the frak context key
|
|
52
|
+
const urlObj = new URL(url);
|
|
53
|
+
const frakContext = urlObj.searchParams.get(contextKey);
|
|
54
|
+
if (!frakContext) return null;
|
|
55
|
+
|
|
56
|
+
// Decompress and return it
|
|
57
|
+
return decompress(frakContext);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Populate the current url with the given Frak context
|
|
62
|
+
* @param args
|
|
63
|
+
* @param args.url - The url to update
|
|
64
|
+
* @param args.context - The context to update
|
|
65
|
+
* @returns The new url with the Frak context
|
|
66
|
+
*/
|
|
67
|
+
function update({
|
|
68
|
+
url,
|
|
69
|
+
context,
|
|
70
|
+
}: {
|
|
71
|
+
url?: string;
|
|
72
|
+
context: Partial<FrakContext>;
|
|
73
|
+
}) {
|
|
74
|
+
if (!url) return null;
|
|
75
|
+
|
|
76
|
+
// Parse the current context
|
|
77
|
+
const currentContext = parse({ url });
|
|
78
|
+
|
|
79
|
+
// Merge the current context with the new context
|
|
80
|
+
const mergedContext = currentContext
|
|
81
|
+
? { ...currentContext, ...context }
|
|
82
|
+
: context;
|
|
83
|
+
|
|
84
|
+
// If we don't have a referrer, early exit
|
|
85
|
+
if (!mergedContext.r) return null;
|
|
86
|
+
|
|
87
|
+
// Compress it
|
|
88
|
+
const compressedContext = compress(mergedContext);
|
|
89
|
+
if (!compressedContext) return null;
|
|
90
|
+
|
|
91
|
+
// Build the new url and return it
|
|
92
|
+
const urlObj = new URL(url);
|
|
93
|
+
urlObj.searchParams.set(contextKey, compressedContext);
|
|
94
|
+
return urlObj.toString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Remove Frak context from current url
|
|
99
|
+
* @param url - The url to update
|
|
100
|
+
* @returns The new url without the Frak context
|
|
101
|
+
*/
|
|
102
|
+
function remove(url: string) {
|
|
103
|
+
const urlObj = new URL(url);
|
|
104
|
+
urlObj.searchParams.delete(contextKey);
|
|
105
|
+
return urlObj.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Replace the current url with the given Frak context
|
|
110
|
+
* @param args
|
|
111
|
+
* @param args.url - The url to update
|
|
112
|
+
* @param args.context - The context to update
|
|
113
|
+
*/
|
|
114
|
+
function replaceUrl({
|
|
115
|
+
url: baseUrl,
|
|
116
|
+
context,
|
|
117
|
+
}: {
|
|
118
|
+
url?: string;
|
|
119
|
+
context: Partial<FrakContext> | null;
|
|
120
|
+
}) {
|
|
121
|
+
// If no window here early exit
|
|
122
|
+
if (!window.location?.href || typeof window === "undefined") {
|
|
123
|
+
console.error("No window found, can't update context");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If no url, try to use the current one
|
|
128
|
+
const url = baseUrl ?? window.location.href;
|
|
129
|
+
|
|
130
|
+
// Get our new url with the frak context
|
|
131
|
+
let newUrl: string | null;
|
|
132
|
+
if (context !== null) {
|
|
133
|
+
newUrl = update({
|
|
134
|
+
url,
|
|
135
|
+
context,
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
newUrl = remove(url);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// If no new url, early exit
|
|
142
|
+
if (!newUrl) return;
|
|
143
|
+
|
|
144
|
+
// Update the url
|
|
145
|
+
window.history.replaceState(null, "", newUrl.toString());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Export our frak context
|
|
150
|
+
*/
|
|
151
|
+
export const FrakContextManager = {
|
|
152
|
+
compress,
|
|
153
|
+
decompress,
|
|
154
|
+
parse,
|
|
155
|
+
update,
|
|
156
|
+
remove,
|
|
157
|
+
replaceUrl,
|
|
158
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { getBackendUrl } from "./backendUrl";
|
|
3
|
+
|
|
4
|
+
describe("getBackendUrl", () => {
|
|
5
|
+
const originalWindow = globalThis.window;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.stubGlobal("window", { ...originalWindow });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("with explicit walletUrl", () => {
|
|
16
|
+
test("should return localhost backend for localhost:3000", () => {
|
|
17
|
+
expect(getBackendUrl("https://localhost:3000")).toBe(
|
|
18
|
+
"http://localhost:3030"
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("should return localhost backend for localhost:3010", () => {
|
|
23
|
+
expect(getBackendUrl("https://localhost:3010")).toBe(
|
|
24
|
+
"http://localhost:3030"
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("should return dev backend for wallet-dev.frak.id", () => {
|
|
29
|
+
expect(getBackendUrl("https://wallet-dev.frak.id")).toBe(
|
|
30
|
+
"https://backend.gcp-dev.frak.id"
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("should return dev backend for wallet.gcp-dev.frak.id", () => {
|
|
35
|
+
expect(getBackendUrl("https://wallet.gcp-dev.frak.id")).toBe(
|
|
36
|
+
"https://backend.gcp-dev.frak.id"
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("should return production backend for wallet.frak.id", () => {
|
|
41
|
+
expect(getBackendUrl("https://wallet.frak.id")).toBe(
|
|
42
|
+
"https://backend.frak.id"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("should return production backend for unknown URLs", () => {
|
|
47
|
+
expect(getBackendUrl("https://some-other-url.com")).toBe(
|
|
48
|
+
"https://backend.frak.id"
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("with FrakSetup global config", () => {
|
|
54
|
+
test("should derive from window.FrakSetup.client.config.walletUrl", () => {
|
|
55
|
+
vi.stubGlobal("window", {
|
|
56
|
+
FrakSetup: {
|
|
57
|
+
client: {
|
|
58
|
+
config: {
|
|
59
|
+
walletUrl: "https://wallet-dev.frak.id",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(getBackendUrl()).toBe("https://backend.gcp-dev.frak.id");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("should fall back to production when FrakSetup has no walletUrl", () => {
|
|
69
|
+
vi.stubGlobal("window", {
|
|
70
|
+
FrakSetup: { client: { config: {} } },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(getBackendUrl()).toBe("https://backend.frak.id");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("fallback", () => {
|
|
78
|
+
test("should return production URL when no walletUrl and no FrakSetup", () => {
|
|
79
|
+
vi.stubGlobal("window", {});
|
|
80
|
+
expect(getBackendUrl()).toBe("https://backend.frak.id");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default production backend URL
|
|
3
|
+
*/
|
|
4
|
+
const DEFAULT_BACKEND_URL = "https://backend.frak.id";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if wallet URL is local development (port 3000 or 3010)
|
|
8
|
+
*/
|
|
9
|
+
function isLocalDevelopment(walletUrl: string): boolean {
|
|
10
|
+
return (
|
|
11
|
+
walletUrl.includes("localhost:3000") ||
|
|
12
|
+
walletUrl.includes("localhost:3010")
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Derive backend URL from wallet URL
|
|
18
|
+
* Maps wallet URLs to their corresponding backend URLs
|
|
19
|
+
*/
|
|
20
|
+
function deriveBackendUrl(walletUrl: string): string {
|
|
21
|
+
if (isLocalDevelopment(walletUrl)) {
|
|
22
|
+
return "http://localhost:3030";
|
|
23
|
+
}
|
|
24
|
+
// Dev environment
|
|
25
|
+
if (
|
|
26
|
+
walletUrl.includes("wallet-dev.frak.id") ||
|
|
27
|
+
walletUrl.includes("wallet.gcp-dev.frak.id")
|
|
28
|
+
) {
|
|
29
|
+
return "https://backend.gcp-dev.frak.id";
|
|
30
|
+
}
|
|
31
|
+
// Production
|
|
32
|
+
return DEFAULT_BACKEND_URL;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the backend URL for API calls
|
|
37
|
+
* Tries to derive from SDK config, falls back to production
|
|
38
|
+
*
|
|
39
|
+
* @param walletUrl - Optional wallet URL to derive from (overrides global config)
|
|
40
|
+
*/
|
|
41
|
+
export function getBackendUrl(walletUrl?: string): string {
|
|
42
|
+
// If explicit walletUrl provided, derive from it
|
|
43
|
+
if (walletUrl) {
|
|
44
|
+
return deriveBackendUrl(walletUrl);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Try to get from global FrakSetup config
|
|
48
|
+
if (typeof window !== "undefined") {
|
|
49
|
+
const configWalletUrl = (
|
|
50
|
+
window as {
|
|
51
|
+
FrakSetup?: { client?: { config?: { walletUrl?: string } } };
|
|
52
|
+
}
|
|
53
|
+
).FrakSetup?.client?.config?.walletUrl;
|
|
54
|
+
|
|
55
|
+
if (configWalletUrl) {
|
|
56
|
+
return deriveBackendUrl(configWalletUrl);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback to production
|
|
61
|
+
return DEFAULT_BACKEND_URL;
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { getClientId } from "./clientId";
|
|
3
|
+
|
|
4
|
+
describe("clientId", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// Clear localStorage before each test
|
|
7
|
+
localStorage.clear();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("getClientId", () => {
|
|
15
|
+
it("should generate and store a new client ID when none exists", () => {
|
|
16
|
+
const clientId = getClientId();
|
|
17
|
+
|
|
18
|
+
expect(clientId).toBeDefined();
|
|
19
|
+
expect(clientId).toMatch(
|
|
20
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
21
|
+
);
|
|
22
|
+
expect(localStorage.getItem("frak-client-id")).toBe(clientId);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should return existing client ID from localStorage", () => {
|
|
26
|
+
const existingId = "existing-uuid-1234";
|
|
27
|
+
localStorage.setItem("frak-client-id", existingId);
|
|
28
|
+
|
|
29
|
+
const clientId = getClientId();
|
|
30
|
+
|
|
31
|
+
expect(clientId).toBe(existingId);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should generate consistent UUIDs", () => {
|
|
35
|
+
const id1 = getClientId();
|
|
36
|
+
const id2 = getClientId();
|
|
37
|
+
|
|
38
|
+
expect(id1).toBe(id2);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client ID utilities for anonymous tracking
|
|
3
|
+
* Generates and persists a UUID fingerprint for referral attribution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CLIENT_ID_KEY = "frak-client-id";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a UUID v4
|
|
10
|
+
* Uses crypto.randomUUID if available, otherwise falls back to a manual implementation
|
|
11
|
+
*/
|
|
12
|
+
function generateUUID(): string {
|
|
13
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
14
|
+
return crypto.randomUUID();
|
|
15
|
+
}
|
|
16
|
+
// Fallback for older browsers
|
|
17
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
18
|
+
const r = (Math.random() * 16) | 0;
|
|
19
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
20
|
+
return v.toString(16);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get the client ID from localStorage, creating one if it doesn't exist
|
|
26
|
+
* @returns The client ID (UUID format)
|
|
27
|
+
*/
|
|
28
|
+
export function getClientId(): string {
|
|
29
|
+
if (typeof window === "undefined" || !window.localStorage) {
|
|
30
|
+
// SSR or no localStorage - generate ephemeral ID
|
|
31
|
+
return generateUUID();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let clientId = localStorage.getItem(CLIENT_ID_KEY);
|
|
35
|
+
if (!clientId) {
|
|
36
|
+
clientId = generateUUID();
|
|
37
|
+
localStorage.setItem(CLIENT_ID_KEY, clientId);
|
|
38
|
+
}
|
|
39
|
+
return clientId;
|
|
40
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for base64url encoding and decoding utilities
|
|
3
|
+
* Tests encoding, decoding, and round-trip operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, test } from "../../../tests/vitest-fixtures";
|
|
7
|
+
import { base64urlDecode, base64urlEncode } from "./b64";
|
|
8
|
+
|
|
9
|
+
describe("base64urlEncode", () => {
|
|
10
|
+
test("should encode empty Uint8Array", ({ mockUint8Arrays }) => {
|
|
11
|
+
const result = base64urlEncode(mockUint8Arrays.empty);
|
|
12
|
+
|
|
13
|
+
expect(result).toBe("");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("should encode simple Uint8Array", ({ mockUint8Arrays }) => {
|
|
17
|
+
const result = base64urlEncode(mockUint8Arrays.simple);
|
|
18
|
+
|
|
19
|
+
// "Hello" should encode to "SGVsbG8"
|
|
20
|
+
expect(result).toBe("SGVsbG8");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("should replace + with - for URL safety", () => {
|
|
24
|
+
// Create data that would produce + in standard base64
|
|
25
|
+
const data = new Uint8Array([0xfb, 0xff]);
|
|
26
|
+
const result = base64urlEncode(data);
|
|
27
|
+
|
|
28
|
+
// Should not contain +
|
|
29
|
+
expect(result).not.toContain("+");
|
|
30
|
+
expect(result).toContain("-");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("should replace / with _ for URL safety", () => {
|
|
34
|
+
// Create data that would produce / in standard base64
|
|
35
|
+
const data = new Uint8Array([0xff, 0xff]);
|
|
36
|
+
const result = base64urlEncode(data);
|
|
37
|
+
|
|
38
|
+
// Should not contain /
|
|
39
|
+
expect(result).not.toContain("/");
|
|
40
|
+
expect(result).toContain("_");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("should remove padding =", () => {
|
|
44
|
+
// Create data that would have padding
|
|
45
|
+
const data = new Uint8Array([72]); // "H" -> "SA==" in standard base64
|
|
46
|
+
const result = base64urlEncode(data);
|
|
47
|
+
|
|
48
|
+
// Should not contain =
|
|
49
|
+
expect(result).not.toContain("=");
|
|
50
|
+
expect(result).toBe("SA");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("should handle various byte lengths", () => {
|
|
54
|
+
const lengths = [1, 2, 3, 4, 5, 10, 20];
|
|
55
|
+
|
|
56
|
+
for (const length of lengths) {
|
|
57
|
+
const data = new Uint8Array(length).fill(65); // Fill with 'A' character code
|
|
58
|
+
const result = base64urlEncode(data);
|
|
59
|
+
|
|
60
|
+
// Should produce a string
|
|
61
|
+
expect(typeof result).toBe("string");
|
|
62
|
+
expect(result.length).toBeGreaterThan(0);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("should handle complex byte array", ({ mockUint8Arrays }) => {
|
|
67
|
+
const result = base64urlEncode(mockUint8Arrays.complex);
|
|
68
|
+
|
|
69
|
+
// Should produce valid base64url string
|
|
70
|
+
expect(result).toMatch(/^[A-Za-z0-9_-]*$/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("base64urlDecode", () => {
|
|
75
|
+
test("should decode empty string", ({ mockBase64Strings }) => {
|
|
76
|
+
const result = base64urlDecode(mockBase64Strings.empty);
|
|
77
|
+
|
|
78
|
+
expect(result).toEqual(new Uint8Array([]));
|
|
79
|
+
expect(result.length).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("should decode simple base64url string", ({ mockBase64Strings }) => {
|
|
83
|
+
const result = base64urlDecode(mockBase64Strings.simple);
|
|
84
|
+
|
|
85
|
+
// "SGVsbG8" should decode to "Hello"
|
|
86
|
+
const expected = new Uint8Array([72, 101, 108, 108, 111]);
|
|
87
|
+
expect(result).toEqual(expected);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("should handle strings without padding", () => {
|
|
91
|
+
// Base64url strings don't have padding
|
|
92
|
+
const encoded = "SGVsbG8"; // "Hello" without padding
|
|
93
|
+
const result = base64urlDecode(encoded);
|
|
94
|
+
|
|
95
|
+
const expected = new Uint8Array([72, 101, 108, 108, 111]);
|
|
96
|
+
expect(result).toEqual(expected);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("should reverse URL-safe character replacements", ({
|
|
100
|
+
mockBase64Strings,
|
|
101
|
+
}) => {
|
|
102
|
+
const result = base64urlDecode(mockBase64Strings.withSpecialChars);
|
|
103
|
+
|
|
104
|
+
// Should handle - and _ characters
|
|
105
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("should handle various valid base64url string lengths", () => {
|
|
109
|
+
// Use valid base64url strings
|
|
110
|
+
const testStrings = ["QQ", "QUI", "QUJD", "QUJDRA"]; // "A", "AB", "ABC", "ABCD" encoded
|
|
111
|
+
|
|
112
|
+
for (const str of testStrings) {
|
|
113
|
+
const result = base64urlDecode(str);
|
|
114
|
+
|
|
115
|
+
// Should produce Uint8Array
|
|
116
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("base64url round-trip", () => {
|
|
122
|
+
test("should successfully round-trip empty data", ({ mockUint8Arrays }) => {
|
|
123
|
+
const encoded = base64urlEncode(mockUint8Arrays.empty);
|
|
124
|
+
const decoded = base64urlDecode(encoded);
|
|
125
|
+
|
|
126
|
+
expect(decoded).toEqual(mockUint8Arrays.empty);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("should successfully round-trip simple data", ({
|
|
130
|
+
mockUint8Arrays,
|
|
131
|
+
}) => {
|
|
132
|
+
const encoded = base64urlEncode(mockUint8Arrays.simple);
|
|
133
|
+
const decoded = base64urlDecode(encoded);
|
|
134
|
+
|
|
135
|
+
expect(decoded).toEqual(mockUint8Arrays.simple);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("should successfully round-trip complex data", ({
|
|
139
|
+
mockUint8Arrays,
|
|
140
|
+
}) => {
|
|
141
|
+
const encoded = base64urlEncode(mockUint8Arrays.complex);
|
|
142
|
+
const decoded = base64urlDecode(encoded);
|
|
143
|
+
|
|
144
|
+
expect(decoded).toEqual(mockUint8Arrays.complex);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should handle all byte values (0-255)", () => {
|
|
148
|
+
// Test with all possible byte values
|
|
149
|
+
const allBytes = new Uint8Array(256);
|
|
150
|
+
for (let i = 0; i < 256; i++) {
|
|
151
|
+
allBytes[i] = i;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const encoded = base64urlEncode(allBytes);
|
|
155
|
+
const decoded = base64urlDecode(encoded);
|
|
156
|
+
|
|
157
|
+
expect(decoded).toEqual(allBytes);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should preserve binary data integrity", () => {
|
|
161
|
+
const binaryData = new Uint8Array([0, 1, 127, 128, 255, 254, 100, 200]);
|
|
162
|
+
|
|
163
|
+
const encoded = base64urlEncode(binaryData);
|
|
164
|
+
const decoded = base64urlDecode(encoded);
|
|
165
|
+
|
|
166
|
+
expect(decoded).toEqual(binaryData);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should handle random data correctly", () => {
|
|
170
|
+
// Generate some pseudo-random data
|
|
171
|
+
const randomData = new Uint8Array(32);
|
|
172
|
+
for (let i = 0; i < 32; i++) {
|
|
173
|
+
randomData[i] = Math.floor(Math.random() * 256);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const encoded = base64urlEncode(randomData);
|
|
177
|
+
const decoded = base64urlDecode(encoded);
|
|
178
|
+
|
|
179
|
+
expect(decoded).toEqual(randomData);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encode a buffer to a base64url encoded string
|
|
3
|
+
* @param buffer The buffer to encode
|
|
4
|
+
* @returns The encoded string
|
|
5
|
+
*/
|
|
6
|
+
export function base64urlEncode(buffer: Uint8Array): string {
|
|
7
|
+
return btoa(Array.from(buffer, (b) => String.fromCharCode(b)).join(""))
|
|
8
|
+
.replace(/\+/g, "-")
|
|
9
|
+
.replace(/\//g, "_")
|
|
10
|
+
.replace(/=+$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decode a base64url encoded string
|
|
15
|
+
* @param value The value to decode
|
|
16
|
+
* @returns The decoded value
|
|
17
|
+
*/
|
|
18
|
+
export function base64urlDecode(value: string): Uint8Array {
|
|
19
|
+
const m = value.length % 4;
|
|
20
|
+
return Uint8Array.from(
|
|
21
|
+
atob(
|
|
22
|
+
value
|
|
23
|
+
.replace(/-/g, "+")
|
|
24
|
+
.replace(/_/g, "/")
|
|
25
|
+
.padEnd(value.length + (m === 0 ? 0 : 4 - m), "=")
|
|
26
|
+
),
|
|
27
|
+
(c) => c.charCodeAt(0)
|
|
28
|
+
);
|
|
29
|
+
}
|