@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.
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 +210 -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 +269 -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
@@ -104,7 +104,7 @@ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
104
104
  export { createSessionClientHost } from './session/sessionClientHost';
105
105
  export { createSessionClient } from './session/createSessionClient';
106
106
  export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
107
- export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
107
+ export { isSwitchTargetAccount, projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
108
108
  export type { SwitchableAccount, SwitchableAccountUser, ProjectSwitchableAccountsInput, } from './session/accountProjection';
109
109
  export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
110
110
  export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
@@ -66,8 +66,8 @@ export type AccountMemberStatus = 'active' | 'invited' | 'removed';
66
66
  */
67
67
  export type AccountMemberSource = 'direct' | 'inherited';
68
68
  /**
69
- * Client-facing AccountMember shape. `permissions` is derived from `role` on the
70
- * server at write time.
69
+ * Client-facing AccountMember shape. `permissions` is the effective permission
70
+ * set (role baseline plus `permissionGrants` minus `permissionRevokes`).
71
71
  */
72
72
  export interface AccountMember {
73
73
  _id: string;
@@ -77,6 +77,10 @@ export interface AccountMember {
77
77
  memberUserId: string;
78
78
  role: AccountRole;
79
79
  permissions: string[];
80
+ /** Permissions granted beyond the role baseline. */
81
+ permissionGrants?: string[];
82
+ /** Permissions revoked from the role baseline. */
83
+ permissionRevokes?: string[];
80
84
  /**
81
85
  * Whether this membership cascades to the account's subtree. `true` (default)
82
86
  * lets descendants inherit this role unless a nearer row overrides it; `false`
@@ -85,12 +89,20 @@ export interface AccountMember {
85
89
  inherit: boolean;
86
90
  status: AccountMemberStatus;
87
91
  /**
88
- * Origin of the membership when the API resolves an effective role. Present on
89
- * a resolved `callerMembership` to indicate whether the caller's access is
90
- * `direct` on the account or `inherited` from an ancestor. Absent on plain
91
- * member-list rows (which are always direct rows on the account).
92
+ * Where this membership COMES FROM relative to the account it is being
93
+ * reported for: `direct` when the row lives on that account, `inherited` when
94
+ * it lives on an ancestor whose `inherit` flag cascades it down.
95
+ *
96
+ * Present on every membership the API serialises — a resolved
97
+ * `callerMembership` and every entry of a member list alike. It is not
98
+ * decoration: an `inherited` entry's `accountId` is the ANCESTOR's, and the
99
+ * member-mutation endpoints are scoped to rows on the account named in the
100
+ * path, so `PATCH`/`DELETE .../members/<that row's _id>` against the
101
+ * descendant 404s. **Branch on `source === 'direct'` before offering to edit,
102
+ * remove or transfer to a member**, and count owners for a last-owner check
103
+ * over direct entries only.
92
104
  */
93
- source?: AccountMemberSource;
105
+ source: AccountMemberSource;
94
106
  invitedByUserId?: string | null;
95
107
  joinedAt?: string | null;
96
108
  createdAt: string;
@@ -246,7 +258,10 @@ export interface InviteAccountMemberInput {
246
258
  }
247
259
  /** Input accepted by `updateAccountMember`. The owner role cannot be assigned. */
248
260
  export interface UpdateAccountMemberInput {
249
- role: Exclude<AccountRole, 'owner'>;
261
+ role?: Exclude<AccountRole, 'owner'>;
262
+ inherit?: boolean;
263
+ permissionGrants?: string[];
264
+ permissionRevokes?: string[];
250
265
  }
251
266
  /** Input accepted by `transferAccountOwnership`. */
252
267
  export interface TransferAccountOwnershipInput {
@@ -609,7 +624,27 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
609
624
  */
610
625
  listChildAccounts(accountId: string): Promise<AccountNode[]>;
611
626
  /**
612
- * List members of an account (direct membership rows on the account).
627
+ * List the members of an account: the membership rows ON it, plus the rows
628
+ * on its ancestors that cascade into it. Each entry carries `source`
629
+ * (`direct` | `inherited`) saying which it is.
630
+ *
631
+ * Inherited entries are members in every sense the server enforces — an
632
+ * ancestor row with `inherit: true` resolves through
633
+ * `resolveEffectiveAccess` and confers every account permission on the
634
+ * descendant, `account:act_as` included — so a roster that omitted them
635
+ * answered `[]` for accounts several people could act on.
636
+ *
637
+ * Two things follow for a caller. An entry's `accountId` is the account its
638
+ * ROW lives on, so an inherited entry names an ancestor rather than the
639
+ * account you asked about; and the member-mutation endpoints only accept
640
+ * rows on the account in the path, so gate any edit/remove/transfer
641
+ * affordance on `source === 'direct'`.
642
+ *
643
+ * Asking what the CALLER holds over an account is a different question, and
644
+ * scanning this list for yourself is the wrong way to answer it — use
645
+ * {@link OxyServicesAccountsMixin.getAccount}, whose `callerMembership` is
646
+ * the server's own resolution.
647
+ *
613
648
  * @param accountId - The account's Mongo `_id`.
614
649
  */
615
650
  listAccountMembers(accountId: string): Promise<AccountMember[]>;
@@ -734,28 +769,18 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
734
769
  */
735
770
  getAppUsage(applicationId: string, period?: ApplicationUsagePeriod): Promise<ApplicationUsageStats>;
736
771
  /**
737
- * Bust every cached account list. `listAccounts({tree?})` keys the flat list
738
- * as `GET:/accounts` and the tree variant as `GET:/accounts?tree=true` (the
739
- * query string is part of the URL path). A change to the accessible forest
740
- * (create/archive/ownership transfer) invalidates both, so we clear the
741
- * unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
742
- * `GET:/accounts?` matches only the query-string list variants, never the
743
- * `GET:/accounts/<id>…` detail/sub-resource keys.
772
+ * Bust the cached member list and detail for an account after a membership
773
+ * mutation. The member list (`listAccountMembers`) and the detail
774
+ * (`getAccount`, which can embed the caller's membership) both go stale when
775
+ * the member set or a member's role changes.
744
776
  *
745
777
  * Internal helper (leading underscore); not part of the supported public
746
778
  * surface. Public rather than `private` because mixins compose into an
747
779
  * exported anonymous class, where TypeScript cannot represent a private
748
780
  * member in the emitted declaration file (TS4094).
749
- */
750
- _invalidateAccountLists(): void;
751
- /**
752
- * Bust the cached member list and detail for an account after a membership
753
- * mutation. The member list (`listAccountMembers`) and the detail
754
- * (`getAccount`, which can embed the caller's membership) both go stale when
755
- * the member set or a member's role changes.
756
781
  *
757
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
758
- * this is public rather than `private`.
782
+ * The forest keys themselves are NOT owned here — see
783
+ * `utils/accountCacheSweep`, which the user mixin has to reach as well.
759
784
  */
760
785
  _invalidateAccountMembership(accountId: string): void;
761
786
  /**
@@ -767,8 +792,8 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
767
792
  * query-string list variants, never the `GET:/applications/<id>…`
768
793
  * detail/sub-resource keys.
769
794
  *
770
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
771
- * this is public rather than `private`.
795
+ * Internal helper (leading underscore); see `_invalidateAccountMembership`
796
+ * for why this is public rather than `private`.
772
797
  */
773
798
  _invalidateAppLists(): void;
774
799
  httpService: import("../HttpService").HttpService;
@@ -0,0 +1,210 @@
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
+ import type { FollowListPage, FollowMutation, FollowOptions, FollowStatus, UnfollowMutation } from '@oxyhq/contracts';
29
+ import type { OxyServicesBase } from '../OxyServices.base';
30
+ export declare function OxyServicesFollowGraphMixin<T extends typeof OxyServicesBase>(Base: T): {
31
+ new (...args: any[]): {
32
+ /**
33
+ * Follow a target. Idempotent — following something already followed
34
+ * returns the same relationship with `created: false`.
35
+ *
36
+ * The follower and the acting application are BOTH derived server-side from
37
+ * the session. There is deliberately no parameter for either: a client that
38
+ * could name them could forge a follow on another user's behalf, or record
39
+ * one as coming from an application it is not.
40
+ *
41
+ * @param targetId - The registered target's id, not its URI. Registration is
42
+ * a separate operation precisely so following cannot silently create
43
+ * targets — a typo would otherwise become a permanent row nobody follows.
44
+ * @param options.expiresIn - Seconds until the follow lapses on its own. For
45
+ * an event, a trial, a topic followed for a week. The server bounds it.
46
+ */
47
+ followTarget(targetId: string, options?: FollowOptions): Promise<FollowMutation>;
48
+ /**
49
+ * Unfollow everywhere.
50
+ *
51
+ * There is no "unfollow here" — that is `setFollowApplicationMode(...,
52
+ * 'disabled')`, and keeping the two distinct is the point of the design. An
53
+ * application that quietly turned a global unfollow into a local one would
54
+ * leave the user believing they had stopped following something they still
55
+ * follow everywhere else.
56
+ *
57
+ * Idempotent: `removed: false` when it was already gone, because the state
58
+ * the caller asked for is the state that holds.
59
+ */
60
+ unfollowTarget(relationshipId: string): Promise<UnfollowMutation>;
61
+ /**
62
+ * The three-part status: globally, in this application, and in effect.
63
+ *
64
+ * Render `effectiveState` on the button and keep the other two for the
65
+ * explanation. A UI that collapses them cannot tell the user why a follow
66
+ * they can see in their list is not showing up in this app's feed.
67
+ */
68
+ getFollowTargetStatus(targetId: string): Promise<FollowStatus>;
69
+ /**
70
+ * Turn a relationship off, or back on, in ONE application.
71
+ *
72
+ * Omit `applicationId` and it applies to the calling application, which is
73
+ * the only form an ordinary app should ever need. Naming a DIFFERENT
74
+ * application requires `follows:manage` server-side — acting on another
75
+ * app's behalf is exactly the cross-application authority this design
76
+ * otherwise refuses, so it is a distinct permission and not a parameter an
77
+ * app happens to fill in.
78
+ */
79
+ setFollowApplicationMode(relationshipId: string, mode: "enabled" | "disabled", applicationId?: string): Promise<{
80
+ ok: true;
81
+ }>;
82
+ /**
83
+ * Drop the override so this application follows the global relationship
84
+ * again. Distinct from setting `enabled`: inheriting means a later global
85
+ * change takes effect here, and an explicit `enabled` means it does not.
86
+ */
87
+ restoreFollowInheritance(relationshipId: string, applicationId?: string): Promise<{
88
+ ok: true;
89
+ }>;
90
+ /**
91
+ * Resolve a target by canonical URI, registering it the first time anyone
92
+ * asks. The call an application makes on the way into a screen, before it
93
+ * can render a button.
94
+ *
95
+ * Idempotent on the URI, which is what makes two applications describing
96
+ * the same thing — the same fediverse actor, the same topic — arrive at ONE
97
+ * row, and therefore at one relationship per user rather than one per app.
98
+ *
99
+ * `metadata` is a display snapshot (name, handle, icon) and is refreshed
100
+ * only for the application that provides the target: a second application
101
+ * passing its own idea of the name would make the display flip depending on
102
+ * which app last looked.
103
+ */
104
+ ensureFollowTarget(input: {
105
+ uri: string;
106
+ kind: string;
107
+ metadata?: Record<string, unknown>;
108
+ providerReference?: string;
109
+ localUserId?: string;
110
+ }): Promise<{
111
+ id: string;
112
+ uri: string;
113
+ kind: string;
114
+ created: boolean;
115
+ }>;
116
+ /**
117
+ * Claim a namespace for the calling application. First come, and idempotent
118
+ * for the holder — an application that registers on every boot must not
119
+ * fail the second time.
120
+ */
121
+ claimFollowNamespace(namespace: string): Promise<{
122
+ namespace: string;
123
+ created: boolean;
124
+ }>;
125
+ /**
126
+ * Declare what following a kind of thing MEANS: the verb clients render,
127
+ * whether reverse lookups are public, whether it federates.
128
+ *
129
+ * Declared once by the application that owns the concept, rather than
130
+ * passed per call site — otherwise two screens of one app can disagree
131
+ * about whether a store is followed or subscribed to.
132
+ */
133
+ registerFollowKind(input: {
134
+ kind: string;
135
+ label?: string;
136
+ capabilities?: {
137
+ verb?: "follow" | "subscribe" | "join" | "watch";
138
+ reverse?: "public" | "private" | "aggregate" | "unavailable";
139
+ federated?: boolean;
140
+ };
141
+ }): Promise<{
142
+ kind: string;
143
+ created: boolean;
144
+ }>;
145
+ /**
146
+ * Everything the signed-in user follows, newest first.
147
+ *
148
+ * Owner-only by construction server-side — there is no parameter naming a
149
+ * user, so this cannot be pointed at somebody else's graph.
150
+ *
151
+ * Paginate by passing back `nextCursor`, never an offset: the list changes
152
+ * while it is being read, and an offset silently skips or repeats rows
153
+ * exactly when it does.
154
+ */
155
+ listFollows(params?: {
156
+ kind?: string;
157
+ cursor?: string;
158
+ limit?: number;
159
+ }): Promise<FollowListPage>;
160
+ httpService: import("../HttpService").HttpService;
161
+ cloudURL: string;
162
+ config: import("../OxyServices.base").OxyConfig;
163
+ __resetTokensForTests(): void;
164
+ makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
165
+ getBaseURL(): string;
166
+ getClient(): import("../HttpService").HttpService;
167
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
168
+ getMetrics(): {
169
+ totalRequests: number;
170
+ successfulRequests: number;
171
+ failedRequests: number;
172
+ cacheHits: number;
173
+ cacheMisses: number;
174
+ averageResponseTime: number;
175
+ };
176
+ clearCache(): void;
177
+ clearCacheEntry(key: string): void;
178
+ clearCacheByPrefix(prefix: string): number;
179
+ getCacheStats(): {
180
+ size: number;
181
+ hits: number;
182
+ misses: number;
183
+ hitRate: number;
184
+ };
185
+ getCloudURL(): string;
186
+ setTokens(accessToken: string): void;
187
+ clearTokens(): void;
188
+ onTokensChanged(listener: (accessToken: string | null) => void): () => void;
189
+ _cachedUserId: string | null | undefined;
190
+ _cachedAccessToken: string | null;
191
+ getCurrentUserId(): string | null;
192
+ hasValidToken(): boolean;
193
+ getAccessToken(): string | null;
194
+ getAccessTokenExpiry(): number | null;
195
+ waitForAuth(timeoutMs?: number): Promise<boolean>;
196
+ withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
197
+ maxRetries?: number;
198
+ retryDelay?: number;
199
+ authTimeoutMs?: number;
200
+ }): Promise<T_1>;
201
+ validate(): Promise<boolean>;
202
+ handleError(error: unknown): Error;
203
+ healthCheck(): Promise<{
204
+ status: string;
205
+ users?: number;
206
+ timestamp?: string;
207
+ [key: string]: any;
208
+ }>;
209
+ };
210
+ } & T;
@@ -259,10 +259,11 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
259
259
  * a new identity read is added in one place instead of to each writer
260
260
  * separately (this method's own hand-written copy had already drifted from
261
261
  * the server-side one, missing `GET /auth/lookup/*` and
262
- * `GET /profiles/resolve`). Without the sweep a follow-up
263
- * `getUserBySession` inside the cache window returns the pre-update user
264
- * most visibly during onboarding, where the username step flickers back as
265
- * if nothing was saved.
262
+ * `GET /profiles/resolve`). The account forest (`GET /accounts` and the
263
+ * caller's own detail row) is swept too a personal account IS this user,
264
+ * and `AccountNode.account` embeds the whole profile from the list that
265
+ * {@link evictOxyAccountForestCache} owns, for the same reason: the accounts
266
+ * mixin writes those keys as well, and two hand-written copies drift.
266
267
  *
267
268
  * TanStack Query handles offline queuing automatically.
268
269
  */
@@ -29,6 +29,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
29
29
  import { OxyServicesCivicMixin } from './OxyServices.civic';
30
30
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
31
31
  import { OxyServicesLinksMixin } from './OxyServices.links';
32
+ import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
32
33
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
33
34
  import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
34
35
  /**
@@ -40,7 +41,7 @@ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
40
41
  * If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
41
42
  * are visible without a cast.
42
43
  */
43
- type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
44
+ type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
44
45
  /**
45
46
  * Constructor type for the fully composed mixin pipeline. Each mixin returns
46
47
  * a new constructor that augments its input; reducing across the pipeline
@@ -91,6 +91,36 @@ export interface SwitchableAccount {
91
91
  /** The underlying per-account user payload. */
92
92
  user: SwitchableAccountUser;
93
93
  }
94
+ /**
95
+ * Whether the caller can BECOME this account — the one question every account
96
+ * switcher asks, answered here so no surface has to re-derive it.
97
+ *
98
+ * Two independent grounds, either of which suffices:
99
+ *
100
+ * - **It is already the caller's own identity** (`relationship: 'self'`).
101
+ * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
102
+ * is the HUMAN operator's personal account even while they are operating an
103
+ * org — never the operated account. Kind is irrelevant on this ground: the
104
+ * caller IS that account, so returning to it asks the server for nothing.
105
+ * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
106
+ * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
107
+ * on this ground is never a dead button.
108
+ *
109
+ * `isActAsEligibleKind` ALONE is not this question, and reaching for it
110
+ * directly is the mistake this function exists to prevent: it is false for
111
+ * `personal` as well as `channel`, so a switcher gated on it alone renders an
112
+ * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
113
+ * this question either — it silently admits every kind invented after it was
114
+ * written, which is the same trap `isActAsEligibleKind` was introduced to close
115
+ * on the server.
116
+ *
117
+ * Takes a structural subset rather than a whole {@link AccountNode} so a caller
118
+ * holding a projected {@link SwitchableAccount} can ask it too.
119
+ */
120
+ export declare function isSwitchTargetAccount(node: {
121
+ kind?: AccountKind | null;
122
+ relationship?: AccountRelationship;
123
+ }): boolean;
94
124
  /** Input to {@link projectSwitchableAccounts}. */
95
125
  export interface ProjectSwitchableAccountsInput {
96
126
  /**
@@ -131,8 +161,9 @@ export interface ProjectSwitchableAccountsInput {
131
161
  * and a graph node is deduped into ONE device row enriched with the graph
132
162
  * metadata (relationship / kind / parent / membership).
133
163
  *
134
- * Graph nodes of a kind nobody may act as (`channel`) are omitted see the
135
- * filter below.
164
+ * Graph nodes that are not switch targets a `channel`, which nobody may act
165
+ * as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
166
+ * below.
136
167
  */
137
168
  export declare function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[];
138
169
  /**
@@ -142,7 +173,10 @@ export declare function projectSwitchableAccounts(input: ProjectSwitchableAccoun
142
173
  * document, but including their ids lets the caller pass one id set and lets the
143
174
  * projection prefer freshly-fetched profiles uniformly.
144
175
  *
145
- * Applies the SAME act-as filter as {@link projectSwitchableAccounts} to graph
146
- * nodes, so this never fetches a profile for a row the projection will drop.
176
+ * Applies the SAME {@link isSwitchTargetAccount} filter as
177
+ * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
178
+ * profile for a row the projection will drop — and, just as importantly, never
179
+ * SKIPS one the projection will keep, which would leave that row unrendered
180
+ * until some later fetch happened to resolve it.
147
181
  */
148
182
  export declare function switchableAccountIds(state: DeviceSessionState | null, graph: AccountNode[]): string[];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * THE enumeration of `OxyServices` GET-cache keys that serve the ACCOUNT FOREST
3
+ * — the caller's accessible accounts, as lists and as individual detail rows —
4
+ * and the one sweep that clears them.
5
+ *
6
+ * WHY THIS IS NOT A METHOD ON THE ACCOUNTS MIXIN
7
+ * ---------------------------------------------
8
+ * `AccountNode.account` is a whole `User`, so a forest read embeds the very
9
+ * profile the identity reads serve. That makes an IDENTITY write a writer of
10
+ * these keys too: `updateProfile` edits the caller's own personal account,
11
+ * which is a row in `GET /accounts` and is its own `GET /accounts/<id>`. Leave
12
+ * those cached and the account switcher keeps drawing the pre-edit name and
13
+ * picture for the full TTL, against a perfectly healthy server.
14
+ *
15
+ * The mixins compose into one class at runtime but are typed one at a time, so
16
+ * the user mixin cannot call a method the accounts mixin owns. The key list
17
+ * therefore lives here, once, and every writer calls {@link
18
+ * evictOxyAccountForestCache} — exactly like the identity key list in
19
+ * `identityCacheSweep`, which the accounts mixin already calls for the
20
+ * mirror-image case (an account write staling the identity reads). The
21
+ * alternative — a second hand-written copy of these keys in the other mixin —
22
+ * is the drift that shipped the two stale-profile bugs `identityCacheSweep`
23
+ * documents.
24
+ *
25
+ * WHY THE LIST NEEDS A PREFIX AND THE DETAIL DOES NOT
26
+ * --------------------------------------------------
27
+ * `listAccounts({tree?})` keys the flat list as `GET:/accounts` and every
28
+ * option variant as `GET:/accounts?<query>` (the query string is part of the
29
+ * URL, hence of the key), and a writer cannot enumerate which variants a caller
30
+ * has read. The detail key, by contrast, is derivable from the account id the
31
+ * writer already holds.
32
+ *
33
+ * The `GET:/accounts?` prefix matches ONLY the query-string list variants —
34
+ * never `GET:/accounts/<id>` or its `…/members`, `…/credentials`, `…/children`
35
+ * sub-resources, which are the accounts mixin's own business and stay there.
36
+ */
37
+ import type { OxyIdentityCacheEvictor } from './identityCacheSweep';
38
+ /**
39
+ * The cache-eviction surface of an `OxyServices` instance. Reused from
40
+ * `identityCacheSweep` rather than re-declared: it is the SDK's one published
41
+ * name for these two methods, and a second identical interface would be one
42
+ * more shape to keep in step.
43
+ */
44
+ export type OxyAccountCacheEvictor = OxyIdentityCacheEvictor;
45
+ /** The cache key `listAccounts()` reads under with no options. */
46
+ export declare const OXY_ACCOUNT_LIST_CACHE_KEY = "GET:/accounts";
47
+ /**
48
+ * The prefix covering every option-carrying `listAccounts(opts)` variant
49
+ * (`?tree=true`, …), none of which a writer can enumerate.
50
+ */
51
+ export declare const OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = "GET:/accounts?";
52
+ /**
53
+ * Prefix covering every per-account sub-resource cache key
54
+ * (`GET:/accounts/<id>`, `…/members`, `…/credentials`, `…/children`). A
55
+ * membership mutation on an ancestor must sweep ALL of these, not only the
56
+ * account named in the path: descendant member rosters embed inherited rows
57
+ * resolved from that ancestor, and the writer cannot enumerate which descendant
58
+ * ids a caller has already read. The trailing slash deliberately excludes the
59
+ * forest list keys (`GET:/accounts`, `GET:/accounts?…`) documented above.
60
+ */
61
+ export declare const OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = "GET:/accounts/";
62
+ /**
63
+ * Build the exact cache key `getAccount(accountId)` reads under.
64
+ */
65
+ export declare function oxyAccountDetailCacheKey(accountId: string): string;
66
+ /**
67
+ * Sweep an `OxyServices` GET response cache of the account forest.
68
+ *
69
+ * @param oxy - Anything exposing the SDK's two eviction methods.
70
+ * @param accountId - The account whose detail row to drop as well. Optional: a
71
+ * writer that changed the SHAPE of the forest rather than one
72
+ * account in it (create, archive, ownership transfer) has no
73
+ * detail row to name, and clears only the lists.
74
+ */
75
+ export declare function evictOxyAccountForestCache(oxy: OxyAccountCacheEvictor, accountId?: string): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "19.0.0",
3
+ "version": "19.1.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -115,7 +115,7 @@
115
115
  "dependencies": {
116
116
  "@noble/ciphers": "^1.3.0",
117
117
  "@noble/hashes": "^1.8.0",
118
- "@oxyhq/contracts": "^0.23.0",
118
+ "@oxyhq/contracts": "^0.24.0",
119
119
  "@oxyhq/protocol": "^0.1.6",
120
120
  "@scure/bip39": "^1.6.0",
121
121
  "@types/elliptic": "^6.4.18",
@@ -1839,6 +1839,7 @@
1839
1839
  "title": "Members",
1840
1840
  "subtitle": "People with access to this account.",
1841
1841
  "empty": "No members yet.",
1842
+ "inherited": "Inherited",
1842
1843
  "actions": {
1843
1844
  "remove": "Remove member",
1844
1845
  "transfer": "Transfer ownership"
@@ -1839,6 +1839,7 @@
1839
1839
  "title": "Miembros",
1840
1840
  "subtitle": "Personas con acceso a esta cuenta.",
1841
1841
  "empty": "Aún no hay miembros.",
1842
+ "inherited": "Heredado",
1842
1843
  "actions": {
1843
1844
  "remove": "Eliminar miembro",
1844
1845
  "transfer": "Transferir propiedad"
package/src/index.ts CHANGED
@@ -647,7 +647,14 @@ export {
647
647
  // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
648
648
  // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
649
649
  // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
650
- export {
650
+ // `isSwitchTargetAccount` is the switcher's own question ("can I become this
651
+ // account?"), exported so a surface that renders `AccountNode`s rather than the
652
+ // projection — the Console's workspace switcher, the accounts app's
653
+ // managed-accounts rows — asks the SAME question instead of testing a kind
654
+ // literal. It is NOT `isActAsEligibleKind`: that one is false for `personal`
655
+ // too, so gating a switcher on it alone empties the list.
656
+ export {
657
+ isSwitchTargetAccount,
651
658
  projectSwitchableAccounts,
652
659
  switchableAccountIds,
653
660
  } from './session/accountProjection';