@oxyhq/core 5.1.1 → 5.2.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 (114) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +27 -5
  3. package/dist/cjs/OxyServices.base.js +71 -0
  4. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  5. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  6. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  7. package/dist/cjs/i18n/locales/en-US.json +1 -0
  8. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  9. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  10. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  11. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  12. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  24. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  26. package/dist/cjs/index.js +3 -1
  27. package/dist/cjs/mixins/OxyServices.accounts.js +10 -25
  28. package/dist/cjs/mixins/OxyServices.assets.js +76 -0
  29. package/dist/cjs/utils/cache.js +2 -0
  30. package/dist/cjs/utils/ssoBounce.js +59 -0
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/AuthManager.js +27 -5
  33. package/dist/esm/OxyServices.base.js +71 -0
  34. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  35. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  36. package/dist/esm/i18n/locales/de-DE.json +1 -0
  37. package/dist/esm/i18n/locales/en-US.json +1 -0
  38. package/dist/esm/i18n/locales/es-ES.json +1 -0
  39. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  40. package/dist/esm/i18n/locales/it-IT.json +1 -0
  41. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  42. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  43. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  44. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  45. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  46. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  47. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  48. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  49. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  50. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  52. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  53. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  54. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  55. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  56. package/dist/esm/index.js +1 -1
  57. package/dist/esm/mixins/OxyServices.accounts.js +10 -25
  58. package/dist/esm/mixins/OxyServices.assets.js +76 -0
  59. package/dist/esm/utils/cache.js +2 -0
  60. package/dist/esm/utils/ssoBounce.js +57 -0
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/OxyServices.base.d.ts +38 -0
  63. package/dist/types/index.d.ts +2 -2
  64. package/dist/types/mixins/OxyServices.accounts.d.ts +1 -0
  65. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  66. package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
  67. package/dist/types/mixins/OxyServices.assets.d.ts +36 -1
  68. package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
  69. package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
  70. package/dist/types/mixins/OxyServices.connectedApps.d.ts +1 -0
  71. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  73. package/dist/types/mixins/OxyServices.features.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  75. package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
  76. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  77. package/dist/types/mixins/OxyServices.links.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
  80. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.user.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  90. package/dist/types/models/interfaces.d.ts +23 -0
  91. package/dist/types/utils/ssoBounce.d.ts +47 -0
  92. package/package.json +1 -1
  93. package/src/AuthManager.ts +29 -5
  94. package/src/OxyServices.base.ts +79 -0
  95. package/src/__tests__/establishDeviceRefreshSlot.test.ts +221 -0
  96. package/src/i18n/locales/ar-SA.json +1 -0
  97. package/src/i18n/locales/ca-ES.json +1 -0
  98. package/src/i18n/locales/de-DE.json +1 -0
  99. package/src/i18n/locales/en-US.json +1 -0
  100. package/src/i18n/locales/es-ES.json +1 -0
  101. package/src/i18n/locales/fr-FR.json +1 -0
  102. package/src/i18n/locales/it-IT.json +1 -0
  103. package/src/i18n/locales/ja-JP.json +1 -0
  104. package/src/i18n/locales/ko-KR.json +1 -0
  105. package/src/i18n/locales/pt-PT.json +1 -0
  106. package/src/i18n/locales/zh-CN.json +1 -0
  107. package/src/index.ts +3 -0
  108. package/src/mixins/OxyServices.accounts.ts +10 -33
  109. package/src/mixins/OxyServices.assets.ts +92 -1
  110. package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
  111. package/src/models/interfaces.ts +24 -0
  112. package/src/utils/__tests__/ssoBounce.test.ts +41 -0
  113. package/src/utils/cache.ts +2 -0
  114. package/src/utils/ssoBounce.ts +61 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `establishDeviceRefreshSlot` + primary-session slot-registration tests.
