@oxyhq/core 18.0.0 → 19.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +49 -5
  3. package/dist/cjs/i18n/locales/es-ES.json +49 -5
  4. package/dist/cjs/i18n/locales/locales/en-US.json +49 -5
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +49 -5
  6. package/dist/cjs/index.js +17 -6
  7. package/dist/cjs/mixins/OxyServices.accounts.js +69 -31
  8. package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +17 -20
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/server/index.js +8 -2
  12. package/dist/cjs/server/userInvalidation.js +6 -28
  13. package/dist/cjs/session/accountProjection.js +45 -9
  14. package/dist/cjs/utils/accountCacheSweep.js +80 -0
  15. package/dist/cjs/utils/identityCacheSweep.js +97 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/i18n/locales/en-US.json +49 -5
  18. package/dist/esm/i18n/locales/es-ES.json +49 -5
  19. package/dist/esm/i18n/locales/locales/en-US.json +49 -5
  20. package/dist/esm/i18n/locales/locales/es-ES.json +49 -5
  21. package/dist/esm/index.js +8 -2
  22. package/dist/esm/mixins/OxyServices.accounts.js +64 -30
  23. package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
  24. package/dist/esm/mixins/OxyServices.user.js +17 -20
  25. package/dist/esm/mixins/index.js +4 -0
  26. package/dist/esm/server/index.js +5 -1
  27. package/dist/esm/server/userInvalidation.js +5 -26
  28. package/dist/esm/session/accountProjection.js +44 -9
  29. package/dist/esm/utils/accountCacheSweep.js +75 -0
  30. package/dist/esm/utils/identityCacheSweep.js +92 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/index.d.ts +3 -3
  33. package/dist/types/mixins/OxyServices.accounts.d.ts +91 -34
  34. package/dist/types/mixins/OxyServices.followGraph.d.ts +211 -0
  35. package/dist/types/mixins/OxyServices.user.d.ts +10 -7
  36. package/dist/types/mixins/index.d.ts +2 -1
  37. package/dist/types/models/interfaces.d.ts +11 -3
  38. package/dist/types/server/index.d.ts +4 -2
  39. package/dist/types/server/userInvalidation.d.ts +5 -24
  40. package/dist/types/session/accountProjection.d.ts +38 -4
  41. package/dist/types/utils/accountCacheSweep.d.ts +75 -0
  42. package/dist/types/utils/identityCacheSweep.d.ts +80 -0
  43. package/package.json +2 -2
  44. package/src/i18n/locales/en-US.json +49 -5
  45. package/src/i18n/locales/es-ES.json +49 -5
  46. package/src/index.ts +15 -2
  47. package/src/mixins/OxyServices.accounts.ts +123 -45
  48. package/src/mixins/OxyServices.followGraph.ts +266 -0
  49. package/src/mixins/OxyServices.user.ts +17 -20
  50. package/src/mixins/__tests__/accounts.test.ts +5 -0
  51. package/src/mixins/__tests__/followGraph.test.ts +128 -0
  52. package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
  53. package/src/mixins/index.ts +5 -0
  54. package/src/models/interfaces.ts +11 -3
  55. package/src/server/__tests__/userInvalidation.test.ts +3 -20
  56. package/src/server/index.ts +5 -2
  57. package/src/server/userInvalidation.ts +8 -36
  58. package/src/session/__tests__/accountProjection.test.ts +109 -2
  59. package/src/session/accountProjection.ts +47 -9
  60. package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
  61. package/src/utils/accountCacheSweep.ts +93 -0
  62. package/src/utils/identityCacheSweep.ts +104 -0
@@ -33,10 +33,12 @@
33
33
  * registers the switched session into the operator's device-set directly).
34
34
  */
35
35
  import type { User } from '../models/interfaces';
36
- import type { AccountKind, OrganizationCategory, ChildAccountKind } from '@oxyhq/contracts';
36
+ import type { AccountCategoryId, AccountKind, ChildAccountKind } from '@oxyhq/contracts';
37
37
  import type { SessionLoginResponse } from '../models/session';
38
38
  import type { OxyServicesBase } from '../OxyServices.base';
