@oxyhq/core 11.0.0 → 12.0.0

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.
Files changed (34) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +13 -6
  3. package/dist/cjs/mixins/OxyServices.auth.js +65 -74
  4. package/dist/cjs/mixins/OxyServices.identity.js +16 -12
  5. package/dist/cjs/session/accountDialogController.js +2 -55
  6. package/dist/cjs/utils/officialOrigins.js +6 -0
  7. package/dist/cjs/utils/webauthnOrigin.js +51 -0
  8. package/dist/esm/.tsbuildinfo +1 -1
  9. package/dist/esm/index.js +10 -5
  10. package/dist/esm/mixins/OxyServices.auth.js +65 -74
  11. package/dist/esm/mixins/OxyServices.identity.js +16 -12
  12. package/dist/esm/session/accountDialogController.js +2 -55
  13. package/dist/esm/utils/officialOrigins.js +6 -1
  14. package/dist/esm/utils/webauthnOrigin.js +48 -0
  15. package/dist/types/.tsbuildinfo +1 -1
  16. package/dist/types/index.d.ts +3 -2
  17. package/dist/types/mixins/OxyServices.auth.d.ts +50 -36
  18. package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
  19. package/dist/types/session/accountDialogController.d.ts +4 -34
  20. package/dist/types/utils/officialOrigins.d.ts +6 -0
  21. package/dist/types/utils/webauthnOrigin.d.ts +32 -0
  22. package/package.json +2 -2
  23. package/src/index.ts +11 -4
  24. package/src/mixins/OxyServices.auth.ts +95 -100
  25. package/src/mixins/OxyServices.identity.ts +19 -15
  26. package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
  27. package/src/mixins/__tests__/webauthnAuth.test.ts +206 -0
  28. package/src/session/__tests__/accountDialogController.test.ts +0 -66
  29. package/src/session/accountDialogController.ts +4 -78
  30. package/src/utils/__tests__/officialOrigins.test.ts +14 -0
  31. package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
  32. package/src/utils/officialOrigins.ts +6 -1
  33. package/src/utils/webauthnOrigin.ts +52 -0
  34. package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
package/dist/esm/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * ```ts
11
11
  * import { OxyServices, oxyClient } from '@oxyhq/core';
12
12
  *
13
- * const user = await oxyClient.signIn(publicKey);
13
+ * const user = await oxyClient.getCurrentUser();
14
14
  * ```
15
15
  *
16
16
  * Every export below is NOMINAL — no `export *`, no barrels, no compat shims.
@@ -130,13 +130,17 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
130
130
  // ---------------------------------------------------------------------------
131
131
  export { registrableApex } from './utils/registrableApex.js';
132
132
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl.js';
133
+ // WebAuthn relying-party origin guard (client side). Mirrors the server's
134
+ // `isOxyApexOrigin` so consumers can decide whether to offer passkey UI on the
135
+ // current page (first-party Oxy origin / loopback only).
136
+ export { isOxyRpOrigin } from './utils/webauthnOrigin.js';
133
137
  export { runColdBoot } from './utils/coldBoot.js';
134
138
  // ---------------------------------------------------------------------------
135
139
  // OAuth 2.0 Authorization Code + PKCE helpers ("Sign in with Oxy" third party).
136
140
  // Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
137
141
  // ---------------------------------------------------------------------------
138
142
  export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, } from './utils/oauthPkce.js';
139
- export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins.js';
143
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isLoopbackOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins.js';
140
144
  export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync.js';
141
145
  // ---------------------------------------------------------------------------
142
146
  // Session sync (device-scoped multi-account session client)
@@ -157,9 +161,10 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
157
161
  export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection.js';
158
162
  // Headless controller for the unified account dialog. Framework-agnostic
159
163
  // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
160
- // — no password/2FA logic (that lives at the IdP; `openPasswordAtOxyAuth` only
161
- // hands off). Reuses `SessionClient.switchAccount` / `oxyServices.switchToAccount`
162
- // for the uniform switch and the existing device-flow methods for sign-in.
164
+ // — sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff;
165
+ // password, social login, and 2FA were removed ecosystem-wide. Reuses
166
+ // `SessionClient.switchAccount` / `oxyServices.switchToAccount` for the uniform
167
+ // switch and the existing device-flow methods for sign-in.
163
168
  export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController.js';
164
169
  // ---------------------------------------------------------------------------
165
170
  // Device-first session machinery (zero-cookie transport).
