@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +3 -1
- package/dist/cjs/HttpService.js +89 -0
- package/dist/cjs/OxyServices.js +1 -1
- package/dist/cjs/constants/version.js +1 -1
- package/dist/cjs/i18n/locales/en-US.json +44 -44
- package/dist/cjs/i18n/locales/es-ES.json +44 -44
- package/dist/cjs/i18n/locales/locales/en-US.json +44 -44
- package/dist/cjs/i18n/locales/locales/es-ES.json +44 -44
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/mixins/OxyServices.applications.js +3 -1
- package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
- package/dist/cjs/mixins/OxyServices.workspaces.js +3 -1
- package/dist/cjs/mixins/index.js +2 -2
- package/dist/cjs/utils/accountUtils.js +12 -5
- package/dist/cjs/utils/ssoReturn.js +80 -33
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +3 -1
- package/dist/esm/HttpService.js +89 -0
- package/dist/esm/OxyServices.js +1 -1
- package/dist/esm/constants/version.js +1 -1
- package/dist/esm/i18n/locales/en-US.json +44 -44
- package/dist/esm/i18n/locales/es-ES.json +44 -44
- package/dist/esm/i18n/locales/locales/en-US.json +44 -44
- package/dist/esm/i18n/locales/locales/es-ES.json +44 -44
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.applications.js +3 -1
- package/dist/esm/mixins/OxyServices.reputation.js +241 -0
- package/dist/esm/mixins/OxyServices.workspaces.js +3 -1
- package/dist/esm/mixins/index.js +2 -2
- package/dist/esm/utils/accountUtils.js +12 -5
- package/dist/esm/utils/ssoReturn.js +80 -33
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +57 -0
- package/dist/types/OxyServices.d.ts +1 -1
- package/dist/types/constants/version.d.ts +2 -2
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.applications.d.ts +8 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -1
- package/dist/types/mixins/OxyServices.reputation.d.ts +436 -0
- package/dist/types/mixins/OxyServices.workspaces.d.ts +8 -2
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/models/interfaces.d.ts +15 -26
- package/dist/types/utils/accountUtils.d.ts +17 -4
- package/dist/types/utils/ssoReturn.d.ts +30 -9
- package/package.json +2 -1
- package/src/AuthManager.ts +3 -1
- package/src/HttpService.ts +91 -0
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/httpServiceCache.test.ts +198 -0
- package/src/constants/version.ts +1 -1
- package/src/i18n/locales/en-US.json +44 -44
- package/src/i18n/locales/es-ES.json +44 -44
- package/src/index.ts +32 -4
- package/src/mixins/OxyServices.applications.ts +8 -2
- package/src/mixins/OxyServices.auth.ts +2 -1
- package/src/mixins/OxyServices.features.ts +0 -1
- package/src/mixins/OxyServices.reputation.ts +674 -0
- package/src/mixins/OxyServices.workspaces.ts +8 -2
- package/src/mixins/__tests__/reputation.test.ts +408 -0
- package/src/mixins/index.ts +3 -3
- package/src/models/interfaces.ts +16 -32
- package/src/utils/__tests__/accountUtils.test.ts +142 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +229 -37
- package/src/utils/accountUtils.ts +20 -5
- package/src/utils/ssoReturn.ts +98 -37
- package/dist/cjs/mixins/OxyServices.developer.js +0 -97
- package/dist/cjs/mixins/OxyServices.karma.js +0 -108
- package/dist/esm/mixins/OxyServices.developer.js +0 -94
- package/dist/esm/mixins/OxyServices.karma.js +0 -105
- package/dist/types/mixins/OxyServices.developer.d.ts +0 -106
- package/dist/types/mixins/OxyServices.karma.d.ts +0 -92
- 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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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).
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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.
|
|
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",
|
package/src/AuthManager.ts
CHANGED
|
@@ -1071,7 +1071,9 @@ export class AuthManager {
|
|
|
1071
1071
|
const hydrated: RefreshAllAccountUser = {
|
|
1072
1072
|
id: me.id,
|
|
1073
1073
|
username: me.username,
|
|
1074
|
-
name
|
|
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,
|
package/src/HttpService.ts
CHANGED
|
@@ -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
|
/**
|
package/src/OxyServices.ts
CHANGED
|
@@ -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
|
-
* - **
|
|
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
|
+
});
|
package/src/constants/version.ts
CHANGED
|
@@ -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,
|
|
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"
|