39
39
  import { normalizeUserIdentity } from '../utils/userIdentity';
40
+ import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
41
+ import { evictOxyAccountForestCache, oxyAccountDetailCacheKey, OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX } from '../utils/accountCacheSweep';
40
42
  import { CACHE_TIMES } from './mixinHelpers';
41
43
 
42
44
  // ---------------------------------------------------------------------------
@@ -52,8 +54,16 @@ import { CACHE_TIMES } from './mixinHelpers';
52
54
  *
53
55
  * Single source of truth is `@oxyhq/contracts`.
54
56
  */
55
- export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
56
- export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
57
+ export type { AccountCategoryId, AccountKind } from '@oxyhq/contracts';
58
+ export {
59
+ ACCOUNT_CATEGORY_IDS,
60
+ ACCOUNT_KINDS,
61
+ MAX_ACCOUNT_CATEGORIES,
62
+ SELECTABLE_ACCOUNT_CATEGORY_IDS,
63
+ isActAsEligibleKind,
64
+ isSelectableAccountCategoryId,
65
+ kindAcceptsAccountCategories,
66
+ } from '@oxyhq/contracts';
57
67
 
58
68
  /**
59
69
  * The calling user's relationship to an account node, as resolved by the API:
@@ -78,8 +88,8 @@ export type AccountMemberStatus = 'active' | 'invited' | 'removed';
78
88
  export type AccountMemberSource = 'direct' | 'inherited';
79
89
 
80
90
  /**
81
- * Client-facing AccountMember shape. `permissions` is derived from `role` on the
82
- * server at write time.
91
+ * Client-facing AccountMember shape. `permissions` is the effective permission
92
+ * set (role baseline plus `permissionGrants` minus `permissionRevokes`).
83
93
  */
