@oxyhq/services 13.1.2 → 13.1.4

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 (43) hide show
  1. package/lib/commonjs/index.js +1 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/ProfileButton.js +126 -11
  4. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +45 -17
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/index.js.map +1 -1
  8. package/lib/commonjs/ui/screens/NotificationsScreen.js +1 -1
  9. package/lib/commonjs/ui/screens/NotificationsScreen.js.map +1 -1
  10. package/lib/commonjs/ui/utils/activeAuthuser.js +35 -14
  11. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  12. package/lib/module/index.js +1 -0
  13. package/lib/module/index.js.map +1 -1
  14. package/lib/module/ui/components/ProfileButton.js +126 -11
  15. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  16. package/lib/module/ui/context/OxyContext.js +45 -17
  17. package/lib/module/ui/context/OxyContext.js.map +1 -1
  18. package/lib/module/ui/index.js +1 -0
  19. package/lib/module/ui/index.js.map +1 -1
  20. package/lib/module/ui/screens/NotificationsScreen.js +1 -1
  21. package/lib/module/ui/screens/NotificationsScreen.js.map +1 -1
  22. package/lib/module/ui/utils/activeAuthuser.js +35 -14
  23. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  24. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  29. package/lib/typescript/module/index.d.ts.map +1 -1
  30. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  31. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  32. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  33. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  34. package/lib/typescript/types/react-native-web-style.d.ts +27 -0
  35. package/package.json +1 -1
  36. package/src/index.ts +1 -0
  37. package/src/types/react-native-web-style.d.ts +27 -0
  38. package/src/ui/components/ProfileButton.tsx +135 -11
  39. package/src/ui/context/OxyContext.tsx +45 -17
  40. package/src/ui/index.ts +1 -0
  41. package/src/ui/screens/NotificationsScreen.tsx +1 -1
  42. package/src/ui/utils/__tests__/activeAuthuser.test.ts +75 -0
  43. package/src/ui/utils/activeAuthuser.ts +34 -14
@@ -1334,16 +1334,27 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1334
1334
  // to the cross-domain fallbacks exactly as before.
1335
1335
  const prioritizeMultiAccount = isWebBrowser() && readActiveAuthuser() !== null;
1336
1336
 
1337
- // DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", the
1338
- // central IdP session (FedCM credential association / per-apex `fedcm_session`
1339
- // cookie) can still be live, so the AUTOMATIC silent steps below
1340
- // (`fedcm-silent`, `silent-iframe`) would re-mint a session on the very next
1341
- // cold boot and sign the user back in without intent. Read the durable flag
1342
- // ONCE here (synchronously usable by the step `enabled` gates) and skip those
1343
- // two steps while it is set. Any deliberate sign-in clears it. The `sso-bounce`
1344
- // step is already self-suppressed after sign-out (its `hasPriorSession` hint is
1345
- // cleared), and the local restore steps (`stored-session`, cookie-restore) are
1346
- // unaffected a deliberate sign-out clears those credentials anyway.
1337
+ // DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", any
1338
+ // credential that can silently re-mint a session may still be live on the
1339
+ // next reload the central IdP session (FedCM credential association /
1340
+ // per-apex `fedcm_session` cookie) AND the device refresh cookies. Since
1341
+ // PR #455 the PRIMARY web session also joins the `oxy_rt_<authuser>` device
1342
+ // set, so the un-gated cookie-restore steps would `refresh-all` that still-
1343
+ // present cookie and sign the user back in without intent exactly the
1344
+ // "sign-out is silently undone" regression. So this flag gates EVERY
1345
+ // AUTOMATIC silent-restore cold-boot step: `stored-session`,
1346
+ // `cookie-restore-active`, `fedcm-silent`, `silent-iframe`, and
1347
+ // `cookie-restore`. The gate — not a cookie wipe — is the authority: the
1348
+ // account may stay "known" for a fast deliberate re-sign-in, but no step
1349
+ // restores it silently while the flag is set. Read the durable flag ONCE here
1350
+ // (synchronously usable by every step `enabled` gate). Any deliberate sign-in
1351
+ // (password, FedCM, account switch, device claim) clears it, so there is no
1352
+ // "stuck signed out" state. The `sso-bounce` step needs no extra gate: it is
1353
+ // already self-suppressed after sign-out (its `hasPriorSession` hint is
1354
+ // cleared). `sso-return` is NOT gated — it commits the result of a deliberate
1355
+ // top-level `/sso` bounce the user just initiated. `shared-key-signin` is
1356
+ // native-only and the flag never sets on native (`markSignedOut` no-ops
1357
+ // off-web), so its gate would be moot; it is left untouched.
1347
1358
  const silentRestoreBlocked = isSilentRestoreSuppressed();
