@bounded-sh/client 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { getActiveSessionManager, genAuthNonce, genSolanaMessage, createSessionWithSignature, getConfig, WebSessionManager, ReactNativeSessionManager, revokeSession, init as init$1, reconnectWithNewAuth, deriveUserIdentityFromIdToken, getIdToken as getIdToken$1, convertRemainingAccounts, buildSetDocumentsTransaction } from '@bounded-sh/core';
1
+ import { getActiveSessionManager, genAuthNonce, genSolanaMessage, createSessionWithSignature, getConfig, revokeSession, WebSessionManager, ReactNativeSessionManager, init as init$1, reconnectWithNewAuth, deriveUserIdentityFromIdToken, getIdToken as getIdToken$1, convertRemainingAccounts, buildSetDocumentsTransaction } from '@bounded-sh/core';
2
2
  export * from '@bounded-sh/core';
3
3
  export { aggregate, count, docId, get, getConfig, getFiles, getMany, queryAggregate, runExpression, runExpressionMany, runQuery, runQueryMany, search, set, setFile, setMany, signAndSubmitTransaction, signMessage, signTransaction, subscribe } from '@bounded-sh/core';
4
4
  import * as React from 'react';
@@ -3576,10 +3576,15 @@ class GuestAuthProvider {
3576
3576
 
3577
3577
  // Web uses localStorage (WebSessionManager); React Native uses the configured
3578
3578
  // ReactNativeSessionManager. getActiveSessionManager() picks the right one by
3579
- // whether the RN manager was configured.
3579
+ // whether the RN manager was configured (NOT `typeof window`, which is truthy on
3580
+ // RN too and would wrongly route to localStorage). Same selector the core
3581
+ // request path uses, so headless email OTP persists + authenticates on BOTH.
3580
3582
  const sessionManager$1 = getActiveSessionManager;
3581
- const NO_WALLET = 'Email login is offchain - it has no Solana wallet and cannot sign/submit ' +
3583
+ const NO_WALLET = 'Email login is offchain — it has no Solana wallet and cannot sign/submit ' +
3582
3584
  'transactions. Use a wallet provider for onchain actions, or link a wallet later.';
3585
+ // ---------------------------------------------------------------------------
3586
+ // token / session helpers
3587
+ // ---------------------------------------------------------------------------
3583
3588
  function decodeJwt$1(token) {
3584
3589
  try {
3585
3590
  let b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
@@ -3588,6 +3593,7 @@ function decodeJwt$1(token) {
3588
3593
  b64 += '==';
3589
3594
  else if (pad === 3)
3590
3595
  b64 += '=';
3596
+ // getPlatform().atob is RN-safe (global atob is undefined on Hermes/JSC).
3591
3597
  return JSON.parse(getPlatform().atob(b64));
3592
3598
  }
3593
3599
  catch (_a) {
@@ -3598,12 +3604,194 @@ function userFromIdToken$1(idToken, fallbackEmail) {
3598
3604
  const c = decodeJwt$1(idToken);
3599
3605
  const id = c['custom:userId'] || c.sub || c['custom:walletAddress'] || '';
3600
3606
  const email = (c.email || fallbackEmail || null);
3607
+ // Email logins have no wallet — address is null (the User type allows it).
3601
3608
  return { id, address: (c['custom:walletAddress'] || null), email, provider: emailProviderSingleton };
3602
3609
  }
3610
+ async function humanAuthBase() {
3611
+ const cfg = await getConfig();
3612
+ const base = cfg.humanAuthApiUrl || 'https://auth.bounded.sh';
3613
+ return base.replace(/\/$/, '');
3614
+ }
3615
+ async function post(path, body) {
3616
+ const base = await humanAuthBase();
3617
+ let res;
3618
+ try {
3619
+ // Credential-free + no custom header: /email and /verify are open-CORS
3620
+ // (ACAO:*) public OTP routes, so this works inline from ANY app origin (no
3621
+ // popup, no redirect). The OTP is the body secret; the token returns in the
3622
+ // body. Adding credentials or a custom header would break the open-CORS
3623
+ // preflight — keep this a simple JSON POST.
3624
+ res = await fetch(base + path, {
3625
+ method: 'POST',
3626
+ headers: { 'content-type': 'application/json' },
3627
+ body: JSON.stringify(body),
3628
+ });
3629
+ }
3630
+ catch (err) {
3631
+ throw new Error(`Bounded auth request to ${path} failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
3632
+ }
3633
+ let data = null;
3634
+ try {
3635
+ data = await res.json();
3636
+ }
3637
+ catch ( /* non-JSON */_a) { /* non-JSON */ }
3638
+ if (!res.ok) {
3639
+ const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
3640
+ throw new Error(String(msg));
3641
+ }
3642
+ return data || {};
3643
+ }
3644
+ // ---------------------------------------------------------------------------
3645
+ // headless primitives (used by the modal AND exported for custom UIs)
3646
+ // ---------------------------------------------------------------------------
3647
+ function normalizePhoneNumber(value) {
3648
+ const compact = String(value || '').trim().replace(/[()\s.-]/g, '');
3649
+ return compact.startsWith('00') ? `+${compact.slice(2)}` : compact;
3650
+ }
3651
+ function assertPhoneNumber(value) {
3652
+ const phoneNumber = normalizePhoneNumber(value);
3653
+ if (!/^\+[1-9]\d{7,14}$/.test(phoneNumber)) {
3654
+ throw new Error('phoneNumber must be in E.164 format, e.g. +14155550132');
3655
+ }
3656
+ return phoneNumber;
3657
+ }
3658
+ /** Send a login code to `email`. Step 1 of the inline email-OTP flow. */
3659
+ async function sendEmailOtp$1(email) {
3660
+ const cfg = await getConfig();
3661
+ const e = (email || '').trim().toLowerCase();
3662
+ if (!e)
3663
+ throw new Error('email is required');
3664
+ const data = await post('/email', { email: e, appId: cfg.appId });
3665
+ return { ok: true, expiresInSeconds: data === null || data === void 0 ? void 0 : data.expiresInSeconds };
3666
+ }
3667
+ /** Send a login code by text message. Requires the Bounded issuer to have text OTP explicitly enabled and SMS configured. */
3668
+ async function sendTextOtp$1(phoneNumber) {
3669
+ const cfg = await getConfig();
3670
+ const phone = assertPhoneNumber(phoneNumber);
3671
+ const data = await post('/text', { phoneNumber: phone, appId: cfg.appId });
3672
+ return { ok: true, expiresInSeconds: data === null || data === void 0 ? void 0 : data.expiresInSeconds };
3673
+ }
3674
+ /**
3675
+ * Verify the code for `email` and establish the session. Step 2 of the flow.
3676
+ * Stores the Bounded id token (the realtime bearer) and sets the current user.
3677
+ */
3678
+ async function verifyEmailOtp$1(email, otp) {
3679
+ setAuthLoading(true);
3680
+ try {
3681
+ const cfg = await getConfig();
3682
+ const e = (email || '').trim().toLowerCase();
3683
+ const code = (otp || '').trim();
3684
+ if (!e || !code)
3685
+ throw new Error('email and code are required');
3686
+ const data = await post('/verify', { email: e, appId: cfg.appId, otp: code });
3687
+ const idToken = data === null || data === void 0 ? void 0 : data.idToken;
3688
+ if (!idToken)
3689
+ throw new Error('No token returned from verification.');
3690
+ const user = userFromIdToken$1(idToken, e);
3691
+ // Store the idToken as BOTH id + access (it is the JWT the realtime worker
3692
+ // verifies and carries `exp`, so the session-manager expiry check works).
3693
+ // address is "" for email (no wallet); the realtime bearer is the idToken.
3694
+ // Persist the rotating refresh token + the issuer base (humanAuthApiUrl) so the
3695
+ // session manager can silently renew via {issuer}/session/refresh — sessions now
3696
+ // live as long as the 30d refresh token instead of dying at the access TTL.
3697
+ const refreshToken = typeof (data === null || data === void 0 ? void 0 : data.refreshToken) === 'string' ? data.refreshToken : '';
3698
+ const issuer = (cfg.humanAuthApiUrl || '').replace(/\/$/, '');
3699
+ await sessionManager$1().storeSession(String(user.address || ''), idToken, idToken, refreshToken, issuer);
3700
+ setCurrentUser(user);
3701
+ return user;
3702
+ }
3703
+ finally {
3704
+ setAuthLoading(false);
3705
+ }
3706
+ }
3707
+ /**
3708
+ * Verify the text-message code for `phoneNumber` and establish the session.
3709
+ * Phone-only users have `user.email === null`; use `user.id` for ownership.
3710
+ */
3711
+ async function verifyTextOtp$1(phoneNumber, otp) {
3712
+ setAuthLoading(true);
3713
+ try {
3714
+ const cfg = await getConfig();
3715
+ const phone = assertPhoneNumber(phoneNumber);
3716
+ const code = (otp || '').trim();
3717
+ if (!code)
3718
+ throw new Error('code is required');
3719
+ const data = await post('/text/verify', { phoneNumber: phone, appId: cfg.appId, otp: code });
3720
+ const idToken = data === null || data === void 0 ? void 0 : data.idToken;
3721
+ if (!idToken)
3722
+ throw new Error('No token returned from verification.');
3723
+ const user = userFromIdToken$1(idToken);
3724
+ const refreshToken = typeof (data === null || data === void 0 ? void 0 : data.refreshToken) === 'string' ? data.refreshToken : '';
3725
+ const issuer = (cfg.humanAuthApiUrl || '').replace(/\/$/, '');
3726
+ await sessionManager$1().storeSession(String(user.address || ''), idToken, idToken, refreshToken, issuer);
3727
+ setCurrentUser(user);
3728
+ return user;
3729
+ }
3730
+ finally {
3731
+ setAuthLoading(false);
3732
+ }
3733
+ }
3734
+ /**
3735
+ * UPGRADE an anonymous (guest) session by LINKING an email credential — Firebase
3736
+ * `linkWithCredential` parity. The guest's `guestToken` is sent to the auth issuer
3737
+ * which, IF the email is brand-new, makes the new account ADOPT the guest's id so
3738
+ * `@user.id` is preserved and the guest's data carries over. If the email already
3739
+ * exists, the user just signs into it (stays separate; `linked:false`). On success
3740
+ * the upgraded session replaces the guest session and the user is no longer
3741
+ * anonymous. `guestToken` is captured by the caller BEFORE switching auth methods.
3742
+ */
3743
+ async function linkEmailWithGuest(email, otp, guestToken) {
3744
+ var _a;
3745
+ setAuthLoading(true);
3746
+ try {
3747
+ const cfg = await getConfig();
3748
+ const e = (email || '').trim().toLowerCase();
3749
+ const code = (otp || '').trim();
3750
+ if (!e || !code)
3751
+ throw new Error('email and code are required');
3752
+ if (!guestToken)
3753
+ throw new Error('an active anonymous session is required to link an email');
3754
+ // Capture the OLD guest session's refresh token + issuer BEFORE it's overwritten,
3755
+ // so we can revoke the guest refresh family after the upgrade. Otherwise a stolen
3756
+ // guest refresh token would keep minting access to the now-upgraded identity.
3757
+ const oldRefreshToken = sessionManager$1().getRefreshToken();
3758
+ const oldIssuer = (_a = sessionManager$1().getIssuer()) !== null && _a !== void 0 ? _a : undefined;
3759
+ const data = await post('/link/email', { email: e, appId: cfg.appId, otp: code, guestToken });
3760
+ const idToken = data === null || data === void 0 ? void 0 : data.idToken;
3761
+ if (!idToken)
3762
+ throw new Error('No token returned from link.');
3763
+ const user = userFromIdToken$1(idToken, e);
3764
+ // Persist the REAL rotating refresh token + human issuer so the upgraded session
3765
+ // silently renews (was previously stored as '' -> upgraded users couldn't refresh).
3766
+ const refreshToken = typeof (data === null || data === void 0 ? void 0 : data.refreshToken) === 'string' ? data.refreshToken : '';
3767
+ const issuer = (cfg.humanAuthApiUrl || '').replace(/\/$/, '');
3768
+ await sessionManager$1().storeSession(String(user.address || ''), idToken, idToken, refreshToken, issuer);
3769
+ setCurrentUser(user);
3770
+ // Best-effort revoke of the old guest refresh family (upgrade is already committed;
3771
+ // a revoke failure must not break the new session).
3772
+ if (oldRefreshToken && oldRefreshToken !== refreshToken) {
3773
+ try {
3774
+ await revokeSession(oldRefreshToken, oldIssuer);
3775
+ }
3776
+ catch ( /* best-effort */_b) { /* best-effort */ }
3777
+ }
3778
+ return { user, linked: !!(data === null || data === void 0 ? void 0 : data.linked), isNewAccount: !!(data === null || data === void 0 ? void 0 : data.isNewAccount) };
3779
+ }
3780
+ finally {
3781
+ setAuthLoading(false);
3782
+ }
3783
+ }
3784
+ // ---------------------------------------------------------------------------
3785
+ // provider
3786
+ // ---------------------------------------------------------------------------
3603
3787
  class EmailAuthProvider {
3604
3788
  async login() {
3605
- throw new Error('login() no longer opens inline email OTP. Use loginWithRedirect({ redirectUri, methods: ["email"] }) ' +
3606
- 'or loginWithPopup({ methods: ["email"] }) instead.');
3789
+ if (!getPlatform().hasDOM) {
3790
+ throw new Error('Email login on this platform (e.g. React Native) is headless: call ' +
3791
+ "sendEmailOtp(email) then verifyEmailOtp(email, code), or, when text OTP is enabled, " +
3792
+ "sendTextOtp(phone) then verifyTextOtp(phone, code). Render your own OTP inputs. The built-in modal is web-only.");
3793
+ }
3794
+ return openEmailModal();
3607
3795
  }
3608
3796
  async restoreSession() {
3609
3797
  const sm = sessionManager$1();
@@ -3621,23 +3809,290 @@ class EmailAuthProvider {
3621
3809
  sessionManager$1().clearSession();
3622
3810
  setCurrentUser(null);
3623
3811
  }
3624
- async signMessage(_message) {
3625
- throw new Error(NO_WALLET);
3626
- }
3627
- async runTransaction(_evm, _sol, _options) {
3628
- throw new Error(NO_WALLET);
3629
- }
3630
- async signTransaction(_transaction) {
3631
- throw new Error(NO_WALLET);
3632
- }
3633
- async signAndSubmitTransaction() {
3634
- throw new Error(NO_WALLET);
3635
- }
3636
- async getNativeMethods() {
3637
- return {};
3638
- }
3812
+ async signMessage(_message) { throw new Error(NO_WALLET); }
3813
+ async runTransaction(_evm, _sol, _options) { throw new Error(NO_WALLET); }
3814
+ async signTransaction(_t) { throw new Error(NO_WALLET); }
3815
+ async signAndSubmitTransaction() { throw new Error(NO_WALLET); }
3816
+ async getNativeMethods() { return {}; }
3639
3817
  }
3640
3818
  const emailProviderSingleton = new EmailAuthProvider();
3819
+ // ---------------------------------------------------------------------------
3820
+ // inline modal (web only) — mobile-first, no popup, no redirect
3821
+ // ---------------------------------------------------------------------------
3822
+ function openEmailModal() {
3823
+ return new Promise((resolve) => {
3824
+ var _a;
3825
+ const root = createEmailModalTree();
3826
+ const style = document.createElement('style');
3827
+ style.textContent = MODAL_CSS;
3828
+ root.appendChild(style);
3829
+ document.body.appendChild(root);
3830
+ const $ = (id) => root.querySelector('#' + id);
3831
+ const emailStep = $('ba-email-step');
3832
+ const otpStep = $('ba-otp-step');
3833
+ const emailInput = $('ba-email');
3834
+ const otpInput = $('ba-otp');
3835
+ const sentTo = $('ba-sent-to');
3836
+ const msg = $('ba-msg');
3837
+ const emailBtn = $('ba-email-btn');
3838
+ const otpBtn = $('ba-otp-btn');
3839
+ let email = '';
3840
+ let done = false;
3841
+ const setMsg = (t, err = false) => { msg.textContent = t; msg.className = 'ba-msg' + (err ? ' err' : ''); };
3842
+ const close = (user) => {
3843
+ var _a;
3844
+ if (done)
3845
+ return;
3846
+ done = true;
3847
+ document.removeEventListener('keydown', onEsc);
3848
+ (_a = root.querySelector('.ba-overlay')) === null || _a === void 0 ? void 0 : _a.classList.add('out');
3849
+ setTimeout(() => root.remove(), 160);
3850
+ resolve(user);
3851
+ };
3852
+ const onEsc = (e) => { if (e.key === 'Escape')
3853
+ close(null); };
3854
+ document.addEventListener('keydown', onEsc);
3855
+ (_a = root.querySelector('.ba-overlay')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', (e) => {
3856
+ if (e.target.classList.contains('ba-overlay'))
3857
+ close(null);
3858
+ });
3859
+ $('ba-close').addEventListener('click', () => close(null));
3860
+ requestAnimationFrame(() => emailInput.focus());
3861
+ emailBtn.addEventListener('click', async () => {
3862
+ email = emailInput.value.trim();
3863
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
3864
+ setMsg('Enter a valid email.', true);
3865
+ return;
3866
+ }
3867
+ emailBtn.disabled = true;
3868
+ setMsg('Sending code…');
3869
+ try {
3870
+ await sendEmailOtp$1(email);
3871
+ sentTo.textContent = email;
3872
+ emailStep.classList.add('hidden');
3873
+ otpStep.classList.remove('hidden');
3874
+ setMsg('');
3875
+ otpInput.focus();
3876
+ }
3877
+ catch (err) {
3878
+ setMsg((err === null || err === void 0 ? void 0 : err.message) || 'Could not send the code.', true);
3879
+ }
3880
+ finally {
3881
+ emailBtn.disabled = false;
3882
+ }
3883
+ });
3884
+ emailInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
3885
+ emailBtn.click(); });
3886
+ otpBtn.addEventListener('click', async () => {
3887
+ const code = otpInput.value.trim();
3888
+ if (!/^\d{4,8}$/.test(code)) {
3889
+ setMsg('Enter the code from your email.', true);
3890
+ return;
3891
+ }
3892
+ otpBtn.disabled = true;
3893
+ setMsg('Verifying…');
3894
+ try {
3895
+ const user = await verifyEmailOtp$1(email, code);
3896
+ close(user);
3897
+ }
3898
+ catch (err) {
3899
+ setMsg((err === null || err === void 0 ? void 0 : err.message) || 'That code did not work.', true);
3900
+ otpBtn.disabled = false;
3901
+ }
3902
+ });
3903
+ otpInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
3904
+ otpBtn.click(); });
3905
+ $('ba-back').addEventListener('click', () => {
3906
+ otpStep.classList.add('hidden');
3907
+ emailStep.classList.remove('hidden');
3908
+ setMsg('');
3909
+ emailInput.focus();
3910
+ });
3911
+ });
3912
+ }
3913
+ function createEmailModalTree() {
3914
+ const root = document.createElement('div');
3915
+ root.id = 'bounded-auth-modal';
3916
+ const overlay = document.createElement('div');
3917
+ overlay.className = 'ba-overlay';
3918
+ root.appendChild(overlay);
3919
+ const card = document.createElement('div');
3920
+ card.className = 'ba-card';
3921
+ card.setAttribute('role', 'dialog');
3922
+ card.setAttribute('aria-modal', 'true');
3923
+ card.setAttribute('aria-label', 'Sign in');
3924
+ overlay.appendChild(card);
3925
+ const close = document.createElement('button');
3926
+ close.id = 'ba-close';
3927
+ close.className = 'ba-x';
3928
+ close.setAttribute('aria-label', 'Close');
3929
+ close.type = 'button';
3930
+ close.textContent = '×';
3931
+ card.appendChild(close);
3932
+ // Brand row — the real Bounded mark (same geometry as bounded.sh) + wordmark.
3933
+ const brand = document.createElement('div');
3934
+ brand.className = 'ba-brand';
3935
+ const mark = document.createElement('span');
3936
+ mark.className = 'ba-mark';
3937
+ mark.setAttribute('aria-hidden', 'true');
3938
+ const svgNs = 'http://www.w3.org/2000/svg';
3939
+ const markSvg = document.createElementNS(svgNs, 'svg');
3940
+ markSvg.setAttribute('width', '24');
3941
+ markSvg.setAttribute('height', '24');
3942
+ markSvg.setAttribute('viewBox', '0 0 32 32');
3943
+ markSvg.setAttribute('fill', 'none');
3944
+ for (const [className, d] of [
3945
+ ['ba-c ba-c1', 'M16 5 H5 V16'],
3946
+ ['ba-c ba-c2', 'M16 5 H27 V16'],
3947
+ ['ba-c ba-c3', 'M5 16 V27 H16'],
3948
+ ['ba-c ba-c4', 'M27 16 V27 H16'],
3949
+ ]) {
3950
+ const path = document.createElementNS(svgNs, 'path');
3951
+ path.setAttribute('class', className);
3952
+ path.setAttribute('d', d);
3953
+ markSvg.appendChild(path);
3954
+ }
3955
+ mark.appendChild(markSvg);
3956
+ brand.appendChild(mark);
3957
+ const word = document.createElement('span');
3958
+ word.className = 'ba-word';
3959
+ word.textContent = 'bounded';
3960
+ brand.appendChild(word);
3961
+ card.appendChild(brand);
3962
+ const title = document.createElement('h1');
3963
+ title.className = 'ba-title';
3964
+ title.textContent = 'Sign in';
3965
+ card.appendChild(title);
3966
+ const sub = document.createElement('p');
3967
+ sub.className = 'ba-subtitle';
3968
+ sub.textContent = 'Continue with your email — no wallet needed.';
3969
+ card.appendChild(sub);
3970
+ const emailStep = document.createElement('div');
3971
+ emailStep.id = 'ba-email-step';
3972
+ card.appendChild(emailStep);
3973
+ const emailLabel = document.createElement('label');
3974
+ emailLabel.className = 'ba-label';
3975
+ emailLabel.htmlFor = 'ba-email';
3976
+ emailLabel.textContent = 'Email';
3977
+ emailStep.appendChild(emailLabel);
3978
+ const emailInput = document.createElement('input');
3979
+ emailInput.id = 'ba-email';
3980
+ emailInput.className = 'ba-input';
3981
+ emailInput.type = 'email';
3982
+ emailInput.inputMode = 'email';
3983
+ emailInput.autocomplete = 'email';
3984
+ emailInput.placeholder = 'you@example.com';
3985
+ emailStep.appendChild(emailInput);
3986
+ const emailButton = document.createElement('button');
3987
+ emailButton.id = 'ba-email-btn';
3988
+ emailButton.className = 'ba-btn';
3989
+ emailButton.type = 'button';
3990
+ emailButton.textContent = 'Continue with email';
3991
+ emailStep.appendChild(emailButton);
3992
+ const otpStep = document.createElement('div');
3993
+ otpStep.id = 'ba-otp-step';
3994
+ otpStep.className = 'hidden';
3995
+ card.appendChild(otpStep);
3996
+ const otpLabel = document.createElement('label');
3997
+ otpLabel.className = 'ba-label';
3998
+ otpLabel.htmlFor = 'ba-otp';
3999
+ otpLabel.appendChild(document.createTextNode('Enter the code sent to '));
4000
+ const sentTo = document.createElement('span');
4001
+ sentTo.id = 'ba-sent-to';
4002
+ sentTo.className = 'ba-em';
4003
+ otpLabel.appendChild(sentTo);
4004
+ otpStep.appendChild(otpLabel);
4005
+ const otpInput = document.createElement('input');
4006
+ otpInput.id = 'ba-otp';
4007
+ otpInput.className = 'ba-input ba-code';
4008
+ otpInput.inputMode = 'numeric';
4009
+ otpInput.autocomplete = 'one-time-code';
4010
+ otpInput.maxLength = 8;
4011
+ otpInput.placeholder = '······';
4012
+ otpStep.appendChild(otpInput);
4013
+ const otpButton = document.createElement('button');
4014
+ otpButton.id = 'ba-otp-btn';
4015
+ otpButton.className = 'ba-btn';
4016
+ otpButton.type = 'button';
4017
+ otpButton.textContent = 'Verify & continue';
4018
+ otpStep.appendChild(otpButton);
4019
+ const backButton = document.createElement('button');
4020
+ backButton.id = 'ba-back';
4021
+ backButton.className = 'ba-link';
4022
+ backButton.type = 'button';
4023
+ backButton.textContent = 'Use a different email';
4024
+ otpStep.appendChild(backButton);
4025
+ const msg = document.createElement('div');
4026
+ msg.id = 'ba-msg';
4027
+ msg.className = 'ba-msg';
4028
+ card.appendChild(msg);
4029
+ const foot = document.createElement('div');
4030
+ foot.className = 'ba-foot';
4031
+ foot.textContent = 'Secured by Bounded';
4032
+ card.appendChild(foot);
4033
+ return root;
4034
+ }
4035
+ // Design language matches the hosted Bounded login (auth.bounded.sh) and bounded.sh:
4036
+ // dark editorial panel, green accent, Schibsted Grotesk / Newsreader / JetBrains Mono,
4037
+ // the Bounded mark drawing itself in. Fonts load best-effort (scoped @import); every
4038
+ // rule has a system fallback so the modal looks right even if Google Fonts is blocked.
4039
+ // All selectors are scoped under #bounded-auth-modal so we never leak into the host page.
4040
+ const MODAL_CSS = `
4041
+ @import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500&family=Schibsted+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
4042
+ #bounded-auth-modal{--ba-bg:#0a0a0b;--ba-panel:#0e0f11;--ba-inset:#060607;--ba-raise:#131416;
4043
+ --ba-line:rgba(232,230,222,.08);--ba-line-strong:rgba(232,230,222,.16);
4044
+ --ba-text:#e8e6de;--ba-dim:#9b9991;--ba-faint:#85837c;--ba-green:#58c98f;
4045
+ --ba-green-bg:rgba(88,201,143,.09);--ba-green-line:rgba(88,201,143,.28);--ba-red:#ec8488;
4046
+ --ba-body:'Schibsted Grotesk',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
4047
+ --ba-display:'Newsreader','Iowan Old Style',Georgia,serif;
4048
+ --ba-mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;}
4049
+ #bounded-auth-modal .ba-overlay{position:fixed;inset:0;background:rgba(6,6,7,.66);backdrop-filter:blur(4px);
4050
+ display:flex;align-items:center;justify-content:center;z-index:2147483000;opacity:1;transition:opacity .16s ease;
4051
+ font-family:var(--ba-body);padding:16px;}
4052
+ #bounded-auth-modal .ba-overlay.out{opacity:0;}
4053
+ #bounded-auth-modal *{box-sizing:border-box;}
4054
+ #bounded-auth-modal .ba-card{width:100%;max-width:372px;background:var(--ba-panel);color:var(--ba-text);
4055
+ border:1px solid var(--ba-line);border-radius:12px;padding:28px 28px 22px;position:relative;
4056
+ box-shadow:0 1px 0 rgba(232,230,222,.03) inset,0 30px 80px -40px rgba(0,0,0,.8);animation:ba-in .18s ease;}
4057
+ @keyframes ba-in{from{transform:translateY(8px);opacity:.5}to{transform:translateY(0);opacity:1}}
4058
+ #bounded-auth-modal .ba-x{position:absolute;top:14px;right:14px;width:28px;height:28px;border:1px solid var(--ba-line);
4059
+ background:var(--ba-raise);color:var(--ba-faint);border-radius:7px;font-size:18px;line-height:1;cursor:pointer;
4060
+ transition:color .15s,border-color .15s;}
4061
+ #bounded-auth-modal .ba-x:hover{color:var(--ba-text);border-color:var(--ba-faint);}
4062
+ #bounded-auth-modal .ba-brand{display:flex;align-items:center;gap:10px;margin:0 0 24px;}
4063
+ #bounded-auth-modal .ba-mark{display:flex;color:var(--ba-text);}
4064
+ #bounded-auth-modal .ba-mark svg{overflow:visible;display:block;}
4065
+ #bounded-auth-modal .ba-c{stroke:currentColor;stroke-width:2.4;fill:none;}
4066
+ @media (prefers-reduced-motion:no-preference){
4067
+ #bounded-auth-modal .ba-c{stroke-dasharray:23;stroke-dashoffset:23;animation:ba-draw .4s cubic-bezier(.45,0,.2,1) forwards;}
4068
+ #bounded-auth-modal .ba-c1{animation-delay:.1s}#bounded-auth-modal .ba-c2{animation-delay:.38s}
4069
+ #bounded-auth-modal .ba-c3{animation-delay:.66s}#bounded-auth-modal .ba-c4{animation-delay:.94s}}
4070
+ @keyframes ba-draw{to{stroke-dashoffset:0}}
4071
+ #bounded-auth-modal .ba-word{font-family:var(--ba-body);font-weight:500;font-size:15px;letter-spacing:.2px;color:var(--ba-text);}
4072
+ #bounded-auth-modal .ba-title{font-family:var(--ba-display);font-weight:400;font-size:25px;letter-spacing:-.01em;margin:0 0 5px;}
4073
+ #bounded-auth-modal .ba-subtitle{color:var(--ba-dim);font-size:14px;margin:0 0 22px;}
4074
+ #bounded-auth-modal .ba-label{display:block;font-size:12px;color:var(--ba-dim);margin:0 0 7px;}
4075
+ #bounded-auth-modal .ba-em{color:var(--ba-text);font-weight:600;}
4076
+ #bounded-auth-modal .ba-input{width:100%;background:var(--ba-inset);border:1px solid var(--ba-line-strong);color:var(--ba-text);
4077
+ border-radius:6px;padding:11px 13px;font-family:var(--ba-body);font-size:15px;outline:none;transition:border-color .15s,box-shadow .15s;}
4078
+ #bounded-auth-modal .ba-input::placeholder{color:var(--ba-faint);}
4079
+ #bounded-auth-modal .ba-input:focus{border-color:var(--ba-green-line);box-shadow:0 0 0 3px var(--ba-green-bg);}
4080
+ #bounded-auth-modal .ba-code{font-family:var(--ba-mono);letter-spacing:8px;text-align:center;font-size:21px;padding-left:8px;}
4081
+ #bounded-auth-modal .ba-btn{width:100%;margin-top:16px;background:var(--ba-text);color:var(--ba-bg);
4082
+ border:0;border-radius:6px;padding:12px;font-family:var(--ba-body);font-size:14px;font-weight:600;cursor:pointer;
4083
+ transition:opacity .15s,transform .04s;}
4084
+ #bounded-auth-modal .ba-btn:hover{opacity:.9;}
4085
+ #bounded-auth-modal .ba-btn:active{transform:translateY(.5px);}
4086
+ #bounded-auth-modal .ba-btn:disabled{opacity:.45;cursor:default;}
4087
+ #bounded-auth-modal .ba-link{width:100%;margin-top:14px;background:none;border:none;color:var(--ba-dim);
4088
+ font-family:var(--ba-body);font-size:12.5px;cursor:pointer;}
4089
+ #bounded-auth-modal .ba-link:hover{color:var(--ba-text);}
4090
+ #bounded-auth-modal .ba-msg{min-height:16px;margin-top:16px;font-size:12.5px;color:var(--ba-dim);text-align:center;}
4091
+ #bounded-auth-modal .ba-msg.err{color:var(--ba-red);}
4092
+ #bounded-auth-modal .ba-foot{margin-top:26px;padding-top:16px;border-top:1px solid var(--ba-line);
4093
+ text-align:center;font-size:11px;color:var(--ba-faint);}
4094
+ #bounded-auth-modal .hidden{display:none;}
4095
+ @media (max-width:480px){#bounded-auth-modal .ba-card{max-width:none;}}`;
3641
4096
 
3642
4097
  const SURFNET_RPC_URL = "https://surfpool.fly.dev";
3643
4098
  const SUPPORTED_SOLANA_NETWORKS = new Set([
@@ -3775,7 +4230,11 @@ async function buildAuthorize(opts, guestHint) {
3775
4230
  const cfg = await getConfig();
3776
4231
  if (!cfg.appId)
3777
4232
  throw new Error('appId is not configured (init the SDK with your appId)');
3778
- const redirectUri = opts.redirectUri;
4233
+ // Web: default to the current page so callers don't have to pass redirectUri. Native has no
4234
+ // window — it MUST pass an https universal link (the system auth session needs a real URL).
4235
+ const redirectUri = opts.redirectUri || (hasDOM() ? window.location.origin + window.location.pathname : undefined);
4236
+ if (!redirectUri)
4237
+ throw new Error('redirectUri is required on React Native — pass the https universal link the app owner registered.');
3779
4238
  const { verifier, challenge } = await makePkce();
3780
4239
  const state = randomState();
3781
4240
  const base = await issuerBase();
@@ -3934,6 +4393,21 @@ async function completeLoginFromRedirect() {
3934
4393
  const returnedState = url.searchParams.get('state');
3935
4394
  if (!code)
3936
4395
  return null;
4396
+ // POPUP path: if we're the redirect_uri page running INSIDE the loginWithPopup() window,
4397
+ // hand the code to the opener and close — no separate completeLoginInPopup() call needed.
4398
+ // The opener is the same app/origin, so post to our own origin (the opener's message
4399
+ // handler checks ev.origin === redirect_uri origin). One call now finishes BOTH flows.
4400
+ if (window.opener && window.opener !== window) {
4401
+ try {
4402
+ window.opener.postMessage({ type: 'bounded-oidc-code', code, state: returnedState }, window.location.origin);
4403
+ }
4404
+ catch ( /* ignore */_a) { /* ignore */ }
4405
+ try {
4406
+ window.close();
4407
+ }
4408
+ catch ( /* ignore */_b) { /* ignore */ }
4409
+ return null;
4410
+ }
3937
4411
  setAuthLoading(true);
3938
4412
  try {
3939
4413
  const raw = sessionStorage.getItem(PKCE_STORE_KEY);
@@ -3967,7 +4441,9 @@ async function loginWithPopup(opts) {
3967
4441
  const popup = window.open(url, 'bounded-oidc', `width=${w},height=${h},left=${left},top=${top}`);
3968
4442
  if (!popup)
3969
4443
  throw new Error('Popup blocked — use loginWithRedirect() instead');
3970
- const expectedOrigin = new URL(opts.redirectUri).origin;
4444
+ // Use the RESOLVED redirect_uri (buildAuthorize defaulted it to the current page on web),
4445
+ // not opts.redirectUri which may be undefined.
4446
+ const expectedOrigin = new URL(pkce.redirectUri).origin;
3971
4447
  return await new Promise((resolve, reject) => {
3972
4448
  const onMsg = async (ev) => {
3973
4449
  if (ev.origin !== expectedOrigin)
@@ -4339,43 +4815,77 @@ async function loginWithPrivy(options) {
4339
4815
  const result = await provider.login();
4340
4816
  return result ? Object.assign(Object.assign({}, result), { provider }) : null;
4341
4817
  }
4818
+ // Activate the inline email provider (method=email so reload-restore + logout work),
4819
+ // mirroring signInAnonymously. Used by verifyEmailOtp/verifyTextOtp/linkEmail.
4820
+ async function activateEmailProvider() {
4821
+ const provider = new EmailAuthProvider();
4822
+ currentAuthProvider = provider;
4823
+ currentAuthMethod = 'email';
4824
+ currentAuthProviderConfigKey = 'direct:email';
4825
+ const coreConfig = await getConfig();
4826
+ coreConfig.authProvider = provider;
4827
+ coreConfig.authMethod = 'email';
4828
+ setAuthProviderInstance(provider);
4829
+ setStoredAuthMethod('email');
4830
+ return provider;
4831
+ }
4342
4832
  /**
4343
- * @deprecated Inline app-origin OTP is retired. Use
4344
- * `loginWithRedirect({ redirectUri, methods: ["email"] })` so the credential is
4345
- * entered on the Bounded hosted issuer.
4833
+ * Send a 6-digit login code to `email` (Bounded Better Auth). Step 1 of the
4834
+ * inline email flow — build your own email/code UI for full control over the
4835
+ * look, or just call `login()` for the built-in modal. Works on web AND React
4836
+ * Native (no DOM needed). Prefer `loginWithRedirect()`/`loginWithPopup()` if you
4837
+ * want the credential entered on Bounded's hosted page instead.
4346
4838
  */
4347
4839
  async function sendEmailOtp(email) {
4348
- throw new Error('sendEmailOtp is retired. Use loginWithRedirect({ redirectUri, methods: ["email"] }) instead.');
4840
+ assertBrowserAuthEnv('sendEmailOtp');
4841
+ return sendEmailOtp$1(email);
4349
4842
  }
4350
4843
  /**
4351
- * @deprecated Inline app-origin text OTP is retired. Use hosted login with
4352
- * `provider: "text"` or `methods: ["text"]` when text OTP is enabled.
4844
+ * Send a 6-digit login code by text message. Requires the configured Bounded
4845
+ * auth issuer to have text OTP explicitly enabled and SMS delivery configured.
4846
+ * Works on web AND React Native.
4353
4847
  */
4354
4848
  async function sendTextOtp(phoneNumber) {
4355
- throw new Error('sendTextOtp is retired. Use loginWithRedirect({ redirectUri, provider: "text" }) instead.');
4849
+ assertBrowserAuthEnv('sendTextOtp');
4850
+ return sendTextOtp$1(phoneNumber);
4356
4851
  }
4357
4852
  /**
4358
- * @deprecated Inline app-origin OTP is retired. Finish hosted login with
4359
- * `completeLoginFromRedirect()` on web; React Native `loginWithRedirect()`
4360
- * resolves inline.
4853
+ * Verify the `code` for `email`, establish the session, and return the user.
4854
+ * Step 2 of the inline email flow. Coexists with signInAnonymously()/wallets.
4361
4855
  */
4362
4856
  async function verifyEmailOtp(email, code) {
4363
- throw new Error('verifyEmailOtp is retired. Use hosted loginWithRedirect instead.');
4857
+ assertBrowserAuthEnv('verifyEmailOtp');
4858
+ await activateEmailProvider();
4859
+ return verifyEmailOtp$1(email, code);
4364
4860
  }
4365
4861
  /**
4366
- * @deprecated Inline app-origin text OTP is retired. Finish hosted login with
4367
- * `completeLoginFromRedirect()` on web; React Native `loginWithRedirect()`
4368
- * resolves inline.
4862
+ * Verify the text-message `code`, establish the session, and return the user.
4863
+ * Phone-only users have no `user.email`; use `user.id` for app identity.
4369
4864
  */
4370
4865
  async function verifyTextOtp(phoneNumber, code) {
4371
- throw new Error('verifyTextOtp is retired. Use hosted loginWithRedirect instead.');
4866
+ assertBrowserAuthEnv('verifyTextOtp');
4867
+ await activateEmailProvider();
4868
+ return verifyTextOtp$1(phoneNumber, code);
4372
4869
  }
4373
4870
  /**
4374
- * @deprecated Inline app-origin guest upgrade is retired. Send the user through
4375
- * hosted login and transfer guest-owned data explicitly.
4871
+ * UPGRADE the current anonymous (guest) session by linking an email — Firebase
4872
+ * `linkWithCredential` parity. Send the code first with `sendEmailOtp(email)`,
4873
+ * then call `linkEmail(email, code)`. If the email is brand-new the guest's
4874
+ * `@user.id` (and its data) is preserved; if the email already exists the user
4875
+ * just signs into it (no merge). Requires an active guest session, and the issuer
4876
+ * refuses if the wallet is already linked. Browser/React-Native only.
4376
4877
  */
4377
4878
  async function linkEmail(email, code) {
4378
- throw new Error('linkEmail is retired. Use hosted login and transfer guest-owned data explicitly.');
4879
+ assertBrowserAuthEnv('linkEmail');
4880
+ // Capture the GUEST token BEFORE switching auth methods — the issuer needs it
4881
+ // to prove guest ownership and adopt its id into the new email account.
4882
+ const guestToken = getActiveSessionManager().getIdToken();
4883
+ if (!guestToken) {
4884
+ throw new Error('linkEmail requires an active anonymous session — call signInAnonymously() first.');
4885
+ }
4886
+ await activateEmailProvider(); // method=email so the upgraded user is no longer anonymous
4887
+ const { user } = await linkEmailWithGuest(email, code, guestToken);
4888
+ return user;
4379
4889
  }
4380
4890
  async function login$1(options) {
4381
4891
  // Re-initialize provider if it was cleared by logout