@@ -737,106 +737,97 @@ export function OxyServicesAuthMixin(Base) {
737
737
  }
738
738
  }
739
739
  /**
740
- * Register a new user with email/username and password
740
+ * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
741
+ * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
742
+ * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
743
+ *
744
+ * With a bearer token planted this links a passkey to the signed-in account
745
+ * (`username` ignored); without one it is a prospective signup and `username`
746
+ * is the desired handle. The returned options are OPAQUE — Oxy does not own
747
+ * their shape (the browser / `@simplewebauthn` does), so they pass through
748
+ * as `unknown` for the caller to hand straight to the ceremony.
741
749
  */
742
- async signUp(username, email, password, deviceName, deviceFingerprint) {
750
+ async webauthnRegisterOptions(username) {
743
751
  try {
744
- const session = await this.makeRequest('POST', '/auth/signup', {
745
- username,
746
- email,
747
- password,
748
- deviceName,
749
- deviceFingerprint,
750
- }, { cache: false });
751
- return {
752
- ...session,
753
- user: normalizeUserIdentity(session.user),
754
- };
752
+ return await this.makeRequest('POST', '/auth/webauthn/register/options', { ...(username !== undefined ? { username } : {}) }, { cache: false });
755
753
  }
756
754
  catch (error) {
757
755
  throw this.handleError(error);
758
756
  }
759
757
  }
760
758
  /**
761
- * Sign in with email or username and password
762
- */
763
- async signIn(identifier, password, deviceName, deviceFingerprint) {
759
+ * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
760
+ * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
761
+ * (desired `username` for signup + the device-session naming fields).
762
+ *
763
+ * Two server branches, disambiguated by the response shape:
764
+ * - **Signup** (no bearer): the account is created and a session minted —
765
+ * the response carries `sessionId`, is the SAME {@link LoginResult}
766
+ * contract as `POST /auth/verify`, and its access token is planted here.
767
+ * - **Link** (bearer present): the passkey is attached to the signed-in
768
+ * account and the server returns `{ success, message }` with no session,
769
+ * which is returned verbatim (no token planting).
770
+ */
771
+ async webauthnRegisterVerify(response, envelope = {}) {
764
772
  try {
765
- const session = await this.makeRequest('POST', '/auth/login', {
766
- identifier,
767
- password,
768
- deviceName,
769
- deviceFingerprint,
770
- }, { cache: false });
771
- return {
772
- ...session,
773
- user: normalizeUserIdentity(session.user),
774
- };
773
+ const res = await this.makeRequest('POST', '/auth/webauthn/register/verify', { response, ...envelope }, { cache: false });
774
+ if (res && typeof res === 'object') {
775
+ const record = res;
776
+ // Signup branch: mints a session (LoginSessionResult, carries
777
+ // `sessionId`). Parse against the login contract and plant the token.
778
+ if ('sessionId' in record) {
779
+ const parsed = safeParseContract(loginResultSchema, record);
780
+ if (!parsed) {
781
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
782
+ }
783
+ if (parsed.accessToken) {
784
+ this.setTokens(parsed.accessToken);
785
+ }
786
+ return parsed;
787
+ }
788
+ // Link branch: passkey attached to the signed-in account, no session.
789
+ if (record.success === true && typeof record.message === 'string') {
790
+ return { success: true, message: record.message };
791
+ }
792
+ }
793
+ throw new Error('auth/webauthn/register/verify returned an unexpected response shape');
775
794
  }
776
795
  catch (error) {
777
796
  throw this.handleError(error);
778
797
  }
779
798
  }
780
799
  /**
781
- * Convenience helper for email sign-in
782
- */
783
- async signInWithEmail(email, password, deviceName, deviceFingerprint) {
784
- return this.signIn(email, password, deviceName, deviceFingerprint);
785
- }
786
- /**
787
- * Device-first password sign-in. Unlike the legacy {@link signIn} (which
788
- * assumes a one-step session and is kept intact for existing callers until
789
- * the F4 cutover), this returns the FULL `POST /auth/login` contract — the
790
- * discriminated {@link LoginResult}: either a 2FA challenge
791
- * (`{ twoFactorRequired, loginToken }`) to complete via
792
- * {@link completeTwoFactorSignIn}, or a session arm.
800
+ * Begin a WebAuthn / passkey AUTHENTICATION ceremony. Requests the
801
+ * `PublicKeyCredentialRequestOptions` the browser's `navigator.credentials
802
+ * .get()` (or `@simplewebauthn/browser`'s `startAuthentication`) needs.
793
803
  *
794
- * On the session arm, a returned access token is planted immediately
795
- * (mirroring {@link verifyChallenge}), so the caller has an authenticated
796
- * client without a second round-trip. The response's `deviceId` +
797
- * `deviceSecret` are the zero-cookie restore credential the caller persists.
804
+ * When `username` is present the server scopes `allowCredentials` to that
805
+ * user's passkeys (username-first); when omitted it returns an empty
806
+ * allow-list for the usernameless / discoverable-credential flow. The
807
+ * returned options are OPAQUE and pass through as `unknown`.
798
808
  */
799
- async passwordSignIn(identifier, password, options = {}) {
809
+ async webauthnLoginOptions(username) {
800
810
  try {
801
- const res = await this.makeRequest('POST', '/auth/login', {
802
- identifier,
803
- password,
804
- deviceName: options.deviceName,
805
- deviceFingerprint: options.deviceFingerprint,
806
- ...(options.deviceId ? { deviceId: options.deviceId } : {}),
807
- }, { cache: false });
808
- const parsed = safeParseContract(loginResultSchema, res);
809
- if (!parsed) {
810
- throw new Error('auth/login returned an unexpected response shape');
811
- }
812
- if (!('twoFactorRequired' in parsed) && parsed.accessToken) {
813
- this.setTokens(parsed.accessToken);
814
- }
815
- return parsed;
811
+ return await this.makeRequest('POST', '/auth/webauthn/login/options', { ...(username !== undefined ? { username } : {}) }, { cache: false });
816
812
  }
817
813
  catch (error) {
818
814
  throw this.handleError(error);
819
815
  }
820
816
  }
821
817
  /**
822
- * Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
823
- * the short-lived `loginToken` with either a TOTP `token` or a `backupCode`
824
- * to `POST /security/2fa/verify-login`, which must resolve to the session
825
- * arm of {@link LoginResult} (a second 2FA challenge here is a protocol
826
- * error). A returned access token is planted immediately.
818
+ * Finish a WebAuthn / passkey AUTHENTICATION ceremony. Forwards the opaque
819
+ * browser `AuthenticationResponseJSON` (`response`) alongside the
820
+ * device-session envelope. Resolves to the SAME {@link LoginResult} contract
821
+ * as `POST /auth/verify`; the access token is planted immediately, and the
822
+ * response's `deviceId` + `deviceSecret` are the zero-cookie restore
823
+ * credential.
827
824
  */
828
- async completeTwoFactorSignIn(params) {
825
+ async webauthnLoginVerify(response, envelope = {}) {
829
826
  try {
830
- const res = await this.makeRequest('POST', '/security/2fa/verify-login', {
831
- loginToken: params.loginToken,
832
- token: params.token,
833
- backupCode: params.backupCode,
834
- deviceName: params.deviceName,
835
- ...(params.deviceId ? { deviceId: params.deviceId } : {}),
836
- }, { cache: false });
827
+ const res = await this.makeRequest('POST', '/auth/webauthn/login/verify', { response, ...envelope }, { cache: false });
837
828
  const parsed = safeParseContract(loginResultSchema, res);
838
- if (!parsed || 'twoFactorRequired' in parsed) {
839
- throw new Error('security/2fa/verify-login returned an unexpected response shape');
829
+ if (!parsed) {
830
+ throw new Error('auth/webauthn/login/verify returned an unexpected response shape');
840
831
  }
841
832
  if (parsed.accessToken) {
842
833
  this.setTokens(parsed.accessToken);
@@ -849,7 +840,7 @@ export function OxyServicesAuthMixin(Base) {
849
840
  }
850
841
  /**
851
842
  * Exchange an OAuth authorization code (returned to the RP redirect URI
852
- * after password sign-in at auth.oxy.so) for a device-first session.
843
+ * after sign-in at auth.oxy.so) for a device-first session.
853
844
  * Public first-party clients use PKCE (`codeVerifier`); the access token is
854
845
  * planted immediately on success.
855
846
  */
@@ -100,15 +100,15 @@ export function OxyServicesIdentityMixin(Base) {
100
100
  }
101
101
  }
102
102
  /**
103
- * Link password authentication to the current account. Adds a `password`
104
- * auth method (does not remove existing methods).
103
+ * Unlink an authentication method from the current account. The server
104
+ * refuses to remove the last remaining method (the account would become
105
+ * inaccessible). Unlinking `identity` downgrades the account to custodial.
105
106
  *
106
- * @param email - The email to associate with password auth.
107
- * @param password - The new password (server enforces strength rules).
107
+ * @param type - The auth-method type to remove.
108
108
  */
109
- async linkPassword(email, password) {
109
+ async unlinkAuthMethod(type) {
110
110
  try {
111
- const result = await this.makeRequest('POST', '/auth/link', { type: 'password', email, password }, { cache: false });
111
+ const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(type)}`, undefined, { cache: false });
112
112
  this._invalidateIdentityCaches(this.getCurrentUserId());
113
113
  return result;
114
114
  }
@@ -117,15 +117,19 @@ export function OxyServicesIdentityMixin(Base) {
117
117
  }
118
118
  }
119
119
  /**
120
- * Unlink an authentication method from the current account. The server
121
- * refuses to remove the last remaining method (the account would become
122
- * inaccessible). Unlinking `identity` downgrades the account to custodial.
120
+ * Remove ONE passkey (WebAuthn credential) from the current account.
123
121
  *
124
- * @param type - The auth-method type to remove.
122
+ * Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
123
+ * removes an auth method by type) this targets a specific credential id.
124
+ * The server refuses to remove the last remaining auth method (the account
125
+ * would become inaccessible) and deletes the stored `WebauthnCredential`.
126
+ *
127
+ * @param credentialId - The passkey's public credential id
128
+ * (`AuthMethodEntry.credentialId`).
125
129
  */
126
- async unlinkAuthMethod(type) {
130
+ async removePasskey(credentialId) {
127
131
  try {
128
- const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(type)}`, undefined, { cache: false });
132
+ const result = await this.makeRequest('DELETE', `/auth/link/webauthn/${encodeURIComponent(credentialId)}`, undefined, { cache: false });
129
133
  this._invalidateIdentityCaches(this.getCurrentUserId());
130
134
  return result;
131
135
  }
@@ -21,14 +21,11 @@
21
21
  * `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
22
22
  * via `startCommonsSignIn` → poll → `claimSessionByToken`).
23
23
  *
24
- * It deliberately owns NO password/2FA logic those live at the IdP
25
- * (auth.oxy.so). {@link AccountDialogController.openPasswordAtOxyAuth} only
26
- * builds the hand-off URL; device-first convergence syncs the session back.
24
+ * Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff
25
+ * password, social login, and 2FA were removed ecosystem-wide.
27
26
  */
28
27
  import { logger } from '../logger/index.js';
29
28
  import { extractErrorStatus } from '../utils/errorUtils.js';
30
- import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
31
- import { generateOAuthState, generatePkcePair, normalizeOAuthRedirectUri, persistOAuthHandshake, } from '../utils/oauthPkce.js';
32
29
  import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
33
30
  /**
34
31
  * Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
@@ -95,8 +92,6 @@ export class AccountDialogController {
95
92
  this.locale = options.locale;
96
93
  this.commitSession = options.commitSession;
97
94
  this.onSignedIn = options.onSignedIn;
98
- this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
99
- this.authRedirectUri = options.authRedirectUri ?? null;
100
95
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
101
96
  this.openUrl = options.openUrl;
102
97
  this.canOpenApp = options.canOpenApp;
@@ -485,46 +480,6 @@ export class AccountDialogController {
485
480
  this.setSignIn(IDLE_SIGN_IN);
486
481
  }
487
482
  }
488
- /**
489
- * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
490
- * password sign-in URL. Password + 2FA are NOT in the SDK — they live at the
491
- * IdP; this only hands off. Device-first: after login at the IdP the device
492
- * session converges and the caller is woken via the device socket /
493
- * `BroadcastChannel`, so the URL only needs to point at the IdP sign-in with
494
- * the right return.
495
- *
496
- * @param params.returnUrl - Where the IdP returns after login. Defaults to the
497
- * current document URL on web (`globalThis.location.href`); pass explicitly
498
- * on native (no `location`).
499
- * @param params.state - Optional opaque state echoed back on return.
500
- * @returns The absolute auth.oxy.so sign-in URL.
501
- */
502
- async openPasswordAtOxyAuth(params = {}) {
503
- const base = `https://auth.${this.idpApex}`;
504
- const url = new URL('/login', base);
505
- const rawRedirect = params.redirectUri ??
506
- this.authRedirectUri ??
507
- params.returnUrl ??
508
- currentLocationOrigin();
509
- const redirectUri = rawRedirect ? normalizeOAuthRedirectUri(rawRedirect) : '';
510
- if (redirectUri) {
511
- url.searchParams.set('redirect_uri', redirectUri);
512
- }
513
- if (this.clientId) {
514
- url.searchParams.set('client_id', this.clientId);
515
- }
516
- const state = params.state ?? (await generateOAuthState());
517
- url.searchParams.set('state', state);
518
- const { codeChallenge, codeVerifier } = await generatePkcePair();
519
- url.searchParams.set('code_challenge', codeChallenge);
520
- url.searchParams.set('code_challenge_method', 'S256');
521
- if (!persistOAuthHandshake(state, codeVerifier)) {
522
- throw new Error('Could not persist OAuth handshake for password sign-in');
523
- }
524
- const href = url.toString();
525
- this.openUrl?.(href);
526
- return href;
527
- }
528
483
  // =========================================================================
529
484
  // Internal sign-in helpers
530
485
  // =========================================================================
@@ -772,11 +727,3 @@ export class AccountDialogController {
772
727
  export function createAccountDialogController(options) {
773
728
  return new AccountDialogController(options);
774
729
  }
775
- // ---------------------------------------------------------------------------
776
- // Local helpers
777
- // ---------------------------------------------------------------------------
778
- /** Current document origin on web; empty string where `location` is absent (native/SSR). */
779
- function currentLocationOrigin() {
780
- const location = globalThis.location;
781
- return typeof location?.origin === 'string' ? location.origin : '';
782
- }
@@ -37,7 +37,12 @@ export function isIdpHubOrigin() {
37
37
  return false;
38
38
  }
39
39
  }
40
- function isLoopbackOrigin(origin) {
40
+ /**
41
+ * Whether an origin is a loopback / local-dev origin (`localhost`, `127.0.0.1`,
42
+ * or `[::1]` on any port, http or https). Local dev must never be bounced to a
43
+ * hosted IdP for cross-origin session restore.
44
+ */
45
+ export function isLoopbackOrigin(origin) {
41
46
  try {
42
47
  const parsed = new URL(origin);
43
48
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * WebAuthn relying-party origin guard (client side).
3
+ *
4
+ * The passkey ceremonies (`OxyServices.webauthn*`) are only meaningful when the
5
+ * page is served from a first-party Oxy web origin: a credential minted with
6
+ * `WEBAUTHN_RP_ID=oxy.so` can only be created/asserted from `oxy.so`, one of its
7
+ * subdomains, or a loopback dev server. This is the browser-side mirror of the
8
+ * server's `isOxyApexOrigin` (`packages/api/src/utils/origin.ts`), which forms
9
+ * the server's `expectedOrigin` allow-set — consumers use it to decide whether to
10
+ * even offer the passkey UI on the current page.
11
+ *
12
+ * It reads `globalThis.location` directly (no argument) because that is the only
13
+ * origin the browser will let a WebAuthn ceremony run against. On native / SSR /
14
+ * any environment without a DOM `location`, it returns `false` (there is no
15
+ * relying-party origin, so passkeys are not applicable).
16
+ */
17
+ /**
18
+ * True iff the current page's host is a first-party Oxy relying-party origin:
19
+ * `oxy.so`, any `*.oxy.so` subdomain, or a loopback dev host
20
+ * (`localhost` / `127.0.0.1` / `[::1]`).
21
+ *
22
+ * Fails closed: no `location` (native/SSR), a non-string/empty hostname, or a
23
+ * host that merely ends in the literal `oxy.so` without the dot boundary
24
+ * (`evil-oxy.so`, `oxy.so.evil.com`) all return `false`.
25
+ *
26
+ * @example
27
+ * isOxyRpOrigin() // true on https://accounts.oxy.so
28
+ * isOxyRpOrigin() // true on http://localhost:8081
29
+ * isOxyRpOrigin() // false on https://evil.com
30
+ * isOxyRpOrigin() // false in a React Native / SSR context (no location)
31
+ */
32
+ export function isOxyRpOrigin() {
33
+ // `globalThis.location` is typed `Location` by the DOM lib, but is genuinely
34
+ // `undefined` on native/SSR — widen to a nullable local so the guard is a real
35
+ // runtime check, not a type-level no-op.
36
+ const location = globalThis.location;
37
+ if (!location || typeof location.hostname !== 'string') {
38
+ return false;
39
+ }
40
+ const hostname = location.hostname.toLowerCase();
41
+ if (hostname.length === 0) {
42
+ return false;
43
+ }
44
+ if (hostname === 'oxy.so' || hostname.endsWith('.oxy.so')) {
45
+ return true;
46
+ }
47
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
48
+ }