@oxyhq/core 1.11.24 → 2.1.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 (150) hide show
  1. package/README.md +5 -6
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/AuthManager.js +678 -4
  4. package/dist/cjs/AuthManagerTypes.js +13 -0
  5. package/dist/cjs/CrossDomainAuth.js +45 -3
  6. package/dist/cjs/OxyServices.base.js +16 -0
  7. package/dist/cjs/i18n/locales/ar-SA.json +83 -0
  8. package/dist/cjs/i18n/locales/ca-ES.json +83 -0
  9. package/dist/cjs/i18n/locales/de-DE.json +83 -0
  10. package/dist/cjs/i18n/locales/en-US.json +83 -0
  11. package/dist/cjs/i18n/locales/es-ES.json +99 -4
  12. package/dist/cjs/i18n/locales/fr-FR.json +83 -0
  13. package/dist/cjs/i18n/locales/it-IT.json +83 -0
  14. package/dist/cjs/i18n/locales/ja-JP.json +83 -0
  15. package/dist/cjs/i18n/locales/ko-KR.json +83 -0
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +83 -1
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +83 -1
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +83 -1
  19. package/dist/cjs/i18n/locales/locales/en-US.json +83 -0
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +99 -4
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +83 -1
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +83 -1
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +200 -117
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +83 -1
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +83 -1
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +83 -1
  27. package/dist/cjs/i18n/locales/pt-PT.json +83 -0
  28. package/dist/cjs/i18n/locales/zh-CN.json +83 -0
  29. package/dist/cjs/index.js +121 -57
  30. package/dist/cjs/mixins/OxyServices.auth.js +235 -0
  31. package/dist/cjs/mixins/OxyServices.fedcm.js +36 -0
  32. package/dist/cjs/mixins/OxyServices.popup.js +61 -1
  33. package/dist/cjs/mixins/OxyServices.user.js +18 -0
  34. package/dist/cjs/utils/accountUtils.js +64 -1
  35. package/dist/cjs/utils/coldBoot.js +71 -0
  36. package/dist/cjs/utils/fapiAutoDetect.js +88 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/AuthManager.js +678 -4
  39. package/dist/esm/AuthManagerTypes.js +12 -0
  40. package/dist/esm/CrossDomainAuth.js +45 -3
  41. package/dist/esm/OxyServices.base.js +16 -0
  42. package/dist/esm/i18n/locales/ar-SA.json +83 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +83 -0
  44. package/dist/esm/i18n/locales/de-DE.json +83 -0
  45. package/dist/esm/i18n/locales/en-US.json +83 -0
  46. package/dist/esm/i18n/locales/es-ES.json +99 -4
  47. package/dist/esm/i18n/locales/fr-FR.json +83 -0
  48. package/dist/esm/i18n/locales/it-IT.json +83 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +83 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +83 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +83 -1
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +83 -1
  53. package/dist/esm/i18n/locales/locales/de-DE.json +83 -1
  54. package/dist/esm/i18n/locales/locales/en-US.json +83 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +99 -4
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +83 -1
  57. package/dist/esm/i18n/locales/locales/it-IT.json +83 -1
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +200 -117
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +83 -1
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +83 -1
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +83 -1
  62. package/dist/esm/i18n/locales/pt-PT.json +83 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +83 -0
  64. package/dist/esm/index.js +74 -26
  65. package/dist/esm/mixins/OxyServices.auth.js +235 -0
  66. package/dist/esm/mixins/OxyServices.fedcm.js +36 -0
  67. package/dist/esm/mixins/OxyServices.popup.js +61 -1
  68. package/dist/esm/mixins/OxyServices.user.js +18 -0
  69. package/dist/esm/utils/accountUtils.js +61 -0
  70. package/dist/esm/utils/coldBoot.js +68 -0
  71. package/dist/esm/utils/fapiAutoDetect.js +85 -0
  72. package/dist/types/.tsbuildinfo +1 -1
  73. package/dist/types/AuthManager.d.ts +243 -3
  74. package/dist/types/AuthManagerTypes.d.ts +68 -0
  75. package/dist/types/CrossDomainAuth.d.ts +23 -0
  76. package/dist/types/OxyServices.base.d.ts +14 -0
  77. package/dist/types/OxyServices.d.ts +7 -0
  78. package/dist/types/index.d.ts +31 -17
  79. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.assets.d.ts +4 -1
  82. package/dist/types/mixins/OxyServices.auth.d.ts +73 -1
  83. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.features.d.ts +2 -5
  87. package/dist/types/mixins/OxyServices.fedcm.d.ts +34 -0
  88. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  92. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  93. package/dist/types/mixins/OxyServices.popup.d.ts +40 -0
  94. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  95. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  96. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  97. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  98. package/dist/types/mixins/OxyServices.user.d.ts +16 -1
  99. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  100. package/dist/types/models/interfaces.d.ts +98 -0
  101. package/dist/types/models/session.d.ts +8 -0
  102. package/dist/types/utils/accountUtils.d.ts +33 -0
  103. package/dist/types/utils/coldBoot.d.ts +102 -0
  104. package/dist/types/utils/fapiAutoDetect.d.ts +37 -0
  105. package/package.json +9 -18
  106. package/src/AuthManager.ts +776 -7
  107. package/src/AuthManagerTypes.ts +72 -0
  108. package/src/CrossDomainAuth.ts +54 -3
  109. package/src/OxyServices.base.ts +17 -0
  110. package/src/OxyServices.ts +7 -0
  111. package/src/__tests__/authManager.cookiePath.test.ts +339 -0
  112. package/src/__tests__/authManager.security.test.ts +342 -0
  113. package/src/__tests__/crossDomainAuth.test.ts +191 -0
  114. package/src/i18n/locales/ar-SA.json +83 -1
  115. package/src/i18n/locales/ca-ES.json +83 -1
  116. package/src/i18n/locales/de-DE.json +83 -1
  117. package/src/i18n/locales/en-US.json +83 -0
  118. package/src/i18n/locales/es-ES.json +99 -4
  119. package/src/i18n/locales/fr-FR.json +83 -1
  120. package/src/i18n/locales/it-IT.json +83 -1
  121. package/src/i18n/locales/ja-JP.json +200 -117
  122. package/src/i18n/locales/ko-KR.json +83 -1
  123. package/src/i18n/locales/pt-PT.json +83 -1
  124. package/src/i18n/locales/zh-CN.json +83 -1
  125. package/src/index.ts +309 -112
  126. package/src/mixins/OxyServices.auth.ts +268 -1
  127. package/src/mixins/OxyServices.fedcm.ts +63 -0
  128. package/src/mixins/OxyServices.popup.ts +79 -1
  129. package/src/mixins/OxyServices.user.ts +33 -1
  130. package/src/mixins/__tests__/popup.test.ts +307 -0
  131. package/src/mixins/__tests__/sessionBaseUrl.test.ts +61 -0
  132. package/src/models/interfaces.ts +116 -0
  133. package/src/models/session.ts +8 -0
  134. package/src/utils/__tests__/coldBoot.test.ts +226 -0
  135. package/src/utils/__tests__/fapiAutoDetect.test.ts +93 -0
  136. package/src/utils/accountUtils.ts +84 -0
  137. package/src/utils/coldBoot.ts +136 -0
  138. package/src/utils/fapiAutoDetect.ts +82 -0
  139. package/dist/cjs/crypto/index.js +0 -22
  140. package/dist/cjs/shared/index.js +0 -70
  141. package/dist/cjs/utils/index.js +0 -26
  142. package/dist/esm/crypto/index.js +0 -13
  143. package/dist/esm/shared/index.js +0 -31
  144. package/dist/esm/utils/index.js +0 -7
  145. package/dist/types/crypto/index.d.ts +0 -11
  146. package/dist/types/shared/index.d.ts +0 -28
  147. package/dist/types/utils/index.d.ts +0 -6
  148. package/src/crypto/index.ts +0 -30
  149. package/src/shared/index.ts +0 -82
  150. package/src/utils/index.ts +0 -21
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Popup mixin regression tests.
3
+ *
4
+ * Locks in the §6c fix: cross-domain sign-in popups (auth.oxy.so) were being
5
+ * blocked by Chrome on consumer apps (mention.earth, homiio.com, alia.onl)
6
+ * because the caller chain awaited FedCM / silent SSO BEFORE
7
+ * `signInWithPopup` reached `window.open`. The transient user-activation
8
+ * had been consumed by the first `await`, so the popup-blocker killed the
9
+ * subsequent `window.open` call.
10
+ *
11
+ * The fix exposes two new affordances on the popup mixin:
12
+ * 1. `openBlankPopup(width?, height?)` — a public, synchronous helper that
13
+ * callers invoke from the raw user-gesture handler BEFORE any await, so
14
+ * the popup is opened while the activation is still live.
15
+ * 2. `signInWithPopup({ popup })` — accepts the pre-opened window handle
16
+ * and navigates IT to the auth URL instead of issuing a fresh
17
+ * `window.open` (which would now be blocked).
18
+ *
19
+ * Backward compat: callers that omit `popup` keep the legacy behaviour (the
20
+ * mixin opens its own popup via `openCenteredPopup`). The browser globals
21
+ * are stubbed so the platform-agnostic mixin can run under the node test
22
+ * env.
23
+ */
24
+
25
+ import { OxyServices } from '../../OxyServices';
26
+
27
+ const ORIGIN = 'https://mention.earth';
28
+
29
+ interface MockPopup {
30
+ closed: boolean;
31
+ close: jest.Mock;
32
+ location: {
33
+ href: string;
34
+ replace: jest.Mock;
35
+ };
36
+ }
37
+
38
+ function createMockPopup(overrides: Partial<MockPopup> = {}): MockPopup {
39
+ return {
40
+ closed: false,
41
+ close: jest.fn(),
42
+ location: {
43
+ href: '',
44
+ replace: jest.fn(function (this: { href: string }, url: string) {
45
+ this.href = url;
46
+ }),
47
+ },
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function installBrowserGlobals(options: {
53
+ windowOpen?: jest.Mock;
54
+ postMessageDispatcher?: { current: ((event: { origin: string; data: unknown }) => void) | null };
55
+ } = {}): void {
56
+ const store = new Map<string, string>();
57
+ const sessionStorageStub = {
58
+ getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
59
+ setItem: (k: string, v: string) => { store.set(k, v); },
60
+ removeItem: (k: string) => { store.delete(k); },
61
+ };
62
+ const messageHandlers: Array<(event: { origin: string; data: unknown }) => void> = [];
63
+ const win = {
64
+ location: { origin: ORIGIN, hostname: 'mention.earth' },
65
+ screenX: 0,
66
+ screenY: 0,
67
+ outerWidth: 1280,
68
+ outerHeight: 800,
69
+ sessionStorage: sessionStorageStub,
70
+ open: options.windowOpen ?? jest.fn(() => null),
71
+ addEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
72
+ if (event === 'message') {
73
+ messageHandlers.push(handler);
74
+ if (options.postMessageDispatcher) {
75
+ options.postMessageDispatcher.current = handler;
76
+ }
77
+ }
78
+ },
79
+ removeEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
80
+ if (event === 'message') {
81
+ const idx = messageHandlers.indexOf(handler);
82
+ if (idx >= 0) messageHandlers.splice(idx, 1);
83
+ }
84
+ },
85
+ };
86
+ (globalThis as unknown as { window: unknown }).window = win;
87
+ (globalThis as unknown as { sessionStorage: unknown }).sessionStorage = sessionStorageStub;
88
+ // `crypto.randomUUID` already exists in node 20+ test env — leave it.
89
+ }
90
+
91
+ function clearBrowserGlobals(): void {
92
+ for (const key of ['window', 'sessionStorage'] as const) {
93
+ delete (globalThis as Record<string, unknown>)[key];
94
+ }
95
+ }
96
+
97
+ describe('OxyServices popup mixin — pre-opened popup option', () => {
98
+ afterEach(() => {
99
+ clearBrowserGlobals();
100
+ jest.restoreAllMocks();
101
+ });
102
+
103
+ it('navigates a pre-opened popup to the auth URL instead of opening a new one', async () => {
104
+ const windowOpen = jest.fn();
105
+ installBrowserGlobals({ windowOpen });
106
+ const popup = createMockPopup();
107
+
108
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
109
+
110
+ // Resolve `signInWithPopup` as soon as the popup is navigated — we only
111
+ // care about the open path, not the full postMessage round-trip.
112
+ let dispatchedAuthUrl: string | null = null;
113
+ popup.location.replace.mockImplementation(function (this: MockPopup['location'], url: string) {
114
+ this.href = url;
115
+ dispatchedAuthUrl = url;
116
+ });
117
+
118
+ // Fire the auth-success message immediately after navigation. We do this
119
+ // by intercepting `addEventListener` above.
120
+ const messagePromise = new Promise<void>((resolve) => {
121
+ // Patch addEventListener to capture the handler and dispatch a fake
122
+ // success message on the next microtask.
123
+ const origAdd = (globalThis as unknown as { window: { addEventListener: typeof window.addEventListener } }).window.addEventListener;
124
+ (globalThis as unknown as { window: { addEventListener: typeof window.addEventListener } }).window.addEventListener =
125
+ (event: string, handler: EventListenerOrEventListenerObject) => {
126
+ origAdd(event, handler);
127
+ if (event === 'message') {
128
+ queueMicrotask(() => {
129
+ // We need the state from the URL the popup was navigated to.
130
+ const url = new URL(dispatchedAuthUrl ?? '');
131
+ const state = url.searchParams.get('state') ?? '';
132
+ (handler as (e: { origin: string; data: unknown }) => void)({
133
+ origin: 'https://auth.oxy.so',
134
+ data: {
135
+ type: 'oxy_auth_response',
136
+ state,
137
+ session: {
138
+ sessionId: 'sess_pre_opened',
139
+ deviceId: 'dev_pre',
140
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
141
+ accessToken: 'access_pre',
142
+ user: { id: 'user_pre', username: 'tester' },
143
+ },
144
+ },
145
+ });
146
+ resolve();
147
+ });
148
+ }
149
+ };
150
+ });
151
+
152
+ const session = await oxy.signInWithPopup({ popup: popup as unknown as Window });
153
+ await messagePromise;
154
+
155
+ // The pre-opened popup was navigated — `window.open` was NEVER called.
156
+ expect(windowOpen).not.toHaveBeenCalled();
157
+ expect(popup.location.replace).toHaveBeenCalledTimes(1);
158
+ expect(popup.location.replace).toHaveBeenCalledWith(expect.stringContaining('https://auth.oxy.so/login'));
159
+ expect(session.sessionId).toBe('sess_pre_opened');
160
+ });
161
+
162
+ it('throws a "window was closed" (cancelled) error — NOT "popup blocked" — when the pre-opened handle is already closed', async () => {
163
+ const windowOpen = jest.fn();
164
+ installBrowserGlobals({ windowOpen });
165
+ const popup = createMockPopup({ closed: true });
166
+
167
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
168
+
169
+ // The popup DID open (the blocker allowed it) and the user closed it.
170
+ // The error must communicate a cancel, never a blocker rejection —
171
+ // consumers map "blocked" to "please allow popups" UX guidance, which
172
+ // would be wrong here.
173
+ await expect(
174
+ oxy.signInWithPopup({ popup: popup as unknown as Window })
175
+ ).rejects.toThrow(/Sign-in window was closed/);
176
+ await expect(
177
+ oxy.signInWithPopup({ popup: popup as unknown as Window })
178
+ ).rejects.not.toThrow(/Popup blocked/);
179
+
180
+ // Did not attempt to open a fresh popup either.
181
+ expect(windowOpen).not.toHaveBeenCalled();
182
+ expect(popup.location.replace).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('falls back to assigning `location.href` when `location.replace` throws (sandboxed environments)', async () => {
186
+ const windowOpen = jest.fn();
187
+ installBrowserGlobals({ windowOpen });
188
+
189
+ // Some sandboxed / cross-origin-locked environments make `location.replace`
190
+ // throw. The mixin must recover with a plain `href` assignment so the
191
+ // popup still gets navigated. This is the only path that exercises the
192
+ // catch-and-log branch in OxyServices.popup.ts.
193
+ const popup: MockPopup = {
194
+ closed: false,
195
+ close: jest.fn(),
196
+ location: {
197
+ href: '',
198
+ replace: jest.fn(() => {
199
+ throw new Error('SecurityError: replace blocked by sandbox');
200
+ }),
201
+ },
202
+ };
203
+
204
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
205
+
206
+ // Drive `signInWithPopup` only until the navigation happens, then abort
207
+ // via `closed = true` so the poll loop's cancel path resolves the promise.
208
+ const promise = oxy.signInWithPopup({ popup: popup as unknown as Window });
209
+ // Let the synchronous popup-navigation path run.
210
+ await Promise.resolve();
211
+ popup.closed = true;
212
+
213
+ await expect(promise).rejects.toThrow(/cancelled|timeout/i);
214
+
215
+ // `replace` was attempted (and threw); the fallback wrote to `href`.
216
+ expect(popup.location.replace).toHaveBeenCalledTimes(1);
217
+ expect(popup.location.href).toMatch(/^https:\/\/auth\.oxy\.so\/login/);
218
+ // No fresh popup was opened.
219
+ expect(windowOpen).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('falls back to opening its own popup when `popup` option is omitted (classic behaviour)', async () => {
223
+ const fallbackPopup = createMockPopup();
224
+ const windowOpen = jest.fn(() => fallbackPopup);
225
+ installBrowserGlobals({ windowOpen });
226
+
227
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
228
+
229
+ // Don't actually wait for the full flow; intercept after `window.open`
230
+ // is called and abort.
231
+ const promise = oxy.signInWithPopup();
232
+ // Allow the synchronous `window.open` path to run.
233
+ await Promise.resolve();
234
+ fallbackPopup.closed = true; // trigger "Authentication cancelled by user"
235
+
236
+ await expect(promise).rejects.toThrow(/cancelled|timeout/i);
237
+
238
+ expect(windowOpen).toHaveBeenCalledTimes(1);
239
+ // The first arg is the auth URL (not 'about:blank').
240
+ expect((windowOpen.mock.calls[0] as unknown[])[0]).toMatch(/^https:\/\/auth\.oxy\.so\/login/);
241
+ });
242
+
243
+ it('throws "popup blocked" when the legacy path is used and `window.open` returns null', async () => {
244
+ const windowOpen = jest.fn(() => null);
245
+ installBrowserGlobals({ windowOpen });
246
+
247
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
248
+
249
+ await expect(oxy.signInWithPopup()).rejects.toThrow(/Popup blocked/);
250
+ expect(windowOpen).toHaveBeenCalledTimes(1);
251
+ });
252
+ });
253
+
254
+ describe('OxyServices popup mixin — openBlankPopup helper', () => {
255
+ afterEach(() => {
256
+ clearBrowserGlobals();
257
+ jest.restoreAllMocks();
258
+ });
259
+
260
+ it('opens about:blank synchronously and returns the window handle', () => {
261
+ const fakePopup = createMockPopup();
262
+ const windowOpen = jest.fn(() => fakePopup);
263
+ installBrowserGlobals({ windowOpen });
264
+
265
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
266
+ const popup = oxy.openBlankPopup();
267
+
268
+ expect(windowOpen).toHaveBeenCalledTimes(1);
269
+ const args = windowOpen.mock.calls[0] as unknown[];
270
+ expect(args[0]).toBe('about:blank');
271
+ expect(args[1]).toBe('Oxy Sign In');
272
+ // Features string should include the default width/height.
273
+ expect(typeof args[2]).toBe('string');
274
+ expect(args[2] as string).toContain('width=500');
275
+ expect(args[2] as string).toContain('height=700');
276
+ expect(popup).toBe(fakePopup);
277
+ });
278
+
279
+ it('returns null when the browser blocks the popup', () => {
280
+ const windowOpen = jest.fn(() => null);
281
+ installBrowserGlobals({ windowOpen });
282
+
283
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
284
+ const popup = oxy.openBlankPopup();
285
+
286
+ expect(popup).toBeNull();
287
+ });
288
+
289
+ it('honors caller-supplied dimensions', () => {
290
+ const fakePopup = createMockPopup();
291
+ const windowOpen = jest.fn(() => fakePopup);
292
+ installBrowserGlobals({ windowOpen });
293
+
294
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
295
+ oxy.openBlankPopup(640, 480);
296
+
297
+ const args = windowOpen.mock.calls[0] as unknown[];
298
+ expect(args[2] as string).toContain('width=640');
299
+ expect(args[2] as string).toContain('height=480');
300
+ });
301
+
302
+ it('returns null in non-browser environments', () => {
303
+ // No browser globals installed.
304
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
305
+ expect(oxy.openBlankPopup()).toBeNull();
306
+ });
307
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `OxyServices.getSessionBaseUrl()` resolution tests.
3
+ *
4
+ * Per the 2026 session architecture (docs/SESSION-ARCHITECTURE.md), every app
5
+ * keeps its OWN first-party session on its OWN domain. `getSessionBaseUrl()`
6
+ * is the configurable base URL the SDK's first-party session/refresh calls will
7
+ * target in a later phase:
8
+ * - non-`oxy.so` apps point `sessionBaseUrl` at their own same-site backend
9
+ * (e.g. `https://api.mention.earth`);
10
+ * - `*.oxy.so` apps leave it unset so it falls back to `baseURL`
11
+ * (`https://api.oxy.so`) — their behavior is unchanged.
12
+ *
13
+ * This phase is additive: the getter only surfaces configuration. It must NOT
14
+ * mutate token/auth state and must NOT alter `getBaseURL()`.
15
+ */
16
+
17
+ import { OxyServices } from '../../OxyServices';
18
+
19
+ describe('OxyServices.getSessionBaseUrl', () => {
20
+ it('falls back to baseURL when sessionBaseUrl is not configured', () => {
21
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
22
+
23
+ expect(oxy.getSessionBaseUrl()).toBe('https://api.oxy.so');
24
+ // Must equal the API base URL exactly — no divergence for *.oxy.so apps.
25
+ expect(oxy.getSessionBaseUrl()).toBe(oxy.getBaseURL());
26
+ });
27
+
28
+ it('returns the configured sessionBaseUrl when provided', () => {
29
+ const oxy = new OxyServices({
30
+ baseURL: 'https://api.oxy.so',
31
+ sessionBaseUrl: 'https://api.mention.earth',
32
+ });
33
+
34
+ expect(oxy.getSessionBaseUrl()).toBe('https://api.mention.earth');
35
+ });
36
+
37
+ it('does not change the API base URL when sessionBaseUrl differs', () => {
38
+ const oxy = new OxyServices({
39
+ baseURL: 'https://api.oxy.so',
40
+ sessionBaseUrl: 'https://api.mention.earth',
41
+ });
42
+
43
+ // getBaseURL (the HTTP client's request base) is independent of the
44
+ // session base — only the latter is overridden by config.
45
+ expect(oxy.getBaseURL()).toBe('https://api.oxy.so');
46
+ expect(oxy.getSessionBaseUrl()).not.toBe(oxy.getBaseURL());
47
+ });
48
+
49
+ it('is a pure read — it does not touch token/auth state', () => {
50
+ const oxy = new OxyServices({
51
+ baseURL: 'https://api.oxy.so',
52
+ sessionBaseUrl: 'https://api.mention.earth',
53
+ });
54
+
55
+ expect(oxy.hasValidToken()).toBe(false);
56
+ // Resolving the session base must not plant or clear any token.
57
+ oxy.getSessionBaseUrl();
58
+ expect(oxy.hasValidToken()).toBe(false);
59
+ expect(oxy.getAccessToken()).toBeNull();
60
+ });
61
+ });
@@ -1,6 +1,20 @@
1
1
  export interface OxyConfig {
2
2
  baseURL: string;
3
3
  cloudURL?: string;
4
+ /**
5
+ * Base URL the SDK's first-party session/refresh calls target.
6
+ *
7
+ * Per the 2026 session architecture (docs/SESSION-ARCHITECTURE.md), every app
8
+ * keeps its OWN first-party session on its OWN domain. For non-`oxy.so` apps
9
+ * this is the app's own same-site backend (e.g. `https://api.mention.earth`),
10
+ * whose session bridge forwards the user's refresh credential to
11
+ * `api.oxy.so`. For `*.oxy.so` apps this is omitted and falls back to
12
+ * `baseURL` (`https://api.oxy.so`), so their behavior is unchanged.
13
+ *
14
+ * Resolve via {@link OxyServices.getSessionBaseUrl}; when unset it returns
15
+ * `baseURL`. This is purely additive — no refresh/auth logic reads it yet.
16
+ */
17
+ sessionBaseUrl?: string;
4
18
  authWebUrl?: string;
5
19
  authRedirectUri?: string;
6
20
  // Performance & caching options
@@ -110,9 +124,43 @@ export interface User {
110
124
  // Managed account fields
111
125
  isManagedAccount?: boolean;
112
126
  managedBy?: string;
127
+ // User-controlled notification preferences. All channels default to on; users
128
+ // opt out per-channel. Updated via `PUT /users/me`.
129
+ notificationPreferences?: NotificationPreferences;
130
+ // General app-wide user preferences. Updated via `PUT /users/me`.
131
+ userPreferences?: UserPreferences;
113
132
  [key: string]: unknown;
114
133
  }
115
134
 
135
+ /**
136
+ * User-controlled notification channels. Persisted on the User document.
137
+ */
138
+ export interface NotificationPreferences {
139
+ /** Push notifications on registered devices. */
140
+ pushEnabled?: boolean;
141
+ /** Periodic email digest of activity. */
142
+ emailDigest?: boolean;
143
+ /** Security/account alerts (sign-ins, recovery, key changes). */
144
+ securityAlerts?: boolean;
145
+ /** Marketing / product update emails. */
146
+ marketingEmails?: boolean;
147
+ }
148
+
149
+ /**
150
+ * General per-user preferences applied across all Oxy apps for the user.
151
+ * Persisted on the User document.
152
+ */
153
+ export interface UserPreferences {
154
+ /** BCP-47 language tag, e.g. "en-US", "es-ES". Empty string = follow device. */
155
+ language?: string;
156
+ /** Theme mode preference. */
157
+ theme?: 'light' | 'dark' | 'system';
158
+ /** Mirror of OS reduce-motion preference, persisted server-side. */
159
+ reduceMotion?: boolean;
160
+ /** IANA timezone, e.g. "Europe/Madrid". Empty string = follow device. */
161
+ timezone?: string;
162
+ }
163
+
116
164
  export interface LoginResponse {
117
165
  accessToken?: string;
118
166
  refreshToken?: string;
@@ -590,3 +638,71 @@ export interface UpdateDeviceNameResponse {
590
638
  message: string;
591
639
  deviceName: string;
592
640
  }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // Multi-account "refresh-all" (Google-style)
644
+ // ---------------------------------------------------------------------------
645
+ // Wire shape of `POST /auth/refresh-all`. The server rotates every device-local
646
+ // `oxy_rt_${authuser}` cookie in parallel and returns one entry per VALID
647
+ // account, sorted by `authuser` ascending. Slot-level errors are silently
648
+ // omitted; the response is `{ accounts: [] }` in the worst case (no signed-in
649
+ // accounts, all cookies expired, or origin not allowlisted).
650
+
651
+ /**
652
+ * Minimal user shape included in a `RefreshAllAccount` entry. The server
653
+ * projects a small whitelist (`username name avatar email color`) so the
654
+ * client can render the account chooser without an extra `/users/me` round
655
+ * trip per account.
656
+ *
657
+ * `avatar` and `color` are `string | null` because they are stored as nullable
658
+ * fields in the user document.
659
+ */
660
+ export interface RefreshAllAccountUser {
661
+ id: string;
662
+ username: string;
663
+ name?: string;
664
+ avatar?: string | null;
665
+ email?: string;
666
+ color?: string | null;
667
+ }
668
+
669
+ /**
670
+ * One rotated account entry returned by `POST /auth/refresh-all`. `authuser` is
671
+ * the device-local slot index (0..N-1) the cookie was bound to. The legacy
672
+ * un-suffixed `oxy_rt` cookie yields `authuser: null` server-side, but the SDK
673
+ * normalises that to `0` before exposing it (the chooser always operates on
674
+ * numeric indices).
675
+ *
676
+ * `user` is `null` only on the SDK-side synthesised legacy fallback (when the
677
+ * server is too old to support `/auth/refresh-all` and we wrap a
678
+ * `/auth/refresh` response — that endpoint does not project a user shape).
679
+ * On the modern path every accepted entry carries a non-null user.
680
+ */
681
+ export interface RefreshAllAccount {
682
+ authuser: number;
683
+ accessToken: string;
684
+ expiresAt: string;
685
+ sessionId: string;
686
+ user: RefreshAllAccountUser | null;
687
+ }
688
+
689
+ /**
690
+ * Wire shape of `POST /auth/refresh-all`. Always 200 with a (possibly empty)
691
+ * accounts array — 401 means "no accounts signed in on this device" and is
692
+ * normalised to `{ accounts: [] }` at the SDK layer.
693
+ */
694
+ export interface RefreshAllResponse {
695
+ accounts: RefreshAllAccount[];
696
+ }
697
+
698
+ /**
699
+ * Wire shape of `POST /auth/refresh` (single-account refresh, optionally
700
+ * targeting a specific `?authuser=N` slot). The server includes `authuser` in
701
+ * the response when an indexed slot was rotated; the legacy slot yields
702
+ * `authuser: null`.
703
+ */
704
+ export interface RefreshCookieResponse {
705
+ accessToken: string;
706
+ expiresAt: string;
707
+ authuser: number | null;
708
+ }
@@ -5,6 +5,14 @@ export interface ClientSession {
5
5
  lastActive: string;
6
6
  userId?: string;
7
7
  isCurrent?: boolean;
8
+ /**
9
+ * Web-only: the device-local refresh-cookie slot index (0..N) that backs
10
+ * this session. Populated from `POST /auth/refresh-all` and from login /
11
+ * signup / fedcm-exchange responses. Required for per-session web token
12
+ * refresh via `refreshTokenViaCookie({ authuser })` without a bearer token.
13
+ * Absent on native (RN uses the bearer-protected session id directly).
14
+ */
15
+ authuser?: number;
8
16
  }
9
17
 
10
18
  export interface StorageKeys {