@pollar/core 0.8.0 → 0.8.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/dist/index.mjs CHANGED
@@ -979,6 +979,26 @@ async function pollRampTransaction(api, txId, { intervalMs = 5e3, timeoutMs = 6e
979
979
  throw new Error("Ramp transaction polling timed out");
980
980
  }
981
981
 
982
+ // src/lib/random-uuid.ts
983
+ function randomUUID() {
984
+ const c = globalThis.crypto;
985
+ if (c && typeof c.randomUUID === "function") {
986
+ return c.randomUUID();
987
+ }
988
+ if (c && typeof c.getRandomValues === "function") {
989
+ const bytes = new Uint8Array(16);
990
+ c.getRandomValues(bytes);
991
+ bytes[6] = bytes[6] & 15 | 64;
992
+ bytes[8] = bytes[8] & 63 | 128;
993
+ const hex = [];
994
+ for (let i = 0; i < 16; i++) hex.push(bytes[i].toString(16).padStart(2, "0"));
995
+ return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
996
+ }
997
+ throw new Error(
998
+ "[PollarClient] No secure random source available (crypto.randomUUID / crypto.getRandomValues). DPoP requires a secure context (HTTPS) or, in React Native, the `react-native-get-random-values` polyfill."
999
+ );
1000
+ }
1001
+
982
1002
  // src/dpop.ts
983
1003
  async function buildProof(args, keyManager) {
984
1004
  const jwk = await keyManager.getPublicJwk();
@@ -988,7 +1008,7 @@ async function buildProof(args, keyManager) {
988
1008
  jwk
989
1009
  };
990
1010
  const payload = {
991
- jti: generateJti(),
1011
+ jti: randomUUID(),
992
1012
  htm: args.htm.toUpperCase(),
993
1013
  htu: normalizeHtu(args.htu),
994
1014
  iat: Math.floor(Date.now() / 1e3)
@@ -1022,24 +1042,6 @@ function normalizeHtu(rawUrl) {
1022
1042
  const portPart = port ? `:${port}` : "";
1023
1043
  return `${scheme}//${host}${portPart}${url.pathname}`;
1024
1044
  }
1025
- function generateJti() {
1026
- const c = globalThis.crypto;
1027
- if (c && typeof c.randomUUID === "function") {
1028
- return c.randomUUID();
1029
- }
1030
- if (c && typeof c.getRandomValues === "function") {
1031
- const bytes = new Uint8Array(16);
1032
- c.getRandomValues(bytes);
1033
- bytes[6] = bytes[6] & 15 | 64;
1034
- bytes[8] = bytes[8] & 63 | 128;
1035
- const hex = [];
1036
- for (let i = 0; i < 16; i++) hex.push(bytes[i].toString(16).padStart(2, "0"));
1037
- return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
1038
- }
1039
- throw new Error(
1040
- "[PollarClient:dpop] No secure random source available (crypto.randomUUID / crypto.getRandomValues). DPoP requires a secure context (HTTPS) or, in React Native, the `react-native-get-random-values` polyfill."
1041
- );
1042
- }
1043
1045
 
1044
1046
  // src/storage/web.ts
1045
1047
  var LOG_PREFIX = "[PollarClient:storage]";
@@ -1182,6 +1184,8 @@ function defaultVisibilityProvider() {
1182
1184
  // src/types.ts
1183
1185
  var AUTH_ERROR_CODES = {
1184
1186
  SESSION_CREATE_FAILED: "SESSION_CREATE_FAILED",
1187
+ SESSION_EXPIRED: "SESSION_EXPIRED",
1188
+ SESSION_INVALID: "SESSION_INVALID",
1185
1189
  EMAIL_SEND_FAILED: "EMAIL_SEND_FAILED",
1186
1190
  EMAIL_VERIFY_FAILED: "EMAIL_VERIFY_FAILED",
1187
1191
  EMAIL_CODE_EXPIRED: "EMAIL_CODE_EXPIRED",
@@ -1495,7 +1499,32 @@ async function readWalletType(storage, apiKeyHash) {
1495
1499
  return storage.get(walletTypeStorageKey(apiKeyHash));
1496
1500
  }
1497
1501
 
1502
+ // src/lib/abort.ts
1503
+ function abortError() {
1504
+ if (typeof DOMException !== "undefined") {
1505
+ return new DOMException("Aborted", "AbortError");
1506
+ }
1507
+ const err = new Error("Aborted");
1508
+ err.name = "AbortError";
1509
+ return err;
1510
+ }
1511
+ function throwIfAborted(signal) {
1512
+ if (signal?.aborted) throw abortError();
1513
+ }
1514
+
1498
1515
  // src/client/stream.ts
1516
+ var SessionStatusError = class extends Error {
1517
+ constructor(code) {
1518
+ super(`[PollarClient] Session status terminal: ${code}`);
1519
+ this.code = code;
1520
+ this.name = "SessionStatusError";
1521
+ }
1522
+ };
1523
+ function terminalStatusCode(parsed) {
1524
+ const err = parsed?.error;
1525
+ if (err === "INVALID_CLIENT_SESSION_ID" || err === "EXPIRED_CLIENT_ID") return err;
1526
+ return null;
1527
+ }
1499
1528
  function abortableDelay(ms, signal) {
1500
1529
  return new Promise((resolve, reject) => {
1501
1530
  const t = setTimeout(resolve, ms);
@@ -1503,7 +1532,7 @@ function abortableDelay(ms, signal) {
1503
1532
  "abort",
1504
1533
  () => {
1505
1534
  clearTimeout(t);
1506
- reject(new DOMException("Aborted", "AbortError"));
1535
+ reject(abortError());
1507
1536
  },
1508
1537
  { once: true }
1509
1538
  );
@@ -1518,7 +1547,7 @@ async function streamUntilFound(api, clientSessionId, check, retryDelayMs = 200,
1518
1547
  else await new Promise((r) => setTimeout(r, ms));
1519
1548
  };
1520
1549
  while (true) {
1521
- signal?.throwIfAborted();
1550
+ throwIfAborted(signal);
1522
1551
  let data, error;
1523
1552
  try {
1524
1553
  ({ data, error } = await api.GET("/auth/session/status/{clientSessionId}", {
@@ -1541,7 +1570,7 @@ async function streamUntilFound(api, clientSessionId, check, retryDelayMs = 200,
1541
1570
  let sawAnyChunk = false;
1542
1571
  try {
1543
1572
  while (true) {
1544
- signal?.throwIfAborted();
1573
+ throwIfAborted(signal);
1545
1574
  const { done, value } = await reader.read();
1546
1575
  if (done) {
1547
1576
  streamDone = true;
@@ -1552,17 +1581,22 @@ async function streamUntilFound(api, clientSessionId, check, retryDelayMs = 200,
1552
1581
  for (const message of chunk.split("\n\n").filter(Boolean)) {
1553
1582
  const dataLine = message.split("\n").find((l) => l.startsWith("data:"));
1554
1583
  if (!dataLine) continue;
1584
+ let parsed;
1555
1585
  try {
1556
- const parsed = JSON.parse(dataLine.slice("data:".length).trim());
1557
- if (check(parsed)) {
1558
- return parsed;
1559
- }
1586
+ parsed = JSON.parse(dataLine.slice("data:".length).trim());
1560
1587
  } catch {
1588
+ continue;
1589
+ }
1590
+ const terminal = terminalStatusCode(parsed);
1591
+ if (terminal) throw new SessionStatusError(terminal);
1592
+ if (check(parsed)) {
1593
+ return parsed;
1561
1594
  }
1562
1595
  }
1563
1596
  }
1564
1597
  } catch (e) {
1565
1598
  if (e instanceof Error && e.name === "AbortError") throw e;
1599
+ if (e instanceof SessionStatusError) throw e;
1566
1600
  console.warn(e);
1567
1601
  } finally {
1568
1602
  reader.releaseLock();
@@ -1573,12 +1607,72 @@ async function streamUntilFound(api, clientSessionId, check, retryDelayMs = 200,
1573
1607
  if (delay) await sleep(delay);
1574
1608
  }
1575
1609
  }
1610
+ async function pollUntilFound(baseUrl, clientSessionId, check, intervalMs = 500, signal) {
1611
+ const url = `${baseUrl}/auth/session/status/${encodeURIComponent(clientSessionId)}/poll`;
1612
+ let backoff = intervalMs;
1613
+ const sleep = async (ms) => {
1614
+ if (ms <= 0) return;
1615
+ if (signal) await abortableDelay(ms, signal);
1616
+ else await new Promise((r) => setTimeout(r, ms));
1617
+ };
1618
+ while (true) {
1619
+ throwIfAborted(signal);
1620
+ let envelope = null;
1621
+ let httpStatus = 0;
1622
+ try {
1623
+ const response = await fetch(url, { headers: { accept: "application/json" }, signal: signal ?? null });
1624
+ httpStatus = response.status;
1625
+ envelope = await response.json().catch(() => null);
1626
+ } catch (e) {
1627
+ if (e instanceof Error && e.name === "AbortError") throw e;
1628
+ console.warn(e);
1629
+ }
1630
+ if (httpStatus === 404 || envelope?.code === "INVALID_CLIENT_SESSION_ID") {
1631
+ throw new SessionStatusError("INVALID_CLIENT_SESSION_ID");
1632
+ }
1633
+ if (httpStatus === 410 || envelope?.code === "EXPIRED_CLIENT_ID") {
1634
+ throw new SessionStatusError("EXPIRED_CLIENT_ID");
1635
+ }
1636
+ if (envelope?.success && envelope.content && check(envelope.content)) {
1637
+ return envelope.content;
1638
+ }
1639
+ if (envelope) backoff = intervalMs;
1640
+ else backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
1641
+ await sleep(backoff);
1642
+ }
1643
+ }
1644
+ function waitForSessionReady(args) {
1645
+ const { api, baseUrl, clientSessionId, check, useStreaming, retryDelayMs, signal } = args;
1646
+ return useStreaming ? streamUntilFound(api, clientSessionId, check, retryDelayMs ?? 200, signal) : pollUntilFound(baseUrl, clientSessionId, check, retryDelayMs ?? 500, signal);
1647
+ }
1576
1648
 
1577
1649
  // src/client/auth/authenticate.ts
1578
1650
  async function authenticate(clientSessionId, deps, expectedWallet) {
1579
- const { api, signal, setAuthState, storeSession, clearSession } = deps;
1651
+ const { api, basePath, useStreaming, signal, setAuthState, storeSession, clearSession } = deps;
1580
1652
  setAuthState({ step: "authenticating" });
1581
- await streamUntilFound(api, clientSessionId, (data2) => data2?.status === "READY", 200, signal);
1653
+ try {
1654
+ await waitForSessionReady({
1655
+ api,
1656
+ baseUrl: basePath,
1657
+ clientSessionId,
1658
+ check: (data2) => data2?.status === "READY",
1659
+ useStreaming,
1660
+ signal
1661
+ });
1662
+ } catch (err) {
1663
+ if (err instanceof SessionStatusError) {
1664
+ const expired = err.code === "EXPIRED_CLIENT_ID";
1665
+ setAuthState({
1666
+ step: "error",
1667
+ previousStep: "authenticating",
1668
+ message: expired ? "Login session expired \u2014 please try again" : "Login session is no longer valid \u2014 please try again",
1669
+ errorCode: expired ? AUTH_ERROR_CODES.SESSION_EXPIRED : AUTH_ERROR_CODES.SESSION_INVALID
1670
+ });
1671
+ await clearSession();
1672
+ return;
1673
+ }
1674
+ throw err;
1675
+ }
1582
1676
  const dpopJwk = await deps.getPublicJwk();
1583
1677
  const { data } = await api.POST("/auth/login", {
1584
1678
  body: {
@@ -1702,26 +1796,36 @@ function severOpener(popup) {
1702
1796
  } catch {
1703
1797
  }
1704
1798
  }
1705
- async function loginOAuth(provider, deps) {
1706
- const { setAuthState, basePath, apiKey } = deps;
1707
- const popup = window.open("about:blank", "_blank");
1799
+ var defaultWebOAuthOpener = async ({ getUrl }) => {
1800
+ const popup = typeof window !== "undefined" ? window.open("about:blank", "_blank") : null;
1708
1801
  severOpener(popup);
1709
- const clientSessionId = await createAuthSession(deps);
1710
- if (!clientSessionId) {
1802
+ const url = await getUrl();
1803
+ if (!url) {
1711
1804
  popup?.close();
1712
1805
  return;
1713
1806
  }
1714
- setAuthState({ step: "opening_oauth", provider });
1715
- const url = new URL(`${basePath}/auth/${provider}`);
1716
- url.searchParams.set("api_key", apiKey);
1717
- url.searchParams.set("client_session_id", clientSessionId);
1718
- url.searchParams.set("redirect_uri", window.location.origin);
1719
1807
  if (popup) {
1720
- popup.location.href = url.toString();
1808
+ popup.location.href = url;
1721
1809
  severOpener(popup);
1722
- } else {
1723
- window.open(url.toString(), "_blank", "noopener,noreferrer");
1810
+ } else if (typeof window !== "undefined") {
1811
+ window.open(url, "_blank", "noopener,noreferrer");
1724
1812
  }
1813
+ };
1814
+ async function loginOAuth(provider, deps) {
1815
+ const { setAuthState, basePath, apiKey, openAuthUrl, redirectUri, signal } = deps;
1816
+ let clientSessionId = null;
1817
+ const getUrl = async () => {
1818
+ clientSessionId = await createAuthSession(deps);
1819
+ if (!clientSessionId) return null;
1820
+ setAuthState({ step: "opening_oauth", provider });
1821
+ const url = new URL(`${basePath}/auth/${provider}`);
1822
+ url.searchParams.set("api_key", apiKey);
1823
+ url.searchParams.set("client_session_id", clientSessionId);
1824
+ url.searchParams.set("redirect_uri", redirectUri);
1825
+ return url.toString();
1826
+ };
1827
+ await openAuthUrl({ provider, getUrl, redirectUri, signal });
1828
+ if (!clientSessionId) return;
1725
1829
  await authenticate(clientSessionId, deps);
1726
1830
  }
1727
1831
 
@@ -1731,10 +1835,10 @@ function withSignal(promise, signal) {
1731
1835
  promise,
1732
1836
  new Promise((_, reject) => {
1733
1837
  if (signal.aborted) {
1734
- reject(new DOMException("Aborted", "AbortError"));
1838
+ reject(abortError());
1735
1839
  return;
1736
1840
  }
1737
- signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
1841
+ signal.addEventListener("abort", () => reject(abortError()), { once: true });
1738
1842
  })
1739
1843
  ]);
1740
1844
  }
@@ -1782,6 +1886,8 @@ async function loginWallet(type, deps) {
1782
1886
 
1783
1887
  // src/client/client.ts
1784
1888
  var isBrowser = typeof window !== "undefined" && typeof localStorage !== "undefined";
1889
+ var isReactNative = typeof navigator !== "undefined" && navigator.product === "ReactNative";
1890
+ var isClientRuntime = isBrowser || isReactNative;
1785
1891
  var REFRESH_SKEW_SECONDS = 60;
1786
1892
  function warnServerSide(method) {
1787
1893
  console.warn(
@@ -1837,7 +1943,7 @@ var PollarClient = class {
1837
1943
  this._walletAdapter = null;
1838
1944
  this._loginController = null;
1839
1945
  this.apiKey = config.apiKey;
1840
- this.id = crypto.randomUUID();
1946
+ this.id = randomUUID();
1841
1947
  this.basePath = `${config.baseUrl || "https://sdk.api.pollar.xyz"}/v1`;
1842
1948
  this._storage = config.storage ?? defaultStorage({
1843
1949
  onDegrade: (reason, error) => {
@@ -1851,10 +1957,12 @@ var PollarClient = class {
1851
1957
  this._deviceLabel = config.deviceLabel;
1852
1958
  this._visibilityProvider = config.visibilityProvider ?? defaultVisibilityProvider();
1853
1959
  this._maxIdleMs = config.maxIdleMs;
1960
+ this._openAuthUrl = config.openAuthUrl ?? defaultWebOAuthOpener;
1961
+ this._oauthRedirectUri = config.oauthRedirectUri ?? (isBrowser ? window.location.origin : "");
1854
1962
  this._api = createApiClient(this.basePath);
1855
1963
  this._wireMiddlewares();
1856
1964
  this._networkState = { step: "connected", network: config.stellarNetwork ?? "testnet" };
1857
- if (!isBrowser) {
1965
+ if (!isClientRuntime) {
1858
1966
  warnServerSide("constructor");
1859
1967
  this._initialized = Promise.resolve();
1860
1968
  return;
@@ -1880,7 +1988,7 @@ var PollarClient = class {
1880
1988
  // ─── Lifecycle ────────────────────────────────────────────────────────────
1881
1989
  async _initialize() {
1882
1990
  this._apiKeyHash = await hashApiKey(this.apiKey);
1883
- if (typeof window !== "undefined") {
1991
+ if (isBrowser) {
1884
1992
  const sessionKey = sessionStorageKey(this._apiKeyHash);
1885
1993
  const handler = (e) => {
1886
1994
  if (e.key === sessionKey) {
@@ -1902,7 +2010,7 @@ var PollarClient = class {
1902
2010
  }
1903
2011
  /** Detach the cross-tab storage listener and abort any in-flight login. */
1904
2012
  destroy() {
1905
- if (this._storageEventHandler && typeof window !== "undefined") {
2013
+ if (this._storageEventHandler && isBrowser) {
1906
2014
  window.removeEventListener("storage", this._storageEventHandler);
1907
2015
  this._storageEventHandler = null;
1908
2016
  }
@@ -2185,7 +2293,7 @@ var PollarClient = class {
2185
2293
  }
2186
2294
  // ─── Login (unified entry point) ─────────────────────────────────────────
2187
2295
  login(options) {
2188
- if (!isBrowser) {
2296
+ if (!isClientRuntime) {
2189
2297
  warnServerSide("login");
2190
2298
  return;
2191
2299
  }
@@ -2196,7 +2304,9 @@ var PollarClient = class {
2196
2304
  loginOAuth(options.provider, {
2197
2305
  ...deps,
2198
2306
  basePath: this.basePath,
2199
- apiKey: this.apiKey
2307
+ apiKey: this.apiKey,
2308
+ openAuthUrl: this._openAuthUrl,
2309
+ redirectUri: this._oauthRedirectUri
2200
2310
  }).catch((err) => this._handleFlowError(err));
2201
2311
  } else if (options.provider === "email") {
2202
2312
  const { email } = options;
@@ -2212,7 +2322,7 @@ var PollarClient = class {
2212
2322
  }
2213
2323
  // ─── Email OTP flow (3 steps) ─────────────────────────────────────────────
2214
2324
  beginEmailLogin() {
2215
- if (!isBrowser) {
2325
+ if (!isClientRuntime) {
2216
2326
  warnServerSide("beginEmailLogin");
2217
2327
  return;
2218
2328
  }
@@ -2220,7 +2330,7 @@ var PollarClient = class {
2220
2330
  initEmailSession(this._flowDeps(controller.signal)).catch((err) => this._handleFlowError(err));
2221
2331
  }
2222
2332
  sendEmailCode(email) {
2223
- if (!isBrowser) {
2333
+ if (!isClientRuntime) {
2224
2334
  warnServerSide("sendEmailCode");
2225
2335
  return;
2226
2336
  }
@@ -2232,7 +2342,7 @@ var PollarClient = class {
2232
2342
  sendEmailCode(email, clientSessionId, this._flowDeps(signal)).catch((err) => this._handleFlowError(err));
2233
2343
  }
2234
2344
  verifyEmailCode(code) {
2235
- if (!isBrowser) {
2345
+ if (!isClientRuntime) {
2236
2346
  warnServerSide("verifyEmailCode");
2237
2347
  return;
2238
2348
  }
@@ -2250,7 +2360,7 @@ var PollarClient = class {
2250
2360
  }
2251
2361
  // ─── Wallet flow (single call) ────────────────────────────────────────────
2252
2362
  loginWallet(type) {
2253
- if (!isBrowser) {
2363
+ if (!isClientRuntime) {
2254
2364
  warnServerSide("loginWallet");
2255
2365
  return;
2256
2366
  }
@@ -2276,7 +2386,7 @@ var PollarClient = class {
2276
2386
  * across all devices.
2277
2387
  */
2278
2388
  async logout(options = {}) {
2279
- if (!isBrowser) {
2389
+ if (!isClientRuntime) {
2280
2390
  warnServerSide("logout");
2281
2391
  return;
2282
2392
  }
@@ -2306,7 +2416,7 @@ var PollarClient = class {
2306
2416
  * `current` flag identifies which entry corresponds to this client.
2307
2417
  */
2308
2418
  async listSessions() {
2309
- if (!isBrowser) {
2419
+ if (!isClientRuntime) {
2310
2420
  warnServerSide("listSessions");
2311
2421
  return [];
2312
2422
  }
@@ -2325,7 +2435,7 @@ var PollarClient = class {
2325
2435
  * does NOT clear local state — call `logout()` for that case.
2326
2436
  */
2327
2437
  async revokeSession(familyId) {
2328
- if (!isBrowser) {
2438
+ if (!isClientRuntime) {
2329
2439
  warnServerSide("revokeSession");
2330
2440
  return;
2331
2441
  }
@@ -2823,6 +2933,11 @@ var PollarClient = class {
2823
2933
  _flowDeps(signal) {
2824
2934
  return {
2825
2935
  api: this._api,
2936
+ basePath: this.basePath,
2937
+ // SSE status streaming works on web; React Native's `fetch` has no
2938
+ // readable `response.body`, so those clients poll the non-streaming
2939
+ // status endpoint instead. `isBrowser` is false in RN and SSR alike.
2940
+ useStreaming: isBrowser,
2826
2941
  signal,
2827
2942
  setAuthState: this._setAuthState.bind(this),
2828
2943
  storeSession: this._storeSession.bind(this),