@oxyhq/core 5.5.0 → 7.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 (196) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +7 -4
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +54 -45
  12. package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  15. package/dist/cjs/mixins/index.js +8 -17
  16. package/dist/cjs/server/index.js +2 -2
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/shared/utils/debugUtils.js +3 -3
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +10 -39
  23. package/dist/cjs/utils/coldBoot.js +10 -8
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/registrableApex.js +49 -0
  26. package/dist/cjs/utils/ssoBounce.js +15 -362
  27. package/dist/cjs/utils/validationUtils.js +57 -0
  28. package/dist/esm/.tsbuildinfo +1 -1
  29. package/dist/esm/HttpService.js +7 -4
  30. package/dist/esm/OxyServices.base.js +7 -102
  31. package/dist/esm/boot/coldBootV2.js +344 -0
  32. package/dist/esm/boot/deviceBootReturn.js +146 -0
  33. package/dist/esm/crypto/keyManager.js +95 -0
  34. package/dist/esm/i18n/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/es-ES.json +13 -1
  36. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  37. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  38. package/dist/esm/index.js +34 -17
  39. package/dist/esm/mixins/OxyServices.accounts.js +13 -26
  40. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +8 -17
  43. package/dist/esm/server/index.js +1 -1
  44. package/dist/esm/session/SessionClient.js +40 -1
  45. package/dist/esm/session/authStateStore.js +278 -0
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/shared/utils/debugUtils.js +3 -3
  48. package/dist/esm/utils/accountUtils.js +0 -53
  49. package/dist/esm/utils/authWebUrl.js +9 -37
  50. package/dist/esm/utils/coldBoot.js +10 -8
  51. package/dist/esm/utils/platform.js +18 -0
  52. package/dist/esm/utils/registrableApex.js +46 -0
  53. package/dist/esm/utils/ssoBounce.js +14 -345
  54. package/dist/esm/utils/validationUtils.js +56 -0
  55. package/dist/types/.tsbuildinfo +1 -1
  56. package/dist/types/HttpService.d.ts +14 -1
  57. package/dist/types/OxyServices.base.d.ts +0 -52
  58. package/dist/types/OxyServices.d.ts +0 -25
  59. package/dist/types/boot/coldBootV2.d.ts +76 -0
  60. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  61. package/dist/types/crypto/keyManager.d.ts +21 -0
  62. package/dist/types/index.d.ts +14 -21
  63. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
  64. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  68. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  85. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  86. package/dist/types/mixins/index.d.ts +5 -9
  87. package/dist/types/models/interfaces.d.ts +0 -67
  88. package/dist/types/models/session.d.ts +4 -5
  89. package/dist/types/server/index.d.ts +1 -1
  90. package/dist/types/session/SessionClient.d.ts +26 -1
  91. package/dist/types/session/authStateStore.d.ts +119 -0
  92. package/dist/types/session/refresh.d.ts +93 -0
  93. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  94. package/dist/types/utils/accountUtils.d.ts +2 -17
  95. package/dist/types/utils/authWebUrl.d.ts +9 -35
  96. package/dist/types/utils/coldBoot.d.ts +17 -14
  97. package/dist/types/utils/platform.d.ts +14 -0
  98. package/dist/types/utils/registrableApex.d.ts +31 -0
  99. package/dist/types/utils/ssoBounce.d.ts +14 -280
  100. package/dist/types/utils/validationUtils.d.ts +15 -0
  101. package/package.json +2 -2
  102. package/src/HttpService.ts +20 -4
  103. package/src/OxyServices.base.ts +7 -112
  104. package/src/OxyServices.ts +0 -38
  105. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  106. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  107. package/src/boot/coldBootV2.ts +426 -0
  108. package/src/boot/deviceBootReturn.ts +195 -0
  109. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  110. package/src/crypto/keyManager.ts +101 -0
  111. package/src/i18n/locales/en-US.json +13 -1
  112. package/src/i18n/locales/es-ES.json +13 -1
  113. package/src/index.ts +75 -65
  114. package/src/mixins/OxyServices.accounts.ts +27 -39
  115. package/src/mixins/OxyServices.auth.ts +78 -253
  116. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  117. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  118. package/src/mixins/__tests__/accounts.test.ts +17 -44
  119. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  120. package/src/mixins/index.ts +10 -23
  121. package/src/models/interfaces.ts +0 -79
  122. package/src/models/session.ts +4 -5
  123. package/src/server/index.ts +1 -1
  124. package/src/session/SessionClient.ts +53 -2
  125. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  126. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  127. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  128. package/src/session/__tests__/authStateStore.test.ts +209 -0
  129. package/src/session/__tests__/refresh.test.ts +256 -0
  130. package/src/session/authStateStore.ts +335 -0
  131. package/src/session/refresh.ts +334 -0
  132. package/src/shared/utils/debugUtils.ts +3 -3
  133. package/src/utils/__tests__/authWebUrl.test.ts +5 -40
  134. package/src/utils/__tests__/registrableApex.test.ts +62 -0
  135. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  136. package/src/utils/accountUtils.ts +2 -65
  137. package/src/utils/authWebUrl.ts +9 -39
  138. package/src/utils/coldBoot.ts +17 -14
  139. package/src/utils/platform.ts +21 -0
  140. package/src/utils/registrableApex.ts +45 -0
  141. package/src/utils/ssoBounce.ts +14 -393
  142. package/src/utils/validationUtils.ts +62 -0
  143. package/dist/cjs/AuthManager.js +0 -1110
  144. package/dist/cjs/AuthManagerTypes.js +0 -13
  145. package/dist/cjs/CrossDomainAuth.js +0 -206
  146. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  147. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  148. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  149. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  150. package/dist/cjs/utils/fapiAutoDetect.js +0 -99
  151. package/dist/cjs/utils/ssoEstablish.js +0 -110
  152. package/dist/cjs/utils/ssoReturn.js +0 -275
  153. package/dist/esm/AuthManager.js +0 -1105
  154. package/dist/esm/AuthManagerTypes.js +0 -12
  155. package/dist/esm/CrossDomainAuth.js +0 -201
  156. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  157. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  158. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  159. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  160. package/dist/esm/utils/fapiAutoDetect.js +0 -95
  161. package/dist/esm/utils/ssoEstablish.js +0 -107
  162. package/dist/esm/utils/ssoReturn.js +0 -271
  163. package/dist/types/AuthManager.d.ts +0 -380
  164. package/dist/types/AuthManagerTypes.d.ts +0 -81
  165. package/dist/types/CrossDomainAuth.d.ts +0 -164
  166. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  167. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
  168. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  169. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  170. package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
  171. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  172. package/dist/types/utils/ssoReturn.d.ts +0 -156
  173. package/src/AuthManager.ts +0 -1269
  174. package/src/AuthManagerTypes.ts +0 -86
  175. package/src/CrossDomainAuth.ts +0 -243
  176. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  177. package/src/__tests__/authManager.security.test.ts +0 -377
  178. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  179. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  180. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  181. package/src/mixins/OxyServices.redirect.ts +0 -122
  182. package/src/mixins/OxyServices.silent.ts +0 -272
  183. package/src/mixins/OxyServices.sso.ts +0 -261
  184. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  185. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  186. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  187. package/src/mixins/__tests__/silent.test.ts +0 -102
  188. package/src/mixins/__tests__/sso.test.ts +0 -228
  189. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  190. package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
  191. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  192. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  193. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  194. package/src/utils/fapiAutoDetect.ts +0 -91
  195. package/src/utils/ssoEstablish.ts +0 -174
  196. package/src/utils/ssoReturn.ts +0 -389
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Persisted auth state — ONE shape, web + native.
3
+ *
4
+ * The device-first session model (auth-centralization wave 1) persists the
5
+ * rotating refresh-token family head per ORIGIN so a reload restores the
6
+ * session locally without a redirect. This module is the storage seam: a tiny
7
+ * `load / save / clear` interface plus platform factories, so the cold boot
8
+ * (`coldBootV2`) and the unified refresh handler (`refresh.ts`) never touch a
9
+ * platform storage API directly.
10
+ *
11
+ * Platform-agnostic — the native factory takes an INJECTED key/value store
12
+ * (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
13
+ * never imports `expo-secure-store`. The web factory is self-contained
14
+ * (`localStorage`) and degrades to in-memory when storage is unavailable
15
+ * (sandboxed iframe `SecurityError`, private-mode quota, SSR).
16
+ *
17
+ * ESM-safe (no `require()`).
18
+ */
19
+ /**
20
+ * Versioned storage key. The `.v1` suffix lets a future shape change ship a
21
+ * `.v2` key without reading a stale/incompatible `.v1` blob. Distinct from the
22
+ * `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
23
+ */
24
+ export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
25
+ /**
26
+ * Storage key for the long-lived device-attribution token. Separate from
27
+ * {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
28
+ * (sign-out) — the device is unchanged across sign-ins.
29
+ */
30
+ export const DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
31
+ /**
32
+ * Parse + shape-validate a stored blob. Returns `null` for anything that is
33
+ * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
34
+ * types) so a corrupt entry degrades to "signed out" rather than throwing.
35
+ */
36
+ function deserialize(raw) {
37
+ if (!raw) {
38
+ return null;
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (!parsed || typeof parsed !== 'object') {
48
+ return null;
49
+ }
50
+ const candidate = parsed;
51
+ if (typeof candidate.sessionId !== 'string' ||
52
+ typeof candidate.refreshToken !== 'string' ||
53
+ typeof candidate.userId !== 'string' ||
54
+ candidate.sessionId.length === 0 ||
55
+ candidate.refreshToken.length === 0 ||
56
+ candidate.userId.length === 0) {
57
+ return null;
58
+ }
59
+ const state = {
60
+ sessionId: candidate.sessionId,
61
+ refreshToken: candidate.refreshToken,
62
+ userId: candidate.userId,
63
+ };
64
+ if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
65
+ state.deviceToken = candidate.deviceToken;
66
+ }
67
+ if (typeof candidate.accessToken === 'string' && candidate.accessToken.length > 0) {
68
+ state.accessToken = candidate.accessToken;
69
+ }
70
+ if (typeof candidate.expiresAt === 'string' && candidate.expiresAt.length > 0) {
71
+ state.expiresAt = candidate.expiresAt;
72
+ }
73
+ return state;
74
+ }
75
+ /**
76
+ * A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
77
+ * tests/SSR and as the degraded fallback of the web store when `localStorage`
78
+ * is unreachable. Not durable across reloads — that is acceptable for the
79
+ * fallback because the alternative (throwing) would break cold boot entirely.
80
+ */
81
+ export function createMemoryAuthStateStore() {
82
+ let current = null;
83
+ let deviceToken = null;
84
+ return {
85
+ load: async () => current,
86
+ save: async (state) => {
87
+ current = state;
88
+ },
89
+ clear: async () => {
90
+ current = null;
91
+ },
92
+ loadDeviceToken: async () => deviceToken,
93
+ saveDeviceToken: async (token) => {
94
+ deviceToken = token;
95
+ },
96
+ clearDeviceToken: async () => {
97
+ deviceToken = null;
98
+ },
99
+ };
100
+ }
101
+ /**
102
+ * Read the ambient `localStorage`, tolerating the case where merely ACCESSING
103
+ * `window.localStorage` throws. In a sandboxed / cross-origin iframe the getter
104
+ * itself raises `SecurityError` (not the method calls), so the access must be
105
+ * inside the try. Returns `null` when storage is unavailable.
106
+ */
107
+ function safeGetLocalStorage() {
108
+ try {
109
+ if (typeof globalThis === 'undefined') {
110
+ return null;
111
+ }
112
+ const store = globalThis.localStorage;
113
+ return store ?? null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ /**
120
+ * A `localStorage`-backed {@link AuthStateStore} under the versioned
121
+ * {@link AUTH_STATE_STORAGE_KEY}.
122
+ *
123
+ * Resilience:
124
+ * - If `localStorage` is unreachable (sandboxed-iframe `SecurityError`, SSR),
125
+ * the whole store degrades to an in-memory {@link createMemoryAuthStateStore}
126
+ * for this page's lifetime — never throws on construction.
127
+ * - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
128
+ * throws yields `null`; a write that throws (quota, private mode) is
129
+ * swallowed. The persisted-refresh lane treats a failed persist as "no
130
+ * durable state" (falls back to the bootstrap hop) rather than crashing.
131
+ */
132
+ export function createWebAuthStateStore() {
133
+ const storage = safeGetLocalStorage();
134
+ if (!storage) {
135
+ return createMemoryAuthStateStore();
136
+ }
137
+ // In-memory mirror so a FAILED persist (quota / private mode / locked store)
138
+ // does not silently lose the session for this page's lifetime. `undefined`
139
+ // means "never written this session" → fall back to storage; any set value
140
+ // (including `null` after clear) is authoritative and preferred over storage.
141
+ let sessionMirror;
142
+ let deviceTokenMirror;
143
+ return {
144
+ load: async () => {
145
+ if (sessionMirror !== undefined) {
146
+ return sessionMirror;
147
+ }
148
+ try {
149
+ return deserialize(storage.getItem(AUTH_STATE_STORAGE_KEY));
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ },
155
+ save: async (state) => {
156
+ sessionMirror = state; // mirror FIRST — authoritative even if persist fails
157
+ try {
158
+ storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
159
+ }
160
+ catch {
161
+ // Quota / private-mode / disabled storage — non-fatal. The session
162
+ // stays live via the in-memory mirror; only reload durability is lost.
163
+ }
164
+ },
165
+ clear: async () => {
166
+ sessionMirror = null;
167
+ try {
168
+ storage.removeItem(AUTH_STATE_STORAGE_KEY);
169
+ }
170
+ catch {
171
+ // Non-fatal — see save().
172
+ }
173
+ },
174
+ loadDeviceToken: async () => {
175
+ if (deviceTokenMirror !== undefined) {
176
+ return deviceTokenMirror;
177
+ }
178
+ try {
179
+ return storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ },
185
+ saveDeviceToken: async (token) => {
186
+ deviceTokenMirror = token;
187
+ try {
188
+ storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
189
+ }
190
+ catch {
191
+ // Non-fatal — see save().
192
+ }
193
+ },
194
+ clearDeviceToken: async () => {
195
+ deviceTokenMirror = null;
196
+ try {
197
+ storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
198
+ }
199
+ catch {
200
+ // Non-fatal — see save().
201
+ }
202
+ },
203
+ };
204
+ }
205
+ /**
206
+ * A native {@link AuthStateStore} over an injected async key/value store.
207
+ *
208
+ * `@oxyhq/core` never imports `expo-secure-store`; `@oxyhq/services` constructs
209
+ * the SecureStore-backed adapter and passes it here. Every operation is wrapped
210
+ * so a storage exception degrades gracefully (read → `null`, write → swallowed)
211
+ * exactly like the web store.
212
+ */
213
+ export function createNativeAuthStateStore(storage) {
214
+ // Same in-memory mirror as the web store — a locked/failed SecureStore write
215
+ // must not silently lose the session for the app's lifetime.
216
+ let sessionMirror;
217
+ let deviceTokenMirror;
218
+ return {
219
+ load: async () => {
220
+ if (sessionMirror !== undefined) {
221
+ return sessionMirror;
222
+ }
223
+ try {
224
+ return deserialize(await storage.getItem(AUTH_STATE_STORAGE_KEY));
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ },
230
+ save: async (state) => {
231
+ sessionMirror = state;
232
+ try {
233
+ await storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify(state));
234
+ }
235
+ catch {
236
+ // Non-fatal — session stays live via the in-memory mirror.
237
+ }
238
+ },
239
+ clear: async () => {
240
+ sessionMirror = null;
241
+ try {
242
+ await storage.removeItem(AUTH_STATE_STORAGE_KEY);
243
+ }
244
+ catch {
245
+ // Non-fatal.
246
+ }
247
+ },
248
+ loadDeviceToken: async () => {
249
+ if (deviceTokenMirror !== undefined) {
250
+ return deviceTokenMirror;
251
+ }
252
+ try {
253
+ return await storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ },
259
+ saveDeviceToken: async (token) => {
260
+ deviceTokenMirror = token;
261
+ try {
262
+ await storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
263
+ }
264
+ catch {
265
+ // Non-fatal.
266
+ }
267
+ },
268
+ clearDeviceToken: async () => {
269
+ deviceTokenMirror = null;
270
+ try {
271
+ await storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
272
+ }
273
+ catch {
274
+ // Non-fatal.
275
+ }
276
+ },
277
+ };
278
+ }
@@ -0,0 +1,257 @@
1
+ import { isNative } from '../utils/platform.js';
2
+ import { logger } from '../utils/loggerUtils.js';
3
+ /**
4
+ * Lead time (ms) before access-token expiry at which the proactive scheduler
5
+ * refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
6
+ * (60s) so the scheduled refresh and the request-time preflight refresh use
7
+ * the same window — the scheduler just fires it during idle/background.
8
+ */
9
+ export const TOKEN_REFRESH_LEAD_MS = 60000;
10
+ /**
11
+ * Max `setTimeout` delay (2^31 − 1 ms, ~24.8 days). A larger delay overflows
12
+ * the int32 timer field and fires IMMEDIATELY — with a long-TTL token that
13
+ * turns the reschedule-on-finish loop into a tight busy refresh. Clamp to it.
14
+ */
15
+ const MAX_TIMEOUT_DELAY_MS = 2147483647;
16
+ /**
17
+ * Floor (ms) on ANY scheduled delay. An already-expired / in-lead-window token
18
+ * computes a non-positive `exp − now − lead`; without this floor that becomes
19
+ * `setTimeout(…, 0)`, and a FAILING refresh (offline / server error) would
20
+ * re-arm at 0 in the finally block → a tight 100%-CPU busy loop. The floor
21
+ * guarantees every re-arm yields the event loop.
22
+ */
23
+ const MIN_SCHEDULE_DELAY_MS = 1000;
24
+ /**
25
+ * Backoff schedule (ms) applied when a scheduled refresh FAILS: first retry
26
+ * after {@link MIN_FAILURE_BACKOFF_MS}, doubling up to {@link MAX_FAILURE_BACKOFF_MS}.
27
+ * Reset to 0 on any success or token change. This is what converts the former
28
+ * zero-delay failure loop into a bounded, backing-off retry.
29
+ */
30
+ const MIN_FAILURE_BACKOFF_MS = 5000;
31
+ const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
32
+ /**
33
+ * Error codes (in addition to HTTP 401/403) that mean the stored refresh token
34
+ * is permanently unusable — the family was revoked or a reuse was detected. On
35
+ * any of these the persisted store is CLEARED (the session is truly over);
36
+ * transient failures (network, 5xx) leave the store intact so a later attempt
37
+ * can still succeed.
38
+ */
39
+ const REVOKED_REFRESH_CODES = new Set([
40
+ 'invalid_grant',
41
+ 'refresh_token_revoked',
42
+ 'refresh_token_reuse',
43
+ 'token_reuse',
44
+ 'invalid_token',
45
+ ]);
46
+ /** Does this error mean the refresh token is permanently dead (vs. transient)? */
47
+ function isRevokedRefreshError(error) {
48
+ if (!error || typeof error !== 'object') {
49
+ return false;
50
+ }
51
+ const e = error;
52
+ if (e.status === 401 || e.status === 403) {
53
+ return true;
54
+ }
55
+ return typeof e.code === 'string' && REVOKED_REFRESH_CODES.has(e.code);
56
+ }
57
+ /**
58
+ * Rotate the persisted session and return the fresh access token, or `null`
59
+ * when no arm could produce one.
60
+ *
61
+ * Arm 1 (`POST /auth/refresh-token`): if the store holds a refresh token, rotate
62
+ * it — on success plant + persist the rotated pair; on a REVOKED error clear the
63
+ * store; on a transient error leave the store and return `null`.
64
+ *
65
+ * Arm 2 (native shared-keychain): when there is no refresh token or arm 1 was
66
+ * revoked, re-mint via `signInWithSharedIdentity` (which plants tokens). The
67
+ * shared keychain — not the per-origin store — is the durable native credential,
68
+ * so this arm does not write the store.
69
+ */
70
+ export async function refreshPersistedSession(deps) {
71
+ const { oxy, store } = deps;
72
+ const allowSharedKeyFallback = deps.allowSharedKeyFallback ?? isNative();
73
+ const persisted = await store.load();
74
+ if (persisted?.refreshToken) {
75
+ try {
76
+ const rotated = await oxy.refreshWithToken(persisted.refreshToken);
77
+ oxy.setTokens(rotated.accessToken);
78
+ const next = {
79
+ sessionId: rotated.sessionId,
80
+ refreshToken: rotated.refreshToken,
81
+ userId: persisted.userId,
82
+ accessToken: rotated.accessToken,
83
+ expiresAt: rotated.expiresAt,
84
+ };
85
+ if (persisted.deviceToken) {
86
+ next.deviceToken = persisted.deviceToken;
87
+ }
88
+ await store.save(next);
89
+ return rotated.accessToken;
90
+ }
91
+ catch (error) {
92
+ if (isRevokedRefreshError(error)) {
93
+ await store.clear();
94
+ // Fall through to the native shared-key arm below — on a shared-key
95
+ // device the refresh family being revoked does not end the session.
96
+ }
97
+ else {
98
+ logger.debug('Persisted refresh failed (transient) — keeping store', { component: 'refresh', method: 'refreshPersistedSession' }, error);
99
+ return null;
100
+ }
101
+ }
102
+ }
103
+ if (allowSharedKeyFallback) {
104
+ try {
105
+ const session = await oxy.signInWithSharedIdentity();
106
+ if (session?.accessToken) {
107
+ return session.accessToken;
108
+ }
109
+ }
110
+ catch (error) {
111
+ logger.debug('Shared-key refresh fallback failed', { component: 'refresh', method: 'refreshPersistedSession' }, error);
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * Build the reactive `AuthRefreshHandler` (arm 1 + arm 2). Install it via
118
+ * {@link installAuthRefreshHandler} or directly on
119
+ * `oxy.httpService.setAuthRefreshHandler`. `HttpService` owns single-flight
120
+ * dedup + cooldown, so the timer, the request-time preflight, and a 401 all
121
+ * collapse to one network attempt.
122
+ */
123
+ export function createAuthRefreshHandler(deps) {
124
+ return async (_reason) => {
125
+ return refreshPersistedSession(deps);
126
+ };
127
+ }
128
+ /**
129
+ * Install the unified refresh handler on the owner client's `HttpService`.
130
+ * Returns a disposer that removes it.
131
+ */
132
+ export function installAuthRefreshHandler(deps) {
133
+ deps.oxy.httpService.setAuthRefreshHandler(createAuthRefreshHandler(deps));
134
+ return () => {
135
+ deps.oxy.httpService.setAuthRefreshHandler(null);
136
+ };
137
+ }
138
+ /**
139
+ * Start the proactive refresh scheduler against `oxy`.
140
+ *
141
+ * Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
142
+ * current access token's `exp`, calling
143
+ * `oxy.httpService.refreshAccessToken('preflight')` (which runs the installed
144
+ * handler; deduped + cooldown-guarded). After every attempt it reschedules
145
+ * from the possibly-rotated token. It also reschedules whenever the token
146
+ * changes (a sign-out that clears the token cancels the timer) and, on web
147
+ * tab-focus, refreshes immediately if already inside the lead window (a
148
+ * long-hidden tab throttles timers, so the token can be expired on return).
149
+ *
150
+ * No-ops cleanly when there is no token or an opaque/no-`exp` token — the
151
+ * reactive 401 path stays the only refresh trigger in that case. The timer is
152
+ * `.unref?.()`-ed so it never keeps a Node/Jest event loop alive.
153
+ */
154
+ export function startTokenRefreshScheduler(oxy) {
155
+ let disposed = false;
156
+ let timer = null;
157
+ // 0 = no active backoff; grows on consecutive failures, resets on success /
158
+ // token change. Keeps a failing refresh from re-arming at zero delay.
159
+ let failureBackoffMs = 0;
160
+ const clearTimer = () => {
161
+ if (timer !== null) {
162
+ clearTimeout(timer);
163
+ timer = null;
164
+ }
165
+ };
166
+ /** Arm the timer for `delayMs`, flooring at {@link MIN_SCHEDULE_DELAY_MS} and capping at the int32 max. */
167
+ const armTimer = (delayMs) => {
168
+ clearTimer();
169
+ const clamped = Math.min(Math.max(delayMs, MIN_SCHEDULE_DELAY_MS), MAX_TIMEOUT_DELAY_MS);
170
+ timer = setTimeout(runRefresh, clamped);
171
+ // Never keep a Node/Jest event loop alive for a background refresh timer.
172
+ timer.unref?.();
173
+ };
174
+ /** Schedule the next refresh from the current token's expiry (the healthy path). */
175
+ const scheduleFromExpiry = () => {
176
+ clearTimer();
177
+ if (disposed || !oxy.getAccessToken()) {
178
+ return;
179
+ }
180
+ const expSeconds = oxy.getAccessTokenExpiry();
181
+ if (expSeconds === null) {
182
+ return;
183
+ }
184
+ armTimer(expSeconds * 1000 - Date.now() - TOKEN_REFRESH_LEAD_MS);
185
+ };
186
+ const runRefresh = () => {
187
+ // Clear any pending timer up front so an out-of-band trigger (focus) plus
188
+ // a fired timer can never double-run.
189
+ clearTimer();
190
+ void oxy.httpService.refreshAccessToken('preflight')
191
+ .then((token) => Boolean(token))
192
+ .catch(() => false)
193
+ .then((ok) => {
194
+ if (disposed) {
195
+ return;
196
+ }
197
+ if (ok) {
198
+ // Success — drop any backoff and re-arm from the rotated token's exp.
199
+ failureBackoffMs = 0;
200
+ scheduleFromExpiry();
201
+ return;
202
+ }
203
+ // Failure — back off (never re-arm at zero) and retry.
204
+ failureBackoffMs =
205
+ failureBackoffMs === 0
206
+ ? MIN_FAILURE_BACKOFF_MS
207
+ : Math.min(failureBackoffMs * 2, MAX_FAILURE_BACKOFF_MS);
208
+ armTimer(failureBackoffMs);
209
+ });
210
+ };
211
+ /** Public (re)schedule entry: a fresh token / focus signal — drop backoff and arm from expiry. */
212
+ const schedule = () => {
213
+ failureBackoffMs = 0;
214
+ scheduleFromExpiry();
215
+ };
216
+ const onFocus = () => {
217
+ if (disposed || !oxy.getAccessToken()) {
218
+ return;
219
+ }
220
+ const expSeconds = oxy.getAccessTokenExpiry();
221
+ if (expSeconds === null) {
222
+ return;
223
+ }
224
+ const remainingMs = expSeconds * 1000 - Date.now();
225
+ if (remainingMs <= TOKEN_REFRESH_LEAD_MS) {
226
+ runRefresh();
227
+ }
228
+ else {
229
+ schedule();
230
+ }
231
+ };
232
+ const unsubscribeTokens = oxy.onTokensChanged(() => {
233
+ if (!disposed) {
234
+ schedule();
235
+ }
236
+ });
237
+ let removeFocusListener = null;
238
+ if (typeof document !== 'undefined') {
239
+ const handler = () => {
240
+ if (document.visibilityState === 'visible') {
241
+ onFocus();
242
+ }
243
+ };
244
+ document.addEventListener('visibilitychange', handler);
245
+ removeFocusListener = () => document.removeEventListener('visibilitychange', handler);
246
+ }
247
+ schedule();
248
+ return {
249
+ dispose() {
250
+ disposed = true;
251
+ clearTimer();
252
+ unsubscribeTokens();
253
+ removeFocusListener?.();
254
+ removeFocusListener = null;
255
+ },
256
+ };
257
+ }
@@ -21,7 +21,7 @@ export const isDev = () => {
21
21
  };
22
22
  /**
23
23
  * Log a debug message (only in development)
24
- * @param prefix - Log prefix (e.g., '[FedCM]')
24
+ * @param prefix - Log prefix (e.g., '[ColdBoot]')
25
25
  * @param args - Arguments to log
26
26
  */
27
27
  export const debugLog = (prefix, ...args) => {
@@ -51,12 +51,12 @@ export const debugError = (prefix, ...args) => {
51
51
  };
52
52
  /**
53
53
  * Create a namespaced debug logger
54
- * @param namespace - Logger namespace (e.g., 'FedCM', 'SilentAuth')
54
+ * @param namespace - Logger namespace (e.g., 'ColdBoot', 'DeviceAuth')
55
55
  * @returns Object with log, warn, error methods
56
56
  *
57
57
  * @example
58
58
  * ```ts
59
- * const debug = createDebugLogger('FedCM');
59
+ * const debug = createDebugLogger('ColdBoot');
60
60
  * debug.log('Starting authentication');
61
61
  * debug.warn('Token expires soon');
62
62
  * debug.error('Authentication failed', error);
@@ -116,59 +116,6 @@ export const createQuickAccount = (sessionId, userData, existingAccount, getFile
116
116
  avatarUrl,
117
117
  };
118
118
  };
119
- /**
120
- * Merge a fresh `/auth/refresh-all` snapshot into an existing QuickAccount
121
- * list, preserving any cached fields (`avatarUrl`) for slots that didn't
122
- * change. The fresh response is canonical: the resulting list contains EXACTLY
123
- * the slots present in `fresh`, sorted by `authuser` ascending. Stale stored
124
- * accounts that no longer appear in `fresh` are dropped (the server already
125
- * authoritatively cleared the corresponding cookie).
126
- *
127
- * @param stored Previously persisted QuickAccount list (any order).
128
- * @param fresh Server's authoritative refresh-all response.
129
- * @returns Canonical merged list, sorted by `authuser` asc.
130
- */
131
- export const mergeAccountsFromRefreshAll = (stored, fresh) => {
132
- const storedByAuthuser = new Map();
133
- if (stored) {
134
- for (const account of stored) {
135
- if (typeof account.authuser === 'number') {
136
- storedByAuthuser.set(account.authuser, account);
137
- }
138
- }
139
- }
140
- const merged = fresh.map((entry) => {
141
- const previous = storedByAuthuser.get(entry.authuser);
142
- // Preserve any previously cached identity for a slot that arrives
143
- // without a user shape rather than overwriting it with blanks, and let
144
- // AuthManager's getCurrentUser() hydration refresh it on the next
145
- // snapshot.
146
- const wireUser = entry.user;
147
- const username = wireUser?.username ?? previous?.username ?? '';
148
- const displayName = getAccountDisplayName({
149
- name: wireUser?.name,
150
- username,
151
- });
152
- const avatar = wireUser?.avatar ?? previous?.avatar ?? undefined;
153
- const avatarUrl = previous && previous.avatar === avatar ? previous.avatarUrl : undefined;
154
- return {
155
- sessionId: entry.sessionId,
156
- userId: wireUser?.id ?? previous?.userId,
157
- username,
158
- displayName,
159
- avatar,
160
- avatarUrl,
161
- authuser: entry.authuser,
162
- color: wireUser?.color ?? previous?.color ?? null,
163
- };
164
- });
165
- merged.sort((a, b) => {
166
- const aIdx = a.authuser ?? Number.POSITIVE_INFINITY;
167
- const bIdx = b.authuser ?? Number.POSITIVE_INFINITY;
168
- return aIdx - bIdx;
169
- });
170
- return merged;
171
- };
172
119
  /**
173
120
  * Return the account's preferred Bloom color preset, or `null` if it has no
174
121
  * preference. Centralises the `color ?? null` normalisation so consumers can
@@ -1,44 +1,16 @@
1
1
  /**
2
- * Central IdP (auth web) URL resolution for cross-domain SSO.
2
+ * Central IdP apex constant.
3
3
  *
4
- * The Oxy ecosystem runs a single, central Identity Provider at
5
- * `auth.oxy.so`. For TRUE central cross-domain SSO (Google/Meta/Clerk style),
6
- * FedCM and the opaque-code SSO bounce always target this one origin it owns
7
- * the host-only `fedcm_session` cookie and the central session store reachable
8
- * via `api.oxy.so`. Relying Parties (mention.earth, homiio.com, alia.onl, …)
9
- * delegate to it rather than standing up a per-apex IdP.
10
- *
11
- * This module is intentionally pure: it performs no DOM access, reads no
12
- * `window`/`location`, and has no side effects. It is the single source of
13
- * truth for the central IdP origin so call sites never hardcode the literal.
14
- *
15
- * Note: this is distinct from `autoDetectAuthWebUrl` (per-apex `auth.<rp-apex>`
16
- * derivation). The central-SSO path deliberately does NOT auto-detect per-apex
17
- * IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
4
+ * The client SSO/FedCM resolvers (`resolveCentralAuthUrl`, `CENTRAL_AUTH_URL`)
5
+ * were removed in the device-first / legacy-final cutovers. The lone survivor is
6
+ * `CENTRAL_IDP_APEX`, kept because it has a LIVE consumer
7
+ * `@oxyhq/core/server`'s CORS helper (`server/cors.ts`'s `createOxyCors`)
8
+ * auto-allows `*.oxy.so` from it. That CORS use is permanent, so this stays
9
+ * past the SSO/FedCM teardown.
18
10
  */
19
11
  /**
20
12
  * The registrable apex (eTLD+1) of the Oxy ecosystem's central Identity
21
- * Provider. The central IdP is reachable at `auth.${CENTRAL_IDP_APEX}` and the
22
- * ID-token assertion issuer is always `https://auth.${CENTRAL_IDP_APEX}`
23
- * regardless of which per-apex `auth.<rp>` host served a given request.
24
- *
25
- * Kept as a standalone constant so the IdP worker and the SDK derive the same
26
- * literal from one source of truth (the worker imports it to brand assertions).
13
+ * Provider, reachable at `auth.${CENTRAL_IDP_APEX}`. Single source of truth so
14
+ * the CORS helper (and anything else that needs the central apex) never drifts.
27
15
  */
28
16
  export const CENTRAL_IDP_APEX = 'oxy.so';
29
- /**
30
- * The canonical central Identity Provider origin for the Oxy ecosystem.
31
- * No trailing slash. Derived from {@link CENTRAL_IDP_APEX} so the apex and the
32
- * full origin never drift apart.
33
- */
34
- export const CENTRAL_AUTH_URL = `https://auth.${CENTRAL_IDP_APEX}`;
35
- /**
36
- * Resolve the central IdP origin, honouring an explicit override.
37
- *
38
- * @param explicit - A caller-supplied auth web URL, or `undefined`/empty to use
39
- * the central default. An explicit non-empty value always wins.
40
- * @returns The explicit value when provided, otherwise {@link CENTRAL_AUTH_URL}.
41
- */
42
- export function resolveCentralAuthUrl(explicit) {
43
- return explicit ?? CENTRAL_AUTH_URL;
44
- }