@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +14 -3
- package/dist/cjs/HttpService.js +89 -0
- package/dist/cjs/OxyServices.js +2 -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 +33 -3
- package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
- package/dist/cjs/mixins/OxyServices.workspaces.js +146 -0
- package/dist/cjs/mixins/index.js +4 -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 +14 -3
- package/dist/esm/HttpService.js +89 -0
- package/dist/esm/OxyServices.js +2 -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 +33 -3
- package/dist/esm/mixins/OxyServices.reputation.js +241 -0
- package/dist/esm/mixins/OxyServices.workspaces.js +143 -0
- package/dist/esm/mixins/index.js +4 -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 +2 -1
- package/dist/types/constants/version.d.ts +2 -2
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.applications.d.ts +86 -10
- 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 +205 -0
- package/dist/types/mixins/index.d.ts +3 -2
- package/dist/types/models/interfaces.d.ts +24 -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 +14 -3
- package/src/HttpService.ts +91 -0
- package/src/OxyServices.ts +2 -1
- package/src/__tests__/authManager.cookiePath.test.ts +49 -0
- 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 +51 -4
- package/src/mixins/OxyServices.applications.ts +103 -5
- 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 +315 -0
- package/src/mixins/__tests__/reputation.test.ts +408 -0
- package/src/mixins/index.ts +6 -3
- package/src/models/interfaces.ts +25 -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;
|
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
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,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
|
-
* - **
|
|
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
|
+
});
|
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"
|