@oxyhq/core 3.4.7 → 3.4.9

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.
@@ -34,6 +34,7 @@ export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServic
34
34
  export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount, } from './mixins/OxyServices.managedAccounts';
35
35
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
36
36
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
37
+ export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull } from './utils/userIdentity';
37
38
  export type { Application, PublicApplication, ApplicationMember, ApplicationCredential, ApplicationRole, ApplicationType, ApplicationStatus, ApplicationMemberStatus, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, InviteApplicationMemberInput, UpdateApplicationMemberInput, TransferApplicationOwnershipInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, ApplicationSuccessResult, } from './mixins/OxyServices.applications';
38
39
  export type { Workspace, WorkspaceMember, WorkspaceRole, WorkspaceType, WorkspaceStatus, WorkspaceMemberStatus, CreateWorkspaceInput, UpdateWorkspaceInput, InviteWorkspaceMemberInput, UpdateWorkspaceMemberInput, TransferWorkspaceOwnershipInput, WorkspaceSuccessResult, } from './mixins/OxyServices.workspaces';
39
40
  export type { ReputationCategory, TrustTier, ReputationTransactionStatus, ReputationTargetEntityType, ReputationDisputeStatus, ReputationInfluenceContext, ReputationTransaction, ReputationBalanceBreakdown, ReputationInfluence, ReputationReliability, ReputationBalance, ReputationDispute, ReputationRule, ReputationLeaderboardEntry, ReputationInfluenceResult, ReverseReputationTransactionResult, AwardReputationInput, CreateReputationDisputeInput, ResolveReputationDisputeInput, UpsertReputationRuleInput, ReverseReputationTransactionInput, } from './mixins/OxyServices.reputation';
@@ -262,7 +262,9 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
262
262
  getActingAs(): string | null;
263
263
  waitForAuth(timeoutMs?: number): Promise<boolean>;
264
264
  withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
265
- maxRetries?: number;
265
+ maxRetries? /**
266
+ * Download account data export
267
+ */: number;
266
268
  retryDelay?: number;
267
269
  authTimeoutMs?: number;
268
270
  }): Promise<T_1>;
@@ -0,0 +1,12 @@
1
+ interface UserIdentityInput {
2
+ id?: unknown;
3
+ _id?: unknown;
4
+ }
5
+ export declare function getNormalizedUserId(user: UserIdentityInput | null | undefined): string | null;
6
+ export declare function normalizeUserIdentity<T extends UserIdentityInput>(user: T): T & {
7
+ id: string;
8
+ };
9
+ export declare function normalizeUserIdentityOrNull<T extends UserIdentityInput>(user: T | null | undefined): (T & {
10
+ id: string;
11
+ }) | null;
12
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.4.7",
3
+ "version": "3.4.9",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -32,7 +32,7 @@ interface JwtPayload {
32
32
  userId?: string;
33
33
  id?: string;
34
34
  sessionId?: string;
35
- [key: string]: any;
35
+ [key: string]: unknown;
36
36
  }
37
37
 
38
38
  export type AuthRefreshReason = 'preflight' | 'response-401';
