@oxyhq/services 23.2.0 → 24.0.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.
Files changed (172) hide show
  1. package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +21 -127
  2. package/android/src/main/java/so/oxy/session/OxyBackgroundSession.kt +184 -0
  3. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionApi.kt +162 -0
  4. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionModule.kt +101 -0
  5. package/android/src/main/java/so/oxy/session/OxyBackgroundSessionStore.kt +179 -0
  6. package/android/src/main/java/so/oxy/session/OxyBackgroundToken.kt +61 -0
  7. package/android/src/main/java/so/oxy/storage/OxyEncryptedPrefs.kt +241 -0
  8. package/expo-module.config.json +4 -1
  9. package/lib/commonjs/ui/components/OxyProvider.js +3 -1
  10. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  11. package/lib/commonjs/ui/components/ProfileButton.js +1 -1
  12. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
  13. package/lib/commonjs/ui/components/internal/PinInput.js +3 -1
  14. package/lib/commonjs/ui/components/internal/PinInput.js.map +1 -1
  15. package/lib/commonjs/ui/components/logo/LogoText.js +4 -4
  16. package/lib/commonjs/ui/components/logo/LogoText.js.map +1 -1
  17. package/lib/commonjs/ui/components/payment/PaymentSummaryStep.js +2 -2
  18. package/lib/commonjs/ui/components/payment/PaymentSummaryStep.js.map +1 -1
  19. package/lib/commonjs/ui/context/OxyContext.js +13 -0
  20. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  21. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +2 -3
  22. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  23. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js +2 -3
  24. package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -1
  25. package/lib/commonjs/ui/screens/EditProfileScreen.js +2 -3
  26. package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -1
  27. package/lib/commonjs/ui/screens/FeedbackScreen.js +3 -1
  28. package/lib/commonjs/ui/screens/FeedbackScreen.js.map +1 -1
  29. package/lib/commonjs/ui/screens/ManageAccountScreen.js +1 -1
  30. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  31. package/lib/commonjs/ui/screens/PremiumSubscriptionScreen.js +2 -2
  32. package/lib/commonjs/ui/screens/PremiumSubscriptionScreen.js.map +1 -1
  33. package/lib/commonjs/ui/screens/PrivacySettingsScreen.js +8 -9
  34. package/lib/commonjs/ui/screens/PrivacySettingsScreen.js.map +1 -1
  35. package/lib/commonjs/ui/screens/ProfileScreen.js +1 -1
  36. package/lib/commonjs/ui/screens/ProfileScreen.js.map +1 -1
  37. package/lib/commonjs/ui/screens/UserListScreen.js +2 -3
  38. package/lib/commonjs/ui/screens/UserListScreen.js.map +1 -1
  39. package/lib/commonjs/ui/screens/trust/TrustCenterScreen.js +1 -1
  40. package/lib/commonjs/ui/screens/trust/TrustCenterScreen.js.map +1 -1
  41. package/lib/commonjs/ui/screens/trust/TrustLeaderboardScreen.js +2 -3
  42. package/lib/commonjs/ui/screens/trust/TrustLeaderboardScreen.js.map +1 -1
  43. package/lib/commonjs/ui/screens/trust/TrustRewardsScreen.js +1 -1
  44. package/lib/commonjs/ui/screens/trust/TrustRewardsScreen.js.map +1 -1
  45. package/lib/commonjs/ui/session/backgroundSession.js +264 -0
  46. package/lib/commonjs/ui/session/backgroundSession.js.map +1 -0
  47. package/lib/commonjs/ui/session/index.js +7 -0
  48. package/lib/commonjs/ui/session/index.js.map +1 -1
  49. package/lib/commonjs/ui/session/useBackgroundSessionSync.js +81 -0
  50. package/lib/commonjs/ui/session/useBackgroundSessionSync.js.map +1 -0
  51. package/lib/commonjs/ui/utils/userUtils.js +31 -22
  52. package/lib/commonjs/ui/utils/userUtils.js.map +1 -1
  53. package/lib/module/ui/components/OxyProvider.js +3 -1
  54. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  55. package/lib/module/ui/components/ProfileButton.js +2 -2
  56. package/lib/module/ui/components/ProfileButton.js.map +1 -1
  57. package/lib/module/ui/components/internal/PinInput.js +3 -1
  58. package/lib/module/ui/components/internal/PinInput.js.map +1 -1
  59. package/lib/module/ui/components/logo/LogoText.js +4 -4
  60. package/lib/module/ui/components/logo/LogoText.js.map +1 -1
  61. package/lib/module/ui/components/payment/PaymentSummaryStep.js +2 -2
  62. package/lib/module/ui/components/payment/PaymentSummaryStep.js.map +1 -1
  63. package/lib/module/ui/context/OxyContext.js +14 -1
  64. package/lib/module/ui/context/OxyContext.js.map +1 -1
  65. package/lib/module/ui/screens/AccountSettingsScreen.js +2 -3
  66. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  67. package/lib/module/ui/screens/ChangeAvatarScreen.js +3 -4
  68. package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -1
  69. package/lib/module/ui/screens/EditProfileScreen.js +3 -4
  70. package/lib/module/ui/screens/EditProfileScreen.js.map +1 -1
  71. package/lib/module/ui/screens/FeedbackScreen.js +3 -1
  72. package/lib/module/ui/screens/FeedbackScreen.js.map +1 -1
  73. package/lib/module/ui/screens/ManageAccountScreen.js +2 -2
  74. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  75. package/lib/module/ui/screens/PremiumSubscriptionScreen.js +2 -2
  76. package/lib/module/ui/screens/PremiumSubscriptionScreen.js.map +1 -1
  77. package/lib/module/ui/screens/PrivacySettingsScreen.js +9 -10
  78. package/lib/module/ui/screens/PrivacySettingsScreen.js.map +1 -1
  79. package/lib/module/ui/screens/ProfileScreen.js +1 -1
  80. package/lib/module/ui/screens/ProfileScreen.js.map +1 -1
  81. package/lib/module/ui/screens/UserListScreen.js +2 -3
  82. package/lib/module/ui/screens/UserListScreen.js.map +1 -1
  83. package/lib/module/ui/screens/trust/TrustCenterScreen.js +1 -1
  84. package/lib/module/ui/screens/trust/TrustCenterScreen.js.map +1 -1
  85. package/lib/module/ui/screens/trust/TrustLeaderboardScreen.js +2 -3
  86. package/lib/module/ui/screens/trust/TrustLeaderboardScreen.js.map +1 -1
  87. package/lib/module/ui/screens/trust/TrustRewardsScreen.js +1 -1
  88. package/lib/module/ui/screens/trust/TrustRewardsScreen.js.map +1 -1
  89. package/lib/module/ui/session/backgroundSession.js +258 -0
  90. package/lib/module/ui/session/backgroundSession.js.map +1 -0
  91. package/lib/module/ui/session/index.js +1 -0
  92. package/lib/module/ui/session/index.js.map +1 -1
  93. package/lib/module/ui/session/useBackgroundSessionSync.js +76 -0
  94. package/lib/module/ui/session/useBackgroundSessionSync.js.map +1 -0
  95. package/lib/module/ui/utils/userUtils.js +31 -22
  96. package/lib/module/ui/utils/userUtils.js.map +1 -1
  97. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/components/internal/PinInput.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +22 -0
  102. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/screens/FeedbackScreen.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/ui/screens/PrivacySettingsScreen.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/ProfileScreen.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/screens/trust/trustTier.d.ts +1 -1
  110. package/lib/typescript/commonjs/ui/screens/trust/trustTier.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/session/backgroundSession.d.ts +42 -0
  112. package/lib/typescript/commonjs/ui/session/backgroundSession.d.ts.map +1 -0
  113. package/lib/typescript/commonjs/ui/session/index.d.ts +1 -0
  114. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/session/useBackgroundSessionSync.d.ts +23 -0
  116. package/lib/typescript/commonjs/ui/session/useBackgroundSessionSync.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/ui/types/navigation.d.ts +7 -0
  118. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/ui/utils/userUtils.d.ts +7 -12
  120. package/lib/typescript/commonjs/ui/utils/userUtils.d.ts.map +1 -1
  121. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/components/internal/PinInput.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +22 -0
  126. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -1
  128. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/screens/FeedbackScreen.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/screens/PrivacySettingsScreen.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/screens/ProfileScreen.d.ts.map +1 -1
  133. package/lib/typescript/module/ui/screens/trust/trustTier.d.ts +1 -1
  134. package/lib/typescript/module/ui/screens/trust/trustTier.d.ts.map +1 -1
  135. package/lib/typescript/module/ui/session/backgroundSession.d.ts +42 -0
  136. package/lib/typescript/module/ui/session/backgroundSession.d.ts.map +1 -0
  137. package/lib/typescript/module/ui/session/index.d.ts +1 -0
  138. package/lib/typescript/module/ui/session/index.d.ts.map +1 -1
  139. package/lib/typescript/module/ui/session/useBackgroundSessionSync.d.ts +23 -0
  140. package/lib/typescript/module/ui/session/useBackgroundSessionSync.d.ts.map +1 -0
  141. package/lib/typescript/module/ui/types/navigation.d.ts +7 -0
  142. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/utils/userUtils.d.ts +7 -12
  144. package/lib/typescript/module/ui/utils/userUtils.d.ts.map +1 -1
  145. package/package.json +5 -5
  146. package/src/ui/components/OxyProvider.tsx +2 -0
  147. package/src/ui/components/ProfileButton.tsx +5 -2
  148. package/src/ui/components/internal/PinInput.tsx +1 -0
  149. package/src/ui/components/logo/LogoText.tsx +4 -4
  150. package/src/ui/components/payment/PaymentSummaryStep.tsx +2 -2
  151. package/src/ui/context/OxyContext.tsx +14 -0
  152. package/src/ui/context/oxyContextTypes.ts +22 -0
  153. package/src/ui/screens/AccountSettingsScreen.tsx +4 -4
  154. package/src/ui/screens/ChangeAvatarScreen.tsx +6 -3
  155. package/src/ui/screens/EditProfileScreen.tsx +6 -3
  156. package/src/ui/screens/FeedbackScreen.tsx +1 -0
  157. package/src/ui/screens/ManageAccountScreen.tsx +8 -1
  158. package/src/ui/screens/PremiumSubscriptionScreen.tsx +2 -2
  159. package/src/ui/screens/PrivacySettingsScreen.tsx +13 -11
  160. package/src/ui/screens/ProfileScreen.tsx +3 -1
  161. package/src/ui/screens/UserListScreen.tsx +2 -2
  162. package/src/ui/screens/trust/TrustCenterScreen.tsx +2 -2
  163. package/src/ui/screens/trust/TrustLeaderboardScreen.tsx +3 -3
  164. package/src/ui/screens/trust/TrustRewardsScreen.tsx +1 -1
  165. package/src/ui/screens/trust/TrustRulesScreen.tsx +1 -1
  166. package/src/ui/screens/trust/trustTier.ts +1 -1
  167. package/src/ui/session/__tests__/backgroundSession.test.ts +387 -0
  168. package/src/ui/session/backgroundSession.ts +299 -0
  169. package/src/ui/session/index.ts +1 -0
  170. package/src/ui/session/useBackgroundSessionSync.ts +92 -0
  171. package/src/ui/types/navigation.ts +7 -0
  172. package/src/ui/utils/userUtils.ts +34 -20
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The background credential's ORDERING and REVOCATION rules, which are the parts
3
+ * that can leak one account's data to another or leave a live credential behind.
4
+ *
5
+ * The native store and the mint are exercised on a device (see the design note);
6
+ * what is tested here is every decision JS makes about when to clear, when to
7
+ * provision, and — most importantly — when NOT to write.
8
+ */
9
+ const requireOptionalNativeModule = jest.fn();
10
+
11
+ jest.mock('expo-modules-core', () => ({
12
+ requireOptionalNativeModule: (...args: unknown[]) => requireOptionalNativeModule(...args),
13
+ }));
14
+
15
+ interface NativeCalls {
16
+ order: string[];
17
+ put: jest.Mock;
18
+ clear: jest.Mock;
19
+ peek: jest.Mock;
20
+ }
21
+
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+
24
+ function fakeNative(peekValue: { accountId: string; expiresAt: number } | null): NativeCalls {
25
+ const order: string[] = [];
26
+ return {
27
+ order,
28
+ put: jest.fn(async () => {
29
+ order.push('put');
30
+ return true;
31
+ }),
32
+ clear: jest.fn(async () => {
33
+ order.push('clear');
34
+ return true;
35
+ }),
36
+ peek: jest.fn(async () => {
37
+ order.push('peek');
38
+ return peekValue;
39
+ }),
40
+ };
41
+ }
42
+
43
+ function fakeOxy(
44
+ provision: jest.Mock,
45
+ baseURL = 'https://api.oxy.so',
46
+ ): Parameters<typeof import('../backgroundSession').syncBackgroundSession>[0]['oxyServices'] {
47
+ return {
48
+ provisionBackgroundCredential: provision,
49
+ getBaseURL: () => baseURL,
50
+ } as unknown as Parameters<
51
+ typeof import('../backgroundSession').syncBackgroundSession
52
+ >[0]['oxyServices'];
53
+ }
54
+
55
+ /**
56
+ * Re-import the module per test: it memoises the resolved native module, which is
57
+ * correct in production (one resolution per process) and must not leak between
58
+ * cases here.
59
+ *
60
+ * `platform` is explicit because the react-native stub defaults `Platform.OS` to
61
+ * `'web'`, where this feature is gated OFF by design. Every case therefore has to
62
+ * say which platform it is describing — defaulting to `'android'` here, since that
63
+ * is the only platform the feature ships on.
64
+ */
65
+ async function loadModule(native: NativeCalls | null, platform: 'android' | 'web' = 'android') {
66
+ jest.resetModules();
67
+ requireOptionalNativeModule.mockClear();
68
+ requireOptionalNativeModule.mockReturnValue(
69
+ native ? { put: native.put, clear: native.clear, peek: native.peek } : null,
70
+ );
71
+ const { Platform } = await import('react-native');
72
+ Platform.OS = platform;
73
+ return import('../backgroundSession');
74
+ }
75
+
76
+ function provisioned(overrides: Partial<Record<string, unknown>> = {}) {
77
+ return {
78
+ deviceId: 'device-1',
79
+ secret: 'bg-secret',
80
+ accountId: 'user-1',
81
+ expiresAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ describe('syncBackgroundSession', () => {
87
+ test('signed out clears the credential and never calls the network', async () => {
88
+ const native = fakeNative({ accountId: 'user-1', expiresAt: Date.now() + 30 * DAY_MS });
89
+ const { syncBackgroundSession } = await loadModule(native);
90
+ const provision = jest.fn();
91
+
92
+ await syncBackgroundSession({
93
+ oxyServices: fakeOxy(provision),
94
+ userId: null,
95
+ canUsePrivateApi: true,
96
+ isCurrent: () => true,
97
+ });
98
+
99
+ expect(native.clear).toHaveBeenCalledTimes(1);
100
+ expect(provision).not.toHaveBeenCalled();
101
+ // Not even a read: signing out must not depend on anything succeeding first.
102
+ expect(native.peek).not.toHaveBeenCalled();
103
+ });
104
+
105
+ test('a credential for a DIFFERENT account is cleared BEFORE any network call', async () => {
106
+ const native = fakeNative({ accountId: 'previous-user', expiresAt: Date.now() + 30 * DAY_MS });
107
+ const { syncBackgroundSession } = await loadModule(native);
108
+ const provision = jest.fn(async () => {
109
+ native.order.push('provision');
110
+ return provisioned();
111
+ });
112
+
113
+ await syncBackgroundSession({
114
+ oxyServices: fakeOxy(provision),
115
+ userId: 'user-1',
116
+ canUsePrivateApi: true,
117
+ isCurrent: () => true,
118
+ });
119
+
120
+ // The ordering IS the guarantee: a crash after the clear leaves background
121
+ // code signed out, never serving the previous account's data.
122
+ expect(native.order).toEqual(['peek', 'clear', 'provision', 'put']);
123
+ });
124
+
125
+ test('a healthy credential for the signed-in account makes no network call', async () => {
126
+ const native = fakeNative({ accountId: 'user-1', expiresAt: Date.now() + 30 * DAY_MS });
127
+ const { syncBackgroundSession } = await loadModule(native);
128
+ const provision = jest.fn();
129
+
130
+ await syncBackgroundSession({
131
+ oxyServices: fakeOxy(provision),
132
+ userId: 'user-1',
133
+ canUsePrivateApi: true,
134
+ isCurrent: () => true,
135
+ });
136
+
137
+ expect(provision).not.toHaveBeenCalled();
138
+ expect(native.clear).not.toHaveBeenCalled();
139
+ expect(native.put).not.toHaveBeenCalled();
140
+ });
141
+
142
+ test('renews a credential inside the renewal window', async () => {
143
+ const native = fakeNative({ accountId: 'user-1', expiresAt: Date.now() + 2 * DAY_MS });
144
+ const { syncBackgroundSession } = await loadModule(native);
145
+ const expiresAt = new Date(Date.now() + 30 * DAY_MS).toISOString();
146
+ const provision = jest.fn(async () => provisioned({ expiresAt }));
147
+
148
+ await syncBackgroundSession({
149
+ oxyServices: fakeOxy(provision),
150
+ userId: 'user-1',
151
+ canUsePrivateApi: true,
152
+ isCurrent: () => true,
153
+ });
154
+
155
+ expect(provision).toHaveBeenCalledTimes(1);
156
+ expect(native.put).toHaveBeenCalledWith({
157
+ baseUrl: 'https://api.oxy.so',
158
+ deviceId: 'device-1',
159
+ secret: 'bg-secret',
160
+ accountId: 'user-1',
161
+ expiresAt: Date.parse(expiresAt),
162
+ });
163
+ // Same account, so nothing was revoked on the way.
164
+ expect(native.clear).not.toHaveBeenCalled();
165
+ });
166
+
167
+ test('waits for a bearer instead of provisioning without one', async () => {
168
+ const native = fakeNative(null);
169
+ const { syncBackgroundSession } = await loadModule(native);
170
+ const provision = jest.fn();
171
+
172
+ await syncBackgroundSession({
173
+ oxyServices: fakeOxy(provision),
174
+ userId: 'user-1',
175
+ canUsePrivateApi: false,
176
+ isCurrent: () => true,
177
+ });
178
+
179
+ expect(provision).not.toHaveBeenCalled();
180
+ expect(native.put).not.toHaveBeenCalled();
181
+ });
182
+
183
+ test('a not-yet-deployed endpoint (null) is quiet and writes nothing', async () => {
184
+ const native = fakeNative(null);
185
+ const { syncBackgroundSession } = await loadModule(native);
186
+ const provision = jest.fn(async () => null);
187
+
188
+ await syncBackgroundSession({
189
+ oxyServices: fakeOxy(provision),
190
+ userId: 'user-1',
191
+ canUsePrivateApi: true,
192
+ isCurrent: () => true,
193
+ });
194
+
195
+ expect(provision).toHaveBeenCalledTimes(1);
196
+ expect(native.put).not.toHaveBeenCalled();
197
+ expect(native.clear).not.toHaveBeenCalled();
198
+ });
199
+
200
+ test('a credential minted for another account mid-flight is dropped, not stored', async () => {
201
+ const native = fakeNative(null);
202
+ const { syncBackgroundSession } = await loadModule(native);
203
+ // The active account moved while the request was in flight, so the server
204
+ // answered for someone other than the account this run is syncing.
205
+ const provision = jest.fn(async () => provisioned({ accountId: 'other-user' }));
206
+
207
+ await syncBackgroundSession({
208
+ oxyServices: fakeOxy(provision),
209
+ userId: 'user-1',
210
+ canUsePrivateApi: true,
211
+ isCurrent: () => true,
212
+ });
213
+
214
+ expect(native.put).not.toHaveBeenCalled();
215
+ expect(native.clear).toHaveBeenCalledTimes(1);
216
+ });
217
+
218
+ test('a superseded run does not write the credential it fetched', async () => {
219
+ const native = fakeNative(null);
220
+ const { syncBackgroundSession } = await loadModule(native);
221
+ const provision = jest.fn(async () => provisioned());
222
+
223
+ await syncBackgroundSession({
224
+ oxyServices: fakeOxy(provision),
225
+ userId: 'user-1',
226
+ canUsePrivateApi: true,
227
+ // Superseded while the request was in flight (unmount, or a newer run).
228
+ isCurrent: () => false,
229
+ });
230
+
231
+ expect(native.put).not.toHaveBeenCalled();
232
+ });
233
+
234
+ test('an unparseable expiry is rejected rather than stored as garbage', async () => {
235
+ const native = fakeNative(null);
236
+ const { syncBackgroundSession } = await loadModule(native);
237
+ const provision = jest.fn(async () => provisioned({ expiresAt: 'not-a-date' }));
238
+
239
+ await syncBackgroundSession({
240
+ oxyServices: fakeOxy(provision),
241
+ userId: 'user-1',
242
+ canUsePrivateApi: true,
243
+ isCurrent: () => true,
244
+ });
245
+
246
+ expect(native.put).not.toHaveBeenCalled();
247
+ });
248
+
249
+ test('a provisioning failure never escapes to the caller', async () => {
250
+ const native = fakeNative(null);
251
+ const { syncBackgroundSession } = await loadModule(native);
252
+ const provision = jest.fn(async () => {
253
+ throw new Error('network down');
254
+ });
255
+
256
+ await expect(
257
+ syncBackgroundSession({
258
+ oxyServices: fakeOxy(provision),
259
+ userId: 'user-1',
260
+ canUsePrivateApi: true,
261
+ isCurrent: () => true,
262
+ }),
263
+ ).resolves.toBeUndefined();
264
+ expect(native.put).not.toHaveBeenCalled();
265
+ });
266
+ });
267
+
268
+ describe('a @oxyhq/core too old to provision', () => {
269
+ /**
270
+ * The ONE failure that must not be quiet. Every other failure here is caught and
271
+ * logged at warn, which is right for a flaky network — but a version skew means
272
+ * background refreshes never work at all, and hiding that behind a warning is how
273
+ * it would go unnoticed indefinitely.
274
+ */
275
+ function oxyWithoutProvisioning(): Parameters<
276
+ typeof import('../backgroundSession').syncBackgroundSession
277
+ >[0]['oxyServices'] {
278
+ return { getBaseURL: () => 'https://api.oxy.so' } as unknown as Parameters<
279
+ typeof import('../backgroundSession').syncBackgroundSession
280
+ >[0]['oxyServices'];
281
+ }
282
+
283
+ test('rejects loudly and logs at error rather than warning quietly', async () => {
284
+ const native = fakeNative(null);
285
+ const { syncBackgroundSession } = await loadModule(native);
286
+ const { logger } = await import('@oxyhq/core');
287
+ const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
288
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
289
+
290
+ try {
291
+ await expect(
292
+ syncBackgroundSession({
293
+ oxyServices: oxyWithoutProvisioning(),
294
+ userId: 'user-1',
295
+ canUsePrivateApi: true,
296
+ isCurrent: () => true,
297
+ }),
298
+ ).rejects.toThrow(/provisionBackgroundCredential/);
299
+
300
+ expect(errorSpy).toHaveBeenCalledTimes(1);
301
+ // If this ever becomes a warn, the skew is silent again.
302
+ expect(warnSpy).not.toHaveBeenCalled();
303
+ expect(native.put).not.toHaveBeenCalled();
304
+ } finally {
305
+ errorSpy.mockRestore();
306
+ warnSpy.mockRestore();
307
+ }
308
+ });
309
+
310
+ test('still lets a signed-out user clear, since that needs no core method', async () => {
311
+ const native = fakeNative({ accountId: 'user-1', expiresAt: Date.now() + 30 * DAY_MS });
312
+ const { syncBackgroundSession } = await loadModule(native);
313
+
314
+ // A sign-out must never be blocked by a dependency problem.
315
+ await expect(
316
+ syncBackgroundSession({
317
+ oxyServices: oxyWithoutProvisioning(),
318
+ userId: null,
319
+ canUsePrivateApi: false,
320
+ isCurrent: () => true,
321
+ }),
322
+ ).resolves.toBeUndefined();
323
+ expect(native.clear).toHaveBeenCalledTimes(1);
324
+ });
325
+ });
326
+
327
+ describe('on web', () => {
328
+ /**
329
+ * Web must never provision. The credential is non-rotating and long-lived, which
330
+ * is only safe because native background code is its sole consumer; a browser
331
+ * origin already holds the ROTATING deviceSecret and has no background worker, so
332
+ * a successful provision there is a security DOWNGRADE. The harm lands at the
333
+ * network call, which mints a live server-side credential, so "storage failed
334
+ * anyway" is not a defence.
335
+ *
336
+ * These tests present a native module that IS available, which is the whole
337
+ * point: they fail if the gate ever relies on `requireOptionalNativeModule`
338
+ * returning null on web rather than on the platform check.
339
+ */
340
+ test('does not provision even when a native module IS present', async () => {
341
+ const native = fakeNative(null);
342
+ const { syncBackgroundSession, isBackgroundSessionSupported } = await loadModule(native, 'web');
343
+ const provision = jest.fn(async () => provisioned());
344
+
345
+ expect(isBackgroundSessionSupported()).toBe(false);
346
+ await syncBackgroundSession({
347
+ oxyServices: fakeOxy(provision),
348
+ userId: 'user-1',
349
+ canUsePrivateApi: true,
350
+ isCurrent: () => true,
351
+ });
352
+
353
+ expect(provision).not.toHaveBeenCalled();
354
+ expect(native.put).not.toHaveBeenCalled();
355
+ // Not even a read, so nothing in a browser origin is touched at all.
356
+ expect(native.peek).not.toHaveBeenCalled();
357
+ expect(native.clear).not.toHaveBeenCalled();
358
+ });
359
+
360
+ test('never asks the native registry on web', async () => {
361
+ const { isBackgroundSessionSupported } = await loadModule(fakeNative(null), 'web');
362
+ expect(isBackgroundSessionSupported()).toBe(false);
363
+ // The platform decides BEFORE the registry is consulted, so a web-build
364
+ // native-module mock or a future web shim cannot reopen the gate.
365
+ expect(requireOptionalNativeModule).not.toHaveBeenCalled();
366
+ });
367
+ });
368
+
369
+ describe('without the native module', () => {
370
+ test('every entry point is an inert no-op', async () => {
371
+ const { syncBackgroundSession, clearBackgroundSession, isBackgroundSessionSupported } =
372
+ await loadModule(null);
373
+ const provision = jest.fn();
374
+
375
+ expect(isBackgroundSessionSupported()).toBe(false);
376
+ await expect(clearBackgroundSession()).resolves.toBeUndefined();
377
+ await expect(
378
+ syncBackgroundSession({
379
+ oxyServices: fakeOxy(provision),
380
+ userId: 'user-1',
381
+ canUsePrivateApi: true,
382
+ isCurrent: () => true,
383
+ }),
384
+ ).resolves.toBeUndefined();
385
+ expect(provision).not.toHaveBeenCalled();
386
+ });
387
+ });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The JS half of the native background session credential.
3
+ *
4
+ * A home-screen widget or a sync job refreshes while the app process is dead:
5
+ * there is no JS runtime, so it cannot go through this SDK's normal session lane
6
+ * at all. Native code needs its own way to obtain a bearer, and this module is
7
+ * what gives it one — while the app runs, it provisions a purpose-built
8
+ * credential and hands it to the native store that `so.oxy.session.OxyBackgroundSession`
9
+ * reads.
10
+ *
11
+ * ## Why a separate credential rather than the device secret
12
+ *
13
+ * `POST /session/device/token` ROTATES the device secret on every mint, and the
14
+ * presented secret dies 60 seconds later. Native code minting with it would be a
15
+ * second writer to the value this SDK's whole session depends on — and since
16
+ * `createNativeAuthStateStore` serves JS from an in-process mirror that never
17
+ * re-reads storage, JS would be blind to that rotation. A worker killed between
18
+ * the server's rotation and its local write would sign the user out of the app.
19
+ * A credential that nobody rotates removes that failure mode instead of managing
20
+ * it.
21
+ *
22
+ * ## What this does NOT do
23
+ *
24
+ * It never reads or writes `oxy.auth.v1` / expo-secure-store. The two credentials
25
+ * live in different stores with one writer each, so there is nothing to keep in
26
+ * lockstep and no state that can disagree.
27
+ *
28
+ * ## Android only, and web is refused rather than merely unsupported
29
+ *
30
+ * Web is gated off by an explicit platform check, not by the native module being
31
+ * absent — provisioning in a browser origin would be a security downgrade, so it
32
+ * must not depend on an incidental fact. See {@link loadNativeModule}. iOS has no
33
+ * implementation yet and resolves to no module, so every function here degrades to
34
+ * a no-op: an app can enable this and lose nothing on other platforms.
35
+ */
36
+ import { requireOptionalNativeModule } from 'expo-modules-core';
37
+ import { Platform } from 'react-native';
38
+ import type { OxyServices } from '@oxyhq/core';
39
+ import { logger } from '@oxyhq/core';
40
+
41
+ /**
42
+ * The native module's surface. Deliberately write-mostly: there is no `get`,
43
+ * because JS has no use for the secret it just wrote and keeping native as the
44
+ * secret's only reader is one less place it can leak from.
45
+ */
46
+ interface OxyBackgroundSessionNativeModule {
47
+ /** Store (replacing) the credential. Resolves `false` when it was rejected or storage failed. */
48
+ put(credential: {
49
+ baseUrl: string;
50
+ deviceId: string;
51
+ secret: string;
52
+ accountId: string;
53
+ /** Epoch millis — parsed from the server's ISO-8601 string here, not natively. */
54
+ expiresAt: number;
55
+ }): Promise<boolean>;
56
+ /** Drop the credential and any cached access token. */
57
+ clear(): Promise<boolean>;
58
+ /** What is stored: the owning account and its expiry (epoch millis), or `null`. */
59
+ peek(): Promise<{ accountId: string; expiresAt: number } | null>;
60
+ }
61
+
62
+ /**
63
+ * Re-provision once the remaining lifetime drops below this.
64
+ *
65
+ * The server issues a 30-day credential; renewing with a week to spare means an
66
+ * app opened even occasionally always holds a live one, while an app left unopened
67
+ * for a month lets it lapse — which is the intended outcome, not a failure.
68
+ */
69
+ const RENEW_WHEN_REMAINING_MS = 7 * 24 * 60 * 60 * 1000;
70
+
71
+ let nativeModule: OxyBackgroundSessionNativeModule | null | undefined;
72
+
73
+ /**
74
+ * Resolve the native module once — and refuse outright on web.
75
+ *
76
+ * ## Web is excluded on purpose, and not merely because the module is absent
77
+ *
78
+ * The credential is deliberately NON-ROTATING and long-lived, which is safe only
79
+ * because native background code is its sole consumer and nobody rotates it. A
80
+ * browser origin already holds the ROTATING `deviceSecret`, and it has no
81
+ * background worker to serve — so provisioning there would add a strictly weaker
82
+ * long-lived secret alongside a stronger short-lived one. That is a downgrade, not
83
+ * a harmless no-op, and the harm lands at the PROVISION CALL (which mints a live
84
+ * server-side credential) regardless of whether anything manages to store it.
85
+ *
86
+ * That property must not rest on `requireOptionalNativeModule` happening to return
87
+ * `null` on web. It does today — the module is registered for android only — but
88
+ * that is an incidental fact a future web shim or a web-build native-module mock
89
+ * would quietly reverse. So the platform is checked FIRST, before we even ask the
90
+ * registry, and every entry point in this module inherits the gate.
91
+ *
92
+ * (A pre-deploy server makes this visible too: oxy-api's `sessionDevice` router
93
+ * applies `requireSameSiteOrigin` path-agnostically, so an unmatched path answers
94
+ * `404` to a native caller but `403 BAD_ORIGIN` to a browser one. Core's degrade is
95
+ * deliberately narrow — `404 → null` only — because a real origin misconfiguration
96
+ * on a CURRENT server also returns 403, and swallowing that would hide a genuine
97
+ * bug. Platform gating belongs here, where the platform is actually known.)
98
+ *
99
+ * `requireOptionalNativeModule` (a static import Metro always resolves) rather
100
+ * than a dynamic `import(moduleName)`: the latter compiles to `require(variable)`
101
+ * in the CJS build, which Metro cannot resolve inside a consuming app — it
102
+ * silently returned `null` there, which is exactly how the cross-app SSO bridge
103
+ * broke once. Same reasoning as `loadSharedIdentityBridge` in `@oxyhq/protocol`.
104
+ */
105
+ function loadNativeModule(): OxyBackgroundSessionNativeModule | null {
106
+ if (nativeModule === undefined) {
107
+ if (Platform.OS === 'web') {
108
+ nativeModule = null;
109
+ return nativeModule;
110
+ }
111
+ const native = requireOptionalNativeModule<Partial<OxyBackgroundSessionNativeModule>>(
112
+ 'OxyBackgroundSession',
113
+ );
114
+ nativeModule =
115
+ native &&
116
+ typeof native.put === 'function' &&
117
+ typeof native.clear === 'function' &&
118
+ typeof native.peek === 'function'
119
+ ? {
120
+ put: native.put.bind(native),
121
+ clear: native.clear.bind(native),
122
+ peek: native.peek.bind(native),
123
+ }
124
+ : null;
125
+ }
126
+ return nativeModule;
127
+ }
128
+
129
+ /** Whether this platform/app can hold a background credential at all. */
130
+ export function isBackgroundSessionSupported(): boolean {
131
+ return loadNativeModule() !== null;
132
+ }
133
+
134
+ /**
135
+ * Drop the stored credential. No-op where unsupported, and never throws.
136
+ *
137
+ * Called on sign-out and BEFORE an account switch's network work, so the window
138
+ * in which background code could still serve the previous account is as short as
139
+ * one local write.
140
+ */
141
+ export async function clearBackgroundSession(): Promise<void> {
142
+ const native = loadNativeModule();
143
+ if (!native) {
144
+ return;
145
+ }
146
+ try {
147
+ await native.clear();
148
+ } catch (error) {
149
+ logger.warn('[backgroundSession] could not clear the native background credential', {
150
+ component: 'backgroundSession',
151
+ error,
152
+ });
153
+ }
154
+ }
155
+
156
+ export interface BackgroundSessionSyncInput {
157
+ oxyServices: OxyServices;
158
+ /** The signed-in account, or `null` when signed out. */
159
+ userId: string | null;
160
+ /** Whether a bearer is available yet — provisioning requires one. */
161
+ canUsePrivateApi: boolean;
162
+ /**
163
+ * Whether this run is still the current one. A run that loses its relevance
164
+ * mid-flight (unmount, or another identity change) must not write the
165
+ * credential it was about to write.
166
+ */
167
+ isCurrent: () => boolean;
168
+ }
169
+
170
+ /**
171
+ * Fail LOUDLY when the installed `@oxyhq/core` predates
172
+ * `provisionBackgroundCredential`.
173
+ *
174
+ * This is the one failure here that is a build/dependency mistake rather than a
175
+ * runtime condition, and it is the one that must never be quiet. Everything else
176
+ * in `syncBackgroundSession` is caught and logged at warn, which is right for a
177
+ * flaky network — but applying that to a version skew would leave background
178
+ * refreshes permanently non-functional behind a warning nobody reads. So this
179
+ * throws, and logs at ERROR first so it is still visible in a release build whose
180
+ * rejection handler is silent.
181
+ *
182
+ * Reachable only through a runtime skew: the declared `@oxyhq/core` range covers
183
+ * the method, so a correctly-resolved install cannot get here, and TypeScript
184
+ * rules it out at compile time.
185
+ */
186
+ function assertProvisioningAvailable(oxyServices: OxyServices): void {
187
+ if (typeof oxyServices.provisionBackgroundCredential === 'function') {
188
+ return;
189
+ }
190
+ const message =
191
+ '[backgroundSession] the installed @oxyhq/core has no provisionBackgroundCredential, ' +
192
+ 'so no background credential can be provisioned and native background refreshes will ' +
193
+ 'never authenticate. Install a @oxyhq/core that satisfies this package\'s declared range.';
194
+ logger.error(message, undefined, { component: 'backgroundSession' });
195
+ throw new Error(message);
196
+ }
197
+
198
+ /**
199
+ * Bring the native credential in line with the session JS currently holds.
200
+ *
201
+ * Ordering is the load-bearing part: a credential belonging to a DIFFERENT
202
+ * account is cleared before any network call, so a crash mid-provision leaves
203
+ * background code signed out rather than serving the previous account's data
204
+ * under the new account's name. The server enforces the same rule independently
205
+ * (a mint is re-authorized against the live device session, and against its ACTIVE
206
+ * account), so this is the fast local half of a belt-and-braces pair, not the only
207
+ * guard.
208
+ *
209
+ * Throws for exactly one thing — a `@oxyhq/core` too old to provision (see
210
+ * {@link assertProvisioningAvailable}). Every other failure is caught and logged:
211
+ * a background credential is an enhancement, and failing to provision one must
212
+ * not disturb the session that is working.
213
+ */
214
+ export async function syncBackgroundSession(input: BackgroundSessionSyncInput): Promise<void> {
215
+ const native = loadNativeModule();
216
+ if (!native) {
217
+ return;
218
+ }
219
+ const { oxyServices, userId, canUsePrivateApi, isCurrent } = input;
220
+
221
+ // Before the try, so the catch below cannot turn a dependency skew into a
222
+ // warning. Signed-out is exempt: clearing needs no core method, and a sign-out
223
+ // must never be blocked by one.
224
+ if (userId) {
225
+ assertProvisioningAvailable(oxyServices);
226
+ }
227
+
228
+ try {
229
+ if (!userId) {
230
+ // Signed out. Local-only, so it works with no bearer and no network.
231
+ await native.clear();
232
+ return;
233
+ }
234
+
235
+ const stored = await native.peek();
236
+ if (stored && stored.accountId !== userId) {
237
+ // Identity changed under a live credential — clear FIRST, provision after.
238
+ await native.clear();
239
+ }
240
+
241
+ const isForCurrentAccount = stored?.accountId === userId;
242
+ const hasLifeLeft =
243
+ isForCurrentAccount && stored.expiresAt - Date.now() > RENEW_WHEN_REMAINING_MS;
244
+ if (hasLifeLeft) {
245
+ return;
246
+ }
247
+
248
+ // Everything below needs a bearer. Without one yet (cold boot still
249
+ // resolving) there is nothing to do but wait for the next run — the token
250
+ // landing re-triggers this.
251
+ if (!canUsePrivateApi || !isCurrent()) {
252
+ return;
253
+ }
254
+
255
+ const provisioned = await oxyServices.provisionBackgroundCredential();
256
+ if (!provisioned) {
257
+ // `null` means the endpoint is not deployed yet. Expected during the
258
+ // rollout window (api leads the SDK) and deliberately quiet.
259
+ return;
260
+ }
261
+ if (!isCurrent()) {
262
+ return;
263
+ }
264
+ if (provisioned.accountId !== userId) {
265
+ // The active account moved while the request was in flight, so this
266
+ // credential is already for the wrong account. Drop it; the next run
267
+ // provisions for the account that is actually signed in.
268
+ await native.clear();
269
+ return;
270
+ }
271
+
272
+ const expiresAt = Date.parse(provisioned.expiresAt);
273
+ if (!Number.isFinite(expiresAt)) {
274
+ logger.warn('[backgroundSession] server returned an unparseable credential expiry', {
275
+ component: 'backgroundSession',
276
+ });
277
+ return;
278
+ }
279
+
280
+ const persisted = await native.put({
281
+ baseUrl: oxyServices.getBaseURL(),
282
+ deviceId: provisioned.deviceId,
283
+ secret: provisioned.secret,
284
+ accountId: provisioned.accountId,
285
+ expiresAt,
286
+ });
287
+ if (!persisted) {
288
+ logger.warn(
289
+ '[backgroundSession] the native store did not retain the background credential; background refreshes stay signed out until the next attempt',
290
+ { component: 'backgroundSession' },
291
+ );
292
+ }
293
+ } catch (error) {
294
+ logger.warn('[backgroundSession] could not sync the native background credential', {
295
+ component: 'backgroundSession',
296
+ error,
297
+ });
298
+ }
299
+ }
@@ -13,6 +13,7 @@
13
13
  export { createSessionClient } from './createSessionClient';
14
14
  export { createTokenTransport } from './tokenTransport';
15
15
  export { createPlatformAuthStateStore } from './authStore';
16
+ export { useBackgroundSessionSync } from './useBackgroundSessionSync';
16
17
  export { createIdentitySessionBinding, IdentityBoundSessionError } from './identityBinding';
17
18
  export type { IdentitySessionBinding } from './identityBinding';
18
19
  export {