@oxyhq/core 19.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 (46) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +1 -0
  3. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  4. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  6. package/dist/cjs/index.js +9 -2
  7. package/dist/cjs/mixins/OxyServices.accounts.js +42 -29
  8. package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +7 -4
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/session/accountProjection.js +45 -9
  12. package/dist/cjs/utils/accountCacheSweep.js +80 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/i18n/locales/en-US.json +1 -0
  15. package/dist/esm/i18n/locales/es-ES.json +1 -0
  16. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  17. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  18. package/dist/esm/index.js +7 -1
  19. package/dist/esm/mixins/OxyServices.accounts.js +42 -29
  20. package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
  21. package/dist/esm/mixins/OxyServices.user.js +7 -4
  22. package/dist/esm/mixins/index.js +4 -0
  23. package/dist/esm/session/accountProjection.js +44 -9
  24. package/dist/esm/utils/accountCacheSweep.js +75 -0
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/mixins/OxyServices.accounts.d.ts +52 -27
  28. package/dist/types/mixins/OxyServices.followGraph.d.ts +211 -0
  29. package/dist/types/mixins/OxyServices.user.d.ts +5 -4
  30. package/dist/types/mixins/index.d.ts +2 -1
  31. package/dist/types/session/accountProjection.d.ts +38 -4
  32. package/dist/types/utils/accountCacheSweep.d.ts +75 -0
  33. package/package.json +2 -2
  34. package/src/i18n/locales/en-US.json +1 -0
  35. package/src/i18n/locales/es-ES.json +1 -0
  36. package/src/index.ts +8 -1
  37. package/src/mixins/OxyServices.accounts.ts +65 -38
  38. package/src/mixins/OxyServices.followGraph.ts +266 -0
  39. package/src/mixins/OxyServices.user.ts +7 -4
  40. package/src/mixins/__tests__/accounts.test.ts +5 -0
  41. package/src/mixins/__tests__/followGraph.test.ts +128 -0
  42. package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +37 -0
  43. package/src/mixins/index.ts +5 -0
  44. package/src/session/__tests__/accountProjection.test.ts +109 -2
  45. package/src/session/accountProjection.ts +47 -9
  46. package/src/utils/accountCacheSweep.ts +93 -0
@@ -38,6 +38,7 @@ import type { SessionLoginResponse } from '../models/session';
38
38
  import type { OxyServicesBase } from '../OxyServices.base';
39
39
  import { normalizeUserIdentity } from '../utils/userIdentity';
40
40
  import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
41
+ import { evictOxyAccountForestCache, oxyAccountDetailCacheKey, OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX } from '../utils/accountCacheSweep';
41
42
  import { CACHE_TIMES } from './mixinHelpers';
42
43
 
43
44
  // ---------------------------------------------------------------------------
@@ -87,8 +88,8 @@ export type AccountMemberStatus = 'active' | 'invited' | 'removed';
87
88
  export type AccountMemberSource = 'direct' | 'inherited';
88
89
 
89
90
  /**
90
- * Client-facing AccountMember shape. `permissions` is derived from `role` on the
91
- * server at write time.
91
+ * Client-facing AccountMember shape. `permissions` is the effective permission
92
+ * set (role baseline plus `permissionGrants` minus `permissionRevokes`).
92
93
  */