84
94
  export interface AccountMember {
85
95
  _id: string;
@@ -89,6 +99,10 @@ export interface AccountMember {
89
99
  memberUserId: string;
90
100
  role: AccountRole;
91
101
  permissions: string[];
102
+ /** Permissions granted beyond the role baseline. */
103
+ permissionGrants?: string[];
104
+ /** Permissions revoked from the role baseline. */
105
+ permissionRevokes?: string[];
92
106
  /**
93
107
  * Whether this membership cascades to the account's subtree. `true` (default)
94
108
  * lets descendants inherit this role unless a nearer row overrides it; `false`
@@ -97,12 +111,20 @@ export interface AccountMember {
97
111
  inherit: boolean;
98
112
  status: AccountMemberStatus;
99
113
  /**
100
- * Origin of the membership when the API resolves an effective role. Present on
101
- * a resolved `callerMembership` to indicate whether the caller's access is
102
- * `direct` on the account or `inherited` from an ancestor. Absent on plain
103
- * member-list rows (which are always direct rows on the account).
114
+ * Where this membership COMES FROM relative to the account it is being
115
+ * reported for: `direct` when the row lives on that account, `inherited` when
116
+ * it lives on an ancestor whose `inherit` flag cascades it down.
117
+ *
118
+ * Present on every membership the API serialises — a resolved
119
+ * `callerMembership` and every entry of a member list alike. It is not
120
+ * decoration: an `inherited` entry's `accountId` is the ANCESTOR's, and the
121
+ * member-mutation endpoints are scoped to rows on the account named in the
122
+ * path, so `PATCH`/`DELETE .../members/<that row's _id>` against the
123
+ * descendant 404s. **Branch on `source === 'direct'` before offering to edit,
124
+ * remove or transfer to a member**, and count owners for a last-owner check
125
+ * over direct entries only.
104
126
  */
105
- source?: AccountMemberSource;
127
+ source: AccountMemberSource;
106
128
  invitedByUserId?: string | null;
107
129
  joinedAt?: string | null;
108
130
  createdAt: string;
@@ -179,8 +201,17 @@ export interface CreateAccountInput {
179
201
  name?: { first?: string; last?: string; displayName?: string };
180
202
  bio?: string;
181
203
  avatar?: string;
182
- /** Meaningful only when `kind` is `organization`. */
183
- organizationCategory?: OrganizationCategory;
204
+ /**
205
+ * What the account is about. ORDERED — the FIRST element is the primary
206
+ * category, so a picker must submit them in the order the user arranged them
207
+ * and must not sort. Stable ids, never labels: render each one through the
208
+ * `accounts.accountCategory.<id>` translation key.
209
+ *
210
+ * Offer `SELECTABLE_ACCOUNT_CATEGORY_IDS`, not `ACCOUNT_CATEGORY_IDS` — the
211
+ * latter still contains withdrawn ids so that accounts already carrying one
212
+ * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
213
+ */
214
+ accountCategories?: AccountCategoryId[];
184
215
  }
185
216
 
186
217
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
@@ -195,8 +226,19 @@ export interface UpdateAccountInput {
195
226
  name?: { first?: string; last?: string; displayName?: string };
196
227
  bio?: string | null;
197
228
  avatar?: string | null;
198
- /** Clears the category when `null`; only valid on `kind: 'organization'`. */
199
- organizationCategory?: OrganizationCategory | null;
229
+ /**
230
+ * Replaces the WHOLE list, in the order given — there is no add/remove verb,
231
+ * because a partial edit cannot express a re-ordering and the order is what
232
+ * names the primary category. `[]` clears it.
233
+ *
234
+ * Not nullable, unlike `bio` and `avatar`: the empty case already has a
235
+ * spelling of its own, so a second one could only ever disagree with it.
236
+ *
237
+ * Rejected for a `personal` account, and rejected when it ADDS a withdrawn
238
+ * id the account did not already carry — keeping or re-ordering one it has is
239
+ * always allowed.
240
+ */
241
+ accountCategories?: AccountCategoryId[];
200
242
  }
201
243
 
202
244
  /** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
@@ -235,7 +277,10 @@ export interface InviteAccountMemberInput {
235
277
 
236
278
  /** Input accepted by `updateAccountMember`. The owner role cannot be assigned. */
237
279
  export interface UpdateAccountMemberInput {
238
- role: Exclude<AccountRole, 'owner'>;
280
+ role?: Exclude<AccountRole, 'owner'>;
281
+ inherit?: boolean;
282
+ permissionGrants?: string[];
283
+ permissionRevokes?: string[];
239
284
  }
240
285
 
241
286
  /** Input accepted by `transferAccountOwnership`. */
@@ -673,7 +718,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
673
718
  );
674
719
  // A new account changes the accessible forest — bust every cached list
675
720
  // (flat + tree) so it appears on the next `listAccounts()` read.
676
- this._invalidateAccountLists();
721
+ evictOxyAccountForestCache(this);
677
722
  return res.account;
678
723
  } catch (error) {
679
724
  throw this.handleError(error);
@@ -736,6 +781,18 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
736
781
  /**
737
782
  * Update an account's mutable profile fields. Tree placement changes
738
783
  * (reparenting) go through the dedicated move endpoint, not here.
784
+ *
785
+ * An account IS a user, so this write changes identity — and a profile
786
+ * screen never reads `/accounts/<id>`. It reads `GET /users/<id>` and
787
+ * `GET /profiles/username/<handle>`, both cached for 5 minutes in the
788
+ * CALLER'S OWN process, so busting only the account-graph keys left every
789
+ * profile surface serving the pre-edit avatar and name for the full TTL
790
+ * with a perfectly healthy server (the cross-service `oxy:user:invalidate`
791
+ * signal does not help: it evicts BACKEND caches, and cannot reach a cache
792
+ * living in a browser tab). {@link evictOxyIdentityCache} owns that key
793
+ * list — see its docs for why the handle-keyed entries are prefix-swept
794
+ * (a RENAME leaves the old handle's entry unreachable by any targeted key).
795
+ *
739
796
  * @param accountId - The account's Mongo `_id`.
740
797
  * @param data - Subset of updatable profile fields.
741
798
  */
@@ -752,8 +809,17 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
752
809
  );
753
810
  // Bust the cached detail and every list (which embeds account profile
754
811
  // data) so neither serves the pre-update snapshot.
