@oxyhq/core 3.2.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 (73) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +3 -1
  3. package/dist/cjs/HttpService.js +89 -0
  4. package/dist/cjs/OxyServices.js +1 -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 +3 -1
  12. package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
  13. package/dist/cjs/mixins/OxyServices.workspaces.js +3 -1
  14. package/dist/cjs/mixins/index.js +2 -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 +3 -1
  19. package/dist/esm/HttpService.js +89 -0
  20. package/dist/esm/OxyServices.js +1 -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 +3 -1
  28. package/dist/esm/mixins/OxyServices.reputation.js +241 -0
  29. package/dist/esm/mixins/OxyServices.workspaces.js +3 -1
  30. package/dist/esm/mixins/index.js +2 -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 +1 -1
  36. package/dist/types/constants/version.d.ts +2 -2
  37. package/dist/types/index.d.ts +2 -1
  38. package/dist/types/mixins/OxyServices.applications.d.ts +8 -2
  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 +8 -2
  42. package/dist/types/mixins/index.d.ts +2 -2
  43. package/dist/types/models/interfaces.d.ts +15 -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 +3 -1
  48. package/src/HttpService.ts +91 -0
  49. package/src/OxyServices.ts +1 -1
  50. package/src/__tests__/httpServiceCache.test.ts +198 -0
  51. package/src/constants/version.ts +1 -1
  52. package/src/i18n/locales/en-US.json +44 -44
  53. package/src/i18n/locales/es-ES.json +44 -44
  54. package/src/index.ts +32 -4
  55. package/src/mixins/OxyServices.applications.ts +8 -2
  56. package/src/mixins/OxyServices.auth.ts +2 -1
  57. package/src/mixins/OxyServices.features.ts +0 -1
  58. package/src/mixins/OxyServices.reputation.ts +674 -0
  59. package/src/mixins/OxyServices.workspaces.ts +8 -2
  60. package/src/mixins/__tests__/reputation.test.ts +408 -0
  61. package/src/mixins/index.ts +3 -3
  62. package/src/models/interfaces.ts +16 -32
  63. package/src/utils/__tests__/accountUtils.test.ts +142 -0
  64. package/src/utils/__tests__/consumeSsoReturn.test.ts +229 -37
  65. package/src/utils/accountUtils.ts +20 -5
  66. package/src/utils/ssoReturn.ts +98 -37
  67. package/dist/cjs/mixins/OxyServices.developer.js +0 -97
  68. package/dist/cjs/mixins/OxyServices.karma.js +0 -108
  69. package/dist/esm/mixins/OxyServices.developer.js +0 -94
  70. package/dist/esm/mixins/OxyServices.karma.js +0 -105
  71. package/dist/types/mixins/OxyServices.developer.d.ts +0 -106
  72. package/dist/types/mixins/OxyServices.karma.d.ts +0 -92
  73. package/src/mixins/OxyServices.karma.ts +0 -111