93
94
  export interface AccountMember {
94
95
  _id: string;
@@ -98,6 +99,10 @@ export interface AccountMember {
98
99
  memberUserId: string;
99
100
  role: AccountRole;
100
101
  permissions: string[];
102
+ /** Permissions granted beyond the role baseline. */
103
+ permissionGrants?: string[];
104
+ /** Permissions revoked from the role baseline. */
105
+ permissionRevokes?: string[];
101
106
  /**
102
107
  * Whether this membership cascades to the account's subtree. `true` (default)
103
108
  * lets descendants inherit this role unless a nearer row overrides it; `false`
@@ -106,12 +111,20 @@ export interface AccountMember {
106
111
  inherit: boolean;
107
112
  status: AccountMemberStatus;
108
113
  /**
109
- * Origin of the membership when the API resolves an effective role. Present on
110
- * a resolved `callerMembership` to indicate whether the caller's access is
111
- * `direct` on the account or `inherited` from an ancestor. Absent on plain
112
- * 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.
113
126
  */
114
- source?: AccountMemberSource;
127
+ source: AccountMemberSource;
115
128
  invitedByUserId?: string | null;
116
129
  joinedAt?: string | null;
117
130
  createdAt: string;
@@ -264,7 +277,10 @@ export interface InviteAccountMemberInput {
264
277
 
265
278
  /** Input accepted by `updateAccountMember`. The owner role cannot be assigned. */
266
279
  export interface UpdateAccountMemberInput {
267
- role: Exclude<AccountRole, 'owner'>;
280
+ role?: Exclude<AccountRole, 'owner'>;
281
+ inherit?: boolean;
282
+ permissionGrants?: string[];
283
+ permissionRevokes?: string[];
268
284
  }
269
285
 
270
286
  /** Input accepted by `transferAccountOwnership`. */
@@ -702,7 +718,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
702
718
  );
703
719
  // A new account changes the accessible forest — bust every cached list
704
720
  // (flat + tree) so it appears on the next `listAccounts()` read.
705
- this._invalidateAccountLists();
721
+ evictOxyAccountForestCache(this);
706
722
  return res.account;
707
723
  } catch (error) {
708
724
  throw this.handleError(error);
@@ -793,8 +809,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
793
809
  );
794
810
  // Bust the cached detail and every list (which embeds account profile
795
811
  // data) so neither serves the pre-update snapshot.
796
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
797
- this._invalidateAccountLists();
812
+ evictOxyAccountForestCache(this, accountId);
798
813
  // The parent's children list embeds this account's profile and is keyed
799
814
  // by the PARENT id, so it is reachable only from the response node.
800
815
  const parentAccountId = res.account?.parentAccountId;
@@ -826,10 +841,9 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
826
841
  { cache: false },
827
842
  );
828
843
  // Bust every cached representation of the archived account.
829
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
830
844
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
831
845
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
832
- this._invalidateAccountLists();
846
+ evictOxyAccountForestCache(this, accountId);
833
847
  return result;