755
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
756
- this._invalidateAccountLists();
812
+ evictOxyAccountForestCache(this, accountId);
813
+ // The parent's children list embeds this account's profile and is keyed
814
+ // by the PARENT id, so it is reachable only from the response node.
815
+ const parentAccountId = res.account?.parentAccountId;
816
+ if (parentAccountId) {
817
+ this.clearCacheEntry(
818
+ `GET:/accounts/${encodeURIComponent(parentAccountId)}/children`,
819
+ );
820
+ }
821
+ // Every identity read of this account, whichever key it lands under.
822
+ evictOxyIdentityCache(this, accountId);
757
823
  return res.account;
758
824
  } catch (error) {
759
825
  throw this.handleError(error);
@@ -775,10 +841,9 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
775
841
  { cache: false },
776
842
  );
777
843
  // Bust every cached representation of the archived account.
778
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
779
844
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
780
845
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
781
- this._invalidateAccountLists();
846
+ evictOxyAccountForestCache(this, accountId);
782
847
  return result;
783
848
  } catch (error) {
784
849
  throw this.handleError(error);
@@ -808,7 +873,27 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
808
873
  // =========================================================================
809
874
 
810
875
  /**
811
- * List members of an account (direct membership rows on the account).
876
+ * List the members of an account: the membership rows ON it, plus the rows
877
+ * on its ancestors that cascade into it. Each entry carries `source`
878
+ * (`direct` | `inherited`) saying which it is.
879
+ *
880
+ * Inherited entries are members in every sense the server enforces — an
881
+ * ancestor row with `inherit: true` resolves through
882
+ * `resolveEffectiveAccess` and confers every account permission on the
883
+ * descendant, `account:act_as` included — so a roster that omitted them
884
+ * answered `[]` for accounts several people could act on.
885
+ *
886
+ * Two things follow for a caller. An entry's `accountId` is the account its
887
+ * ROW lives on, so an inherited entry names an ancestor rather than the
888
+ * account you asked about; and the member-mutation endpoints only accept
889
+ * rows on the account in the path, so gate any edit/remove/transfer
890
+ * affordance on `source === 'direct'`.
891
+ *
892
+ * Asking what the CALLER holds over an account is a different question, and
893
+ * scanning this list for yourself is the wrong way to answer it — use
894
+ * {@link OxyServicesAccountsMixin.getAccount}, whose `callerMembership` is
895
+ * the server's own resolution.
896
+ *
812
897
  * @param accountId - The account's Mongo `_id`.
813
898
  */
814
899
  async listAccountMembers(accountId: string): Promise<AccountMember[]> {
@@ -917,7 +1002,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
917
1002
  // Ownership change alters roles in the member list AND the detail, and
918
1003
  // can change which accounts the caller "owns" in the list view.
919
1004
  this._invalidateAccountMembership(accountId);
920
- this._invalidateAccountLists();
1005
+ evictOxyAccountForestCache(this);
921
1006
  return result;
922
1007
  } catch (error) {
923
1008
  throw this.handleError(error);
@@ -1259,36 +1344,29 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
1259
1344
  // =========================================================================
1260
1345
 
1261
1346
  /**
1262
- * Bust every cached account list. `listAccounts({tree?})` keys the flat list
1263
- * as `GET:/accounts` and the tree variant as `GET:/accounts?tree=true` (the
1264
- * query string is part of the URL path). A change to the accessible forest
1265
- * (create/archive/ownership transfer) invalidates both, so we clear the
1266
- * unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
1267
- * `GET:/accounts?` matches only the query-string list variants, never the
1268
- * `GET:/accounts/<id>…` detail/sub-resource keys.
1347
+ * Bust the cached member list and detail for an account after a membership
1348
+ * mutation. The member list (`listAccountMembers`) and the detail
1349
+ * (`getAccount`, which can embed the caller's membership) both go stale when
1350
+ * the member set or a member's role changes.
1269
1351
  *
1270
1352
  * Internal helper (leading underscore); not part of the supported public
1271
1353
  * surface. Public rather than `private` because mixins compose into an
1272
1354
  * exported anonymous class, where TypeScript cannot represent a private
1273
1355
  * member in the emitted declaration file (TS4094).
1274
- */
1275
- _invalidateAccountLists(): void {
1276
- this.clearCacheEntry('GET:/accounts');
1277
- this.clearCacheByPrefix('GET:/accounts?');
1278
- }
1279
-
1280
- /**
1281
- * Bust the cached member list and detail for an account after a membership
1282
- * mutation. The member list (`listAccountMembers`) and the detail
1283
- * (`getAccount`, which can embed the caller's membership) both go stale when
1284
- * the member set or a member's role changes.
1285
1356
  *
1286
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
1287
- * this is public rather than `private`.
1357
+ * The forest keys themselves are NOT owned here — see
1358
+ * `utils/accountCacheSweep`, which the user mixin has to reach as well.
1288
1359
  */
1289
1360
  _invalidateAccountMembership(accountId: string): void {
1290
1361
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
1291
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
1362
+ this.clearCacheEntry(oxyAccountDetailCacheKey(accountId));
1363
+ // Inherited rows on descendant rosters are derived from this account's
1364
+ // membership table — a targeted clear of only the mutated account's keys
1365
+ // leaves every other cached `…/members` list serving stale inherited
1366
+ // roles until MEDIUM TTL. Sweep all per-account sub-resource keys instead;
1367
+ // the forest list keys (`GET:/accounts`, `GET:/accounts?…`) are excluded
1368
+ // by the trailing slash on the prefix (see accountCacheSweep).
1369
+ this.clearCacheByPrefix(OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX);
1292
1370
  }
1293
1371
 
1294
1372
  /**
@@ -1300,8 +1378,8 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
1300
1378
  * query-string list variants, never the `GET:/applications/<id>…`
1301
1379
  * detail/sub-resource keys.
1302
1380
  *
1303
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
1304
- * this is public rather than `private`.
1381
+ * Internal helper (leading underscore); see `_invalidateAccountMembership`
1382
+ * for why this is public rather than `private`.
1305
1383
  */
1306
1384
  _invalidateAppLists(): void {
1307
1385
  this.clearCacheEntry('GET:/applications');
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Follow Graph Mixin (`/v2/follows`)
3
+ *
4
+ * The user-owned follow graph: one relationship per user and target, shared by
5
+ * every application, with per-application context on top. This is the SDK half
6
+ * of #809 and the replacement for the per-app follow endpoints each application
7
+ * grew for itself.
8
+ *
9
+ * ## Why this is not `followUser` with more parameters
10
+ *
11
+ * `followUser` answers "does A follow B" and nothing else. This answers "what
12
+ * does this user follow, anywhere, and which applications act on it" — a
13
+ * different question with a different owner. The legacy methods stay for the
14
+ * Mongo-backed social graph they were written for; new kinds (topics, stores,
15
+ * artists, channels) come here, and users will migrate behind an adapter rather
16
+ * than through a flag day.
17
+ *
18
+ * ## Caching
19
+ *
20
+ * Every method is `cache: false`. A follow status is exactly the shape that
21
+ * must never be served stale: the SDK's GET cache is identity-scoped but
22
+ * time-based, and a status cached across a write is the "follow reverts after
23
+ * navigating away and back" bug — which the legacy `followUser` had to fix with
24
+ * explicit invalidation. Not caching at this layer means an app's own store
25
+ * (React Query, Zustand) is the single cache authority, which is the rule the
26
+ * ecosystem already follows for anything written and read in the same session.
27
+ */
28
+
29
+ import type {
30
+ FollowListPage,
31
+ FollowMutation,
32
+ FollowOptions,
33
+ FollowStatus,
34
+ UnfollowMutation,
35
+ } from '@oxyhq/contracts';
36
+ import type { OxyServicesBase } from '../OxyServices.base';
37
+ import { buildUrl } from '../utils/apiUtils';
38
+
39
+ export function OxyServicesFollowGraphMixin<T extends typeof OxyServicesBase>(Base: T) {
40
+ return class extends Base {
41
+ constructor(...args: any[]) {
42
+ super(...(args as [any]));
43
+ }
44
+
45
+ /**
46
+ * Follow a target. Idempotent — following something already followed
47
+ * returns the same relationship with `created: false`.
48
+ *
49
+ * The follower and the acting application are BOTH derived server-side from
50
+ * the session. There is deliberately no parameter for either: a client that
51
+ * could name them could forge a follow on another user's behalf, or record
52
+ * one as coming from an application it is not.
53
+ *
54
+ * @param targetId - The registered target's id, not its URI. Registration is
55
+ * a separate operation precisely so following cannot silently create
56
+ * targets — a typo would otherwise become a permanent row nobody follows.
57
+ * @param options.expiresIn - Seconds until the follow lapses on its own. For
58
+ * an event, a trial, a topic followed for a week. The server bounds it.
59
+ */
60
+ async followTarget(targetId: string, options?: FollowOptions): Promise<FollowMutation> {
61
+ try {
62
+ return await this.makeRequest<FollowMutation>(
63
+ 'PUT',
64
+ `/v2/follows/${encodeURIComponent(targetId)}`,
65
+ options?.expiresIn !== undefined ? { expiresIn: options.expiresIn } : {},
66
+ { cache: false },
67
+ );
68
+ } catch (error) {
69
+ throw this.handleError(error);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Unfollow everywhere.
75
+ *
76
+ * There is no "unfollow here" — that is `setFollowApplicationMode(...,
77
+ * 'disabled')`, and keeping the two distinct is the point of the design. An
78
+ * application that quietly turned a global unfollow into a local one would
79
+ * leave the user believing they had stopped following something they still
80
+ * follow everywhere else.
81
+ *
82
+ * Idempotent: `removed: false` when it was already gone, because the state
83
+ * the caller asked for is the state that holds.
84
+ */
85
+ async unfollowTarget(relationshipId: string): Promise<UnfollowMutation> {
86
+ try {
87
+ return await this.makeRequest<UnfollowMutation>(
88
+ 'DELETE',
89
+ `/v2/follows/${encodeURIComponent(relationshipId)}`,
90
+ undefined,
91
+ { cache: false },
92
+ );
93
+ } catch (error) {
94
+ throw this.handleError(error);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The three-part status: globally, in this application, and in effect.
100
+ *
101
+ * Render `effectiveState` on the button and keep the other two for the
102
+ * explanation. A UI that collapses them cannot tell the user why a follow
103
+ * they can see in their list is not showing up in this app's feed.
104
+ */
105
+ async getFollowTargetStatus(targetId: string): Promise<FollowStatus> {
106
+ try {
107
+ return await this.makeRequest<FollowStatus>(
108
+ 'GET',
109
+ `/v2/follows/${encodeURIComponent(targetId)}/status`,
110
+ undefined,
111
+ { cache: false },
112
+ );
113
+ } catch (error) {
114
+ throw this.handleError(error);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Turn a relationship off, or back on, in ONE application.
120
+ *
121
+ * Omit `applicationId` and it applies to the calling application, which is
122
+ * the only form an ordinary app should ever need. Naming a DIFFERENT
123
+ * application requires `follows:manage` server-side — acting on another
124
+ * app's behalf is exactly the cross-application authority this design
125
+ * otherwise refuses, so it is a distinct permission and not a parameter an
126
+ * app happens to fill in.
127
+ */
128
+ async setFollowApplicationMode(
129
+ relationshipId: string,
130
+ mode: 'enabled' | 'disabled',
131
+ applicationId?: string,
132
+ ): Promise<{ ok: true; mode: 'enabled' | 'disabled' }> {
133
+ try {
134
+ return await this.makeRequest(
135
+ 'PUT',
136
+ `/v2/follows/${encodeURIComponent(relationshipId)}/context`,
137
+ { mode, ...(applicationId ? { applicationId } : {}) },
138
+ { cache: false },
139
+ );
140
+ } catch (error) {
141
+ throw this.handleError(error);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Drop the override so this application follows the global relationship
147
+ * again. Distinct from setting `enabled`: inheriting means a later global
148
+ * change takes effect here, and an explicit `enabled` means it does not.
149
+ */
150
+ async restoreFollowInheritance(
151
+ relationshipId: string,
152
+ applicationId?: string,
153
+ ): Promise<{ ok: true }> {
154
+ try {
155
+ const path = buildUrl(
156
+ `/v2/follows/${encodeURIComponent(relationshipId)}/context`,
157
+ applicationId ? { applicationId } : {},
158
+ );
159
+ return await this.makeRequest('DELETE', path, undefined, { cache: false });
160
+ } catch (error) {
161
+ throw this.handleError(error);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Resolve a target by canonical URI, registering it the first time anyone
167
+ * asks. The call an application makes on the way into a screen, before it
168
+ * can render a button.
169
+ *
170
+ * Idempotent on the URI, which is what makes two applications describing
171
+ * the same thing — the same fediverse actor, the same topic — arrive at ONE
172
+ * row, and therefore at one relationship per user rather than one per app.
173
+ *
174
+ * `metadata` is a display snapshot (name, handle, icon) and is refreshed
175
+ * only for the application that provides the target: a second application
176
+ * passing its own idea of the name would make the display flip depending on
177
+ * which app last looked.
178
+ */
179
+ async ensureFollowTarget(input: {
180
+ uri: string;
181
+ kind: string;
182
+ metadata?: Record<string, unknown>;
183
+ providerReference?: string;
184
+ localUserId?: string;
185
+ }): Promise<{ id: string; uri: string; kind: string; created: boolean }> {
186
+ try {
187
+ return await this.makeRequest('POST', '/v2/follow-targets', input, { cache: false });
188
+ } catch (error) {
189
+ throw this.handleError(error);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Claim a namespace for the calling application. First come, and idempotent
195
+ * for the holder — an application that registers on every boot must not
196
+ * fail the second time.
197
+ */
198
+ async claimFollowNamespace(
199
+ namespace: string
200
+ ): Promise<{ namespace: string; created: boolean }> {
201
+ try {
202
+ return await this.makeRequest(
203
+ 'POST',
204
+ '/v2/follow-targets/namespaces',
205
+ { namespace },
206
+ { cache: false },
207
+ );
208
+ } catch (error) {
209
+ throw this.handleError(error);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Declare what following a kind of thing MEANS: the verb clients render,
215
+ * whether reverse lookups are public, whether it federates.
216
+ *
217
+ * Declared once by the application that owns the concept, rather than
218
+ * passed per call site — otherwise two screens of one app can disagree
219
+ * about whether a store is followed or subscribed to.
220
+ */
221
+ async registerFollowKind(input: {
222
+ kind: string;
223
+ label?: string;
224
+ capabilities?: {
225
+ verb?: 'follow' | 'subscribe' | 'join';
226
+ reverse?: 'public' | 'private' | 'aggregate' | 'unavailable';
227
+ federated?: boolean;
228
+ };
229
+ }): Promise<{ kind: string; created: boolean }> {
230
+ try {
231
+ return await this.makeRequest('POST', '/v2/follow-targets/kinds', input, {
232
+ cache: false,
233
+ });
234
+ } catch (error) {
235
+ throw this.handleError(error);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Everything the signed-in user follows, newest first.
241
+ *
242
+ * Owner-only by construction server-side — there is no parameter naming a
243
+ * user, so this cannot be pointed at somebody else's graph.
244
+ *
245
+ * Paginate by passing back `nextCursor`, never an offset: the list changes
246
+ * while it is being read, and an offset silently skips or repeats rows
247
+ * exactly when it does.
248
+ */
249
+ async listFollows(params?: {
250
+ kind?: string;
251
+ cursor?: string;
252
+ limit?: number;
253
+ }): Promise<FollowListPage> {
254
+ try {
255
+ const path = buildUrl('/v2/me/follows', {
256
+ ...(params?.kind ? { kind: params.kind } : {}),
257
+ ...(params?.cursor ? { cursor: params.cursor } : {}),
258
+ ...(params?.limit ? { limit: params.limit } : {}),
259
+ });
260
+ return await this.makeRequest<FollowListPage>('GET', path, undefined, { cache: false });
261
+ } catch (error) {
262
+ throw this.handleError(error);
263
+ }
264
+ }
265
+ };
266
+ }
@@ -28,6 +28,8 @@ import {
28
28
  import { KeyManager } from '../crypto/keyManager';
29
29
  import { SignatureService } from '../crypto/signatureService';
30
30
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
31
+ import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
32
+ import { evictOxyAccountForestCache } from '../utils/accountCacheSweep';
31
33
  import { logger } from '../logger';
32
34
  import { extractErrorStatus } from '../utils/errorUtils';
33
35
 
@@ -534,13 +536,16 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
534
536
  /**
535
537
  * Update user profile.
536
538
  *
537
- * Invalidates the SDK-side response cache for every endpoint that
538
- * returns the current user (`GET /users/me`, `GET /session/user/*`,
539
- * `GET /users/<id>`, `GET /profiles/username/*`) so the next read
540
- * doesn't return a stale snapshot. Without this, a follow-up
541
- * `getUserBySession` call inside the 2-minute cache window can return
542
- * the pre-update user most visibly during onboarding, where it
543
- * causes the username step to flicker back as if nothing was saved.
539
+ * Invalidates the SDK-side response cache for every endpoint that can
540
+ * return this user — the list is owned by {@link evictOxyIdentityCache}, so
541
+ * a new identity read is added in one place instead of to each writer
542
+ * separately (this method's own hand-written copy had already drifted from
543
+ * the server-side one, missing `GET /auth/lookup/*` and
544
+ * `GET /profiles/resolve`). The account forest (`GET /accounts` and the
545
+ * caller's own detail row) is swept too a personal account IS this user,
546
+ * and `AccountNode.account` embeds the whole profile — from the list that
547
+ * {@link evictOxyAccountForestCache} owns, for the same reason: the accounts
548
+ * mixin writes those keys as well, and two hand-written copies drift.
544
549
  *
545
550
  * TanStack Query handles offline queuing automatically.
546
551
  */
@@ -550,15 +555,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
550
555
  await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false }),
551
556
  );
552
557
 
553
- // Bust every cached representation of the current user. We use a
554
- // prefix sweep rather than an enumeration because the SDK never
555
- // tracks the set of active session IDs centrally.
556
- this.clearCacheByPrefix('GET:/session/user/');
557
- this.clearCacheByPrefix('GET:/users/me');
558
- this.clearCacheByPrefix('GET:/profiles/username/');
559
- if (result?.id) {
560
- this.clearCacheEntry(`GET:/users/${result.id}`);
561
- }
558
+ evictOxyIdentityCache(this, result?.id);
559
+ evictOxyAccountForestCache(this, result?.id);
562
560
 
563
561
  return result;
564
562
  } catch (error) {
@@ -615,10 +613,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
615
613
  const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
616
614
  cache: false,
617
615
  });
618
- this.clearCacheByPrefix('GET:/session/user/');
619
- this.clearCacheByPrefix('GET:/users/me');
620
- this.clearCacheByPrefix('GET:/profiles/username/');
621
- this.clearCacheEntry(`GET:/users/${id}`);
616
+ // Privacy settings ride the user DTO, so every identity read goes stale
617
+ // too — same key list as any other profile write.
618
+ evictOxyIdentityCache(this, id);
622
619
  this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
623
620
  return result;
624
621
  } catch (error) {
@@ -47,6 +47,7 @@ const memberFixture: AccountMember = {
47
47
  permissions: ['account:read', 'apps:read'],
48
48
  inherit: true,
49
49
  status: 'active',
50
+ source: 'direct',
50
51
  createdAt: '2026-06-29T00:00:00.000Z',
51
52
  updatedAt: '2026-06-29T00:00:00.000Z',
52
53
  };
@@ -385,6 +386,7 @@ describe('OxyServices.accounts', () => {
385
386
  );
386
387
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
387
388
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
389
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
388
390
  });
389
391
  });
390
392
 
@@ -403,6 +405,7 @@ describe('OxyServices.accounts', () => {
403
405
  );
404
406
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
405
407
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
408
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
406
409
  });
407
410
  });
408
411
 
@@ -421,6 +424,7 @@ describe('OxyServices.accounts', () => {
421
424
  );
422
425
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
423
426
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
427
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
424
428
  });
425
429
  });
426
430
 
@@ -439,6 +443,7 @@ describe('OxyServices.accounts', () => {
439
443
  );
440
444
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
441
445
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
446
+ expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
442
447
  expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts');
443
448
  expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts?');
444
449
  });