@oxyhq/core 1.11.10 → 1.11.12
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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +26 -18
- package/dist/cjs/OxyServices.base.js +21 -0
- package/dist/cjs/crypto/signatureService.js +11 -11
- package/dist/cjs/mixins/OxyServices.managedAccounts.js +117 -0
- package/dist/cjs/mixins/OxyServices.user.js +11 -0
- package/dist/cjs/mixins/OxyServices.utility.js +81 -2
- package/dist/cjs/mixins/index.js +2 -0
- package/dist/cjs/utils/asyncUtils.js +34 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +26 -18
- package/dist/esm/OxyServices.base.js +21 -0
- package/dist/esm/crypto/keyManager.js +3 -3
- package/dist/esm/crypto/polyfill.js +1 -1
- package/dist/esm/crypto/signatureService.js +12 -12
- package/dist/esm/mixins/OxyServices.language.js +1 -1
- package/dist/esm/mixins/OxyServices.managedAccounts.js +114 -0
- package/dist/esm/mixins/OxyServices.user.js +11 -0
- package/dist/esm/mixins/OxyServices.utility.js +81 -2
- package/dist/esm/mixins/index.js +2 -0
- package/dist/esm/utils/asyncUtils.js +34 -5
- package/dist/esm/utils/deviceManager.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.base.d.ts +17 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +2 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +2 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +2 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +2 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +2 -0
- package/dist/types/mixins/OxyServices.features.d.ts +5 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +2 -0
- package/dist/types/mixins/OxyServices.karma.d.ts +2 -0
- package/dist/types/mixins/OxyServices.language.d.ts +2 -0
- package/dist/types/mixins/OxyServices.location.d.ts +2 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +125 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +2 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +2 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +2 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +2 -0
- package/dist/types/mixins/OxyServices.security.d.ts +2 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +2 -0
- package/dist/types/mixins/OxyServices.user.d.ts +14 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +22 -0
- package/dist/types/models/interfaces.d.ts +2 -0
- package/dist/types/utils/asyncUtils.d.ts +6 -2
- package/package.json +1 -1
- package/src/HttpService.ts +30 -11
- package/src/OxyServices.base.ts +23 -0
- package/src/crypto/keyManager.ts +3 -3
- package/src/crypto/polyfill.ts +1 -1
- package/src/crypto/signatureService.ts +13 -12
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.language.ts +1 -1
- package/src/mixins/OxyServices.managedAccounts.ts +147 -0
- package/src/mixins/OxyServices.user.ts +18 -0
- package/src/mixins/OxyServices.utility.ts +103 -2
- package/src/mixins/index.ts +2 -0
- package/src/models/interfaces.ts +3 -0
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/asyncUtils.ts +39 -9
- package/src/utils/deviceManager.ts +1 -1
|
@@ -9,23 +9,28 @@ import { ec as EC } from 'elliptic';
|
|
|
9
9
|
import { KeyManager } from './keyManager';
|
|
10
10
|
import { isReactNative, isNodeJS } from '../utils/platform';
|
|
11
11
|
|
|
12
|
-
// Lazy
|
|
12
|
+
// Lazy imports for platform-specific crypto
|
|
13
13
|
let ExpoCrypto: typeof import('expo-crypto') | null = null;
|
|
14
|
+
let NodeCrypto: typeof import('crypto') | null = null;
|
|
14
15
|
|
|
15
16
|
const ec = new EC('secp256k1');
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
* Initialize expo-crypto module
|
|
19
|
-
*/
|
|
20
18
|
async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
|
|
21
19
|
if (!ExpoCrypto) {
|
|
22
|
-
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
23
20
|
const moduleName = 'expo-crypto';
|
|
24
|
-
ExpoCrypto = await import(moduleName);
|
|
21
|
+
ExpoCrypto = await import(/* @vite-ignore */ moduleName);
|
|
25
22
|
}
|
|
26
23
|
return ExpoCrypto!;
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
async function initNodeCrypto(): Promise<typeof import('crypto')> {
|
|
27
|
+
if (!NodeCrypto) {
|
|
28
|
+
const moduleName = 'crypto';
|
|
29
|
+
NodeCrypto = await import(/* @vite-ignore */ moduleName);
|
|
30
|
+
}
|
|
31
|
+
return NodeCrypto!;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
/**
|
|
30
35
|
* Compute SHA-256 hash of a string
|
|
31
36
|
*/
|
|
@@ -39,10 +44,9 @@ async function sha256(message: string): Promise<string> {
|
|
|
39
44
|
);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
// In Node.js, use Node's crypto module
|
|
43
47
|
if (isNodeJS()) {
|
|
44
48
|
try {
|
|
45
|
-
const nodeCrypto = await
|
|
49
|
+
const nodeCrypto = await initNodeCrypto();
|
|
46
50
|
return nodeCrypto.createHash('sha256').update(message).digest('hex');
|
|
47
51
|
} catch {
|
|
48
52
|
// Fall through to Web Crypto API
|
|
@@ -85,12 +89,9 @@ export class SignatureService {
|
|
|
85
89
|
.join('');
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
// In Node.js, use Node's crypto module
|
|
89
|
-
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
90
92
|
if (isNodeJS()) {
|
|
91
93
|
try {
|
|
92
|
-
const
|
|
93
|
-
const nodeCrypto = await import(cryptoModuleName);
|
|
94
|
+
const nodeCrypto = await initNodeCrypto();
|
|
94
95
|
return nodeCrypto.randomBytes(32).toString('hex');
|
|
95
96
|
} catch {
|
|
96
97
|
// Fall through to Web Crypto API
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type { PopupAuthOptions } from './mixins/OxyServices.popup';
|
|
|
32
32
|
export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
|
|
33
33
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
34
34
|
export type { ServiceApp } from './mixins/OxyServices.utility';
|
|
35
|
+
export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
|
|
35
36
|
|
|
36
37
|
// --- Crypto / Identity ---
|
|
37
38
|
export { KeyManager, SignatureService, RecoveryPhraseService } from './crypto';
|
|
@@ -25,7 +25,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
25
25
|
try {
|
|
26
26
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
27
27
|
const moduleName = '@react-native-async-storage/async-storage';
|
|
28
|
-
const asyncStorageModule = await import(moduleName);
|
|
28
|
+
const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
|
|
29
29
|
const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
|
|
30
30
|
return {
|
|
31
31
|
getItem: storage.getItem.bind(storage),
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed Accounts Methods Mixin
|
|
3
|
+
*
|
|
4
|
+
* Provides SDK methods for creating and managing sub-accounts (managed identities).
|
|
5
|
+
* Managed accounts are full User documents without passwords, accessible only
|
|
6
|
+
* by their owners/managers via the X-Acting-As header mechanism.
|
|
7
|
+
*/
|
|
8
|
+
import type { User } from '../models/interfaces';
|
|
9
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
|
+
|
|
11
|
+
export interface CreateManagedAccountInput {
|
|
12
|
+
username: string;
|
|
13
|
+
name?: { first?: string; last?: string };
|
|
14
|
+
bio?: string;
|
|
15
|
+
avatar?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ManagedAccountManager {
|
|
19
|
+
userId: string;
|
|
20
|
+
role: 'owner' | 'admin' | 'editor';
|
|
21
|
+
addedAt: string;
|
|
22
|
+
addedBy?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ManagedAccount {
|
|
26
|
+
accountId: string;
|
|
27
|
+
ownerId: string;
|
|
28
|
+
managers: ManagedAccountManager[];
|
|
29
|
+
account?: User;
|
|
30
|
+
createdAt?: string;
|
|
31
|
+
updatedAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
35
|
+
return class extends Base {
|
|
36
|
+
constructor(...args: any[]) {
|
|
37
|
+
super(...(args as [any]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a new managed account (sub-account).
|
|
42
|
+
*
|
|
43
|
+
* The server creates a User document with `isManagedAccount: true` and links
|
|
44
|
+
* it to the authenticated user as owner.
|
|
45
|
+
*/
|
|
46
|
+
async createManagedAccount(data: CreateManagedAccountInput): Promise<ManagedAccount> {
|
|
47
|
+
try {
|
|
48
|
+
return await this.makeRequest<ManagedAccount>('POST', '/managed-accounts', data, {
|
|
49
|
+
cache: false,
|
|
50
|
+
});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw this.handleError(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List all accounts the authenticated user manages.
|
|
58
|
+
*/
|
|
59
|
+
async getManagedAccounts(): Promise<ManagedAccount[]> {
|
|
60
|
+
try {
|
|
61
|
+
return await this.makeRequest<ManagedAccount[]>('GET', '/managed-accounts', undefined, {
|
|
62
|
+
cache: true,
|
|
63
|
+
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw this.handleError(error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get details for a specific managed account.
|
|
72
|
+
*/
|
|
73
|
+
async getManagedAccountDetails(accountId: string): Promise<ManagedAccount> {
|
|
74
|
+
try {
|
|
75
|
+
return await this.makeRequest<ManagedAccount>('GET', `/managed-accounts/${accountId}`, undefined, {
|
|
76
|
+
cache: true,
|
|
77
|
+
cacheTTL: 2 * 60 * 1000,
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw this.handleError(error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Update a managed account's profile data.
|
|
86
|
+
* Requires owner or admin role.
|
|
87
|
+
*/
|
|
88
|
+
async updateManagedAccount(accountId: string, data: Partial<CreateManagedAccountInput>): Promise<ManagedAccount> {
|
|
89
|
+
try {
|
|
90
|
+
return await this.makeRequest<ManagedAccount>('PUT', `/managed-accounts/${accountId}`, data, {
|
|
91
|
+
cache: false,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw this.handleError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Delete a managed account permanently.
|
|
100
|
+
* Requires owner role.
|
|
101
|
+
*/
|
|
102
|
+
async deleteManagedAccount(accountId: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}`, undefined, {
|
|
105
|
+
cache: false,
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw this.handleError(error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Add a manager to a managed account.
|
|
114
|
+
* Requires owner or admin role on the account.
|
|
115
|
+
*
|
|
116
|
+
* @param accountId - The managed account to add the manager to
|
|
117
|
+
* @param userId - The user to grant management access
|
|
118
|
+
* @param role - The role to assign: 'admin' or 'editor'
|
|
119
|
+
*/
|
|
120
|
+
async addManager(accountId: string, userId: string, role: 'admin' | 'editor'): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
await this.makeRequest<void>('POST', `/managed-accounts/${accountId}/managers`, { userId, role }, {
|
|
123
|
+
cache: false,
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw this.handleError(error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove a manager from a managed account.
|
|
132
|
+
* Requires owner role.
|
|
133
|
+
*
|
|
134
|
+
* @param accountId - The managed account
|
|
135
|
+
* @param userId - The manager to remove
|
|
136
|
+
*/
|
|
137
|
+
async removeManager(accountId: string, userId: string): Promise<void> {
|
|
138
|
+
try {
|
|
139
|
+
await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}/managers/${userId}`, undefined, {
|
|
140
|
+
cache: false,
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw this.handleError(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -24,6 +24,24 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Lightweight username lookup for login flows.
|
|
29
|
+
* Returns minimal public info: exists, color, avatar, displayName.
|
|
30
|
+
* Faster than getProfileByUsername — no stats, no formatting.
|
|
31
|
+
*/
|
|
32
|
+
async lookupUsername(username: string): Promise<{
|
|
33
|
+
exists: boolean;
|
|
34
|
+
username: string;
|
|
35
|
+
color: string | null;
|
|
36
|
+
avatar: string | null;
|
|
37
|
+
displayName: string;
|
|
38
|
+
}> {
|
|
39
|
+
return await this.makeRequest('GET', `/auth/lookup/${encodeURIComponent(username)}`, undefined, {
|
|
40
|
+
cache: true,
|
|
41
|
+
cacheTTL: 60 * 1000, // 1 minute cache
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
/**
|
|
28
46
|
* Search user profiles
|
|
29
47
|
*/
|
|
@@ -20,6 +20,15 @@ interface JwtPayload {
|
|
|
20
20
|
[key: string]: any;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Result from the managed-accounts verification endpoint.
|
|
25
|
+
* Indicates whether a user is authorized to act as a given managed account.
|
|
26
|
+
*/
|
|
27
|
+
interface ActingAsVerification {
|
|
28
|
+
authorized: boolean;
|
|
29
|
+
role: 'owner' | 'admin' | 'editor';
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
/**
|
|
24
33
|
* Service app metadata attached to requests authenticated with service tokens
|
|
25
34
|
*/
|
|
@@ -50,9 +59,55 @@ interface AuthMiddlewareOptions {
|
|
|
50
59
|
|
|
51
60
|
export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
52
61
|
return class extends Base {
|
|
62
|
+
/** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
|
|
63
|
+
_actingAsCache = new Map<string, { result: ActingAsVerification | null; expiresAt: number }>();
|
|
64
|
+
|
|
53
65
|
constructor(...args: any[]) {
|
|
54
66
|
super(...(args as [any]));
|
|
55
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verify that a user is authorized to act as a managed account.
|
|
71
|
+
* Results are cached in-memory for 5 minutes to avoid repeated API calls.
|
|
72
|
+
*
|
|
73
|
+
* @internal Used by the auth() middleware — not part of the public API
|
|
74
|
+
*/
|
|
75
|
+
async verifyActingAs(userId: string, accountId: string): Promise<ActingAsVerification | null> {
|
|
76
|
+
const cacheKey = `${userId}:${accountId}`;
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
|
|
79
|
+
// Check cache
|
|
80
|
+
const cached = this._actingAsCache.get(cacheKey);
|
|
81
|
+
if (cached && cached.expiresAt > now) {
|
|
82
|
+
return cached.result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Query the API
|
|
86
|
+
try {
|
|
87
|
+
const result = await this.makeRequest<ActingAsVerification>(
|
|
88
|
+
'GET',
|
|
89
|
+
'/managed-accounts/verify',
|
|
90
|
+
{ accountId, userId },
|
|
91
|
+
{ cache: false, retry: false, timeout: 5000 }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Cache successful result for 5 minutes
|
|
95
|
+
this._actingAsCache.set(cacheKey, {
|
|
96
|
+
result: result && result.authorized ? result : null,
|
|
97
|
+
expiresAt: now + 5 * 60 * 1000,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return result && result.authorized ? result : null;
|
|
101
|
+
} catch {
|
|
102
|
+
// Cache negative result for 1 minute to avoid hammering on transient errors
|
|
103
|
+
this._actingAsCache.set(cacheKey, {
|
|
104
|
+
result: null,
|
|
105
|
+
expiresAt: now + 1 * 60 * 1000,
|
|
106
|
+
});
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
56
111
|
/**
|
|
57
112
|
* Fetch link metadata
|
|
58
113
|
*/
|
|
@@ -125,6 +180,49 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
125
180
|
|
|
126
181
|
// Return an async middleware function
|
|
127
182
|
return async (req: any, res: any, next: any) => {
|
|
183
|
+
// Process X-Acting-As header for managed account identity delegation.
|
|
184
|
+
// Called after successful authentication, before next(). If the header
|
|
185
|
+
// is present, verifies authorization and swaps the request identity to
|
|
186
|
+
// the managed account, preserving the original user for audit trails.
|
|
187
|
+
const processActingAs = async (): Promise<boolean> => {
|
|
188
|
+
const actingAsUserId = req.headers['x-acting-as'] as string | undefined;
|
|
189
|
+
if (!actingAsUserId) return true; // No header, proceed normally
|
|
190
|
+
|
|
191
|
+
const verification = await oxyInstance.verifyActingAs(req.userId, actingAsUserId);
|
|
192
|
+
if (!verification) {
|
|
193
|
+
const error = {
|
|
194
|
+
error: 'ACTING_AS_UNAUTHORIZED',
|
|
195
|
+
message: 'Not authorized to act as this account',
|
|
196
|
+
code: 'ACTING_AS_UNAUTHORIZED',
|
|
197
|
+
status: 403,
|
|
198
|
+
};
|
|
199
|
+
if (onError) {
|
|
200
|
+
onError(error);
|
|
201
|
+
} else {
|
|
202
|
+
res.status(403).json(error);
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Preserve original user for audit trails
|
|
208
|
+
req.originalUser = { id: req.userId, ...req.user };
|
|
209
|
+
req.actingAs = { userId: actingAsUserId, role: verification.role };
|
|
210
|
+
|
|
211
|
+
// Swap user identity to the managed account
|
|
212
|
+
req.userId = actingAsUserId;
|
|
213
|
+
req.user = { id: actingAsUserId } as any;
|
|
214
|
+
// Also set _id for routes that use Pattern B (req.user._id)
|
|
215
|
+
if (req.user) {
|
|
216
|
+
(req.user as any)._id = actingAsUserId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (debug) {
|
|
220
|
+
console.log(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${req.originalUser.id}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return true;
|
|
224
|
+
};
|
|
225
|
+
|
|
128
226
|
try {
|
|
129
227
|
// Extract token from Authorization header or query params
|
|
130
228
|
const authHeader = req.headers['authorization'];
|
|
@@ -360,7 +458,9 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
360
458
|
console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
|
|
361
459
|
}
|
|
362
460
|
|
|
363
|
-
|
|
461
|
+
// Process X-Acting-As header before proceeding
|
|
462
|
+
if (await processActingAs()) return next();
|
|
463
|
+
return;
|
|
364
464
|
} catch (validationError) {
|
|
365
465
|
if (debug) {
|
|
366
466
|
console.log(`[oxy.auth] Session validation failed:`, validationError);
|
|
@@ -414,7 +514,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
414
514
|
console.log(`[oxy.auth] OK user=${userId} (no session)`);
|
|
415
515
|
}
|
|
416
516
|
|
|
417
|
-
|
|
517
|
+
// Process X-Acting-As header before proceeding
|
|
518
|
+
if (await processActingAs()) next();
|
|
418
519
|
} catch (error) {
|
|
419
520
|
const apiError = oxyInstance.handleError(error) as any;
|
|
420
521
|
|
package/src/mixins/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { OxyServicesSecurityMixin } from './OxyServices.security';
|
|
|
24
24
|
import { OxyServicesUtilityMixin } from './OxyServices.utility';
|
|
25
25
|
import { OxyServicesFeaturesMixin } from './OxyServices.features';
|
|
26
26
|
import { OxyServicesTopicsMixin } from './OxyServices.topics';
|
|
27
|
+
import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
|
|
27
28
|
|
|
28
29
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
30
|
type MixinFunction = (Base: any) => any;
|
|
@@ -68,6 +69,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
68
69
|
OxyServicesSecurityMixin,
|
|
69
70
|
OxyServicesFeaturesMixin,
|
|
70
71
|
OxyServicesTopicsMixin,
|
|
72
|
+
OxyServicesManagedAccountsMixin,
|
|
71
73
|
|
|
72
74
|
// Utility (last, can use all above)
|
|
73
75
|
OxyServicesUtilityMixin,
|
package/src/models/interfaces.ts
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { retryAsync } from '../asyncUtils';
|
|
2
|
+
import { handleHttpError } from '../errorUtils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression coverage for the 1.11.11 retry storm:
|
|
6
|
+
*
|
|
7
|
+
* HttpService wraps fetch errors through handleHttpError before rethrowing.
|
|
8
|
+
* handleHttpError returns a flat ApiError ({ message, code, status }) without
|
|
9
|
+
* a nested `.response` field. Prior to the fix, retryAsync's default
|
|
10
|
+
* shouldRetry predicate only inspected `error.response.status`, so every 4xx
|
|
11
|
+
* response was treated as retryable. That turned ~5ms 404 lookups into 8-10s
|
|
12
|
+
* stalls because every Mention endpoint hitting Oxy for a missing
|
|
13
|
+
* user/topic hit the full retry+backoff schedule.
|
|
14
|
+
*
|
|
15
|
+
* These tests lock the fix in place: both the nested and flat shapes MUST
|
|
16
|
+
* short-circuit retries for 4xx, and 5xx/network errors MUST still retry.
|
|
17
|
+
*/
|
|
18
|
+
describe('retryAsync default shouldRetry predicate', () => {
|
|
19
|
+
it('does not retry on a flat ApiError-shaped 404 (handleHttpError output)', async () => {
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
const apiError = { message: 'Not found', code: 'NOT_FOUND', status: 404 };
|
|
23
|
+
|
|
24
|
+
await expect(
|
|
25
|
+
retryAsync(async () => {
|
|
26
|
+
attempts++;
|
|
27
|
+
throw apiError;
|
|
28
|
+
}, 3, 50)
|
|
29
|
+
).rejects.toBe(apiError);
|
|
30
|
+
|
|
31
|
+
expect(attempts).toBe(1);
|
|
32
|
+
// Sanity: we should NOT have slept through any backoff windows.
|
|
33
|
+
expect(Date.now() - started).toBeLessThan(100);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not retry on an axios-style nested 404 (response.status)', async () => {
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
const axiosError = {
|
|
39
|
+
message: 'Not found',
|
|
40
|
+
response: { status: 404, statusText: 'Not Found' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
retryAsync(async () => {
|
|
45
|
+
attempts++;
|
|
46
|
+
throw axiosError;
|
|
47
|
+
}, 3, 50)
|
|
48
|
+
).rejects.toBe(axiosError);
|
|
49
|
+
|
|
50
|
+
expect(attempts).toBe(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not retry on any 4xx flat-shape (400/401/403/422)', async () => {
|
|
54
|
+
for (const status of [400, 401, 403, 422]) {
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
await expect(
|
|
57
|
+
retryAsync(async () => {
|
|
58
|
+
attempts++;
|
|
59
|
+
throw { message: 'client', code: 'X', status };
|
|
60
|
+
}, 2, 10)
|
|
61
|
+
).rejects.toBeDefined();
|
|
62
|
+
expect(attempts).toBe(1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('retries on flat-shape 500 errors until maxRetries', async () => {
|
|
67
|
+
let attempts = 0;
|
|
68
|
+
await expect(
|
|
69
|
+
retryAsync(async () => {
|
|
70
|
+
attempts++;
|
|
71
|
+
throw { message: 'boom', code: 'INTERNAL_ERROR', status: 500 };
|
|
72
|
+
}, 2, 1)
|
|
73
|
+
).rejects.toBeDefined();
|
|
74
|
+
expect(attempts).toBe(3); // initial + 2 retries
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('retries on nested-shape 503 errors until maxRetries', async () => {
|
|
78
|
+
let attempts = 0;
|
|
79
|
+
await expect(
|
|
80
|
+
retryAsync(async () => {
|
|
81
|
+
attempts++;
|
|
82
|
+
throw { message: 'unavailable', response: { status: 503 } };
|
|
83
|
+
}, 2, 1)
|
|
84
|
+
).rejects.toBeDefined();
|
|
85
|
+
expect(attempts).toBe(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('retries on network-style errors without any status (TypeError)', async () => {
|
|
89
|
+
let attempts = 0;
|
|
90
|
+
await expect(
|
|
91
|
+
retryAsync(async () => {
|
|
92
|
+
attempts++;
|
|
93
|
+
throw new TypeError('Failed to fetch');
|
|
94
|
+
}, 2, 1)
|
|
95
|
+
).rejects.toBeDefined();
|
|
96
|
+
expect(attempts).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns the successful result without extra attempts', async () => {
|
|
100
|
+
let attempts = 0;
|
|
101
|
+
const result = await retryAsync(async () => {
|
|
102
|
+
attempts++;
|
|
103
|
+
return 'ok' as const;
|
|
104
|
+
}, 3, 1);
|
|
105
|
+
expect(result).toBe('ok');
|
|
106
|
+
expect(attempts).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('recovers after a transient 5xx followed by success', async () => {
|
|
110
|
+
let attempts = 0;
|
|
111
|
+
const result = await retryAsync(async () => {
|
|
112
|
+
attempts++;
|
|
113
|
+
if (attempts < 2) {
|
|
114
|
+
throw { message: 'transient', status: 502 };
|
|
115
|
+
}
|
|
116
|
+
return 'recovered' as const;
|
|
117
|
+
}, 3, 1);
|
|
118
|
+
expect(result).toBe('recovered');
|
|
119
|
+
expect(attempts).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('honours a custom shouldRetry predicate even when default would retry', async () => {
|
|
123
|
+
let attempts = 0;
|
|
124
|
+
await expect(
|
|
125
|
+
retryAsync(
|
|
126
|
+
async () => {
|
|
127
|
+
attempts++;
|
|
128
|
+
throw { message: 'nope', status: 500 };
|
|
129
|
+
},
|
|
130
|
+
5,
|
|
131
|
+
1,
|
|
132
|
+
() => false
|
|
133
|
+
)
|
|
134
|
+
).rejects.toBeDefined();
|
|
135
|
+
expect(attempts).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('ignores non-numeric status fields instead of treating them as 4xx', async () => {
|
|
139
|
+
let attempts = 0;
|
|
140
|
+
await expect(
|
|
141
|
+
retryAsync(async () => {
|
|
142
|
+
attempts++;
|
|
143
|
+
throw { message: 'weird', status: 'oops' as unknown as number };
|
|
144
|
+
}, 2, 1)
|
|
145
|
+
).rejects.toBeDefined();
|
|
146
|
+
// Non-numeric status must NOT be interpreted as 4xx — should retry normally.
|
|
147
|
+
expect(attempts).toBe(3);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* handleHttpError is the wire between fetch-thrown errors and retryAsync.
|
|
153
|
+
* Lock in that it exposes the HTTP status at the top level so the retry
|
|
154
|
+
* predicate above can see it.
|
|
155
|
+
*/
|
|
156
|
+
describe('handleHttpError preserves HTTP status for retry predicates', () => {
|
|
157
|
+
it('flattens a fetch-style error with .response.status into ApiError.status', () => {
|
|
158
|
+
const fetchError = Object.assign(new Error('Not found'), {
|
|
159
|
+
status: 404,
|
|
160
|
+
response: { status: 404, statusText: 'Not Found' },
|
|
161
|
+
});
|
|
162
|
+
const result = handleHttpError(fetchError);
|
|
163
|
+
expect(result.status).toBe(404);
|
|
164
|
+
expect(result.code).toBe('NOT_FOUND');
|
|
165
|
+
expect(result.message).toBe('Not found');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('preserves 401 status from fetch errors', () => {
|
|
169
|
+
const fetchError = Object.assign(new Error('Unauthorized'), {
|
|
170
|
+
status: 401,
|
|
171
|
+
response: { status: 401, statusText: 'Unauthorized' },
|
|
172
|
+
});
|
|
173
|
+
const result = handleHttpError(fetchError);
|
|
174
|
+
expect(result.status).toBe(401);
|
|
175
|
+
expect(result.code).toBe('UNAUTHORIZED');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('maps 500 to INTERNAL_ERROR with status preserved', () => {
|
|
179
|
+
const fetchError = Object.assign(new Error('boom'), {
|
|
180
|
+
status: 500,
|
|
181
|
+
response: { status: 500, statusText: 'Internal Server Error' },
|
|
182
|
+
});
|
|
183
|
+
const result = handleHttpError(fetchError);
|
|
184
|
+
expect(result.status).toBe(500);
|
|
185
|
+
expect(result.code).toBe('INTERNAL_ERROR');
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/utils/asyncUtils.ts
CHANGED
|
@@ -47,11 +47,38 @@ export async function parallelWithErrorHandling<T>(
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Extract an HTTP status code from an error value, tolerating both the
|
|
52
|
+
* axios-style nested shape (`error.response.status`) and the flat shape
|
|
53
|
+
* produced by {@link handleHttpError} / fetch-based clients (`error.status`).
|
|
54
|
+
*
|
|
55
|
+
* Centralising this lookup prevents retry predicates from silently falling
|
|
56
|
+
* through when one of the two shapes is missing, which previously caused
|
|
57
|
+
* @oxyhq/core to retry 4xx responses and turn sub-10ms failures into
|
|
58
|
+
* multi-second stalls for every missing-resource lookup.
|
|
59
|
+
*/
|
|
60
|
+
function extractHttpStatus(error: unknown): number | undefined {
|
|
61
|
+
if (!error || typeof error !== 'object') return undefined;
|
|
62
|
+
const candidate = error as {
|
|
63
|
+
status?: unknown;
|
|
64
|
+
response?: { status?: unknown } | null;
|
|
65
|
+
};
|
|
66
|
+
const flat = candidate.status;
|
|
67
|
+
if (typeof flat === 'number' && Number.isFinite(flat)) return flat;
|
|
68
|
+
const nested = candidate.response?.status;
|
|
69
|
+
if (typeof nested === 'number' && Number.isFinite(nested)) return nested;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/**
|
|
51
74
|
* Retry an async operation with exponential backoff
|
|
52
|
-
*
|
|
53
|
-
* By default, does not retry on 4xx errors (client errors).
|
|
54
|
-
*
|
|
75
|
+
*
|
|
76
|
+
* By default, does not retry on 4xx errors (client errors). The default
|
|
77
|
+
* predicate accepts both the axios-style `error.response.status` and the
|
|
78
|
+
* flat `error.status` shape produced by {@link handleHttpError}, so callers
|
|
79
|
+
* never accidentally retry a deterministic client failure.
|
|
80
|
+
*
|
|
81
|
+
* Use the `shouldRetry` callback to customize retry behavior.
|
|
55
82
|
*/
|
|
56
83
|
export async function retryAsync<T>(
|
|
57
84
|
operation: () => Promise<T>,
|
|
@@ -60,16 +87,19 @@ export async function retryAsync<T>(
|
|
|
60
87
|
shouldRetry?: (error: any) => boolean
|
|
61
88
|
): Promise<T> {
|
|
62
89
|
let lastError: any;
|
|
63
|
-
|
|
64
|
-
// Default shouldRetry: don't retry on 4xx errors
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
90
|
+
|
|
91
|
+
// Default shouldRetry: don't retry on 4xx errors (client errors).
|
|
92
|
+
// Checks BOTH `error.status` (flat shape from handleHttpError / fetch
|
|
93
|
+
// clients) AND `error.response.status` (axios-style shape) so neither
|
|
94
|
+
// representation can leak a client error into the retry loop.
|
|
95
|
+
const defaultShouldRetry = (error: unknown): boolean => {
|
|
96
|
+
const status = extractHttpStatus(error);
|
|
97
|
+
if (status !== undefined && status >= 400 && status < 500) {
|
|
68
98
|
return false;
|
|
69
99
|
}
|
|
70
100
|
return true;
|
|
71
101
|
};
|
|
72
|
-
|
|
102
|
+
|
|
73
103
|
const retryCheck = shouldRetry || defaultShouldRetry;
|
|
74
104
|
|
|
75
105
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -44,7 +44,7 @@ export class DeviceManager {
|
|
|
44
44
|
try {
|
|
45
45
|
// Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
|
|
46
46
|
const moduleName = '@react-native-async-storage/async-storage';
|
|
47
|
-
const asyncStorageModule = await import(moduleName);
|
|
47
|
+
const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
|
|
48
48
|
const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
|
|
49
49
|
return {
|
|
50
50
|
getItem: storage.getItem.bind(storage),
|