834
848
  } catch (error) {
835
849
  throw this.handleError(error);
@@ -859,7 +873,27 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
859
873
  // =========================================================================
860
874
 
861
875
  /**
862
- * 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
+ *
863
897
  * @param accountId - The account's Mongo `_id`.
864
898
  */
865
899
  async listAccountMembers(accountId: string): Promise<AccountMember[]> {
@@ -968,7 +1002,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
968
1002
  // Ownership change alters roles in the member list AND the detail, and
969
1003
  // can change which accounts the caller "owns" in the list view.
970
1004
  this._invalidateAccountMembership(accountId);
971
- this._invalidateAccountLists();
1005
+ evictOxyAccountForestCache(this);
972
1006
  return result;
973
1007
  } catch (error) {
974
1008
  throw this.handleError(error);
@@ -1310,36 +1344,29 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
1310
1344
  // =========================================================================
1311
1345
 
1312
1346
  /**
1313
- * Bust every cached account list. `listAccounts({tree?})` keys the flat list
1314
- * as `GET:/accounts` and the tree variant as `GET:/accounts?tree=true` (the
1315
- * query string is part of the URL path). A change to the accessible forest
1316
- * (create/archive/ownership transfer) invalidates both, so we clear the
1317
- * unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
1318
- * `GET:/accounts?` matches only the query-string list variants, never the
1319
- * `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.
1320
1351
  *
1321
1352
  * Internal helper (leading underscore); not part of the supported public
1322
1353
  * surface. Public rather than `private` because mixins compose into an
1323
1354
  * exported anonymous class, where TypeScript cannot represent a private
1324
1355
  * member in the emitted declaration file (TS4094).
1325
- */
1326
- _invalidateAccountLists(): void {
1327
- this.clearCacheEntry('GET:/accounts');
1328
- this.clearCacheByPrefix('GET:/accounts?');
1329
- }
1330
-
1331
- /**
1332
- * Bust the cached member list and detail for an account after a membership
1333
- * mutation. The member list (`listAccountMembers`) and the detail
1334
- * (`getAccount`, which can embed the caller's membership) both go stale when
1335
- * the member set or a member's role changes.
1336
1356
  *
1337
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
1338
- * 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.
1339
1359
  */
1340
1360
  _invalidateAccountMembership(accountId: string): void {
1341
1361
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
1342
- 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);
1343
1370
  }
1344
1371
 
1345
1372
  /**
@@ -1351,8 +1378,8 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
1351
1378
  * query-string list variants, never the `GET:/applications/<id>…`
1352
1379
  * detail/sub-resource keys.
1353
1380
  *
1354
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
1355
- * this is public rather than `private`.
1381
+ * Internal helper (leading underscore); see `_invalidateAccountMembership`
1382
+ * for why this is public rather than `private`.
1356
1383
  */
1357
1384
  _invalidateAppLists(): void {
1358
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
+ }
@@ -29,6 +29,7 @@ import { KeyManager } from '../crypto/keyManager';
29
29
  import { SignatureService } from '../crypto/signatureService';
30
30
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
31
31
  import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
32
+ import { evictOxyAccountForestCache } from '../utils/accountCacheSweep';
32
33
  import { logger } from '../logger';
33
34
  import { extractErrorStatus } from '../utils/errorUtils';
34
35
 
@@ -540,10 +541,11 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
540
541
  * a new identity read is added in one place instead of to each writer
541
542
  * separately (this method's own hand-written copy had already drifted from
542
543
  * the server-side one, missing `GET /auth/lookup/*` and
543
- * `GET /profiles/resolve`). Without the sweep a follow-up
544
- * `getUserBySession` inside the cache window returns the pre-update user
545
- * most visibly during onboarding, where the username step flickers back as
546
- * if nothing was saved.
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.
547
549
  *
548
550
  * TanStack Query handles offline queuing automatically.
549
551
  */
@@ -554,6 +556,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
554
556
  );
555
557
 
556
558
  evictOxyIdentityCache(this, result?.id);
559
+ evictOxyAccountForestCache(this, result?.id);
557
560
 
558
561
  return result;
559
562
  } 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
  });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Follow Graph Mixin Tests