3
+ *
4
+ * ROOT CAUSE these lock in: a web PRIMARY session (FedCM exchange, central `/sso`
5
+ * return, IdP `/auth/silent` iframe, password) plants only an access token. The
6
+ * cross-origin/credential-less restore endpoints (`/sso/exchange`, `/auth/silent`)
7
+ * cannot set the device's `oxy_rt_<authuser>` cookie, so without an explicit
8
+ * `POST /auth/session` the primary never joins the device refresh-cookie set:
9
+ * `/auth/refresh-all` returns zero accounts and account-switch persistence has no
10
+ * foundation. `establishDeviceRefreshSlot()` is the single shared primitive (on the
11
+ * base, reused by `switchToAccount` and `AuthManager.handleAuthSuccess`) that plants
12
+ * the slot where the cookie is visible, re-plants the rotated token, and returns the
13
+ * authoritative `authuser`.
14
+ *
15
+ * `testEnvironment` is `node`, where `getPlatformOS()` resolves to `'web'`
16
+ * (`isWeb()` is true) and `window` is undefined — so the FIRST-PARTY apex gate is
17
+ * inert and the method exercises its happy path. Browser-only apex gating is
18
+ * verified separately by stubbing a `window`.
19
+ */
20
+
21
+ import { OxyServices } from '../OxyServices';
22
+ import { AuthManager } from '../AuthManager';
23
+ import type { StorageAdapter } from '../AuthManager';
24
+ import type { RefreshAllResponse, RefreshCookieResponse, User } from '../models/interfaces';
25
+ import type { SessionLoginResponse } from '../models/session';
26
+
27
+ function buildAccessToken(claims: Record<string, unknown>): string {
28
+ const b64url = (value: string): string =>
29
+ Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
30
+ const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
31
+ const payload = b64url(JSON.stringify(claims));
32
+ return `${header}.${payload}.signature`;
33
+ }
34
+
35
+ describe('OxyServices.establishDeviceRefreshSlot', () => {
36
+ let oxy: OxyServices;
37
+ let makeRequestSpy: jest.SpyInstance;
38
+
39
+ beforeEach(() => {
40
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
41
+ oxy.httpService.setTokens('primary-token');
42
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
43
+ });
44
+
45
+ afterEach(() => {
46
+ makeRequestSpy.mockRestore();
47
+ });
48
+
49
+ it('POSTs to /auth/session, re-plants the rotated access token, and returns the server authuser', async () => {
50
+ makeRequestSpy.mockResolvedValue({ accessToken: 'rotated-token', authuser: 2 });
51
+
52
+ const authuser = await oxy.establishDeviceRefreshSlot();
53
+
54
+ expect(authuser).toBe(2);
55
+ expect(makeRequestSpy).toHaveBeenCalledWith('POST', '/auth/session', undefined, { cache: false });
56
+ // The rotated token from /auth/session is now the active token.
57
+ expect(oxy.getAccessToken()).toBe('rotated-token');
58
+ });
59
+
60
+ it('returns the authuser even when the response carries no rotated token (keeps the existing token)', async () => {
61
+ makeRequestSpy.mockResolvedValue({ authuser: 0 });
62
+
63
+ const authuser = await oxy.establishDeviceRefreshSlot();
64
+
65
+ expect(authuser).toBe(0);
66
+ expect(oxy.getAccessToken()).toBe('primary-token');
67
+ });
68
+
69
+ it('returns null when the server omits a numeric authuser', async () => {
70
+ makeRequestSpy.mockResolvedValue({ accessToken: 'rotated-token' });
71
+
72
+ const authuser = await oxy.establishDeviceRefreshSlot();
73
+
74
+ expect(authuser).toBeNull();
75
+ });
76
+
77
+ it('is best-effort: a failed /auth/session resolves null and never throws', async () => {
78
+ makeRequestSpy.mockRejectedValue(new Error('network down'));
79
+
80
+ await expect(oxy.establishDeviceRefreshSlot()).resolves.toBeNull();
81
+ });
82
+
83
+ it('FIRST-PARTY gate: skips /auth/session when the page apex differs from the API apex (cross-apex RP)', async () => {
84
+ // Simulate a cross-apex RP page (mention.earth) calling api.oxy.so.
85
+ const rp = new OxyServices({ baseURL: 'https://api.oxy.so' });
86
+ rp.httpService.setTokens('primary-token');
87
+ const rpSpy = jest.spyOn(rp, 'makeRequest');
88
+ const originalWindow = (globalThis as { window?: unknown }).window;
89
+ (globalThis as { window?: unknown }).window = { location: { hostname: 'mention.earth' } };
90
+ try {
91
+ const authuser = await rp.establishDeviceRefreshSlot();
92
+ expect(authuser).toBeNull();
93
+ expect(rpSpy).not.toHaveBeenCalled();
94
+ } finally {
95
+ if (originalWindow === undefined) {
96
+ delete (globalThis as { window?: unknown }).window;
97
+ } else {
98
+ (globalThis as { window?: unknown }).window = originalWindow;
99
+ }
100
+ rpSpy.mockRestore();
101
+ }
102
+ });
103
+
104
+ it('FIRST-PARTY gate: proceeds when the page apex matches the API apex (first-party *.oxy.so)', async () => {
105
+ const fp = new OxyServices({ baseURL: 'https://api.oxy.so' });
106
+ fp.httpService.setTokens('primary-token');
107
+ const fpSpy = jest.spyOn(fp, 'makeRequest').mockResolvedValue({ accessToken: 'rotated', authuser: 1 });
108
+ const originalWindow = (globalThis as { window?: unknown }).window;
109
+ (globalThis as { window?: unknown }).window = { location: { hostname: 'accounts.oxy.so' } };
110
+ try {
111
+ const authuser = await fp.establishDeviceRefreshSlot();
112
+ expect(authuser).toBe(1);
113
+ expect(fpSpy).toHaveBeenCalledWith('POST', '/auth/session', undefined, { cache: false });
114
+ } finally {
115
+ if (originalWindow === undefined) {
116
+ delete (globalThis as { window?: unknown }).window;
117
+ } else {
118
+ (globalThis as { window?: unknown }).window = originalWindow;
119
+ }
120
+ fpSpy.mockRestore();
121
+ }
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // AuthManager.handleAuthSuccess — every web primary now joins the device set
127
+ // ---------------------------------------------------------------------------
128
+
129
+ class InMemoryStorage implements StorageAdapter {
130
+ private store = new Map<string, string>();
131
+ getItem(key: string): string | null { return this.store.get(key) ?? null; }
132
+ setItem(key: string, value: string): void { this.store.set(key, value); }
133
+ removeItem(key: string): void { this.store.delete(key); }
134
+ raw(): Map<string, string> { return this.store; }
135
+ }
136
+
137
+ interface MockHttpService {
138
+ setTokens: jest.Mock;
139
+ setAuthRefreshHandler: jest.Mock;
140
+ }
141
+
142
+ interface MockServices {
143
+ establishDeviceRefreshSlot: jest.Mock<Promise<number | null>, []>;
144
+ getAccessToken: jest.Mock<string | null, []>;
145
+ refreshAllSessions: jest.Mock<Promise<RefreshAllResponse>, []>;
146
+ refreshTokenViaCookie: jest.Mock<Promise<RefreshCookieResponse | null>, [{ authuser?: number }]>;
147
+ logoutSessionByAuthuser: jest.Mock<Promise<void>, [number]>;
148
+ logoutAllSessionsViaCookie: jest.Mock<Promise<void>, []>;
149
+ getCurrentUser: jest.Mock<Promise<User>, []>;
150
+ httpService: MockHttpService;
151
+ }
152
+
153
+ const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
154
+
155
+ function makeMockServices(): MockServices {
156
+ return {
157
+ establishDeviceRefreshSlot: jest.fn(async () => null),
158
+ getAccessToken: jest.fn(() => null),
159
+ refreshAllSessions: jest.fn(async (): Promise<RefreshAllResponse> => ({ accounts: [] })),
160
+ refreshTokenViaCookie: jest.fn(),
161
+ logoutSessionByAuthuser: jest.fn(async () => undefined),
162
+ logoutAllSessionsViaCookie: jest.fn(async () => undefined),
163
+ getCurrentUser: jest.fn(async (): Promise<User> => ({ id: 'user-fedcm', publicKey: '0xabc', username: 'nate' } as User)),
164
+ httpService: { setTokens: jest.fn(), setAuthRefreshHandler: jest.fn() },
165
+ };
166
+ }
167
+
168
+ function makeManager(services: MockServices, storage: InMemoryStorage): AuthManager {
169
+ return new AuthManager(services as unknown as OxyServices, {
170
+ storage,
171
+ autoRefresh: false,
172
+ crossTabSync: false,
173
+ });
174
+ }
175
+
176
+ function fedcmSession(): SessionLoginResponse {
177
+ // No `authuser` claim in the FedCM-restored token — mirrors a cross-domain
178
+ // restore whose token is not slot-bound until /auth/session runs.
179
+ return {
180
+ accessToken: buildAccessToken({ sessionId: 'sess-fedcm', userId: 'user-fedcm', exp: 9999999999 }),
181
+ sessionId: 'sess-fedcm',
182
+ deviceId: 'dev-1',
183
+ expiresAt: '2099-01-01T00:00:00.000Z',
184
+ user: { id: 'user-fedcm', username: 'nate', name: { displayName: 'Nate' } },
185
+ } as SessionLoginResponse;
186
+ }
187
+
188
+ describe('AuthManager.handleAuthSuccess — primary session joins the device set', () => {
189
+ it('establishes the device refresh slot and adopts the server-authoritative authuser', async () => {
190
+ const services = makeMockServices();
191
+ // /auth/session allocated slot 3 and rotated the token.
192
+ services.establishDeviceRefreshSlot.mockResolvedValueOnce(3);
193
+ const rotated = buildAccessToken({ sessionId: 'sess-fedcm', userId: 'user-fedcm', authuser: 3, exp: 9999999999 });
194
+ services.getAccessToken.mockReturnValue(rotated);
195
+ const storage = new InMemoryStorage();
196
+ const manager = makeManager(services, storage);
197
+
198
+ await manager.handleAuthSuccess(fedcmSession(), 'fedcm');
199
+
200
+ expect(services.establishDeviceRefreshSlot).toHaveBeenCalledTimes(1);
201
+ // The authoritative slot from /auth/session is recorded as the active account.
202
+ expect(manager.getActiveAuthuser()).toBe(3);
203
+ expect(storage.raw().get(ACTIVE_AUTHUSER_KEY)).toBe('3');
204
+ expect(manager.getActiveAccount()?.authuser).toBe(3);
205
+ });
206
+
207
+ it('falls back to the JWT-decoded authuser (then 0) when /auth/session is a no-op (native / cross-apex)', async () => {
208
+ const services = makeMockServices();
209
+ services.establishDeviceRefreshSlot.mockResolvedValueOnce(null);
210
+ const storage = new InMemoryStorage();
211
+ const manager = makeManager(services, storage);
212
+
213
+ // FedCM session token carries no authuser claim → falls back to slot 0.
214
+ await manager.handleAuthSuccess(fedcmSession(), 'fedcm');
215
+
216
+ expect(manager.getActiveAuthuser()).toBe(0);
217
+ // getAccessToken() is only consulted for the rotated token when a slot was
218
+ // actually established — a null slot must not re-read it.
219
+ expect(services.getAccessToken).not.toHaveBeenCalled();
220
+ });
221
+ });
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "قائمة الحساب",
209
209
  "manage": "إدارة حساب Oxy الخاص بك",
210
+ "viewProfile": "عرض الملف الشخصي",
210
211
  "addAnother": "إضافة حساب آخر",
211
212
  "signOutAll": "تسجيل الخروج من جميع الحسابات",
212
213
  "open": "قائمة الحساب",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menú del compte",
209
209
  "manage": "Gestiona el teu compte d'Oxy",
210
+ "viewProfile": "Mostra el perfil",
210
211
  "addAnother": "Afegeix un altre compte",
211
212
  "signOutAll": "Tanca la sessió de tots els comptes",
212
213
  "open": "Menú del compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Kontomenü",
209
209
  "manage": "Ihr Oxy-Konto verwalten",
210
+ "viewProfile": "Profil ansehen",
210
211
  "addAnother": "Weiteres Konto hinzufügen",
211
212
  "signOutAll": "Von allen Konten abmelden",
212
213
  "open": "Kontomenü",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Account menu",
1557
1557
  "manage": "Manage your Oxy Account",
1558
+ "viewProfile": "View profile",
1558
1559
  "addAnother": "Add another account",
1559
1560
  "signOutAll": "Sign out of all accounts",
1560
1561
  "open": "Account menu",
@@ -1555,6 +1555,7 @@
1555
1555
  "accountMenu": {
1556
1556
  "label": "Menú de cuenta",
1557
1557
  "manage": "Gestiona tu cuenta de Oxy",
1558
+ "viewProfile": "Ver perfil",
1558
1559
  "addAnother": "Añadir otra cuenta",
1559
1560
  "signOutAll": "Cerrar sesión en todas las cuentas",
1560
1561
  "open": "Menú de cuenta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu du compte",
209
209
  "manage": "Gérer votre compte Oxy",
210
+ "viewProfile": "Voir le profil",
210
211
  "addAnother": "Ajouter un autre compte",
211
212
  "signOutAll": "Se déconnecter de tous les comptes",
212
213
  "open": "Menu du compte",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu account",
209
209
  "manage": "Gestisci il tuo account Oxy",
210
+ "viewProfile": "Visualizza profilo",
210
211
  "addAnother": "Aggiungi un altro account",
211
212
  "signOutAll": "Esci da tutti gli account",
212
213
  "open": "Menu account",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "アカウントメニュー",
209
209
  "manage": "Oxy アカウントを管理",
210
+ "viewProfile": "プロフィールを表示",
210
211
  "addAnother": "別のアカウントを追加",
211
212
  "signOutAll": "すべてのアカウントからログアウト",
212
213
  "open": "アカウントメニュー",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "계정 메뉴",
209
209
  "manage": "Oxy 계정 관리",
210
+ "viewProfile": "프로필 보기",
210
211
  "addAnother": "다른 계정 추가",
211
212
  "signOutAll": "모든 계정에서 로그아웃",
212
213
  "open": "계정 메뉴",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "Menu da conta",
209
209
  "manage": "Gerir a sua conta Oxy",
210
+ "viewProfile": "Ver perfil",
210
211
  "addAnother": "Adicionar outra conta",
211
212
  "signOutAll": "Terminar sessão em todas as contas",
212
213
  "open": "Menu da conta",
@@ -207,6 +207,7 @@
207
207
  "accountMenu": {
208
208
  "label": "账户菜单",
209
209
  "manage": "管理您的 Oxy 账户",
210
+ "viewProfile": "查看个人资料",
210
211
  "addAnother": "添加其他账户",
211
212
  "signOutAll": "退出所有账户",
212
213
  "open": "账户菜单",
package/src/index.ts CHANGED
@@ -325,6 +325,7 @@ export type {
325
325
  AssetUpdateVisibilityRequest,
326
326
  AssetUpdateVisibilityResponse,
327
327
  ServiceAssetMetadata,
328
+ ServiceAssetMetadataBySha,
328
329
  AccountStorageCategoryUsage,
329
330
  AccountStorageUsageResponse,
330
331
  SecurityEventType,
@@ -539,12 +540,14 @@ export {
539
540
  ssoNoSessionKey,
540
541
  ssoAttemptedKey,
541
542
  ssoPriorSessionKey,
543
+ ssoSignedOutKey,
542
544
  ssoCallbackBootstrapKey,
543
545
  ssoNavigate,
544
546
  getSsoCallbackBootstrapScript,
545
547
  buildSsoBounceUrl,
546
548
  isCentralIdPOrigin,
547
549
  guardActive,
550
+ silentRestoreSuppressed,
548
551
  allowSsoBounce,
549
552
  } from './utils/ssoBounce';
550
553
  export type { SsoBounceGate } from './utils/ssoBounce';
@@ -35,8 +35,6 @@ import type { User } from '../models/interfaces';
35
35
  import type { SessionLoginResponse } from '../models/session';
36
36
  import type { OxyServicesBase } from '../OxyServices.base';
37
37
  import { normalizeUserIdentity } from '../utils/userIdentity';
38
- import { isWeb } from '../utils/platform';
39
- import { logger } from '../utils/loggerUtils';
40
38
  import { CACHE_TIMES } from './mixinHelpers';
41
39
 
42
40
  // ---------------------------------------------------------------------------
@@ -564,37 +562,16 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
564
562
 
565
563
  // Register the switched session in the device's multi-account set by
566
564
  // establishing its first-party refresh cookie. This MUST be a separate
567
- // call to `POST /auth/session`: the switch route is at `/accounts/*`,
568
- // outside the `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can
569
- // never read the device's existing slots and would overwrite slot 0
570
- // (destroying the operator's own session). `/auth/session` runs where the
571
- // cookies ARE visible, so the server allocates a NEW slot that coexists
572
- // with the operator's and returns its `authuser`. Web-only; best-effort.
573
- let authuser = res.authuser;
574
- if (isWeb()) {
575
- try {
576
- const established = await this.makeRequest<{ accessToken?: string; authuser?: number }>(
577
- 'POST',
578
- '/auth/session',
579
- undefined,
580
- { cache: false },
581
- );
582
- if (typeof established?.authuser === 'number') {
583
- authuser = established.authuser;
584
- }
585
- // /auth/session mints a fresh access token off the same session;
586
- // re-plant it so the active token matches the rotated cookie.
587
- if (established?.accessToken) {
588
- this.setTokens(established.accessToken);
589
- }
590
- } catch (error) {
591
- logger.warn(
592
- '[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload',
593
- { component: 'OxyServices', method: 'switchToAccount' },
594
- error,
595
- );
596
- }
597
- }
565
+ // call to `POST /auth/session` (the shared `establishDeviceRefreshSlot`
566
+ // primitive): the switch route is at `/accounts/*`, outside the
567
+ // `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can never read
568
+ // the device's existing slots and would overwrite slot 0 (destroying the
569
+ // operator's own session). `/auth/session` runs where the cookies ARE
570
+ // visible, so the server allocates a NEW slot that coexists with the
571
+ // operator's and returns its `authuser`. Web-only; best-effort (the helper
572
+ // re-plants the rotated access token and returns `null` on native/failure).
573
+ const establishedAuthuser = await this.establishDeviceRefreshSlot();
574
+ const authuser = establishedAuthuser ?? res.authuser;
598
575
 
599
576
  // Identity changed → drop the entire GET response cache so no entry
600
577
  // personalised for the previous identity is reused. Cache keys are
@@ -1,4 +1,4 @@
1
- import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata } from '../models/interfaces';
1
+ import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor, ServiceAssetMetadata, ServiceAssetMetadataBySha } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
  import { isReactNative } from '@oxyhq/protocol';
4
4
  import { logger } from '../utils/loggerUtils';
@@ -12,6 +12,21 @@ import { extractErrorStatus } from '../utils/errorUtils';
12
12
  */
13
13
  const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
14
14
 
15
+ /**
16
+ * Maximum number of content hashes sent per `POST /assets/service/by-sha256`
17
+ * request. Matches the server-side cap (the route rejects empty or > 100 hash
18
+ * arrays with a 400); larger inputs are chunked and merged. Same ceiling as
19
+ * {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} for the forward id lookup.
20
+ */
21
+ const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
22
+
23
+ /**
24
+ * Lowercase hex SHA-256 digest matcher (exactly 64 hex chars). The reverse
25
+ * lookup drops non-conforming hashes client-side so a single malformed value
26
+ * never 400s an otherwise-valid chunk on the server.
27
+ */
28
+ const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
29
+
15
30
  export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
16
31
  return class extends Base {
17
32
  constructor(...args: any[]) {
@@ -261,6 +276,82 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
261
276
  return settled.flat();
262
277
  }
263
278
 
279
+ /**
280
+ * Reverse content-address lookup: resolve many content `sha256` digests to
281
+ * the servable Oxy asset holding each, in one round-trip per chunk via
282
+ * `POST /assets/service/by-sha256` (body `{ sha256s }`).
283
+ *
284
+ * This is the INVERSE of {@link getServiceAssetMetadataByIds}: given a
285
+ * record's `blob.sha256`, it returns the asset's `id`, `mime`, byte `size`,
286
+ * `status`, and — for active, public, CDN-reachable assets only — a public
287
+ * `url` (`cloud.oxy.so`). Built for server-to-server callers (e.g. Mention's
288
+ * MTN materializer / node-blob sync) that hold a content hash and need to
289
+ * map it back to a servable asset. Hashes are lowercased, validated against
290
+ * a 64-char hex pattern (malformed entries dropped client-side), and
291
+ * deduplicated before being split into chunks of
292
+ * {@link SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE} (the server-side cap). The
293
+ * server omits unknown/deleted hashes from each chunk's `data`, so the
294
+ * merged result may be shorter than the requested list and the caller is
295
+ * expected to map by `sha256`.
296
+ *
297
+ * **Service-token auth (required).** `/assets/service/by-sha256` is guarded
298
+ * by `serviceAuthMiddleware` + the `files:read` scope and is called via
299
+ * `makeServiceRequest` (`Authorization: Bearer <serviceToken>`, `cache:false`).
300
+ * The calling client MUST be service-configured (`configureServiceAuth`)
301
+ * before invoking; a plain user-session request is rejected by the route's
302
+ * service-auth guard.
303
+ *
304
+ * Resilience: chunks are independent. A failed chunk is logged and skipped —
305
+ * every entry that resolved is still returned. An empty input (or one whose
306
+ * every value is malformed) resolves immediately with `[]` and performs no
307
+ * network call.
308
+ *
309
+ * Not cached at the SDK layer: it's a POST keyed on a multi-hash body (low
310
+ * hit rate), mirroring the sibling service/POST methods which never cache.
311
+ */
312
+ async getServiceAssetMetadataBySha256(sha256s: string[]): Promise<ServiceAssetMetadataBySha[]> {
313
+ const uniqueShas = Array.from(
314
+ new Set(
315
+ sha256s
316
+ .filter((sha): sha is string => typeof sha === 'string')
317
+ .map((sha) => sha.trim().toLowerCase())
318
+ .filter((sha) => SHA256_HEX_PATTERN.test(sha)),
319
+ ),
320
+ );
321
+ if (uniqueShas.length === 0) {
322
+ return [];
323
+ }
324
+
325
+ const chunks: string[][] = [];
326
+ for (let i = 0; i < uniqueShas.length; i += SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE) {
327
+ chunks.push(uniqueShas.slice(i, i + SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE));
328
+ }
329
+
330
+ // Run chunks concurrently; a single chunk failure must not sink the rest.
331
+ const settled = await Promise.all(
332
+ chunks.map(async (chunk): Promise<ServiceAssetMetadataBySha[]> => {
333
+ try {
334
+ const entries = await this.makeServiceRequest<ServiceAssetMetadataBySha[]>(
335
+ 'POST',
336
+ '/assets/service/by-sha256',
337
+ { sha256s: chunk },
338
+ );
339
+ return Array.isArray(entries) ? entries : [];
340
+ } catch (error: unknown) {
341
+ logger.warn('getServiceAssetMetadataBySha256: chunk failed, continuing with remaining chunks', {
342
+ method: 'getServiceAssetMetadataBySha256',
343
+ chunkSize: chunk.length,
344
+ status: extractErrorStatus(error),
345
+ error: error instanceof Error ? error.message : String(error),
346
+ });
347
+ return [];
348
+ }
349
+ }),
350
+ );
351
+
352
+ return settled.flat();
353
+ }
354
+
264
355
  /**
265
356
  * Upload raw file data
266
357
  */
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `getServiceAssetMetadataBySha256` mixin tests (reverse content-address lookup).
3
+ *
4
+ * Stubs `makeServiceRequest` (the service-token transport used by the route's
5
+ * `serviceAuthMiddleware` + `files:read` scope) so the tests run with no network
6
+ * and no `getServiceToken()` round-trip, then asserts:
7
+ * - empty / whitespace / all-malformed input no-ops to `[]` with no network call;
8
+ * - hashes are lowercased, hex-validated (malformed dropped), and de-duplicated,
9
+ * and a single chunk is POSTed to `/assets/service/by-sha256` as `{ sha256s }`;
10
+ * - the `{ data }` envelope is unwrapped to a bare `ServiceAssetMetadataBySha[]`
11
+ * (mirroring how `makeServiceRequest<T[]>` returns the inner array), with the
12
+ * optional `url` preserved for public assets and absent for private ones;
13
+ * - inputs > 100 hashes are chunked at 100/request and merged;
14
+ * - a failed chunk is logged and skipped — successful chunks still return.
15
+ */
16
+
17
+ import type { ServiceAssetMetadataBySha } from '../../models/interfaces';
18
+ import { OxyServices } from '../../OxyServices';
19
+
20
+ const publicEntry: ServiceAssetMetadataBySha = {
21
+ sha256: 'a'.repeat(64),
22
+ id: 'asset-1',
23
+ mime: 'image/jpeg',
24
+ size: 12345,
25
+ status: 'active',
26
+ url: 'https://cloud.oxy.so/content/2026/06/aa/abc.jpg',
27
+ };
28
+
29
+ const privateEntry: ServiceAssetMetadataBySha = {
30
+ sha256: 'b'.repeat(64),
31
+ id: 'asset-2',
32
+ mime: 'application/octet-stream',
33
+ size: 7,
34
+ status: 'active',
35
+ // no url — private/unlisted assets stream through the origin
36
+ };
37
+
38
+ describe('OxyServices.assets — getServiceAssetMetadataBySha256', () => {
39
+ let oxy: OxyServices;
40
+ let makeServiceRequestSpy: jest.SpyInstance;
41
+
42
+ beforeEach(() => {
43
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
44
+ makeServiceRequestSpy = jest.spyOn(oxy, 'makeServiceRequest');
45
+ });
46
+
47
+ afterEach(() => {
48
+ jest.restoreAllMocks();
49
+ });
50
+
51
+ it('returns [] and performs no network call for empty / malformed input', async () => {
52
+ await expect(oxy.getServiceAssetMetadataBySha256([])).resolves.toEqual([]);
53
+ // whitespace, wrong length, and non-hex are all dropped → no chunk to send
54
+ await expect(
55
+ oxy.getServiceAssetMetadataBySha256([' ', 'zzz', 'a'.repeat(63), 'a'.repeat(65)]),
56
+ ).resolves.toEqual([]);
57
+ expect(makeServiceRequestSpy).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it('lowercases, hex-validates, de-duplicates, and sends a single chunk', async () => {
61
+ // makeServiceRequest unwraps the API's `{ data }` envelope, so the resolved
62
+ // value is the bare array (NOT `{ data: [...] }`) — mirror that real shape.
63
+ makeServiceRequestSpy.mockResolvedValueOnce([publicEntry, privateEntry]);
64
+
65
+ const result = await oxy.getServiceAssetMetadataBySha256([
66
+ 'A'.repeat(64), // uppercase → normalized to 'a'*64
67
+ 'a'.repeat(64), // duplicate after lowercasing
68
+ 'B'.repeat(64),
69
+ 'not-hex', // dropped
70
+ ' ', // dropped
71
+ ]);
72
+
73
+ expect(result).toEqual([publicEntry, privateEntry]);
74
+ // public asset carries url; private one does not
75
+ expect(result[0].url).toBe(publicEntry.url);
76
+ expect(result[1].url).toBeUndefined();
77
+
78
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(1);
79
+ expect(makeServiceRequestSpy).toHaveBeenCalledWith(
80
+ 'POST',
81
+ '/assets/service/by-sha256',
82
+ { sha256s: ['a'.repeat(64), 'b'.repeat(64)] },
83
+ );
84
+ });
85
+
86
+ it('chunks at 100 hashes per request and merges each chunk', async () => {
87
+ // 250 distinct valid hex digests.
88
+ const shas = Array.from({ length: 250 }, (_, i) =>
89
+ i.toString(16).padStart(64, '0'),
90
+ );
91
+
92
+ makeServiceRequestSpy.mockImplementation(
93
+ async (
94
+ _method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
95
+ _url: string,
96
+ data?: { sha256s: string[] },
97
+ ): Promise<ServiceAssetMetadataBySha[]> =>
98
+ (data?.sha256s ?? []).map((sha) => ({
99
+ sha256: sha,
100
+ id: `asset-${sha}`,
101
+ mime: 'application/octet-stream',
102
+ size: 1,
103
+ status: 'active' as const,
104
+ })),
105
+ );
106
+
107
+ const result = await oxy.getServiceAssetMetadataBySha256(shas);
108
+
109
+ // 250 unique hashes => 100 + 100 + 50 across three POSTs.
110
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(3);
111
+ const chunkSizes = makeServiceRequestSpy.mock.calls.map(
112
+ (call) => (call[2] as { sha256s: string[] }).sha256s.length,
113
+ );
114
+ expect(chunkSizes).toEqual([100, 100, 50]);
115
+
116
+ expect(result).toHaveLength(250);
117
+ expect(result[0].sha256).toBe(shas[0]);
118
+ expect(result[249].sha256).toBe(shas[249]);
119
+ });
120
+
121
+ it('skips a failed chunk and returns the entries that resolved', async () => {
122
+ const shas = Array.from({ length: 150 }, (_, i) =>
123
+ i.toString(16).padStart(64, '0'),
124
+ );
125
+
126
+ makeServiceRequestSpy
127
+ .mockResolvedValueOnce([publicEntry]) // first chunk (100 hashes) succeeds
128
+ .mockRejectedValueOnce(new Error('chunk failed')); // second chunk (50) fails
129
+
130
+ const result = await oxy.getServiceAssetMetadataBySha256(shas);
131
+
132
+ expect(makeServiceRequestSpy).toHaveBeenCalledTimes(2);
133
+ expect(result).toEqual([publicEntry]);
134
+ });
135
+ });