@@ -314,9 +314,11 @@ export class HttpService {
314
314
  // Get auth token (with auto-refresh)
315
315
  const authHeader = await this.getAuthHeader();
316
316
 
317
- // Get CSRF token for state-changing requests
317
+ // CSRF protects cookie-authenticated browser writes. Bearer-authenticated
318
+ // SDK clients are not vulnerable to ambient-cookie CSRF, and linked app
319
+ // APIs should not need to implement a duplicate `/csrf-token` route.
318
320
  const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
319
- const csrfToken = isStateChangingMethod ? await this.fetchCsrfToken() : null;
321
+ const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
320
322
 
321
323
  // Determine if data is FormData using robust detection
322
324
  const isFormData = this.isFormData(data);
@@ -356,10 +358,8 @@ export class HttpService {
356
358
  headers['X-Native-App'] = 'true';
357
359
  }
358
360
 
359
- // Debug logging for CSRF issues routed through the SimpleLogger so
360
- // it only fires when consumers opt in via `enableLogging`. Previously
361
- // this was a bare console.log that leaked noise into every host app's
362
- // stdout in development.
361
+ // Debug logging for CSRF issues, routed through SimpleLogger so it only
362
+ // fires when consumers opt in via `enableLogging`.
363
363
  if (isStateChangingMethod) {
364
364
  this.logger.debug('CSRF Debug:', {
365
365
  url,
@@ -756,7 +756,9 @@ export class HttpService {
756
756
  * Build full URL with query params
757
757
  */
758
758
  private buildURL(url: string, params?: Record<string, unknown>): string {
759
- const base = url.startsWith('http') ? url : `${this.baseURL}${url}`;
759
+ const base = /^https?:\/\//i.test(url)
760
+ ? url
761
+ : `${this.baseURL.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
760
762
 
761
763
  if (!params || Object.keys(params).length === 0) {
762
764
  return base;
@@ -0,0 +1,92 @@
1
+ import { HttpService } from '../HttpService';
2
+
3
+ interface FetchCall {
4
+ url: string;
5
+ init: RequestInit;
6
+ }
7
+
8
+ function createJwt(payload: Record<string, unknown>): string {
9
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
10
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
11
+ }
12
+
13
+ function jsonResponse(data: unknown): Response {
14
+ return new Response(JSON.stringify({ data }), {
15
+ status: 200,
16
+ headers: { 'content-type': 'application/json' },
17
+ });
18
+ }
19
+
20
+ function readHeaders(init: RequestInit | undefined): Record<string, string> {
21
+ const headers = init?.headers;
22
+ if (!headers) {
23
+ return {};
24
+ }
25
+ if (headers instanceof Headers) {
26
+ return Object.fromEntries(headers.entries());
27
+ }
28
+ if (Array.isArray(headers)) {
29
+ return Object.fromEntries(headers);
30
+ }
31
+ return headers as Record<string, string>;
32
+ }
33
+
34
+ describe('HttpService CSRF behavior', () => {
35
+ const originalFetch = globalThis.fetch;
36
+
37
+ afterEach(() => {
38
+ globalThis.fetch = originalFetch;
39
+ jest.restoreAllMocks();
40
+ });
41
+
42
+ it('does not fetch csrf-token before bearer-authenticated writes', async () => {
43
+ const calls: FetchCall[] = [];
44
+ const fetchMock = jest.fn(async (url: string, init: RequestInit) => {
45
+ calls.push({ url, init });
46
+ return jsonResponse({ ok: true });
47
+ });
48
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
49
+
50
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
51
+ const accessToken = createJwt({
52
+ userId: 'user_1',
53
+ exp: Math.floor(Date.now() / 1000) + 3600,
54
+ });
55
+ http.setTokens(accessToken);
56
+
57
+ await http.post('/posts', { text: 'hello' });
58
+
59
+ expect(calls).toHaveLength(1);
60
+ expect(calls[0].url).toBe('https://api.mention.earth/posts');
61
+ const headers = readHeaders(calls[0].init);
62
+ expect(headers.Authorization).toBe(`Bearer ${accessToken}`);
63
+ expect(headers['X-CSRF-Token']).toBeUndefined();
64
+ });
65
+
66
+ it('still fetches csrf-token for cookie-authenticated writes without bearer', async () => {
67
+ const calls: FetchCall[] = [];
68
+ const fetchMock = jest.fn(async (url: string, init: RequestInit) => {
69
+ calls.push({ url, init });
70
+ if (url.endsWith('/csrf-token')) {
71
+ return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
72
+ status: 200,
73
+ headers: { 'content-type': 'application/json' },
74
+ });
75
+ }
76
+ return jsonResponse({ ok: true });
77
+ });
78
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
79
+
80
+ const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
81
+
82
+ await http.post('/posts', { text: 'hello' });
83
+
84
+ expect(calls.map((call) => call.url)).toEqual([
85
+ 'https://api.mention.earth/csrf-token',
86
+ 'https://api.mention.earth/posts',
87
+ ]);
88
+ const headers = readHeaders(calls[1].init);
89
+ expect(headers.Authorization).toBeUndefined();
90
+ expect(headers['X-CSRF-Token']).toBe('csrf_1');
91
+ });
92
+ });
@@ -4,6 +4,13 @@ function createServices(): OxyServices {
4
4
  return new OxyServices({ baseURL: 'https://api.oxy.so' });
5
5
  }
6
6
 
7
+ function jsonResponse(data: unknown): Response {
8
+ return new Response(JSON.stringify({ data }), {
9
+ status: 200,
10
+ headers: { 'content-type': 'application/json' },
11
+ });
12
+ }
13
+
7
14
  describe('OxyServices.createLinkedClient', () => {
8
15
  it('mirrors token changes from the session owner', () => {
9
16
  const oxy = createServices();
@@ -89,4 +96,24 @@ describe('OxyServices.createLinkedClient', () => {
89
96
 
90
97
  expect(linked.client.getAccessToken()).toBeNull();
91
98
  });
99
+
100
+ it('joins linked base URLs with relative paths that omit the leading slash', async () => {
101
+ const originalFetch = globalThis.fetch;
102
+ const fetchMock = jest.fn(async () => jsonResponse({ ok: true }));
103
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
104
+
105
+ try {
106
+ const oxy = createServices();
107
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
108
+
109
+ await linked.client.get('profile/settings/me');
110
+
111
+ expect(fetchMock).toHaveBeenCalledTimes(1);
112
+ expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://api.mention.earth/profile/settings/me');
113
+
114
+ linked.dispose();
115
+ } finally {
116
+ globalThis.fetch = originalFetch;
117
+ }
118
+ });
92
119
  });
@@ -0,0 +1,75 @@
1
+ import { OxyServices } from '../OxyServices';
2
+ import { getNormalizedUserId, normalizeUserIdentity } from '../utils/userIdentity';
3
+
4
+ interface FetchCall {
5
+ url: string;
6
+ init: RequestInit;
7
+ }
8
+
9
+ function jsonResponse(data: unknown): Response {
10
+ return new Response(JSON.stringify({ data }), {
11
+ status: 200,
12
+ headers: { 'content-type': 'application/json' },
13
+ });
14
+ }
15
+
16
+ function createJwt(payload: Record<string, unknown>): string {
17
+ const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
18
+ return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
19
+ }
20
+
21
+ describe('user identity normalization', () => {
22
+ const originalFetch = globalThis.fetch;
23
+
24
+ afterEach(() => {
25
+ globalThis.fetch = originalFetch;
26
+ jest.restoreAllMocks();
27
+ });
28
+
29
+ it('normalizes Mongo _id to id', () => {
30
+ expect(getNormalizedUserId({ _id: 'mongo_id' })).toBe('mongo_id');
31
+ expect(normalizeUserIdentity({ _id: 'mongo_id', username: 'nate' })).toEqual({
32
+ _id: 'mongo_id',
33
+ id: 'mongo_id',
34
+ username: 'nate',
35
+ });
36
+ });
37
+
38
+ it('normalizes getCurrentUser responses before exposing them to apps', async () => {
39
+ const calls: FetchCall[] = [];
40
+ const fetchMock = jest.fn(async (url: string, init: RequestInit) => {
41
+ calls.push({ url, init });
42
+ return jsonResponse({ _id: 'user_1', username: 'nate', publicKey: 'pub_1' });
43
+ });
44
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
45
+
46
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
47
+ oxy.setTokens(createJwt({
48
+ userId: 'user_1',
49
+ exp: Math.floor(Date.now() / 1000) + 3600,
50
+ }));
51
+ const user = await oxy.getCurrentUser();
52
+
53
+ expect(user.id).toBe('user_1');
54
+ expect(user.username).toBe('nate');
55
+ expect(calls[0].url).toBe('https://api.oxy.so/users/me');
56
+ });
57
+
58
+ it('normalizes validateSession users before services stores them', async () => {
59
+ const fetchMock = jest.fn(async () =>
60
+ jsonResponse({
61
+ valid: true,
62
+ expiresAt: '2099-01-01T00:00:00.000Z',
63
+ lastActivity: '2026-06-18T00:00:00.000Z',
64
+ user: { _id: 'user_1', username: 'nate', publicKey: 'pub_1' },
65
+ }),
66
+ );
67
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
68
+
69
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
70
+ const validation = await oxy.validateSession('session_1', { useHeaderValidation: true });
71
+
72
+ expect(validation.user.id).toBe('user_1');
73
+ expect(validation.user.username).toBe('nate');
74
+ });
75
+ });
package/src/index.ts CHANGED
@@ -62,6 +62,7 @@ export type {
62
62
  ContactDiscoveryResponse,
63
63
  } from './mixins/OxyServices.contacts';
64
64
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
65
+ export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull } from './utils/userIdentity';
65
66
 
66
67
  // ---------------------------------------------------------------------------
67
68
  // Applications (multi-user apps: membership, roles, credentials)
@@ -15,6 +15,7 @@ import type { OxyServicesBase } from '../OxyServices.base';
15
15
  import { OxyAuthenticationError } from '../OxyServices.errors';
16
16
  import { loadNodeCrypto } from '../utils/platformCrypto';
17
17
  import { logger } from '../utils/loggerUtils';
18
+ import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
18
19
 
19
20
  export interface ChallengeResponse {
20
21
  challenge: string;
@@ -451,7 +452,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
451
452
  this.setTokens(res.accessToken);
452
453
  }
453
454
 
454
- return res;
455
+ return {
456
+ ...res,
457
+ user: normalizeUserIdentity(res.user),
458
+ };
455
459
  } catch (error) {
456
460
  throw this.handleError(error);
457
461
  }
@@ -478,12 +482,13 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
478
482
  */
479
483
  async getUserByPublicKey(publicKey: string): Promise<User> {
480
484
  try {
481
- return await this.makeRequest<User>(
485
+ const user = await this.makeRequest<User>(
482
486
  'GET',
483
487
  `/auth/user/${encodeURIComponent(publicKey)}`,
484
488
  undefined,
485
489
  { cache: true, cacheTTL: 2 * 60 * 1000 }
486
490
  );
491
+ return normalizeUserIdentity(user);
487
492
  } catch (error) {
488
493
  throw this.handleError(error);
489
494
  }
@@ -494,10 +499,11 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
494
499
  */
495
500
  async getUserBySession(sessionId: string): Promise<User> {
496
501
  try {
497
- return await this.makeRequest<User>('GET', `/session/user/${sessionId}`, undefined, {
502
+ const user = await this.makeRequest<User>('GET', `/session/user/${sessionId}`, undefined, {
498
503
  cache: true,
499
504
  cacheTTL: 2 * 60 * 1000,
500
505
  });
506
+ return normalizeUserIdentity(user);
501
507
  } catch (error) {
502
508
  throw this.handleError(error);
503
509
  }
@@ -514,7 +520,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
514
520
 
515
521
  const uniqueSessionIds = Array.from(new Set(sessionIds)).sort();
516
522
 
517
- return await this.makeRequest<Array<{ sessionId: string; user: User | null }>>(
523
+ const users = await this.makeRequest<Array<{ sessionId: string; user: User | null }>>(
518
524
  'POST',
519
525
  '/session/users/batch',
520
526
  { sessionIds: uniqueSessionIds },
@@ -524,6 +530,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
524
530
  deduplicate: true,
525
531
  }
526
532
  );
533
+ return users.map((entry) => ({
534
+ ...entry,
535
+ user: normalizeUserIdentityOrNull(entry.user),
536
+ }));
527
537
  } catch (error) {
528
538
  throw this.handleError(error);
529
539
  }
@@ -909,7 +919,18 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
909
919
  const urlParams: Record<string, string> = {};
910
920
  if (options.deviceFingerprint) urlParams.deviceFingerprint = options.deviceFingerprint;
911
921
  if (options.useHeaderValidation) urlParams.useHeaderValidation = 'true';
912
- return await this.makeRequest('GET', `/session/validate/${sessionId}`, urlParams, { cache: false });
922
+ const validation = await this.makeRequest<{
923
+ valid: boolean;
924
+ expiresAt: string;
925
+ lastActivity: string;
926
+ user: User;
927
+ sessionId?: string;
928
+ source?: string;
929
+ }>('GET', `/session/validate/${sessionId}`, urlParams, { cache: false });
930
+ return {
931
+ ...validation,
932
+ user: normalizeUserIdentity(validation.user),
933
+ };
913
934
  } catch (error) {
914
935
  // Session is invalid — clear any cached user data for this session (#196)
915
936
  this.clearCacheEntry(`GET:/session/user/${sessionId}`);
@@ -950,13 +971,17 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
950
971
  deviceFingerprint?: any
951
972
  ): Promise<SessionLoginResponse> {
952
973
  try {
953
- return await this.makeRequest<SessionLoginResponse>('POST', '/auth/signup', {
974
+ const session = await this.makeRequest<SessionLoginResponse>('POST', '/auth/signup', {
954
975
  username,
955
976
  email,
956
977
  password,
957
978
  deviceName,
958
979
  deviceFingerprint,
959
980
  }, { cache: false });
981
+ return {
982
+ ...session,
983
+ user: normalizeUserIdentity(session.user),
984
+ };
960
985
  } catch (error) {
961
986
  throw this.handleError(error);
962
987
  }
@@ -972,12 +997,16 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
972
997
  deviceFingerprint?: any
973
998
  ): Promise<SessionLoginResponse> {
974
999
  try {
975
- return await this.makeRequest<SessionLoginResponse>('POST', '/auth/login', {
1000
+ const session = await this.makeRequest<SessionLoginResponse>('POST', '/auth/login', {
976
1001
  identifier,
977
1002
  password,
978
1003
  deviceName,
979
1004
  deviceFingerprint,
980
1005
  }, { cache: false });
1006
+ return {
1007
+ ...session,
1008
+ user: normalizeUserIdentity(session.user),
1009
+ };
981
1010
  } catch (error) {
982
1011
  throw this.handleError(error);
983
1012
  }
@@ -2,6 +2,7 @@ import type { OxyServicesBase } from '../OxyServices.base';
2
2
  import { OxyAuthenticationError } from '../OxyServices.errors';
3
3
  import type { SessionLoginResponse } from '../models/session';
4
4
  import { createDebugLogger } from '../shared/utils/debugUtils';
5
+ import { normalizeUserIdentity } from '../utils/userIdentity';
5
6
 
6
7
  const debug = createDebugLogger('FedCM');
7
8
 
@@ -776,7 +777,10 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
776
777
  hasUser: !!response?.user,
777
778
  });
778
779
 
779
- return response;
780
+ return {
781
+ ...response,
782
+ user: normalizeUserIdentity(response.user),
783
+ };
780
784
  } catch (error) {
781
785
  debug.error('Token exchange failed:', error instanceof Error ? error.message : String(error));
782
786
  throw error;
@@ -14,6 +14,7 @@ import type { OxyServicesBase } from '../OxyServices.base';
14
14
  import { buildSearchParams, buildPaginationParams, type PaginationParams } from '../utils/apiUtils';
15
15
  import { KeyManager } from '../crypto/keyManager';
16
16
  import { SignatureService } from '../crypto/signatureService';
17
+ import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
17
18
 
18
19
  export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T) {
19
20
  return class extends Base {
@@ -25,10 +26,11 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
25
26
  */
26
27
  async getProfileByUsername(username: string): Promise<User> {
27
28
  try {
28
- return await this.makeRequest<User>('GET', `/profiles/username/${username}`, undefined, {
29
+ const user = await this.makeRequest<User>('GET', `/profiles/username/${username}`, undefined, {
29
30
  cache: true,
30
31
  cacheTTL: 5 * 60 * 1000, // 5 minutes cache for profiles
31
32
  });
33
+ return normalizeUserIdentity(user);
32
34
  } catch (error) {
33
35
  throw this.handleError(error);
34
36
  }
@@ -109,7 +111,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
109
111
  cache: true,
110
112
  cacheTTL: 24 * 60 * 60 * 1000, // 24h cache — matches server-side staleness window
111
113
  });
112
- return result ?? null;
114
+ return normalizeUserIdentityOrNull(result);
113
115
  } catch {
114
116
  return null;
115
117
  }
@@ -130,7 +132,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
130
132
  bio?: string;
131
133
  ownerId?: string;
132
134
  }): Promise<User> {
133
- return this.makeRequest<User>('PUT', '/users/resolve', data);
135
+ return normalizeUserIdentity(await this.makeRequest<User>('PUT', '/users/resolve', data));
134
136
  }
135
137
 
136
138
  /**
@@ -175,10 +177,11 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
175
177
  async getSimilarProfiles(userId: string, limit?: number): Promise<User[]> {
176
178
  const params: Record<string, string> = {};
177
179
  if (limit) params.limit = String(limit);
178
- return await this.makeRequest<User[]>('GET', `/profiles/${userId}/similar`, params, {
180
+ const users = await this.makeRequest<User[]>('GET', `/profiles/${userId}/similar`, params, {
179
181
  cache: true,
180
182
  cacheTTL: 5 * 60 * 1000, // 5 min cache
181
183
  });
184
+ return users.map((user) => normalizeUserIdentity(user));
182
185
  }
183
186
 
184
187
  /**
@@ -186,10 +189,11 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
186
189
  */
187
190
  async getUserById(userId: string): Promise<User> {
188
191
  try {
189
- return await this.makeRequest<User>('GET', `/users/${userId}`, undefined, {
192
+ const user = await this.makeRequest<User>('GET', `/users/${userId}`, undefined, {
190
193
  cache: true,
191
194
  cacheTTL: 5 * 60 * 1000, // 5 minutes cache
192
195
  });
196
+ return normalizeUserIdentity(user);
193
197
  } catch (error) {
194
198
  throw this.handleError(error);
195
199
  }
@@ -200,10 +204,11 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
200
204
  */
201
205
  async getCurrentUser(): Promise<User> {
202
206
  return this.withAuthRetry(async () => {
203
- return await this.makeRequest<User>('GET', '/users/me', undefined, {
207
+ const user = await this.makeRequest<User>('GET', '/users/me', undefined, {
204
208
  cache: true,
205
209
  cacheTTL: 1 * 60 * 1000, // 1 minute cache for current user
206
210
  });
211
+ return normalizeUserIdentity(user);
207
212
  }, 'getCurrentUser');
208
213
  }
209
214
 
@@ -222,7 +227,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
222
227
  */
223
228
  async updateProfile(updates: Partial<User>): Promise<User> {
224
229
  try {
225
- const result = await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false });
230
+ const result = normalizeUserIdentity(
231
+ await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false }),
232
+ );
226
233
 
227
234
  // Bust every cached representation of the current user. We use a
228
235
  // prefix sweep rather than an enumeration because the SDK never
@@ -531,4 +538,3 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
531
538
  }
532
539
  };
533
540
  }
534
-
@@ -0,0 +1,51 @@
1
+ interface UserIdentityInput {
2
+ id?: unknown;
3
+ _id?: unknown;
4
+ }
5
+
6
+ function stringifyIdentity(value: unknown): string | null {
7
+ if (typeof value === 'string') {
8
+ const trimmed = value.trim();
9
+ return trimmed.length > 0 ? trimmed : null;
10
+ }
11
+
12
+ if (typeof value === 'number' && Number.isFinite(value)) {
13
+ return String(value);
14
+ }
15
+
16
+ if (value && typeof value === 'object' && 'toString' in value) {
17
+ const toStringFn = value.toString;
18
+ if (typeof toStringFn === 'function' && toStringFn !== Object.prototype.toString) {
19
+ const rendered = toStringFn.call(value);
20
+ if (typeof rendered === 'string') {
21
+ const trimmed = rendered.trim();
22
+ return trimmed.length > 0 && trimmed !== '[object Object]' ? trimmed : null;
23
+ }
24
+ }
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ export function getNormalizedUserId(user: UserIdentityInput | null | undefined): string | null {
31
+ if (!user) {
32
+ return null;
33
+ }
34
+
35
+ return stringifyIdentity(user.id) ?? stringifyIdentity(user._id);
36
+ }
37
+
38
+ export function normalizeUserIdentity<T extends UserIdentityInput>(user: T): T & { id: string } {
39
+ const id = getNormalizedUserId(user);
40
+ if (!id) {
41
+ throw new Error('User response missing id');
42
+ }
43
+
44
+ return { ...user, id };
45
+ }
46
+
47
+ export function normalizeUserIdentityOrNull<T extends UserIdentityInput>(
48
+ user: T | null | undefined,
49
+ ): (T & { id: string }) | null {
50
+ return user ? normalizeUserIdentity(user) : null;
51
+ }