@@ -1,3 +1,4 @@
1
+ import type { UserNameResponse } from '@oxyhq/contracts';
1
2
  export interface OxyConfig {
2
3
  baseURL: string;
3
4
  cloudURL?: string;
@@ -83,14 +84,14 @@ export interface User {
83
84
  avatar?: string;
84
85
  color?: string;
85
86
  privacySettings?: PrivacySettings;
86
- name?: {
87
- first?: string;
88
- last?: string;
89
- full?: string;
90
- [key: string]: unknown;
91
- };
87
+ /**
88
+ * Structured human name. The canonical wire shape ({@link UserNameResponse}):
89
+ * `{ first?, last?, full? }` where `full` is a Mongoose virtual (present only
90
+ * when the query materialised virtuals). The single source of truth lives in
91
+ * `@oxyhq/contracts` — do NOT re-declare a bare `string` here.
92
+ */
93
+ name?: UserNameResponse;
92
94
  bio?: string;
93
- karma?: number;
94
95
  location?: string;
95
96
  website?: string;
96
97
  createdAt?: string;
@@ -228,24 +229,6 @@ export interface SearchProfilesResponse {
228
229
  data: User[];
229
230
  pagination: PaginationInfo;
230
231
  }
231
- export interface KarmaRule {
232
- id: string;
233
- description: string;
234
- }
235
- export interface KarmaHistory {
236
- id: string;
237
- userId: string;
238
- points: number;
239
- }
240
- export interface KarmaLeaderboardEntry {
241
- userId: string;
242
- total: number;
243
- }
244
- export interface KarmaAwardRequest {
245
- userId: string;
246
- points: number;
247
- reason?: string;
248
- }
249
232
  export interface ApiError {
250
233
  message: string;
251
234
  code: string;
@@ -552,7 +535,13 @@ export interface UpdateDeviceNameResponse {
552
535
  export interface RefreshAllAccountUser {
553
536
  id: string;
554
537
  username: string;
555
- name?: string;
538
+ /**
539
+ * Structured human name as emitted by `formatUserResponse` (the canonical
540
+ * {@link UserNameResponse} `{ first?, last?, full? }` subdocument), NOT a bare
541
+ * string. The server projects `name` verbatim from the user document. The
542
+ * single source of truth is `@oxyhq/contracts`.
543
+ */
544
+ name?: UserNameResponse;
556
545
  avatar?: string | null;
557
546
  email?: string;
558
547
  color?: string | null;
@@ -32,6 +32,14 @@ export interface DisplayNameUserShape {
32
32
  full?: string;
33
33
  [key: string]: unknown;
34
34
  };
35
+ /**
36
+ * Pre-resolved display name as emitted by the server's `displayName` virtual
37
+ * (raw `/users/me` responses). NOTE: the server virtual resolves to
38
+ * `username || truncatedPublicKey || 'Anonymous'` — it does NOT compose the
39
+ * structured `name`. It is therefore preferred only AFTER a real structured
40
+ * name, so a first-name-only account never collapses to its username/key.
41
+ */
42
+ displayName?: string;
35
43
  username?: string;
36
44
  publicKey?: string;
37
45
  }
@@ -44,11 +52,16 @@ export declare const formatPublicKeyHandle: (publicKey: string) => string;
44
52
  * Resolve a friendly display name for a user.
45
53
  *
46
54
  * Order of preference:
47
- * 1. `name.full`, or composed `name.first name.last`
55
+ * 1. `name.full`, or composed `name.first name.last` (FIRST-NAME-ONLY SAFE —
56
+ * a user with only a first name resolves to that first name, never to the
57
+ * lowercase username; this is the exact drift bug the auth app hit).
48
58
  * 2. `name` (when stored as a plain string)
49
- * 3. `username`
50
- * 4. `Account 0x12345678…` (derived from publicKey, when present)
51
- * 5. Translated fallback (e.g. "Unnamed")
59
+ * 3. `displayName` (server `displayName` virtual — `username || truncatedKey`).
60
+ * Placed AFTER the structured name on purpose: the server virtual ignores
61
+ * `name`, so preferring it first would re-introduce the first-only bug.
62
+ * 4. `username`
63
+ * 5. `Account 0x12345678…` (derived from publicKey, when present)
64
+ * 6. Translated fallback (e.g. "Unnamed")
52
65
  *
53
66
  * The translation key `common.unnamed` is used for the final fallback. If the
54
67
  * caller does not pass a locale, the default English translation is used.
@@ -77,9 +77,22 @@ export interface ConsumeSsoReturnDeps {
77
77
  * location changed via `history.replaceState`, which does NOT itself emit
78
78
  * `popstate`. Default: dispatch a real `PopStateEvent` on `window` when
79
79
  * present; no-op off-web. Called ONLY after a successful same-origin
80
- * dest restore (never when the dest is rejected/absent). NEVER throws.
80
+ * dest restore on the `ok` path (never when the dest is rejected/absent).
81
+ * NEVER throws.
81
82
  */
82
83
  dispatchPopState?: () => void;
84
+ /**
85
+ * Hard, full-document navigation used to leave the internal callback path on
86
+ * every NON-`ok` outcome (`none`/`error`, state-mismatch, missing code,
87
+ * failed exchange, missing sessionId). A SOFT `history.replaceState` +
88
+ * synthetic `popstate` does NOT reliably make Expo Router / TanStack Router
89
+ * re-resolve away from the 404 they have already rendered for the
90
+ * unregistered callback route — so for these outcomes (where there is no
91
+ * in-memory session to preserve) a full navigation is both safe and
92
+ * guaranteed to clear the 404. Default: `window.location.replace(url)` when
93
+ * present; feature-detected end to end so it never throws off-web.
94
+ */
95
+ hardRedirect?: (url: string) => void;
83
96
  }
84
97
  /**
85
98
  * Consume an SSO return: the commit-free, security-critical kernel of the
@@ -105,14 +118,22 @@ export interface ConsumeSsoReturnDeps {
105
118
  * treated exactly like "no session" (never loops, never rethrows).
106
119
  * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
107
120
  * failed-exchange, no-sessionId) — not just ok — if the page landed on
108
- * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
109
- * from the DEST key so the user is never stranded on the internal callback
110
- * path. Same-origin only (an attacker-planted cross-origin or relative-evil
111
- * dest is rejected). The DEST key is removed unconditionally.
112
- * - After a same-origin dest restore (which uses `history.replaceState`, that
113
- * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
114
- * URL-driven routers (Expo Router / React Navigation web) re-sync to the
115
- * restored route. It is NOT dispatched when the dest is rejected/absent.
121
+ * {@link SSO_CALLBACK_PATH}, the user is taken to a same-origin TARGET so
122
+ * they are never stranded on the internal callback path (which is an
123
+ * unregistered route in every consumer router a hard 404). The target is
124
+ * the stored DEST when it parses as same-origin (an attacker-planted
125
+ * cross-origin / protocol-relative dest is rejected), ELSE the app root
126
+ * (`origin + '/'`). The DEST key is removed unconditionally.
127
+ * - For the `ok` outcome the target is applied via a SOFT
128
+ * `history.replaceState` + synthetic `popstate` so the freshly exchanged
129
+ * in-memory session the provider is about to commit is preserved (no
130
+ * reload). `popstate` is dispatched only on the `ok` same-origin restore.
131
+ * - For every NON-`ok` outcome there is no in-memory session to preserve, and
132
+ * the consumer router has ALREADY synchronously rendered its 404 for the
133
+ * unregistered callback route — a soft replaceState+popstate does not
134
+ * reliably make it re-resolve. So these outcomes perform a HARD
135
+ * full-document navigation to the target (`hardRedirect`), which is both
136
+ * safe (nothing to lose) and guaranteed to clear the 404 in every router.
116
137
  *
117
138
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
118
139
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
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",
@@ -79,6 +79,7 @@
79
79
  }
80
80
  },
81
81
  "dependencies": {
82
+ "@oxyhq/contracts": "workspace:*",
82
83
  "bip39": "^3.1.0",
83
84
  "buffer": "^6.0.3",
84
85
  "elliptic": "^6.6.1",
@@ -1071,7 +1071,9 @@ export class AuthManager {
1071
1071
  const hydrated: RefreshAllAccountUser = {
1072
1072
  id: me.id,
1073
1073
  username: me.username,
1074
- name: typeof me.name === 'string' ? me.name : undefined,
1074
+ // `User.name` and `RefreshAllAccountUser.name` are the same canonical
1075
+ // structured `UserNameResponse` shape, so forward it verbatim.
1076
+ name: me.name,
1075
1077
  avatar: me.avatar ?? null,
1076
1078
  email: me.email,
1077
1079
  color: me.color ?? null,
@@ -675,14 +675,79 @@ export class HttpService {
675
675
  return headers;
676
676
  }
677
677
 
678
+ /**
679
+ * Delimiter that separates the logical `method:url[:data]` portion of a
680
+ * cache key from its identity suffix. Always APPENDED, never used to parse
681
+ * a key apart, so the `method:url` prefix stays intact for
682
+ * `clearCacheByPrefix` sweeps and `clearCacheEntry` base-key matching.
683
+ * The `clearCacheEntry` callsites all pass fixed, dataless logical keys
684
+ * (`GET:/users/<id>`, `GET:/session/user/<sessionId>`,
685
+ * `GET:/fedcm/me/authorized-apps`), so this readable suffix can never be
686
+ * ambiguous with a serialized request body.
687
+ */
688
+ private static readonly CACHE_IDENTITY_DELIM = ' id=';
689
+
690
+ /**
691
+ * Derive a stable, non-sensitive identity discriminator for cache scoping.
692
+ *
693
+ * The GET-response cache MUST be partitioned by caller identity: endpoints
694
+ * with optional auth (e.g. `GET /profiles/recommendations`) return different
695
+ * content for an anonymous vs an authenticated caller, and per-user content
696
+ * for different authenticated users. Keying solely on `method:url:data`
697
+ * (the previous behavior) let an anonymous response be served to an
698
+ * authenticated caller — surfacing as "Who to follow" recommending accounts
699
+ * the user already follows after a cold-boot session restore.
700
+ *
701
+ * We use the access token's decoded user id (`userId || id`) rather than the
702
+ * raw JWT so the token never lands in a cache key (no token leakage through
703
+ * any cache-key logging, no key bloat). The acting-as id is folded in because
704
+ * managed-account responses differ per acting identity — and `X-Acting-As`
705
+ * already changes the server response for the same bearer token. Falls back
706
+ * to `'anon'` when there is no token, and to a short FNV-1a hash of the token
707
+ * only if it is present but cannot be decoded (degraded but still partitioned,
708
+ * never colliding anon with authed).
709
+ */
710
+ private computeIdentityTag(): string {
711
+ const accessToken = this.tokenStore.getAccessToken();
712
+ let principal = 'anon';
713
+ if (accessToken) {
714
+ try {
715
+ const decoded = jwtDecode<JwtPayload>(accessToken);
716
+ principal = decoded.userId || decoded.id || `t${fnv1a32(accessToken)}`;
717
+ } catch {
718
+ // Undecodable token — still partition it away from anon and from
719
+ // other tokens via a hash. Never silently fall back to 'anon'.
720
+ principal = `t${fnv1a32(accessToken)}`;
721
+ }
722
+ }
723
+ return this._actingAsUserId ? `${principal}~as${this._actingAsUserId}` : principal;
724
+ }
725
+
678
726
  /**
679
727
  * Generate cache key efficiently
680
728
  * Uses a content-addressed hash for large payloads so two requests with
681
729
  * the same shape but different values never collide on the same key
682
730
  * (which would silently serve stale data — e.g. paginated search results,
683
731
  * large object updates).
732
+ *
733
+ * The key is identity-scoped: the logical `method:url[:data]` portion is
734
+ * suffixed with ` id=<identityTag>` so two callers with different
735
+ * identities (anon vs authed, or two different users) never share an entry.
736
+ * The identity tag is placed at the END so the key still STARTS with
737
+ * `method:url`, preserving the prefix-based invalidation in
738
+ * `clearCacheByPrefix` (e.g. `GET:/session/user/`) and the base-key matching
739
+ * in `clearCacheEntry`.
684
740
  */
685
741
  private generateCacheKey(method: string, url: string, data?: unknown): string {
742
+ return `${this.generateBaseCacheKey(method, url, data)}${HttpService.CACHE_IDENTITY_DELIM}${this.computeIdentityTag()}`;
743
+ }
744
+
745
+ /**
746
+ * Build the identity-agnostic portion of a cache key (`method:url[:data]`).
747
+ * Kept separate so identity scoping is applied in exactly one place
748
+ * (`generateCacheKey`) and cannot drift between the cache and dedupe paths.
749
+ */
750
+ private generateBaseCacheKey(method: string, url: string, data?: unknown): string {
686
751
  if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
687
752
  return `${method}:${url}`;
688
753
  }
@@ -943,6 +1008,15 @@ export class HttpService {
943
1008
  clearTokens(): void {
944
1009
  this.tokenStore.clearTokens();
945
1010
  this.tokenStore.clearCsrfToken();
1011
+ // Drop the response cache on logout. The cache is identity-scoped, so a
1012
+ // different user could never read these entries, but a logged-out client
1013
+ // must not keep the previous session's personalized data resident in
1014
+ // memory (privacy + correct logout semantics). We do NOT clear on
1015
+ // `setTokens` because a silent token refresh re-issues a token for the
1016
+ // SAME user — the identity tag is unchanged and the warm cache is still
1017
+ // valid; clearing there would defeat caching as refreshes fire near
1018
+ // every token expiry.
1019
+ this.cache.clear();
946
1020
  this.notifyTokenChange();
947
1021
  }
948
1022
 
@@ -1001,8 +1075,25 @@ export class HttpService {
1001
1075
  this.cache.clear();
1002
1076
  }
1003
1077
 
1078
+ /**
1079
+ * Delete a cache entry by its LOGICAL key (`method:url[:data]`).
1080
+ *
1081
+ * Because the response cache is identity-scoped — stored keys carry an
1082
+ * ` id=<identityTag>` suffix — a caller passing the logical key
1083
+ * `GET:/users/<id>` must invalidate that resource for EVERY identity that
1084
+ * cached it (e.g. `updateProfile` busting a user representation that may be
1085
+ * cached under both the owner's id and a viewer's id). We therefore delete
1086
+ * the exact key (for any pre-existing un-suffixed entries) AND every
1087
+ * identity-scoped variant `<key> id=*`.
1088
+ */
1004
1089
  clearCacheEntry(key: string): void {
1005
1090
  this.cache.delete(key);
1091
+ const identityVariantPrefix = `${key}${HttpService.CACHE_IDENTITY_DELIM}`;
1092
+ for (const existing of this.cache.keys()) {
1093
+ if (existing.startsWith(identityVariantPrefix)) {
1094
+ this.cache.delete(existing);
1095
+ }
1096
+ }
1006
1097
  }
1007
1098
 
1008
1099
  /**
@@ -83,7 +83,7 @@ import { composeOxyServices } from './mixins';
83
83
  * - **Privacy**: Blocked and restricted users
84
84
  * - **Language**: Language detection and metadata
85
85
  * - **Payment**: Payment processing
86
- * - **Karma**: Karma system
86
+ * - **Reputation**: Reputation system (Oxy Trust)
87
87
  * - **Assets**: File upload and asset management
88
88
  * - **Applications**: Application, membership, and credential management
89
89
  * - **Workspaces**: Workspace and membership management
@@ -0,0 +1,198 @@
1
+ /**
2
+ * HttpService response-cache identity-scoping tests.
3
+ *
4
+ * Regression coverage for the "Who to follow" bug: the GET-response cache used
5
+ * to key on only `method:url[:data]`, so an anonymous response cached during a
6
+ * web cold boot (session still restoring) was served back to the SAME URL once
7
+ * the session landed (now authenticated) — recommending accounts the user
8
+ * already follows. These tests pin down that:
9
+ *
10
+ * - an anonymous GET response is NOT served to a later authenticated GET of
11
+ * the same URL (different identity tag -> cache miss -> a fresh network call),
12
+ * - two different authenticated users never share a cache entry,
13
+ * - after `clearTokens()` a previously-cached authenticated response is not
14
+ * served to an anonymous caller,
15
+ * - the `clearCacheByPrefix` invalidation used by `updateProfile` still works
16
+ * against identity-scoped keys.
17
+ */
18
+
19
+ import { HttpService } from '../HttpService';
20
+
21
+ /**
22
+ * Build a non-verified JWT whose payload decodes to the given claims.
23
+ * `jwtDecode` only base64url-decodes the middle segment (no signature check),
24
+ * so a fixed header + base64url(JSON) payload + dummy signature is enough.
25
+ */
26
+ function makeJwt(payload: Record<string, unknown>): string {
27
+ const b64url = (obj: Record<string, unknown>): string =>
28
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
29
+ // Far-future expiry so getAuthHeader() never tries to refresh it.
30
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
31
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
32
+ }
33
+
34
+ /** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
35
+ function jsonResponse(data: unknown): Response {
36
+ return new Response(JSON.stringify({ data }), {
37
+ status: 200,
38
+ headers: { 'content-type': 'application/json' },
39
+ });
40
+ }
41
+
42
+ describe('HttpService identity-scoped response cache', () => {
43
+ let originalFetch: typeof globalThis.fetch;
44
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
45
+
46
+ const newService = (): HttpService =>
47
+ new HttpService({
48
+ baseURL: 'http://test.invalid',
49
+ enableRetry: false,
50
+ requestTimeout: 1000,
51
+ });
52
+
53
+ beforeEach(() => {
54
+ originalFetch = globalThis.fetch;
55
+ fetchMock = jest.fn();
56
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
57
+ });
58
+
59
+ afterEach(() => {
60
+ globalThis.fetch = originalFetch;
61
+ jest.clearAllMocks();
62
+ });
63
+
64
+ it('does NOT serve an anonymous cached GET to a later authenticated caller', async () => {
65
+ const http = newService();
66
+
67
+ // 1) Anonymous GET — caches the public response.
68
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['popular-1', 'popular-2'] }));
69
+ const anon = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
70
+ expect(anon.profiles).toEqual(['popular-1', 'popular-2']);
71
+ expect(fetchMock).toHaveBeenCalledTimes(1);
72
+
73
+ // 2) Session lands — now authenticated as user-1.
74
+ http.setTokens(makeJwt({ userId: 'user-1' }));
75
+
76
+ // 3) Same URL, authenticated: MUST be a cache miss -> a real network call,
77
+ // returning the personalized list (no already-followed accounts).
78
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['suggested-a', 'suggested-b'] }));
79
+ const authed = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
80
+
81
+ expect(fetchMock).toHaveBeenCalledTimes(2);
82
+ expect(authed.profiles).toEqual(['suggested-a', 'suggested-b']);
83
+ });
84
+
85
+ it('serves a warm cache hit to the SAME authenticated identity (no extra network call)', async () => {
86
+ const http = newService();
87
+ http.setTokens(makeJwt({ userId: 'user-1' }));
88
+
89
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['suggested-a'] }));
90
+ const first = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
91
+ const second = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
92
+
93
+ expect(first).toEqual(second);
94
+ // Only one network call — the second read is a cache hit for the same id.
95
+ expect(fetchMock).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it('never shares a cache entry between two different authenticated users', async () => {
99
+ const http = newService();
100
+
101
+ http.setTokens(makeJwt({ userId: 'user-1' }));
102
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['for-user-1'] }));
103
+ const u1 = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
104
+ expect(u1.profiles).toEqual(['for-user-1']);
105
+
106
+ // Switch identity to a different user (does not clear cache by itself).
107
+ http.setTokens(makeJwt({ userId: 'user-2' }));
108
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['for-user-2'] }));
109
+ const u2 = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
110
+
111
+ expect(fetchMock).toHaveBeenCalledTimes(2);
112
+ expect(u2.profiles).toEqual(['for-user-2']);
113
+ });
114
+
115
+ it('partitions the cache by acting-as identity for the same bearer token', async () => {
116
+ const http = newService();
117
+ http.setTokens(makeJwt({ userId: 'owner-1' }));
118
+
119
+ // Acting as managed account A.
120
+ http.setActingAs('managed-a');
121
+ fetchMock.mockResolvedValueOnce(jsonResponse({ items: ['a-only'] }));
122
+ const a = await http.get<{ items: string[] }>('/some/managed-resource', { cache: true });
123
+ expect(a.items).toEqual(['a-only']);
124
+
125
+ // Acting as managed account B — different content, must miss the cache.
126
+ http.setActingAs('managed-b');
127
+ fetchMock.mockResolvedValueOnce(jsonResponse({ items: ['b-only'] }));
128
+ const b = await http.get<{ items: string[] }>('/some/managed-resource', { cache: true });
129
+
130
+ expect(fetchMock).toHaveBeenCalledTimes(2);
131
+ expect(b.items).toEqual(['b-only']);
132
+ });
133
+
134
+ it('does NOT serve an authenticated cached response to an anonymous caller after clearTokens()', async () => {
135
+ const http = newService();
136
+ http.setTokens(makeJwt({ userId: 'user-1' }));
137
+
138
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['private-suggestion'] }));
139
+ const authed = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
140
+ expect(authed.profiles).toEqual(['private-suggestion']);
141
+
142
+ // Logout clears the response cache (privacy + correct logout semantics).
143
+ http.clearTokens();
144
+
145
+ fetchMock.mockResolvedValueOnce(jsonResponse({ profiles: ['popular-public'] }));
146
+ const anon = await http.get<{ profiles: string[] }>('/profiles/recommendations', { cache: true });
147
+
148
+ expect(fetchMock).toHaveBeenCalledTimes(2);
149
+ expect(anon.profiles).toEqual(['popular-public']);
150
+ });
151
+
152
+ it('still honors clearCacheByPrefix against identity-scoped keys (updateProfile path)', async () => {
153
+ const http = newService();
154
+ http.setTokens(makeJwt({ userId: 'user-1' }));
155
+
156
+ // Warm a `GET:/session/user/<id>` entry (as updateProfile's prefix targets).
157
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', name: 'old' }));
158
+ await http.get('/session/user/sess-1', { cache: true });
159
+ expect(fetchMock).toHaveBeenCalledTimes(1);
160
+
161
+ // A cache hit confirms the entry is warm.
162
+ await http.get('/session/user/sess-1', { cache: true });
163
+ expect(fetchMock).toHaveBeenCalledTimes(1);
164
+
165
+ // Prefix sweep used by updateProfile must match the identity-scoped key.
166
+ const removed = http.clearCacheByPrefix('GET:/session/user/');
167
+ expect(removed).toBe(1);
168
+
169
+ // Next read re-fetches (cache was invalidated).
170
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'user-1', name: 'new' }));
171
+ const after = await http.get<{ id: string; name: string }>('/session/user/sess-1', { cache: true });
172
+ expect(fetchMock).toHaveBeenCalledTimes(2);
173
+ expect(after.name).toBe('new');
174
+ });
175
+
176
+ it('clearCacheEntry busts every identity-scoped variant of a base key', async () => {
177
+ const http = newService();
178
+
179
+ // Anonymous reads /users/u1.
180
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'u1', name: 'anon-view' }));
181
+ await http.get('/users/u1', { cache: true });
182
+
183
+ // Authenticated user reads the same resource — separate cache entry.
184
+ http.setTokens(makeJwt({ userId: 'user-1' }));
185
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'u1', name: 'authed-view' }));
186
+ await http.get('/users/u1', { cache: true });
187
+ expect(fetchMock).toHaveBeenCalledTimes(2);
188
+
189
+ // updateProfile-style exact invalidation must clear BOTH identity variants.
190
+ http.clearCacheEntry('GET:/users/u1');
191
+
192
+ // Both identities now re-fetch.
193
+ http.setTokens(makeJwt({ userId: 'user-1' }));
194
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: 'u1', name: 'refetched' }));
195
+ await http.get('/users/u1', { cache: true });
196
+ expect(fetchMock).toHaveBeenCalledTimes(3);
197
+ });
198
+ });
@@ -6,7 +6,7 @@
6
6
  export const packageInfo = {
7
7
  name: "@oxyhq/services",
8
8
  version: "5.2.1",
9
- description: "Reusable OxyHQ module to handle authentication, user management, karma system and more 🚀",
9
+ description: "Reusable OxyHQ module to handle authentication, user management, reputation system (Oxy Trust) and more 🚀",
10
10
  main: "lib/commonjs/node/index.js",
11
11
  module: "lib/module/node/index.js",
12
12
  types: "lib/typescript/node/index.d.ts"