@oxyhq/core 13.0.0 → 13.2.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.
@@ -80,8 +80,8 @@ export { HttpStatus, getErrorStatus, getErrorMessage, isAlreadyRegisteredError,
80
80
  export { DEFAULT_CIRCUIT_BREAKER_CONFIG, createCircuitBreakerState, calculateBackoffInterval, recordFailure, recordSuccess, shouldAllowRequest, delay, withRetry, } from './shared/utils/networkUtils';
81
81
  export type { CircuitBreakerState, CircuitBreakerConfig } from './shared/utils/networkUtils';
82
82
  export { translate } from './i18n';
83
- export { buildSearchParams, buildUrl, buildPaginationParams, safeJsonParse, } from './utils/apiUtils';
84
- export type { PaginationParams, ApiResponse, ErrorResponse, } from './utils/apiUtils';
83
+ export { buildQueryParams, buildSearchParams, buildUrl, buildPaginationParams, safeJsonParse, } from './utils/apiUtils';
84
+ export type { PaginationParams, FollowGraphParams, FollowGraphSort, ApiResponse, ErrorResponse, } from './utils/apiUtils';
85
85
  export { ErrorCodes, createApiError, handleHttpError, validateRequiredFields, } from './utils/errorUtils';
86
86
  export { retryAsync } from './utils/asyncUtils';
87
87
  export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
@@ -4,7 +4,7 @@
4
4
  import type { User, Notification, NotificationPreferences, UserPreferences, SearchProfilesResponse, PrivacySettings } from '../models/interfaces';
5
5
  import type { UserNameResponse, UserProfileUpdate, RecommendationRequest, RecommendationItem, ThemePreference } from '@oxyhq/contracts';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
- import { type PaginationParams } from '../utils/apiUtils';
7
+ import { type PaginationParams, type FollowGraphParams } from '../utils/apiUtils';
8
8
  /**
9
9
  * Response of the single follow/unfollow toggle route
10
10
  * (`POST /users/:id/follow` and `DELETE /users/:id/follow`). The route reports a
@@ -329,6 +329,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
329
329
  deleteAccount(confirmText: string): Promise<{
330
330
  message: string;
331
331
  }>;
332
+ /**
333
+ * Invalidate every cached read a follow/unfollow write invalidates.
334
+ *
335
+ * Shared by the four mutation entry points (`followUser`, `unfollowUser`,
336
+ * `followUsers`, `unfollowUsers`) so they can never drift on which caches a
337
+ * write busts.
338
+ *
339
+ * The follower/following/mutuals LISTS are cleared by PREFIX rather than by
340
+ * exact key. Those reads are paginated and ordered, so one logical list is
341
+ * spread across many content-addressed keys
342
+ * (`GET:/users/<id>/followers:{"limit":"20","offset":"40","sort":"oldest"}`);
343
+ * an exact-key clear would only bust whichever page/sort variant happened to
344
+ * be read last and would leave every other page stale. `clearCacheByPrefix`
345
+ * deletes all of them, and all identity-scoped variants of each.
346
+ */
347
+ invalidateFollowGraphCaches(targetUserIds: string[]): void;
332
348
  /**
333
349
  * Follow a user.
334
350
  *
@@ -386,17 +402,22 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
386
402
  */
387
403
  getFollowStatuses(userIds: string[]): Promise<Record<string, boolean>>;
388
404
  /**
389
- * Get user followers
405
+ * Get user followers.
406
+ *
407
+ * `sort` orders the underlying follow edges — `recent` (newest first, the
408
+ * server default) or `oldest`. Because the response is cached and the cache
409
+ * key is content-addressed on the query params, each `limit`/`offset`/`sort`
410
+ * combination is its own entry.
390
411
  */
391
- getUserFollowers(userId: string, pagination?: PaginationParams): Promise<{
412
+ getUserFollowers(userId: string, pagination?: FollowGraphParams): Promise<{
392
413
  followers: User[];
393
414
  total: number;
394
415
  hasMore: boolean;
395
416
  }>;
396
417
  /**
397
- * Get user following
418
+ * Get user following. `sort` behaves as in {@link getUserFollowers}.
398
419
  */
399
- getUserFollowing(userId: string, pagination?: PaginationParams): Promise<{
420
+ getUserFollowing(userId: string, pagination?: FollowGraphParams): Promise<{
400
421
  following: User[];
401
422
  total: number;
402
423
  hasMore: boolean;
@@ -405,7 +426,7 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
405
426
  * Get user mutuals ("followers you know" — users the authenticated viewer
406
427
  * follows who also follow `userId`). The viewer is derived server-side from auth.
407
428
  */
408
- getUserMutuals(userId: string, pagination?: PaginationParams): Promise<{
429
+ getUserMutuals(userId: string, pagination?: FollowGraphParams): Promise<{
409
430
  mutuals: User[];
410
431
  total: number;
411
432
  hasMore: boolean;
@@ -22,6 +22,8 @@ export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamErr
22
22
  export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, SsrfCheckResult, } from './safeFetch';
23
23
  export { createOxyCors } from './cors';
24
24
  export type { OxyCorsOptions } from './cors';
25
+ export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders';
26
+ export type { OxyCspDirective, OxyCspExtensions, OxyPagesHeadersOptions, OxySecurityHeadersOptions, } from './securityHeaders';
25
27
  export { verifySecret } from './verifySecret';
26
28
  export { registrableApex } from '../utils/registrableApex';
27
29
  export { isOfficialWebOrigin } from '../utils/officialOrigins';
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared security headers (Helmet + Content-Security-Policy) for Oxy backends.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * A CSP only governs an origin that serves DOCUMENTS; on a JSON API it governs
7
+ * no browsing context. The Oxy origins that serve HTML through Cloudflare have
8
+ * so far either hand-written their own policy or shipped none at all, and two
9
+ * bugs follow from that:
10
+ *
11
+ * 1. THE CLOUDFLARE INSIGHTS BEACON IS BLOCKED BY A HAND-WRITTEN POLICY.
12
+ * Cloudflare injects `<script src="https://static.cloudflareinsights.com/
13
+ * beacon.min.js/...">` into HTML it proxies. No application code loads it,
14
+ * so it cannot be allowlisted from the app side any other way, and an
15
+ * origin whose policy says `script-src 'self'` logs
16
+ * `Loading the script 'https://static.cloudflareinsights.com/beacon.min.js'
17
+ * violates the following Content Security Policy directive: "script-src
18
+ * 'self'"` and collects nothing. The beacon needs BOTH hosts, and they are
19
+ * different halves of the same feature: `static.cloudflareinsights.com`
20
+ * serves the script (`script-src`), `cloudflareinsights.com` receives the
21
+ * measurements (`connect-src`). Allowing only the script leaves the beacon
22
+ * loading but unable to report, which looks fixed and is not. Verified in
23
+ * production 2026-07-29: `mention.earth` serves HTML behind Cloudflare with
24
+ * the beacon injected and `script-src 'self'` — blocked; `oxy.so` had
25
+ * already allowlisted the same two hosts in its own static `_headers`,
26
+ * independently, which is the divergence this baseline exists to end.
27
+ *
28
+ * 2. AN EXPLICIT DIRECTIVE SILENTLY REPLACES HELMET'S DEFAULT.
29
+ * Writing `scriptSrc: ['https://example.com']` drops `'self'` — the page's
30
+ * own bundle stops loading (or, worse, only some lazily-loaded chunk does,
31
+ * so it ships). This helper makes that structurally impossible: callers can
32
+ * only ADD sources to the Oxy baseline, never replace a directive, and they
33
+ * cannot pass their own `contentSecurityPolicy` through to Helmet at all
34
+ * (the option is typed `never`).
35
+ *
36
+ * WHAT IT PROVIDES
37
+ * ----------------
38
+ * `createOxySecurityHeaders(options)` returns the Helmet middleware with the
39
+ * Oxy-wide CSP baseline applied, plus per-app extensions merged (and deduped)
40
+ * into it. Everything Helmet does that is NOT the CSP (HSTS, frameguard,
41
+ * referrer policy, CORP/COOP, …) is passed straight through, so an app keeps
42
+ * full control of those.
43
+ *
44
+ * `buildOxyCspDirectives(extensions)` is the same resolution as a pure
45
+ * function, for the Oxy document origins that are NOT Express — a Next.js
46
+ * `headers()`, a Cloudflare Pages `_headers` generator — so one policy can
47
+ * cover them without a second implementation.
48
+ *
49
+ * SCOPE: mount this on backends that serve HTML. A JSON-only API gains nothing
50
+ * from a source-list CSP; harden those with the non-CSP headers instead
51
+ * (`hsts`, `noSniff`, `frameguard`, CORP) rather than adding directives that
52
+ * apply to no document.
53
+ *
54
+ * Node/Express-only: exported solely from `@oxyhq/core/server`.
55
+ */
56
+ import type { RequestHandler } from 'express';
57
+ import { type HelmetOptions } from 'helmet';
58
+ /** The CSP directives an Oxy app may extend, in Helmet's camelCase spelling. */
59
+ export type OxyCspDirective = 'baseUri' | 'connectSrc' | 'defaultSrc' | 'fontSrc' | 'formAction' | 'frameAncestors' | 'frameSrc' | 'imgSrc' | 'manifestSrc' | 'mediaSrc' | 'objectSrc' | 'scriptSrc' | 'scriptSrcAttr' | 'scriptSrcElem' | 'styleSrc' | 'styleSrcElem' | 'workerSrc';
60
+ /**
61
+ * Per-app ADDITIONS to the Oxy baseline, keyed by directive. Values are merged
62
+ * into the baseline and deduped — they never replace it, so `'self'` (and the
63
+ * Cloudflare beacon hosts) cannot be lost. Extending a directive the baseline
64
+ * does not define seeds it with `'self'` first, for the same reason.
65
+ */
66
+ export type OxyCspExtensions = Partial<Record<OxyCspDirective, readonly string[]>>;
67
+ /**
68
+ * The Oxy-wide CSP baseline. Deliberately the floor every Oxy origin needs, not
69
+ * a superset of what any one app allows — permissive sources an individual app
70
+ * wants (`https:` images, `blob:` media, embed hosts, LiveKit) are that app's
71
+ * extension, so each widening stays visible at its call site.
72
+ *
73
+ * `style-src` carries `'unsafe-inline'` because react-native-web injects its
74
+ * stylesheet as inline `<style>` at runtime; without it every Oxy web app
75
+ * renders unstyled.
76
+ */
77
+ export declare const OXY_CSP_BASELINE: Readonly<Partial<Record<OxyCspDirective, readonly string[]>>>;
78
+ /**
79
+ * Resolve the effective CSP directives: the Oxy baseline, with each app
80
+ * extension merged in and deduped.
81
+ *
82
+ * Merge rules:
83
+ * - A baseline directive is EXTENDED, never replaced — `'self'` and the
84
+ * Cloudflare beacon hosts always survive.
85
+ * - A directive absent from the baseline is seeded with `'self'`, so adding
86
+ * (say) an embed host to `frame-src` cannot lock the origin out of itself.
87
+ * - A directive whose baseline is exactly `'none'` is CLOSED: extending it
88
+ * drops the sentinel, because `'none'` alongside any other source is
89
+ * meaningless per the CSP spec. This is how an app that must be framable
90
+ * opts back in with `frameAncestors: ["'self'"]`.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * buildOxyCspDirectives({ frameSrc: ['https://player.vimeo.com'] });
95
+ * // → { ..., 'frame-src': ["'self'", 'https://player.vimeo.com'], ... }
96
+ * ```
97
+ */
98
+ export declare function buildOxyCspDirectives(extensions?: OxyCspExtensions): Record<string, string[]>;
99
+ /**
100
+ * Serialize resolved CSP directives into the single-line header value browsers
101
+ * and Cloudflare `_headers` expect. Valueless directives (e.g.
102
+ * `upgrade-insecure-requests`) emit the name alone.
103
+ */
104
+ export declare function formatOxyCspPolicy(directives: Record<string, string[]>): string;
105
+ export interface OxyPagesHeadersOptions {
106
+ /** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
107
+ csp?: OxyCspExtensions;
108
+ /**
109
+ * Emit `Strict-Transport-Security` (default `true`). Cloudflare Pages serves
110
+ * HTTPS only, so static deploys should keep this on.
111
+ */
112
+ hsts?: boolean;
113
+ }
114
+ /**
115
+ * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
116
+ * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
117
+ * hardening headers Helmet would add on an Express HTML backend.
118
+ */
119
+ export declare function buildOxyPagesHeaders(options?: OxyPagesHeadersOptions): string;
120
+ export interface OxySecurityHeadersOptions {
121
+ /**
122
+ * Per-app additions to the Oxy CSP baseline. Merged, deduped, never
123
+ * replacing — see {@link buildOxyCspDirectives}.
124
+ */
125
+ csp?: OxyCspExtensions;
126
+ /**
127
+ * Everything Helmet does that is not the CSP: `hsts`, `frameguard`,
128
+ * `referrerPolicy`, `crossOriginResourcePolicy`, … Passed straight through.
129
+ *
130
+ * `contentSecurityPolicy` is typed `never` on purpose: the CSP is owned by
131
+ * this helper so the baseline cannot be replaced (nor the `'self'` guarantee
132
+ * bypassed) by an app that hands Helmet its own directive block. Extend it
133
+ * through `csp` instead.
134
+ */
135
+ helmet?: HelmetOptions & {
136
+ contentSecurityPolicy?: never;
137
+ };
138
+ }
139
+ /**
140
+ * Build the shared Oxy security-headers middleware: Helmet with the Oxy CSP
141
+ * baseline plus this app's extensions.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * app.use(createOxySecurityHeaders({
146
+ * csp: {
147
+ * connectSrc: ['https://api.example.com', 'wss://api.example.com'],
148
+ * frameSrc: ['https://player.vimeo.com'],
149
+ * },
150
+ * helmet: { crossOriginResourcePolicy: { policy: 'cross-origin' } },
151
+ * }));
152
+ * ```
153
+ */
154
+ export declare function createOxySecurityHeaders(options?: OxySecurityHeadersOptions): RequestHandler;
@@ -2,18 +2,39 @@
2
2
  * Utility functions for common API patterns
3
3
  */
4
4
  /**
5
- * Build URL search parameters from an object
5
+ * Build a plain query-parameter record from an object, stringifying values and
6
+ * dropping `undefined`/`null` entries.
7
+ *
8
+ * This is the shape `OxyServices.makeRequest` expects for a GET's `params`:
9
+ * `HttpService` inspects it with `Object.keys(...)` (both to decide whether to
10
+ * append a query string and to build the request's cache key), and
11
+ * `Object.keys(new URLSearchParams({ limit: '20' }))` is `[]` — a
12
+ * `URLSearchParams` exposes its entries through iterator methods, never as own
13
+ * enumerable properties. Passing one to `makeRequest` therefore silently drops
14
+ * the whole query string. Always hand `makeRequest` a plain record.
15
+ *
16
+ * Generic over the input object rather than taking `Record<string, unknown>`,
17
+ * because a TypeScript `interface` (`PaginationParams`, `FollowGraphParams`, …)
18
+ * has no implicit index signature and so is not assignable to that type.
19
+ */
20
+ export declare function buildQueryParams<T extends object>(params: T): Record<string, string>;
21
+ /**
22
+ * Build URL search parameters from an object.
23
+ *
24
+ * For building a URL string only — see {@link buildQueryParams} for the shape
25
+ * `makeRequest` needs.
26
+ *
6
27
  * @param params Object with parameter key-value pairs
7
28
  * @returns URLSearchParams instance
8
29
  */
9
- export declare function buildSearchParams(params: Record<string, any>): URLSearchParams;
30
+ export declare function buildSearchParams<T extends object>(params: T): URLSearchParams;
10
31
  /**
11
32
  * Build URL with search parameters
12
33
  * @param baseUrl Base URL
13
34
  * @param params Object with parameter key-value pairs
14
35
  * @returns Complete URL with search parameters
15
36
  */
16
- export declare function buildUrl(baseUrl: string, params?: Record<string, any>): string;
37
+ export declare function buildUrl<T extends object>(baseUrl: string, params?: T): string;
17
38
  /**
18
39
  * Common pagination parameters
19
40
  */
@@ -22,11 +43,32 @@ export interface PaginationParams {
22
43
  offset?: number;
23
44
  }
24
45
  /**
25
- * Build pagination search parameters
46
+ * Ordering for the follow-graph list endpoints (`/users/:id/followers`,
47
+ * `/users/:id/following`, `/users/:id/mutuals`).
48
+ *
49
+ * - `recent` — newest follow edge first (the server default).
50
+ * - `oldest` — oldest follow edge first.
51
+ */
52
+ export type FollowGraphSort = 'recent' | 'oldest';
53
+ /**
54
+ * Pagination plus the follow-graph ordering.
55
+ *
56
+ * Kept separate from {@link PaginationParams}, which is shared by endpoints
57
+ * that have no `sort` at all.
58
+ */
59
+ export interface FollowGraphParams extends PaginationParams {
60
+ sort?: FollowGraphSort;
61
+ }
62
+ /**
63
+ * Build pagination query parameters.
64
+ *
65
+ * Returns a plain record — NOT a `URLSearchParams` — because that is the only
66
+ * shape `makeRequest`/`HttpService` can read. See {@link buildQueryParams}.
67
+ *
26
68
  * @param params Pagination parameters
27
- * @returns URLSearchParams with pagination
69
+ * @returns Query record with pagination
28
70
  */
29
- export declare function buildPaginationParams(params: PaginationParams): URLSearchParams;
71
+ export declare function buildPaginationParams(params: PaginationParams): Record<string, string>;
30
72
  /**
31
73
  * Common API response wrapper
32
74
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "13.0.0",
3
+ "version": "13.2.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",
@@ -131,7 +131,8 @@
131
131
  "expo-crypto": "*",
132
132
  "expo-secure-store": "*",
133
133
  "express": "^4.0.0",
134
- "express-rate-limit": "^7.0.0"
134
+ "express-rate-limit": "^7.0.0",
135
+ "helmet": "^8.0.0"
135
136
  },
136
137
  "peerDependenciesMeta": {
137
138
  "@react-native-async-storage/async-storage": {
@@ -145,6 +146,9 @@
145
146
  },
146
147
  "express": {
147
148
  "optional": true
149
+ },
150
+ "helmet": {
151
+ "optional": true
148
152
  }
149
153
  },
150
154
  "devDependencies": {
@@ -160,6 +164,7 @@
160
164
  "express-rate-limit": "^8.6.0",
161
165
  "regexpu-core": "^6.4.0",
162
166
  "release-it": "^19.0.6",
163
- "typescript": "^5.9.2"
167
+ "typescript": "^5.9.2",
168
+ "helmet": "^8.3.0"
164
169
  }
165
170
  }
package/src/index.ts CHANGED
@@ -468,6 +468,7 @@ export { translate } from './i18n';
468
468
  // API request / URL helpers
469
469
  // ---------------------------------------------------------------------------
470
470
  export {
471
+ buildQueryParams,
471
472
  buildSearchParams,
472
473
  buildUrl,
473
474
  buildPaginationParams,
@@ -475,6 +476,8 @@ export {
475
476
  } from './utils/apiUtils';
476
477
  export type {
477
478
  PaginationParams,
479
+ FollowGraphParams,
480
+ FollowGraphSort,
478
481
  ApiResponse,
479
482
  ErrorResponse,
480
483
  } from './utils/apiUtils';
@@ -19,7 +19,12 @@ import type {
19
19
  } from '@oxyhq/contracts';
20
20
  import { recommendationRequestSchema } from '@oxyhq/contracts';
21
21
  import type { OxyServicesBase } from '../OxyServices.base';
22
- import { buildSearchParams, buildPaginationParams, type PaginationParams } from '../utils/apiUtils';
22
+ import {
23
+ buildQueryParams,
24
+ buildPaginationParams,
25
+ type PaginationParams,
26
+ type FollowGraphParams,
27
+ } from '../utils/apiUtils';
23
28
  import { KeyManager } from '../crypto/keyManager';
24
29
  import { SignatureService } from '../crypto/signatureService';
25
30
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
@@ -198,14 +203,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
198
203
  */
199
204
  async searchProfiles(query: string, pagination?: PaginationParams): Promise<SearchProfilesResponse> {
200
205
  try {
201
- const params = { query, ...pagination };
202
- const searchParams = buildSearchParams(params);
203
- const paramsObj = Object.fromEntries(searchParams.entries());
204
-
205
206
  const response = await this.makeRequest<SearchProfilesResponse>(
206
207
  'GET',
207
208
  '/profiles/search',
208
- paramsObj,
209
+ buildQueryParams({ query, ...pagination }),
209
210
  {
210
211
  cache: true,
211
212
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -727,6 +728,42 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
727
728
  }
728
729
 
729
730
 
731
+ /**
732
+ * Invalidate every cached read a follow/unfollow write invalidates.
733
+ *
734
+ * Shared by the four mutation entry points (`followUser`, `unfollowUser`,
735
+ * `followUsers`, `unfollowUsers`) so they can never drift on which caches a
736
+ * write busts.
737
+ *
738
+ * The follower/following/mutuals LISTS are cleared by PREFIX rather than by
739
+ * exact key. Those reads are paginated and ordered, so one logical list is
740
+ * spread across many content-addressed keys
741
+ * (`GET:/users/<id>/followers:{"limit":"20","offset":"40","sort":"oldest"}`);
742
+ * an exact-key clear would only bust whichever page/sort variant happened to
743
+ * be read last and would leave every other page stale. `clearCacheByPrefix`
744
+ * deletes all of them, and all identity-scoped variants of each.
745
+ */
746
+ invalidateFollowGraphCaches(targetUserIds: string[]): void {
747
+ for (const id of targetUserIds) {
748
+ this.clearCacheEntry(`GET:/users/${id}/follow-status`);
749
+ // Profile fetches embed viewer-relative `relationship` — bust so a
750
+ // remount doesn't serve a stale isFollowing for up to 5 minutes.
751
+ this.clearCacheEntry(`GET:/users/${id}`);
752
+ // The target gained/lost a follower, and the viewer's presence in the
753
+ // target's "followers you know" set changed with it.
754
+ this.clearCacheByPrefix(`GET:/users/${id}/followers`);
755
+ this.clearCacheByPrefix(`GET:/users/${id}/mutuals`);
756
+ }
757
+ this.clearCacheByPrefix('GET:/profiles/username/');
758
+ this.clearCacheByPrefix('GET:/profiles/resolve');
759
+ // The write changed the viewer's OWN following list and graph.
760
+ const viewerId = this.getCurrentUserId();
761
+ if (viewerId) {
762
+ this.clearCacheByPrefix(`GET:/users/${viewerId}/following`);
763
+ }
764
+ this.clearCacheEntry('GET:/users/me/graph');
765
+ }
766
+
730
767
  /**
731
768
  * Follow a user.
732
769
  *
@@ -740,16 +777,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
740
777
  async followUser(userId: string): Promise<FollowMutationResult> {
741
778
  try {
742
779
  const result = await this.makeRequest<FollowMutationResult>('POST', `/users/${userId}/follow`, undefined, { cache: false });
743
- this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
744
- // Profile fetches embed viewer-relative `relationship` — bust so a
745
- // remount doesn't serve a stale isFollowing for up to 5 minutes.
746
- this.clearCacheEntry(`GET:/users/${userId}`);
747
- this.clearCacheByPrefix('GET:/profiles/username/');
748
- this.clearCacheByPrefix('GET:/profiles/resolve');
749
- // The follow changed the viewer's graph — bust the cached consolidated
750
- // `GET /users/me/graph` so the next read reflects the new following/
751
- // mutual set instead of the stale pre-write snapshot.
752
- this.clearCacheEntry('GET:/users/me/graph');
780
+ this.invalidateFollowGraphCaches([userId]);
753
781
  return result;
754
782
  } catch (error) {
755
783
  throw this.handleError(error);
@@ -770,15 +798,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
770
798
  }
771
799
  try {
772
800
  const result = await this.makeRequest<BulkFollowResult>('POST', '/users/follow/bulk', { userIds }, { cache: false });
773
- // Bust each affected user's cached follow-status (see `followUser`).
774
- for (const id of userIds) {
775
- this.clearCacheEntry(`GET:/users/${id}/follow-status`);
776
- this.clearCacheEntry(`GET:/users/${id}`);
777
- }
778
- this.clearCacheByPrefix('GET:/profiles/username/');
779
- this.clearCacheByPrefix('GET:/profiles/resolve');
780
- // The batch changed the viewer's graph — bust the consolidated cache.
781
- this.clearCacheEntry('GET:/users/me/graph');
801
+ this.invalidateFollowGraphCaches(userIds);
782
802
  return result;
783
803
  } catch (error) {
784
804
  throw this.handleError(error);
@@ -799,15 +819,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
799
819
  }
800
820
  try {
801
821
  const result = await this.makeRequest<BulkUnfollowResult>('POST', '/users/unfollow/bulk', { userIds }, { cache: false });
802
- // Bust each affected user's cached follow-status (see `followUser`).
803
- for (const id of userIds) {
804
- this.clearCacheEntry(`GET:/users/${id}/follow-status`);
805
- this.clearCacheEntry(`GET:/users/${id}`);
806
- }
807
- this.clearCacheByPrefix('GET:/profiles/username/');
808
- this.clearCacheByPrefix('GET:/profiles/resolve');
809
- // The batch changed the viewer's graph — bust the consolidated cache.
810
- this.clearCacheEntry('GET:/users/me/graph');
822
+ this.invalidateFollowGraphCaches(userIds);
811
823
  return result;
812
824
  } catch (error) {
813
825
  throw this.handleError(error);
@@ -820,13 +832,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
820
832
  async unfollowUser(userId: string): Promise<FollowMutationResult> {
821
833
  try {
822
834
  const result = await this.makeRequest<FollowMutationResult>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
823
- // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
824
- this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
825
- this.clearCacheEntry(`GET:/users/${userId}`);
826
- this.clearCacheByPrefix('GET:/profiles/username/');
827
- this.clearCacheByPrefix('GET:/profiles/resolve');
828
- // The unfollow changed the viewer's graph — bust the consolidated cache.
829
- this.clearCacheEntry('GET:/users/me/graph');
835
+ this.invalidateFollowGraphCaches([userId]);
830
836
  return result;
831
837
  } catch (error) {
832
838
  throw this.handleError(error);
@@ -899,14 +905,19 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
899
905
  }
900
906
 
901
907
  /**
902
- * Get user followers
908
+ * Get user followers.
909
+ *
910
+ * `sort` orders the underlying follow edges — `recent` (newest first, the
911
+ * server default) or `oldest`. Because the response is cached and the cache
912
+ * key is content-addressed on the query params, each `limit`/`offset`/`sort`
913
+ * combination is its own entry.
903
914
  */
904
915
  async getUserFollowers(
905
916
  userId: string,
906
- pagination?: PaginationParams
917
+ pagination?: FollowGraphParams
907
918
  ): Promise<{ followers: User[]; total: number; hasMore: boolean }> {
908
919
  try {
909
- const params = buildPaginationParams(pagination || {});
920
+ const params = buildQueryParams(pagination || {});
910
921
  const response = await this.makeRequest<{ data: User[]; pagination: { total: number; hasMore: boolean } }>('GET', `/users/${userId}/followers`, params, {
911
922
  cache: true,
912
923
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -922,14 +933,14 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
922
933
  }
923
934
 
924
935
  /**
925
- * Get user following
936
+ * Get user following. `sort` behaves as in {@link getUserFollowers}.
926
937
  */
927
938
  async getUserFollowing(
928
939
  userId: string,
929
- pagination?: PaginationParams
940
+ pagination?: FollowGraphParams
930
941
  ): Promise<{ following: User[]; total: number; hasMore: boolean }> {
931
942
  try {
932
- const params = buildPaginationParams(pagination || {});
943
+ const params = buildQueryParams(pagination || {});
933
944
  const response = await this.makeRequest<{ data: User[]; pagination: { total: number; hasMore: boolean } }>('GET', `/users/${userId}/following`, params, {
934
945
  cache: true,
935
946
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
@@ -950,10 +961,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
950
961
  */
951
962
  async getUserMutuals(
952
963
  userId: string,
953
- pagination?: PaginationParams
964
+ pagination?: FollowGraphParams
954
965
  ): Promise<{ mutuals: User[]; total: number; hasMore: boolean }> {
955
966
  try {
956
- const params = buildPaginationParams(pagination || {});
967
+ const params = buildQueryParams(pagination || {});
957
968
  const response = await this.makeRequest<{ data: User[]; pagination: { total: number; hasMore: boolean } }>('GET', `/users/${userId}/mutuals`, params, {
958
969
  cache: true,
959
970
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache