@oxyhq/core 3.1.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +14 -3
  3. package/dist/cjs/HttpService.js +89 -0
  4. package/dist/cjs/OxyServices.js +2 -1
  5. package/dist/cjs/constants/version.js +1 -1
  6. package/dist/cjs/i18n/locales/en-US.json +44 -44
  7. package/dist/cjs/i18n/locales/es-ES.json +44 -44
  8. package/dist/cjs/i18n/locales/locales/en-US.json +44 -44
  9. package/dist/cjs/i18n/locales/locales/es-ES.json +44 -44
  10. package/dist/cjs/index.js +4 -0
  11. package/dist/cjs/mixins/OxyServices.applications.js +33 -3
  12. package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
  13. package/dist/cjs/mixins/OxyServices.workspaces.js +146 -0
  14. package/dist/cjs/mixins/index.js +4 -2
  15. package/dist/cjs/utils/accountUtils.js +12 -5
  16. package/dist/cjs/utils/ssoReturn.js +80 -33
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/AuthManager.js +14 -3
  19. package/dist/esm/HttpService.js +89 -0
  20. package/dist/esm/OxyServices.js +2 -1
  21. package/dist/esm/constants/version.js +1 -1
  22. package/dist/esm/i18n/locales/en-US.json +44 -44
  23. package/dist/esm/i18n/locales/es-ES.json +44 -44
  24. package/dist/esm/i18n/locales/locales/en-US.json +44 -44
  25. package/dist/esm/i18n/locales/locales/es-ES.json +44 -44
  26. package/dist/esm/index.js +4 -0
  27. package/dist/esm/mixins/OxyServices.applications.js +33 -3
  28. package/dist/esm/mixins/OxyServices.reputation.js +241 -0
  29. package/dist/esm/mixins/OxyServices.workspaces.js +143 -0
  30. package/dist/esm/mixins/index.js +4 -2
  31. package/dist/esm/utils/accountUtils.js +12 -5
  32. package/dist/esm/utils/ssoReturn.js +80 -33
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/HttpService.d.ts +57 -0
  35. package/dist/types/OxyServices.d.ts +2 -1
  36. package/dist/types/constants/version.d.ts +2 -2
  37. package/dist/types/index.d.ts +4 -2
  38. package/dist/types/mixins/OxyServices.applications.d.ts +86 -10
  39. package/dist/types/mixins/OxyServices.features.d.ts +0 -1
  40. package/dist/types/mixins/OxyServices.reputation.d.ts +436 -0
  41. package/dist/types/mixins/OxyServices.workspaces.d.ts +205 -0
  42. package/dist/types/mixins/index.d.ts +3 -2
  43. package/dist/types/models/interfaces.d.ts +24 -26
  44. package/dist/types/utils/accountUtils.d.ts +17 -4
  45. package/dist/types/utils/ssoReturn.d.ts +30 -9
  46. package/package.json +2 -1
  47. package/src/AuthManager.ts +14 -3
  48. package/src/HttpService.ts +91 -0
  49. package/src/OxyServices.ts +2 -1
  50. package/src/__tests__/authManager.cookiePath.test.ts +49 -0
  51. package/src/__tests__/httpServiceCache.test.ts +198 -0
  52. package/src/constants/version.ts +1 -1
  53. package/src/i18n/locales/en-US.json +44 -44
  54. package/src/i18n/locales/es-ES.json +44 -44
  55. package/src/index.ts +51 -4
  56. package/src/mixins/OxyServices.applications.ts +103 -5
  57. package/src/mixins/OxyServices.auth.ts +2 -1
  58. package/src/mixins/OxyServices.features.ts +0 -1
  59. package/src/mixins/OxyServices.reputation.ts +674 -0
  60. package/src/mixins/OxyServices.workspaces.ts +315 -0
  61. package/src/mixins/__tests__/reputation.test.ts +408 -0
  62. package/src/mixins/index.ts +6 -3
  63. package/src/models/interfaces.ts +25 -32
  64. package/src/utils/__tests__/accountUtils.test.ts +142 -0
  65. package/src/utils/__tests__/consumeSsoReturn.test.ts +229 -37
  66. package/src/utils/accountUtils.ts +20 -5
  67. package/src/utils/ssoReturn.ts +98 -37
  68. package/dist/cjs/mixins/OxyServices.developer.js +0 -97
  69. package/dist/cjs/mixins/OxyServices.karma.js +0 -108
  70. package/dist/esm/mixins/OxyServices.developer.js +0 -94
  71. package/dist/esm/mixins/OxyServices.karma.js +0 -105
  72. package/dist/types/mixins/OxyServices.developer.d.ts +0 -106
  73. package/dist/types/mixins/OxyServices.karma.d.ts +0 -92
  74. package/src/mixins/OxyServices.karma.ts +0 -111
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Reputation Mixin Tests (Oxy Trust)
3
+ *
4
+ * Exercises the typed helpers around `/reputation/...`. We stub `makeRequest`
5
+ * so the tests run without a network or a database — what we care about here is
6
+ * request shape (method, URL, query params, body, cache options), the response
7
+ * envelope handling (direct object vs `{ data }` paginated vs `{ rules }` /
8
+ * `{ transaction }` / `{ dispute }` wrappers), default fallbacks on missing
9
+ * fields, path-segment URL-encoding, and cache invalidation on writes.
10
+ */
11
+
12
+ import { OxyServices } from '../../OxyServices';
13
+ import type {
14
+ ReputationBalance,
15
+ ReputationTransaction,
16
+ ReputationDispute,
17
+ ReputationRule,
18
+ ReputationLeaderboardEntry,
19
+ ReputationInfluenceResult,
20
+ } from '../OxyServices.reputation';
21
+
22
+ const setAccessTokenForTest = (oxy: OxyServices): void => {
23
+ oxy.httpService.setTokens('test-token', '');
24
+ };
25
+
26
+ const balanceFixture: ReputationBalance = {
27
+ userId: 'u1',
28
+ total: 120,
29
+ positive: 150,
30
+ negative: -30,
31
+ breakdown: {
32
+ content: 80,
33
+ social: 40,
34
+ trust: 0,
35
+ moderation: 0,
36
+ physical: 0,
37
+ penalties: 30,
38
+ },
39
+ trustTier: 'trusted',
40
+ influence: {
41
+ defaultWeight: 1.0,
42
+ reportWeight: 1.0,
43
+ moderationWeight: 1.0,
44
+ rankingFeedbackWeight: 0.8,
45
+ },
46
+ reliability: {
47
+ accurateReports: 2,
48
+ rejectedReports: 0,
49
+ reportAccuracyScore: 1,
50
+ abuseScore: 0,
51
+ },
52
+ recalculatedAt: '2026-06-16T00:00:00.000Z',
53
+ updatedAt: '2026-06-16T00:00:00.000Z',
54
+ };
55
+
56
+ const transactionFixture: ReputationTransaction = {
57
+ id: 't1',
58
+ userId: 'u1',
59
+ points: 10,
60
+ actionType: 'post_created',
61
+ category: 'content',
62
+ status: 'active',
63
+ createdAt: '2026-06-16T00:00:00.000Z',
64
+ updatedAt: '2026-06-16T00:00:00.000Z',
65
+ };
66
+
67
+ const disputeFixture: ReputationDispute = {
68
+ id: 'd1',
69
+ transactionId: 't1',
70
+ userId: 'u1',
71
+ reason: 'I did not do this',
72
+ status: 'open',
73
+ createdAt: '2026-06-16T00:00:00.000Z',
74
+ updatedAt: '2026-06-16T00:00:00.000Z',
75
+ };
76
+
77
+ const ruleFixture: ReputationRule = {
78
+ id: 'r1',
79
+ actionType: 'post_created',
80
+ points: 10,
81
+ category: 'content',
82
+ description: 'Created a post',
83
+ cooldownInMinutes: 0,
84
+ isEnabled: true,
85
+ };
86
+
87
+ describe('OxyServices.reputation', () => {
88
+ let oxy: OxyServices;
89
+ let makeRequestSpy: jest.SpyInstance;
90
+ let clearCacheSpy: jest.SpyInstance;
91
+
92
+ beforeEach(() => {
93
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
94
+ setAccessTokenForTest(oxy);
95
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
96
+ clearCacheSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
97
+ });
98
+
99
+ afterEach(() => {
100
+ makeRequestSpy.mockRestore();
101
+ clearCacheSpy.mockRestore();
102
+ });
103
+
104
+ describe('getReputationBalance', () => {
105
+ it('returns the balance object directly and caches the read', async () => {
106
+ makeRequestSpy.mockResolvedValue(balanceFixture);
107
+
108
+ const result = await oxy.getReputationBalance('u1');
109
+
110
+ expect(result).toEqual(balanceFixture);
111
+ expect(makeRequestSpy).toHaveBeenCalledWith(
112
+ 'GET',
113
+ '/reputation/u1/balance',
114
+ undefined,
115
+ expect.objectContaining({ cache: true }),
116
+ );
117
+ });
118
+
119
+ it('URL-encodes the userId path segment', async () => {
120
+ makeRequestSpy.mockResolvedValue(balanceFixture);
121
+ await oxy.getReputationBalance('a b/c');
122
+ expect(makeRequestSpy.mock.calls[0][1]).toBe('/reputation/a%20b%2Fc/balance');
123
+ });
124
+ });
125
+
126
+ describe('getReputationLeaderboard', () => {
127
+ it('unwraps the paginated `data` array and omits empty query params', async () => {
128
+ const entries: ReputationLeaderboardEntry[] = [
129
+ {
130
+ user: { id: 'u1', username: 'alice', name: { full: 'Alice' }, avatar: 'a', publicKey: 'pk1' },
131
+ total: 120,
132
+ trustTier: 'trusted',
133
+ rank: 1,
134
+ },
135
+ ];
136
+ makeRequestSpy.mockResolvedValue({ data: entries });
137
+
138
+ const result = await oxy.getReputationLeaderboard();
139
+
140
+ expect(result).toEqual(entries);
141
+ expect(makeRequestSpy).toHaveBeenCalledWith(
142
+ 'GET',
143
+ '/reputation/leaderboard',
144
+ undefined,
145
+ expect.objectContaining({ cache: true }),
146
+ );
147
+ });
148
+
149
+ it('passes limit/offset as query params when provided', async () => {
150
+ makeRequestSpy.mockResolvedValue({ data: [] });
151
+ await oxy.getReputationLeaderboard(25, 50);
152
+ expect(makeRequestSpy.mock.calls[0][2]).toEqual({ limit: 25, offset: 50 });
153
+ });
154
+
155
+ it('returns an empty array when `data` is absent', async () => {
156
+ makeRequestSpy.mockResolvedValue({});
157
+ const result = await oxy.getReputationLeaderboard();
158
+ expect(result).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe('getReputationRules', () => {
163
+ it('unwraps the `rules` array and uses a long-lived cache', async () => {
164
+ makeRequestSpy.mockResolvedValue({ rules: [ruleFixture] });
165
+
166
+ const result = await oxy.getReputationRules();
167
+
168
+ expect(result).toEqual([ruleFixture]);
169
+ expect(makeRequestSpy).toHaveBeenCalledWith(
170
+ 'GET',
171
+ '/reputation/rules',
172
+ undefined,
173
+ expect.objectContaining({ cache: true }),
174
+ );
175
+ });
176
+
177
+ it('returns an empty array when `rules` is absent', async () => {
178
+ makeRequestSpy.mockResolvedValue({});
179
+ expect(await oxy.getReputationRules()).toEqual([]);
180
+ });
181
+ });
182
+
183
+ describe('getReputationTransactions', () => {
184
+ it('unwraps the paginated `data` array', async () => {
185
+ makeRequestSpy.mockResolvedValue({ data: [transactionFixture] });
186
+
187
+ const result = await oxy.getReputationTransactions('u1', 10);
188
+
189
+ expect(result).toEqual([transactionFixture]);
190
+ expect(makeRequestSpy).toHaveBeenCalledWith(
191
+ 'GET',
192
+ '/reputation/u1/transactions',
193
+ { limit: 10 },
194
+ expect.objectContaining({ cache: true }),
195
+ );
196
+ });
197
+
198
+ it('returns an empty array when `data` is absent', async () => {
199
+ makeRequestSpy.mockResolvedValue({});
200
+ expect(await oxy.getReputationTransactions('u1')).toEqual([]);
201
+ });
202
+ });
203
+
204
+ describe('getReputationInfluence', () => {
205
+ it('returns the influence result directly and passes the context query', async () => {
206
+ const influence: ReputationInfluenceResult = {
207
+ context: 'report',
208
+ weight: 1.5,
209
+ influence: balanceFixture.influence,
210
+ };
211
+ makeRequestSpy.mockResolvedValue(influence);
212
+
213
+ const result = await oxy.getReputationInfluence('u1', 'report');
214
+
215
+ expect(result).toEqual(influence);
216
+ expect(makeRequestSpy).toHaveBeenCalledWith(
217
+ 'GET',
218
+ '/reputation/u1/influence',
219
+ { context: 'report' },
220
+ expect.objectContaining({ cache: true }),
221
+ );
222
+ });
223
+
224
+ it('omits the context query when not provided', async () => {
225
+ makeRequestSpy.mockResolvedValue({
226
+ context: 'default',
227
+ weight: 1,
228
+ influence: balanceFixture.influence,
229
+ });
230
+ await oxy.getReputationInfluence('u1');
231
+ expect(makeRequestSpy.mock.calls[0][2]).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ describe('awardReputation', () => {
236
+ it('posts the payload, unwraps `transaction`, and invalidates the cache', async () => {
237
+ makeRequestSpy.mockResolvedValue({ transaction: transactionFixture });
238
+
239
+ const result = await oxy.awardReputation({ userId: 'u1', actionType: 'post_created' });
240
+
241
+ expect(result).toEqual(transactionFixture);
242
+ expect(makeRequestSpy).toHaveBeenCalledWith(
243
+ 'POST',
244
+ '/reputation/award',
245
+ { userId: 'u1', actionType: 'post_created' },
246
+ expect.objectContaining({ cache: false }),
247
+ );
248
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
249
+ });
250
+ });
251
+
252
+ describe('createReputationDispute', () => {
253
+ it('posts the payload, unwraps `dispute`, and invalidates the cache', async () => {
254
+ makeRequestSpy.mockResolvedValue({ dispute: disputeFixture });
255
+
256
+ const result = await oxy.createReputationDispute({
257
+ transactionId: 't1',
258
+ reason: 'I did not do this',
259
+ });
260
+
261
+ expect(result).toEqual(disputeFixture);
262
+ expect(makeRequestSpy).toHaveBeenCalledWith(
263
+ 'POST',
264
+ '/reputation/disputes',
265
+ { transactionId: 't1', reason: 'I did not do this' },
266
+ expect.objectContaining({ cache: false }),
267
+ );
268
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
269
+ });
270
+ });
271
+
272
+ describe('getUserReputationDisputes', () => {
273
+ it('unwraps the paginated `data` array', async () => {
274
+ makeRequestSpy.mockResolvedValue({ data: [disputeFixture] });
275
+ const result = await oxy.getUserReputationDisputes('u1', 5, 10);
276
+ expect(result).toEqual([disputeFixture]);
277
+ expect(makeRequestSpy).toHaveBeenCalledWith(
278
+ 'GET',
279
+ '/reputation/u1/disputes',
280
+ { limit: 5, offset: 10 },
281
+ expect.objectContaining({ cache: true }),
282
+ );
283
+ });
284
+ });
285
+
286
+ describe('upsertReputationRule', () => {
287
+ it('posts the rule, unwraps `rule`, and invalidates the cache', async () => {
288
+ makeRequestSpy.mockResolvedValue({ rule: ruleFixture });
289
+
290
+ const result = await oxy.upsertReputationRule({
291
+ actionType: 'post_created',
292
+ points: 10,
293
+ category: 'content',
294
+ description: 'Created a post',
295
+ });
296
+
297
+ expect(result).toEqual(ruleFixture);
298
+ expect(makeRequestSpy).toHaveBeenCalledWith(
299
+ 'POST',
300
+ '/reputation/rules',
301
+ expect.objectContaining({ actionType: 'post_created', points: 10 }),
302
+ expect.objectContaining({ cache: false }),
303
+ );
304
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
305
+ });
306
+ });
307
+
308
+ describe('reverseReputationTransaction', () => {
309
+ it('returns the { original, reversal } pair and invalidates the cache', async () => {
310
+ const reversal = { ...transactionFixture, id: 't2', points: -10, reversedTransactionId: 't1' };
311
+ const original = { ...transactionFixture, status: 'reversed' as const };
312
+ makeRequestSpy.mockResolvedValue({ original, reversal });
313
+
314
+ const result = await oxy.reverseReputationTransaction('t1', { reason: 'mistake' });
315
+
316
+ expect(result).toEqual({ original, reversal });
317
+ expect(makeRequestSpy).toHaveBeenCalledWith(
318
+ 'POST',
319
+ '/reputation/transactions/t1/reverse',
320
+ { reason: 'mistake' },
321
+ expect.objectContaining({ cache: false }),
322
+ );
323
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
324
+ });
325
+
326
+ it('sends an empty body when no input is given', async () => {
327
+ makeRequestSpy.mockResolvedValue({ original: transactionFixture, reversal: transactionFixture });
328
+ await oxy.reverseReputationTransaction('t1');
329
+ expect(makeRequestSpy.mock.calls[0][2]).toEqual({});
330
+ });
331
+ });
332
+
333
+ describe('voidReputationTransaction', () => {
334
+ it('unwraps `transaction` and invalidates the cache', async () => {
335
+ const voided = { ...transactionFixture, status: 'voided' as const };
336
+ makeRequestSpy.mockResolvedValue({ transaction: voided });
337
+
338
+ const result = await oxy.voidReputationTransaction('t1');
339
+
340
+ expect(result).toEqual(voided);
341
+ expect(makeRequestSpy).toHaveBeenCalledWith(
342
+ 'POST',
343
+ '/reputation/transactions/t1/void',
344
+ {},
345
+ expect.objectContaining({ cache: false }),
346
+ );
347
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
348
+ });
349
+ });
350
+
351
+ describe('recalculateReputation', () => {
352
+ it('returns the recomputed balance directly and invalidates the cache', async () => {
353
+ makeRequestSpy.mockResolvedValue(balanceFixture);
354
+
355
+ const result = await oxy.recalculateReputation('u1');
356
+
357
+ expect(result).toEqual(balanceFixture);
358
+ expect(makeRequestSpy).toHaveBeenCalledWith(
359
+ 'POST',
360
+ '/reputation/u1/recalculate',
361
+ undefined,
362
+ expect.objectContaining({ cache: false }),
363
+ );
364
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
365
+ });
366
+ });
367
+
368
+ describe('getReputationDisputeQueue', () => {
369
+ it('unwraps the paginated `data` array', async () => {
370
+ makeRequestSpy.mockResolvedValue({ data: [disputeFixture] });
371
+ const result = await oxy.getReputationDisputeQueue(20);
372
+ expect(result).toEqual([disputeFixture]);
373
+ expect(makeRequestSpy).toHaveBeenCalledWith(
374
+ 'GET',
375
+ '/reputation/disputes',
376
+ { limit: 20 },
377
+ expect.objectContaining({ cache: true }),
378
+ );
379
+ });
380
+ });
381
+
382
+ describe('resolveReputationDispute', () => {
383
+ it('posts the resolution, unwraps `dispute`, and invalidates the cache', async () => {
384
+ const resolved = { ...disputeFixture, status: 'accepted' as const };
385
+ makeRequestSpy.mockResolvedValue({ dispute: resolved });
386
+
387
+ const result = await oxy.resolveReputationDispute('d1', { status: 'accepted' });
388
+
389
+ expect(result).toEqual(resolved);
390
+ expect(makeRequestSpy).toHaveBeenCalledWith(
391
+ 'POST',
392
+ '/reputation/disputes/d1/resolve',
393
+ { status: 'accepted' },
394
+ expect.objectContaining({ cache: false }),
395
+ );
396
+ expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
397
+ });
398
+ });
399
+
400
+ describe('error handling', () => {
401
+ it('surfaces API errors via handleError', async () => {
402
+ makeRequestSpy.mockRejectedValue(
403
+ Object.assign(new Error('boom'), { response: { status: 500 } }),
404
+ );
405
+ await expect(oxy.getReputationBalance('u1')).rejects.toThrow();
406
+ });
407
+ });
408
+ });
@@ -15,9 +15,10 @@ import { OxyServicesUserMixin } from './OxyServices.user';
15
15
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
16
16
  import { OxyServicesLanguageMixin } from './OxyServices.language';
17
17
  import { OxyServicesPaymentMixin } from './OxyServices.payment';
18
- import { OxyServicesKarmaMixin } from './OxyServices.karma';
18
+ import { OxyServicesReputationMixin } from './OxyServices.reputation';
19
19
  import { OxyServicesAssetsMixin } from './OxyServices.assets';
20
20
  import { OxyServicesApplicationsMixin } from './OxyServices.applications';
21
+ import { OxyServicesWorkspacesMixin } from './OxyServices.workspaces';
21
22
  import { OxyServicesLocationMixin } from './OxyServices.location';
22
23
  import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
23
24
  import { OxyServicesDevicesMixin } from './OxyServices.devices';
@@ -48,9 +49,10 @@ type AllMixinInstances =
48
49
  & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
49
50
  & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
50
51
  & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
51
- & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>>
52
+ & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>>
52
53
  & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
53
54
  & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>>
55
+ & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>>
54
56
  & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
55
57
  & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
56
58
  & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>>
@@ -112,9 +114,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
112
114
  // Feature mixins
113
115
  OxyServicesLanguageMixin,
114
116
  OxyServicesPaymentMixin,
115
- OxyServicesKarmaMixin,
117
+ OxyServicesReputationMixin,
116
118
  OxyServicesAssetsMixin,
117
119
  OxyServicesApplicationsMixin,
120
+ OxyServicesWorkspacesMixin,
118
121
  OxyServicesLocationMixin,
119
122
  OxyServicesAnalyticsMixin,
120
123
  OxyServicesDevicesMixin,
@@ -1,3 +1,5 @@
1
+ import type { UserNameResponse } from '@oxyhq/contracts';
2
+
1
3
  export interface OxyConfig {
2
4
  baseURL: string;
3
5
  cloudURL?: string;
@@ -17,6 +19,15 @@ export interface OxyConfig {
17
19
  sessionBaseUrl?: string;
18
20
  authWebUrl?: string;
19
21
  authRedirectUri?: string;
22
+ /**
23
+ * The app's Oxy OAuth client id (ApplicationCredential publicKey).
24
+ *
25
+ * Identifies this app in OAuth authorize / consent flows (issue #214). Purely
26
+ * declarative: the SDK stores it on `OxyServices.config.clientId` for later
27
+ * OAuth-authorize use. It is unrelated to the cross-domain `/sso?client_id=…`
28
+ * bounce (which uses the RP origin, not this registered client id).
29
+ */
30
+ clientId?: string;
20
31
  // Performance & caching options
21
32
  enableCache?: boolean;
22
33
  cacheTTL?: number; // Cache TTL in milliseconds (default: 5 minutes)
@@ -83,14 +94,14 @@ export interface User {
83
94
  color?: string;
84
95
  // Privacy and security settings
85
96
  privacySettings?: PrivacySettings;
86
- name?: {
87
- first?: string;
88
- last?: string;
89
- full?: string; // virtual, not stored in DB, returned by API
90
- [key: string]: unknown;
91
- };
97
+ /**
98
+ * Structured human name. The canonical wire shape ({@link UserNameResponse}):
99
+ * `{ first?, last?, full? }` where `full` is a Mongoose virtual (present only
100
+ * when the query materialised virtuals). The single source of truth lives in
101
+ * `@oxyhq/contracts` — do NOT re-declare a bare `string` here.
102
+ */
103
+ name?: UserNameResponse;
92
104
  bio?: string;
93
- karma?: number;
94
105
  location?: string;
95
106
  website?: string;
96
107
  createdAt?: string;
@@ -252,30 +263,6 @@ export interface SearchProfilesResponse {
252
263
  pagination: PaginationInfo;
253
264
  }
254
265
 
255
- export interface KarmaRule {
256
- id: string;
257
- description: string;
258
- // Add other karma rule fields as needed
259
- }
260
-
261
- export interface KarmaHistory {
262
- id: string;
263
- userId: string;
264
- points: number;
265
- // Add other karma history fields as needed
266
- }
267
-
268
- export interface KarmaLeaderboardEntry {
269
- userId: string;
270
- total: number;
271
- }
272
-
273
- export interface KarmaAwardRequest {
274
- userId: string;
275
- points: number;
276
- reason?: string;
277
- }
278
-
279
266
  export interface ApiError {
280
267
  message: string;
281
268
  code: string;
@@ -660,7 +647,13 @@ export interface UpdateDeviceNameResponse {
660
647
  export interface RefreshAllAccountUser {
661
648
  id: string;
662
649
  username: string;
663
- name?: string;
650
+ /**
651
+ * Structured human name as emitted by `formatUserResponse` (the canonical
652
+ * {@link UserNameResponse} `{ first?, last?, full? }` subdocument), NOT a bare
653
+ * string. The server projects `name` verbatim from the user document. The
654
+ * single source of truth is `@oxyhq/contracts`.
655
+ */
656
+ name?: UserNameResponse;
664
657
  avatar?: string | null;
665
658
  email?: string;
666
659
  color?: string | null;
@@ -0,0 +1,142 @@
1
+ import {
2
+ getAccountDisplayName,
3
+ getAccountFallbackHandle,
4
+ formatPublicKeyHandle,
5
+ } from '../accountUtils';
6
+
7
+ /**
8
+ * Regression coverage for the auth-app display-name drift (Phase 1 of the
9
+ * contract-centralisation refactor).
10
+ *
11
+ * The auth app previously required BOTH `name.first` AND `name.last` to compose
12
+ * a display name, falling back to the lowercase `username` for first-name-only
13
+ * accounts. The canonical resolver in core MUST be first-name-only safe: a user
14
+ * with only a first name resolves to that first name, never to the username.
15
+ */
16
+ describe('getAccountDisplayName', () => {
17
+ it('returns first name for first-name-only accounts (NOT the username)', () => {
18
+ const result = getAccountDisplayName({
19
+ name: { first: 'Nate' },
20
+ username: 'nateus',
21
+ });
22
+ expect(result).toBe('Nate');
23
+ expect(result).not.toBe('nateus');
24
+ });
25
+
26
+ it('composes first + last when both are present', () => {
27
+ expect(
28
+ getAccountDisplayName({
29
+ name: { first: 'Nate', last: 'Isern' },
30
+ username: 'nateus',
31
+ }),
32
+ ).toBe('Nate Isern');
33
+ });
34
+
35
+ it('returns last name only when first is missing', () => {
36
+ expect(
37
+ getAccountDisplayName({
38
+ name: { last: 'Isern' },
39
+ username: 'nateus',
40
+ }),
41
+ ).toBe('Isern');
42
+ });
43
+
44
+ it('prefers name.full when present', () => {
45
+ expect(
46
+ getAccountDisplayName({
47
+ name: { first: 'Nate', last: 'Isern', full: 'Nathaniel Isern' },
48
+ username: 'nateus',
49
+ }),
50
+ ).toBe('Nathaniel Isern');
51
+ });
52
+
53
+ it('uses the structured name over a server displayName virtual', () => {
54
+ // Server `displayName` virtual = `username || truncatedKey`; it ignores
55
+ // the structured name. A real name must win so a first-only account is
56
+ // never collapsed to its username.
57
+ expect(
58
+ getAccountDisplayName({
59
+ name: { first: 'Nate' },
60
+ displayName: 'nateus',
61
+ username: 'nateus',
62
+ }),
63
+ ).toBe('Nate');
64
+ });
65
+
66
+ it('uses displayName when there is no structured name', () => {
67
+ expect(
68
+ getAccountDisplayName({
69
+ displayName: 'Cool Display',
70
+ username: 'nateus',
71
+ }),
72
+ ).toBe('Cool Display');
73
+ });
74
+
75
+ it('supports name stored as a plain string', () => {
76
+ expect(getAccountDisplayName({ name: 'Legacy Name', username: 'nateus' })).toBe(
77
+ 'Legacy Name',
78
+ );
79
+ });
80
+
81
+ it('falls back to username when there is no name', () => {
82
+ expect(getAccountDisplayName({ username: 'nateus' })).toBe('nateus');
83
+ });
84
+
85
+ it('falls back to the public-key handle when there is no name or username', () => {
86
+ const result = getAccountDisplayName({
87
+ publicKey: '0x1234567890abcdef',
88
+ });
89
+ // common.accountFallback = "Account {{handle}}"; handle truncated to 0x12345678…
90
+ expect(result).toBe('Account 0x12345678…');
91
+ expect(result).not.toContain('Unknown');
92
+ });
93
+
94
+ it('returns the translated unnamed fallback when nothing is available', () => {
95
+ expect(getAccountDisplayName({})).toBe('Unnamed');
96
+ expect(getAccountDisplayName(null)).toBe('Unnamed');
97
+ expect(getAccountDisplayName(undefined)).toBe('Unnamed');
98
+ });
99
+
100
+ it('ignores whitespace-only name fields', () => {
101
+ expect(
102
+ getAccountDisplayName({
103
+ name: { first: ' ', last: ' ' },
104
+ username: 'nateus',
105
+ }),
106
+ ).toBe('nateus');
107
+ });
108
+
109
+ it('honours the locale for the fallback strings', () => {
110
+ expect(getAccountDisplayName({}, 'es-ES')).toBe('Sin nombre');
111
+ expect(getAccountDisplayName({ publicKey: '0x1234567890abcdef' }, 'es-ES')).toBe(
112
+ 'Cuenta 0x12345678…',
113
+ );
114
+ });
115
+ });
116
+
117
+ describe('getAccountFallbackHandle', () => {
118
+ it('returns the bare username when present', () => {
119
+ expect(getAccountFallbackHandle({ username: 'nateus' })).toBe('nateus');
120
+ });
121
+
122
+ it('falls back to a truncated public-key handle', () => {
123
+ expect(getAccountFallbackHandle({ publicKey: '0x1234567890abcdef' })).toBe('0x12345678…');
124
+ });
125
+
126
+ it('returns undefined when neither username nor publicKey is present', () => {
127
+ expect(getAccountFallbackHandle({})).toBeUndefined();
128
+ expect(getAccountFallbackHandle(null)).toBeUndefined();
129
+ });
130
+ });
131
+
132
+ describe('formatPublicKeyHandle', () => {
133
+ it('truncates a long key and strips the 0x prefix before re-adding it', () => {
134
+ expect(formatPublicKeyHandle('0x1234567890abcdef')).toBe('0x12345678…');
135
+ expect(formatPublicKeyHandle('1234567890abcdef')).toBe('0x12345678…');
136
+ });
137
+
138
+ it('returns the raw (prefixed) key when too short to truncate', () => {
139
+ expect(formatPublicKeyHandle('0xabcd')).toBe('0xabcd');
140
+ expect(formatPublicKeyHandle('abcd')).toBe('0xabcd');
141
+ });
142
+ });