1348
1359
 
1349
1360
  try {
@@ -1363,12 +1374,17 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1363
1374
  },
1364
1375
  },
1365
1376
  {
1366
- // 2) Stored-session bearer restore. NO `enabled` gate runs on ALL
1367
- // platforms. This is native's ONLY restore path (every web-only step
1368
- // is disabled off-browser, so native reaches exactly this) AND the
1369
- // common WEB reload winner.
1377
+ // 2) Stored-session bearer restore. Runs on ALL platforms EXCEPT when
1378
+ // the deliberately-signed-out flag is set (web only it never sets on
1379
+ // native, so native still always reaches exactly this step, its ONLY
1380
+ // restore path). This is also the common WEB reload winner.
1370
1381
  //
1371
- // ORDERING (FIX A): this step now runs BEFORE the slow web-only
1382
+ // SIGNED-OUT GATE: after a deliberate full sign-out the stored session
1383
+ // state is cleared, so this would normally skip anyway — but the gate
1384
+ // makes that authoritative rather than incidental, so no residual
1385
+ // stored token can silently restore while the user is signed out.
1386
+ //
1387
+ // ORDERING (FIX A): this step runs BEFORE the slow web-only
1372
1388
  // probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
1373
1389
  // normal reload the local bearer validates in one round-trip and
1374
1390
  // wins; `runColdBoot` then short-circuits and never even evaluates
@@ -1379,6 +1395,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1379
1395
  // local session this step skips and the cross-domain fallback chain
1380
1396
  // (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
1381
1397
  id: 'stored-session',
1398
+ enabled: () => !silentRestoreBlocked,
1382
1399
  run: async () => {
1383
1400
  const restored = await restoreStoredSession();
1384
1401
  if (restored) {
@@ -1455,8 +1472,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1455
1472
  // When no slot is persisted this is disabled and the original order is
1456
1473
  // unchanged; when the slot's cookies have lapsed it returns no accounts
1457
1474
  // and the chain falls through to the cross-domain fallbacks below.
1475
+ //
1476
+ // SIGNED-OUT GATE: since PR #455 the primary's `oxy_rt` slot survives a
1477
+ // deliberate sign-out, so without this gate `refresh-all` would re-mint
1478
+ // and silently re-log-in the user on reload. The flag is cleared by any
1479
+ // deliberate sign-in (including an account SWITCH, which also writes the
1480
+ // new active slot), so the switch-survives-reload path is unaffected.
1458
1481
  id: 'cookie-restore-active',
1459
- enabled: () => prioritizeMultiAccount,
1482
+ enabled: () => prioritizeMultiAccount && !silentRestoreBlocked,
1460
1483
  run: async () => {
1461
1484
  const restored = await restoreViaRefreshCookie();
1462
1485
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
@@ -1546,8 +1569,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1546
1569
  // would be a redundant second `refreshAllSessions`. It still runs in the
1547
1570
  // common no-persisted-slot case (e.g. first visit to a first-party app
1548
1571
  // whose central refresh cookies already exist).
1572
+ //
1573
+ // SIGNED-OUT GATE (same rationale as `cookie-restore-active`): the
1574
+ // primary's `oxy_rt` slot survives a deliberate sign-out (PR #455), so
1575
+ // this must be skipped while the signed-out flag is set or it would
1576
+ // silently re-restore the primary on reload.
1549
1577
  id: 'cookie-restore',
1550
- enabled: () => isWebBrowser() && !prioritizeMultiAccount,
1578
+ enabled: () => isWebBrowser() && !prioritizeMultiAccount && !silentRestoreBlocked,
1551
1579
  run: async () => {
1552
1580
  const restored = await restoreViaRefreshCookie();
1553
1581
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
package/src/ui/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  /// <reference path="../types/react-native-classname.d.ts" />
16
+ /// <reference path="../types/react-native-web-style.d.ts" />
16
17
 
17
18
  // Components
18
19
  export { default as OxyProvider } from './components/OxyProvider';
@@ -159,7 +159,7 @@ const NotificationsScreen: React.FC<BaseScreenProps> = ({ onClose, goBack }) =>
159
159
  <SettingsListItem
160
160
  icon={
161
161
  <SettingsIcon
162
- name="megaphone"
162
+ name="bullhorn"
163
163
  color={bloomTheme.colors.secondary}
164
164
  />
165
165
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Fail-safe guarantees for the web `activeAuthuser` storage helpers.
3
+ *
4
+ * The PROPERTY ACCESS `window.localStorage` can throw a `SecurityError`
5
+ * synchronously in opaque-origin / sandboxed iframes or when storage is
6
+ * disabled — not just `getItem`/`setItem`. These helpers run during cold boot
7
+ * (and feed render-phase gate values in the providers), so they MUST never
8
+ * propagate that throw: reads fail safe to a benign default and writes no-op.
9
+ */
10
+
11
+ import {
12
+ readActiveAuthuser,
13
+ writeActiveAuthuser,
14
+ clearActiveAuthuser,
15
+ markSignedOut,
16
+ clearSignedOut,
17
+ isSilentRestoreSuppressed,
18
+ } from '../activeAuthuser';
19
+
20
+ describe('activeAuthuser helpers — localStorage fail-safe', () => {
21
+ const realDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
22
+
23
+ afterEach(() => {
24
+ if (realDescriptor) {
25
+ Object.defineProperty(window, 'localStorage', realDescriptor);
26
+ }
27
+ window.localStorage?.clear?.();
28
+ });
29
+
30
+ /** Replace the `localStorage` accessor so reading the property throws. */
31
+ function makeLocalStorageGetterThrow(): void {
32
+ Object.defineProperty(window, 'localStorage', {
33
+ configurable: true,
34
+ get() {
35
+ throw new DOMException('denied', 'SecurityError');
36
+ },
37
+ });
38
+ }
39
+
40
+ it('readActiveAuthuser returns null (never throws) when the getter throws', () => {
41
+ makeLocalStorageGetterThrow();
42
+ expect(() => readActiveAuthuser()).not.toThrow();
43
+ expect(readActiveAuthuser()).toBeNull();
44
+ });
45
+
46
+ it('isSilentRestoreSuppressed returns false (never throws) when the getter throws', () => {
47
+ makeLocalStorageGetterThrow();
48
+ expect(() => isSilentRestoreSuppressed()).not.toThrow();
49
+ expect(isSilentRestoreSuppressed()).toBe(false);
50
+ });
51
+
52
+ it('writeActiveAuthuser / clearActiveAuthuser / markSignedOut / clearSignedOut no-op when the getter throws', () => {
53
+ makeLocalStorageGetterThrow();
54
+ expect(() => {
55
+ writeActiveAuthuser(2);
56
+ clearActiveAuthuser();
57
+ markSignedOut();
58
+ clearSignedOut();
59
+ }).not.toThrow();
60
+ });
61
+
62
+ it('round-trips normally when storage works (zero behavior change)', () => {
63
+ writeActiveAuthuser(3);
64
+ expect(readActiveAuthuser()).toBe(3);
65
+
66
+ expect(isSilentRestoreSuppressed()).toBe(false);
67
+ markSignedOut();
68
+ expect(isSilentRestoreSuppressed()).toBe(true);
69
+ clearSignedOut();
70
+ expect(isSilentRestoreSuppressed()).toBe(false);
71
+
72
+ clearActiveAuthuser();
73
+ expect(readActiveAuthuser()).toBeNull();
74
+ });
75
+ });
@@ -30,8 +30,22 @@ import {
30
30
 
31
31
  const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
32
32
 
33
- function hasLocalStorage(): boolean {
34
- return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
33
+ /**
34
+ * Safely resolve `window.localStorage`, returning `null` when it is
35
+ * unavailable. The PROPERTY ACCESS itself (`window.localStorage`) can throw a
36
+ * `SecurityError` synchronously in opaque-origin / sandboxed iframes or when
37
+ * storage is disabled — even `typeof window.localStorage` evaluates the getter
38
+ * and throws. Every read/write in this module routes through here so the getter
39
+ * throw is caught once, at the source, and callers stay clean. Returns `null`
40
+ * off-web and on any access failure (fail safe).
41
+ */
42
+ function getLocalStorage(): Storage | null {
43
+ if (typeof window === 'undefined') return null;
44
+ try {
45
+ return window.localStorage ?? null;
46
+ } catch {
47
+ return null;
48
+ }
35
49
  }
36
50
 
37
51
  function getSessionStorage(): Storage | null {
@@ -51,11 +65,12 @@ function getSessionStorage(): Storage | null {
51
65
  * fall back to deterministic selection (lowest authuser).
52
66
  */
53
67
  export function readActiveAuthuser(): number | null {
54
- if (!hasLocalStorage()) {
68
+ const storage = getLocalStorage();
69
+ if (!storage) {
55
70
  return null;
56
71
  }
57
72
  try {
58
- const raw = window.localStorage.getItem(ACTIVE_AUTHUSER_KEY);
73
+ const raw = storage.getItem(ACTIVE_AUTHUSER_KEY);
59
74
  if (raw === null) return null;
60
75
  const parsed = Number.parseInt(raw, 10);
61
76
  if (!Number.isFinite(parsed) || parsed < 0) return null;
@@ -71,10 +86,11 @@ export function readActiveAuthuser(): number | null {
71
86
  * this succeeding — it is best-effort UX persistence, not authoritative.
72
87
  */
73
88
  export function writeActiveAuthuser(authuser: number): void {
74
- if (!hasLocalStorage()) return;
75
89
  if (!Number.isFinite(authuser) || authuser < 0) return;
90
+ const storage = getLocalStorage();
91
+ if (!storage) return;
76
92
  try {
77
- window.localStorage.setItem(ACTIVE_AUTHUSER_KEY, String(authuser));
93
+ storage.setItem(ACTIVE_AUTHUSER_KEY, String(authuser));
78
94
  } catch {
79
95
  // Best-effort persistence; swallow QuotaExceededError / SecurityError.
80
96
  }
@@ -86,9 +102,10 @@ export function writeActiveAuthuser(authuser: number): void {
86
102
  * cleared slot.
87
103
  */
88
104
  export function clearActiveAuthuser(): void {
89
- if (!hasLocalStorage()) return;
105
+ const storage = getLocalStorage();
106
+ if (!storage) return;
90
107
  try {
91
- window.localStorage.removeItem(ACTIVE_AUTHUSER_KEY);
108
+ storage.removeItem(ACTIVE_AUTHUSER_KEY);
92
109
  } catch {
93
110
  // Best-effort.
94
111
  }
@@ -103,9 +120,10 @@ export function clearActiveAuthuser(): void {
103
120
  * failure (best-effort).
104
121
  */
105
122
  export function markSignedOut(): void {
106
- if (!hasLocalStorage()) return;
123
+ const storage = getLocalStorage();
124
+ if (!storage) return;
107
125
  try {
108
- window.localStorage.setItem(ssoSignedOutKey(window.location.origin), '1');
126
+ storage.setItem(ssoSignedOutKey(window.location.origin), '1');
109
127
  } catch {
110
128
  // Best-effort; swallow QuotaExceededError / SecurityError (private mode).
111
129
  }
@@ -118,9 +136,10 @@ export function markSignedOut(): void {
118
136
  * state. No-ops on native / storage failure.
119
137
  */
120
138
  export function clearSignedOut(): void {
121
- if (!hasLocalStorage()) return;
139
+ const storage = getLocalStorage();
140
+ if (!storage) return;
122
141
  try {
123
- window.localStorage.removeItem(ssoSignedOutKey(window.location.origin));
142
+ storage.removeItem(ssoSignedOutKey(window.location.origin));
124
143
  } catch {
125
144
  // Best-effort.
126
145
  }
@@ -134,8 +153,9 @@ export function clearSignedOut(): void {
134
153
  * `fedcm-silent` and `silent-iframe` cold-boot steps.
135
154
  */
136
155
  export function isSilentRestoreSuppressed(): boolean {
137
- if (!hasLocalStorage()) return false;
138
- return silentRestoreSuppressed(window.localStorage, window.location.origin);
156
+ const storage = getLocalStorage();
157
+ if (!storage) return false;
158
+ return silentRestoreSuppressed(storage, window.location.origin);
139
159
  }
140
160
 
141
161
  /**