@oxyhq/core 19.0.0 → 19.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/mixins/OxyServices.accounts.js +42 -29
- package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
- package/dist/cjs/mixins/OxyServices.user.js +7 -4
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/session/accountProjection.js +45 -9
- package/dist/cjs/utils/accountCacheSweep.js +80 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
- package/dist/esm/index.js +7 -1
- package/dist/esm/mixins/OxyServices.accounts.js +42 -29
- package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
- package/dist/esm/mixins/OxyServices.user.js +7 -4
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/session/accountProjection.js +44 -9
- package/dist/esm/utils/accountCacheSweep.js +75 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +52 -27
- package/dist/types/mixins/OxyServices.followGraph.d.ts +210 -0
- package/dist/types/mixins/OxyServices.user.d.ts +5 -4
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/accountProjection.d.ts +38 -4
- package/dist/types/utils/accountCacheSweep.d.ts +75 -0
- package/package.json +2 -2
- package/src/i18n/locales/en-US.json +1 -0
- package/src/i18n/locales/es-ES.json +1 -0
- package/src/index.ts +8 -1
- package/src/mixins/OxyServices.accounts.ts +65 -38
- package/src/mixins/OxyServices.followGraph.ts +269 -0
- package/src/mixins/OxyServices.user.ts +7 -4
- package/src/mixins/__tests__/accounts.test.ts +5 -0
- package/src/mixins/__tests__/followGraph.test.ts +128 -0
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +37 -0
- package/src/mixins/index.ts +5 -0
- package/src/session/__tests__/accountProjection.test.ts +109 -2
- package/src/session/accountProjection.ts +47 -9
- 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
|
|
91
|
-
*
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
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
|
-
*
|
|
1338
|
-
*
|
|
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(
|
|
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 `
|
|
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,269 @@
|
|
|
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 }> {
|
|
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
|
+
// Matches `FollowVerb` in @oxyhq/services, which is what renders it. A
|
|
226
|
+
// kind that can display a verb it cannot record is a kind whose button
|
|
227
|
+
// and registration disagree.
|
|
228
|
+
verb?: 'follow' | 'subscribe' | 'join' | 'watch';
|
|
229
|
+
reverse?: 'public' | 'private' | 'aggregate' | 'unavailable';
|
|
230
|
+
federated?: boolean;
|
|
231
|
+
};
|
|
232
|
+
}): Promise<{ kind: string; created: boolean }> {
|
|
233
|
+
try {
|
|
234
|
+
return await this.makeRequest('POST', '/v2/follow-targets/kinds', input, {
|
|
235
|
+
cache: false,
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
throw this.handleError(error);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Everything the signed-in user follows, newest first.
|
|
244
|
+
*
|
|
245
|
+
* Owner-only by construction server-side — there is no parameter naming a
|
|
246
|
+
* user, so this cannot be pointed at somebody else's graph.
|
|
247
|
+
*
|
|
248
|
+
* Paginate by passing back `nextCursor`, never an offset: the list changes
|
|
249
|
+
* while it is being read, and an offset silently skips or repeats rows
|
|
250
|
+
* exactly when it does.
|
|
251
|
+
*/
|
|
252
|
+
async listFollows(params?: {
|
|
253
|
+
kind?: string;
|
|
254
|
+
cursor?: string;
|
|
255
|
+
limit?: number;
|
|
256
|
+
}): Promise<FollowListPage> {
|
|
257
|
+
try {
|
|
258
|
+
const path = buildUrl('/v2/me/follows', {
|
|
259
|
+
...(params?.kind ? { kind: params.kind } : {}),
|
|
260
|
+
...(params?.cursor ? { cursor: params.cursor } : {}),
|
|
261
|
+
...(params?.limit ? { limit: params.limit } : {}),
|
|
262
|
+
});
|
|
263
|
+
return await this.makeRequest<FollowListPage>('GET', path, undefined, { cache: false });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw this.handleError(error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -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`).
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
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
|
+
});
|