3
+ *
4
+ * `makeRequest` is stubbed, so what these assert is the CONTRACT this mixin
5
+ * offers the applications above it: which request each method makes, and — the
6
+ * part worth a test rather than a comment — the things it must never send.
7
+ */
8
+
9
+ import { OxyServices } from '../../OxyServices';
10
+
11
+ describe('OxyServices.followGraph', () => {
12
+ let oxy: OxyServices;
13
+ let makeRequest: jest.SpyInstance;
14
+
15
+ beforeEach(() => {
16
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
17
+ oxy.httpService.setTokens('test-token');
18
+ makeRequest = jest.spyOn(oxy, 'makeRequest').mockResolvedValue({} as never);
19
+ });
20
+
21
+ afterEach(() => {
22
+ makeRequest.mockRestore();
23
+ });
24
+
25
+ describe('the identities the client must not be able to state', () => {
26
+ it('never sends a follower id', async () => {
27
+ await oxy.followTarget('target-1');
28
+ await oxy.unfollowTarget('rel-1');
29
+ await oxy.listFollows();
30
+
31
+ // A client that could name the follower could forge a follow on somebody
32
+ // else's behalf. The server derives it from the session; there must be no
33
+ // parameter here that even looks like an alternative.
34
+ for (const call of makeRequest.mock.calls) {
35
+ expect(JSON.stringify(call)).not.toMatch(/user_?[Ii]d|follower/);
36
+ }
37
+ });
38
+
39
+ it('sends no application id unless the caller explicitly names another app', async () => {
40
+ await oxy.setFollowApplicationMode('rel-1', 'disabled');
41
+
42
+ // The ordinary case is "this application", derived server-side. Sending an
43
+ // id here by default would make every app's own writes indistinguishable
44
+ // from one app acting on another's behalf, which is the privileged
45
+ // operation.
46
+ expect(makeRequest.mock.calls[0][2]).toEqual({ mode: 'disabled' });
47
+ });
48
+
49
+ it('passes an explicitly named application through, for the privileged path', async () => {
50
+ await oxy.setFollowApplicationMode('rel-1', 'enabled', 'app-9');
51
+ expect(makeRequest.mock.calls[0][2]).toEqual({ mode: 'enabled', applicationId: 'app-9' });
52
+
53
+ await oxy.restoreFollowInheritance('rel-1', 'app-9');
54
+ expect(makeRequest.mock.calls[1][1]).toContain('applicationId=app-9');
55
+ });
56
+ });
57
+
58
+ describe('request shapes', () => {
59
+ it('follows with PUT and no body when the follow is permanent', async () => {
60
+ await oxy.followTarget('target-1');
61
+ const [method, path, body] = makeRequest.mock.calls[0];
62
+ expect(method).toBe('PUT');
63
+ expect(path).toBe('/v2/follows/target-1');
64
+ expect(body).toEqual({});
65
+ });
66
+
67
+ it('carries expiresIn for a timed follow', async () => {
68
+ await oxy.followTarget('target-1', { expiresIn: 72 * 60 * 60 });
69
+ expect(makeRequest.mock.calls[0][2]).toEqual({ expiresIn: 259200 });
70
+ });
71
+
72
+ it('unfollows by relationship id, not by target', async () => {
73
+ // The relationship is the thing that exists; addressing the unfollow by
74
+ // target would make the server re-derive which relationship was meant,
75
+ // and get it wrong for any target a user can follow more than one way.
76
+ await oxy.unfollowTarget('rel-1');
77
+ expect(makeRequest.mock.calls[0].slice(0, 2)).toEqual(['DELETE', '/v2/follows/rel-1']);
78
+ });
79
+
80
+ it('reads status per target', async () => {
81
+ await oxy.getFollowTargetStatus('target-1');
82
+ expect(makeRequest.mock.calls[0].slice(0, 2)).toEqual([
83
+ 'GET',
84
+ '/v2/follows/target-1/status',
85
+ ]);
86
+ });
87
+
88
+ it('restores inheritance with no query string when the app means itself', async () => {
89
+ await oxy.restoreFollowInheritance('rel-1');
90
+ expect(makeRequest.mock.calls[0][1]).toBe('/v2/follows/rel-1/context');
91
+ });
92
+
93
+ it('paginates by cursor and filters by kind', async () => {
94
+ await oxy.listFollows({ kind: 'oxy.topic', cursor: '2026-01-01T00:00:00.000Z', limit: 20 });
95
+ const path = makeRequest.mock.calls[0][1] as string;
96
+ expect(path).toContain('kind=oxy.topic');
97
+ expect(path).toContain('limit=20');
98
+ expect(path).toContain('cursor=');
99
+ // Never an offset: the list changes while it is read, and an offset skips
100
+ // or repeats rows exactly when it does.
101
+ expect(path).not.toContain('offset');
102
+ });
103
+
104
+ it('escapes an id rather than letting it change the path', async () => {
105
+ await oxy.followTarget('../../admin');
106
+ expect(makeRequest.mock.calls[0][1]).toBe('/v2/follows/..%2F..%2Fadmin');
107
+ });
108
+ });
109
+
110
+ describe('caching', () => {
111
+ it('caches nothing', async () => {
112
+ await oxy.followTarget('t');
113
+ await oxy.getFollowTargetStatus('t');
114
+ await oxy.listFollows();
115
+ await oxy.unfollowTarget('r');
116
+ await oxy.setFollowApplicationMode('r', 'disabled');
117
+ await oxy.restoreFollowInheritance('r');
118
+
119
+ // A status cached across a write is the "follow reverts after navigating
120
+ // away and back" bug the legacy path had to fix with explicit
121
+ // invalidation. Not caching here leaves the app's own store as the single
122
+ // cache authority.
123
+ for (const call of makeRequest.mock.calls) {
124
+ expect(call[3]).toEqual({ cache: false });
125
+ }
126
+ });
127
+ });
128
+ });