@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
@@ -1,3 +1,4 @@
1
+ import type { UserNameResponse } from '@oxyhq/contracts';
1
2
  export interface OxyConfig {
2
3
  baseURL: string;
3
4
  cloudURL?: string;
@@ -17,6 +18,15 @@ export interface OxyConfig {
17
18
  sessionBaseUrl?: string;
18
19
  authWebUrl?: string;
19
20
  authRedirectUri?: string;
21
+ /**
22
+ * The app's Oxy OAuth client id (ApplicationCredential publicKey).
23
+ *
24
+ * Identifies this app in OAuth authorize / consent flows (issue #214). Purely
25
+ * declarative: the SDK stores it on `OxyServices.config.clientId` for later
26
+ * OAuth-authorize use. It is unrelated to the cross-domain `/sso?client_id=…`
27
+ * bounce (which uses the RP origin, not this registered client id).
28
+ */
29
+ clientId?: string;
20
30
  enableCache?: boolean;
21
31
  cacheTTL?: number;
22
32
  enableRequestDeduplication?: boolean;
@@ -74,14 +84,14 @@ export interface User {
74
84
  avatar?: string;
75
85
  color?: string;
76
86
  privacySettings?: PrivacySettings;
77
- name?: {
78
- first?: string;
79
- last?: string;
80
- full?: string;
81
- [key: string]: unknown;
82
- };
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;
83
94
  bio?: string;
84
- karma?: number;
85
95
  location?: string;
86
96
  website?: string;
87
97
  createdAt?: string;
@@ -219,24 +229,6 @@ export interface SearchProfilesResponse {
219
229
  data: User[];
220
230
  pagination: PaginationInfo;
221
231
  }
222
- export interface KarmaRule {
223
- id: string;
224
- description: string;
225
- }
226
- export interface KarmaHistory {
227
- id: string;
228
- userId: string;
229
- points: number;
230
- }
231
- export interface KarmaLeaderboardEntry {
232
- userId: string;
233
- total: number;
234
- }
235
- export interface KarmaAwardRequest {
236
- userId: string;
237
- points: number;
238
- reason?: string;
239
- }
240
232
  export interface ApiError {
241
233
  message: string;
242
234
  code: string;
@@ -543,7 +535,13 @@ export interface UpdateDeviceNameResponse {
543
535
  export interface RefreshAllAccountUser {
544
536
  id: string;
545
537
  username: string;
546
- 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;
547
545
  avatar?: string | null;
548
546
  email?: string;
549
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.1.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",
@@ -851,7 +851,14 @@ export class AuthManager {
851
851
  * Get a valid access token, refreshing automatically if expired or expiring soon.
852
852
  */
853
853
  async getAccessToken(): Promise<string | null> {
854
- const token = await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
854
+ // In cookieOnly / cookie-restore flows the active access token lives only in
855
+ // memory (`_lastKnownAccessToken` + httpService) and is intentionally never
856
+ // written to JS storage — the cookieOnly contract forbids persisting tokens
857
+ // in JS-accessible storage. Fall back to the in-memory token when storage has
858
+ // none, otherwise getAccessToken returns null after every cold-boot/reload and
859
+ // standalone API clients (e.g. the Console axios client) send no Authorization
860
+ // header → 401 on every authed endpoint while `isAuthenticated` is still true.
861
+ const token = (await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN)) ?? this._lastKnownAccessToken;
855
862
  if (!token) return null;
856
863
 
857
864
  try {
@@ -862,7 +869,9 @@ export class AuthManager {
862
869
  if (decoded.exp - now < buffer) {
863
870
  const refreshed = await this.refreshToken();
864
871
  if (refreshed) {
865
- return this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
872
+ // refreshToken() updates both storage and `_lastKnownAccessToken`;
873
+ // prefer storage but fall back to memory for the cookieOnly path.
874
+ return (await this.storage.getItem(STORAGE_KEYS.ACCESS_TOKEN)) ?? this._lastKnownAccessToken;
866
875
  }
867
876
  }
868
877
  }
@@ -1062,7 +1071,9 @@ export class AuthManager {
1062
1071
  const hydrated: RefreshAllAccountUser = {
1063
1072
  id: me.id,
1064
1073
  username: me.username,
1065
- 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,
1066
1077
  avatar: me.avatar ?? null,
1067
1078
  email: me.email,
1068
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,9 +83,10 @@ 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
+ * - **Workspaces**: Workspace and membership management
89
90
  * - **Location**: Location-based features
90
91
  * - **Analytics**: Analytics tracking
91
92
  * - **Devices**: Device management
@@ -337,3 +337,52 @@ describe('AuthManager.initialize (cookieOnly)', () => {
337
337
  expect(services.httpService.setTokens).not.toHaveBeenCalled();
338
338
  });
339
339
  });
340
+
341
+ describe('AuthManager.getAccessToken (cookieOnly in-memory fallback)', () => {
342
+ it('returns the in-memory token after a cold-boot cookie restore even though storage holds no token', async () => {
343
+ // Regression: the cookie restore path plants the active token ONLY in memory
344
+ // (`_lastKnownAccessToken` + httpService) and intentionally NEVER writes
345
+ // `oxy_access_token`. getAccessToken() must fall back to that in-memory token,
346
+ // otherwise standalone API clients reading `authManager.getAccessToken()` send
347
+ // no Authorization header → 401 on every authed endpoint after every reload.
348
+ const services = makeMockServices();
349
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
350
+ const storage = new InMemoryStorage();
351
+ const manager = makeManager(services, storage);
352
+
353
+ await manager.restoreFromCookies();
354
+
355
+ // Storage was never touched for the access token (cookieOnly contract holds).
356
+ expect(storage.has('oxy_access_token')).toBe(false);
357
+
358
+ // getAccessToken still resolves the active slot's token from memory.
359
+ const token = await manager.getAccessToken();
360
+ expect(token).toBe(TOKEN_SLOT_0);
361
+ });
362
+
363
+ it('returns null when neither storage nor the in-memory token is present', async () => {
364
+ const services = makeMockServices();
365
+ const storage = new InMemoryStorage();
366
+ const manager = makeManager(services, storage);
367
+
368
+ const token = await manager.getAccessToken();
369
+ expect(token).toBeNull();
370
+ });
371
+
372
+ it('prefers the storage token over the in-memory token when both are present', async () => {
373
+ const services = makeMockServices();
374
+ services.refreshAllSessions.mockResolvedValueOnce(TWO_ACCOUNTS);
375
+ const storage = new InMemoryStorage();
376
+ const manager = makeManager(services, storage);
377
+
378
+ // After restore the in-memory token is TOKEN_SLOT_0.
379
+ await manager.restoreFromCookies();
380
+
381
+ // Simulate a path that DID write storage (legacy/bearer flow). Storage wins.
382
+ const STORAGE_TOKEN = buildAccessToken({ sessionId: 'sess-storage', userId: 'user-storage', exp: 9999999999 });
383
+ storage.setItem('oxy_access_token', STORAGE_TOKEN);
384
+
385
+ const token = await manager.getAccessToken();
386
+ expect(token).toBe(STORAGE_TOKEN);
387
+ });
388
+ });
@@ -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"