@oxyhq/core 18.0.0 → 19.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +49 -5
  3. package/dist/cjs/i18n/locales/es-ES.json +49 -5
  4. package/dist/cjs/i18n/locales/locales/en-US.json +49 -5
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +49 -5
  6. package/dist/cjs/index.js +17 -6
  7. package/dist/cjs/mixins/OxyServices.accounts.js +69 -31
  8. package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +17 -20
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/server/index.js +8 -2
  12. package/dist/cjs/server/userInvalidation.js +6 -28
  13. package/dist/cjs/session/accountProjection.js +45 -9
  14. package/dist/cjs/utils/accountCacheSweep.js +80 -0
  15. package/dist/cjs/utils/identityCacheSweep.js +97 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/i18n/locales/en-US.json +49 -5
  18. package/dist/esm/i18n/locales/es-ES.json +49 -5
  19. package/dist/esm/i18n/locales/locales/en-US.json +49 -5
  20. package/dist/esm/i18n/locales/locales/es-ES.json +49 -5
  21. package/dist/esm/index.js +8 -2
  22. package/dist/esm/mixins/OxyServices.accounts.js +64 -30
  23. package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
  24. package/dist/esm/mixins/OxyServices.user.js +17 -20
  25. package/dist/esm/mixins/index.js +4 -0
  26. package/dist/esm/server/index.js +5 -1
  27. package/dist/esm/server/userInvalidation.js +5 -26
  28. package/dist/esm/session/accountProjection.js +44 -9
  29. package/dist/esm/utils/accountCacheSweep.js +75 -0
  30. package/dist/esm/utils/identityCacheSweep.js +92 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/index.d.ts +3 -3
  33. package/dist/types/mixins/OxyServices.accounts.d.ts +91 -34
  34. package/dist/types/mixins/OxyServices.followGraph.d.ts +211 -0
  35. package/dist/types/mixins/OxyServices.user.d.ts +10 -7
  36. package/dist/types/mixins/index.d.ts +2 -1
  37. package/dist/types/models/interfaces.d.ts +11 -3
  38. package/dist/types/server/index.d.ts +4 -2
  39. package/dist/types/server/userInvalidation.d.ts +5 -24
  40. package/dist/types/session/accountProjection.d.ts +38 -4
  41. package/dist/types/utils/accountCacheSweep.d.ts +75 -0
  42. package/dist/types/utils/identityCacheSweep.d.ts +80 -0
  43. package/package.json +2 -2
  44. package/src/i18n/locales/en-US.json +49 -5
  45. package/src/i18n/locales/es-ES.json +49 -5
  46. package/src/index.ts +15 -2
  47. package/src/mixins/OxyServices.accounts.ts +123 -45
  48. package/src/mixins/OxyServices.followGraph.ts +266 -0
  49. package/src/mixins/OxyServices.user.ts +17 -20
  50. package/src/mixins/__tests__/accounts.test.ts +5 -0
  51. package/src/mixins/__tests__/followGraph.test.ts +128 -0
  52. package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
  53. package/src/mixins/index.ts +5 -0
  54. package/src/models/interfaces.ts +11 -3
  55. package/src/server/__tests__/userInvalidation.test.ts +3 -20
  56. package/src/server/index.ts +5 -2
  57. package/src/server/userInvalidation.ts +8 -36
  58. package/src/session/__tests__/accountProjection.test.ts +109 -2
  59. package/src/session/accountProjection.ts +47 -9
  60. package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
  61. package/src/utils/accountCacheSweep.ts +93 -0
  62. package/src/utils/identityCacheSweep.ts +104 -0
@@ -39,8 +39,8 @@ export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHand
39
39
  export { normalizeProfileLinks } from './utils/profileLinks';
40
40
  export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
41
41
  export type { PublicApplication, ConnectedApp, } from './mixins/OxyServices.connectedApps';
42
- export type { AccountKind, OrganizationCategory, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, AccountCredentialType, AccountCredentialEnvironment, AccountCredentialStatus, AccountCredential, AccountCredentialWithSecret, RotateAccountCredentialResult, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, ProvisionChannelInput, ProvisionChannelMemberInput, ProvisionChannelResult, InviteAccountMemberInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, CreateAccountCredentialInput, AccountSuccessResult, SwitchAccountResult, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, } from './mixins/OxyServices.accounts';
43
- export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts';
42
+ export type { AccountKind, AccountCategoryId, AccountRelationship, AccountRole, AccountMemberStatus, AccountMemberSource, AccountMember, AccountNode, AccountCredentialType, AccountCredentialEnvironment, AccountCredentialStatus, AccountCredential, AccountCredentialWithSecret, RotateAccountCredentialResult, ListAccountsOptions, CreateAccountInput, UpdateAccountInput, ProvisionChannelInput, ProvisionChannelMemberInput, ProvisionChannelResult, InviteAccountMemberInput, UpdateAccountMemberInput, TransferAccountOwnershipInput, CreateAccountCredentialInput, AccountSuccessResult, SwitchAccountResult, Application, ApplicationType, ApplicationStatus, ApplicationCredential, ApplicationCredentialType, ApplicationCredentialStatus, ApplicationEnvironment, CreateApplicationInput, UpdateApplicationInput, CreateApplicationCredentialInput, ApplicationCredentialWithSecret, RotateApplicationCredentialResult, ApplicationUsagePeriod, ApplicationUsageSummary, ApplicationUsageByDay, ApplicationUsageByEndpoint, ApplicationUsageStats, } from './mixins/OxyServices.accounts';
43
+ export { ACCOUNT_CATEGORY_IDS, MAX_ACCOUNT_CATEGORIES, SELECTABLE_ACCOUNT_CATEGORY_IDS, isSelectableAccountCategoryId, kindAcceptsAccountCategories, } from './mixins/OxyServices.accounts';
44
44
  export { buildUserDid } from './mixins/OxyServices.identity';
45
45
  export type { IdentityRecordType, UnlinkableAuthMethodType, LinkAuthMethodResult, PublishRecordResult, VerifyRecordResult, VerifyDomainResult, RemoveDomainResult, RotateKeyProof, RotateKeyOptions, RotateKeyResult, } from './mixins/OxyServices.identity';
46
46
  export { parseIdPayload, parseAttestPayload, verifyPublicCardAttestation, } from './mixins/OxyServices.civic';
@@ -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';
@@ -33,7 +33,7 @@
33
33
  * registers the switched session into the operator's device-set directly).
34
34
  */
35
35
  import type { User } from '../models/interfaces';
36
- import type { AccountKind, OrganizationCategory, ChildAccountKind } from '@oxyhq/contracts';
36
+ import type { AccountCategoryId, AccountKind, ChildAccountKind } from '@oxyhq/contracts';
37
37
  import type { SessionLoginResponse } from '../models/session';
38
38
  import type { OxyServicesBase } from '../OxyServices.base';
39
39
  /**
@@ -45,8 +45,8 @@ import type { OxyServicesBase } from '../OxyServices.base';
45
45
  *
46
46
  * Single source of truth is `@oxyhq/contracts`.
47
47
  */
48
- export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
49
- export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
48
+ export type { AccountCategoryId, AccountKind } from '@oxyhq/contracts';
49
+ export { ACCOUNT_CATEGORY_IDS, ACCOUNT_KINDS, MAX_ACCOUNT_CATEGORIES, SELECTABLE_ACCOUNT_CATEGORY_IDS, isActAsEligibleKind, isSelectableAccountCategoryId, kindAcceptsAccountCategories, } from '@oxyhq/contracts';
50
50
  /**
51
51
  * The calling user's relationship to an account node, as resolved by the API:
52
52
  * - `self` — the caller's own personal (root) account.
@@ -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;
@@ -168,8 +180,17 @@ export interface CreateAccountInput {
168
180
  };
169
181
  bio?: string;
170
182
  avatar?: string;
171
- /** Meaningful only when `kind` is `organization`. */
172
- organizationCategory?: OrganizationCategory;
183
+ /**
184
+ * What the account is about. ORDERED — the FIRST element is the primary
185
+ * category, so a picker must submit them in the order the user arranged them
186
+ * and must not sort. Stable ids, never labels: render each one through the
187
+ * `accounts.accountCategory.<id>` translation key.
188
+ *
189
+ * Offer `SELECTABLE_ACCOUNT_CATEGORY_IDS`, not `ACCOUNT_CATEGORY_IDS` — the
190
+ * latter still contains withdrawn ids so that accounts already carrying one
191
+ * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
192
+ */
193
+ accountCategories?: AccountCategoryId[];
173
194
  }
174
195
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
175
196
  export interface UpdateAccountInput {
@@ -187,8 +208,19 @@ export interface UpdateAccountInput {
187
208
  };
188
209
  bio?: string | null;
189
210
  avatar?: string | null;
190
- /** Clears the category when `null`; only valid on `kind: 'organization'`. */
191
- organizationCategory?: OrganizationCategory | null;
211
+ /**
212
+ * Replaces the WHOLE list, in the order given — there is no add/remove verb,
213
+ * because a partial edit cannot express a re-ordering and the order is what
214
+ * names the primary category. `[]` clears it.
215
+ *
216
+ * Not nullable, unlike `bio` and `avatar`: the empty case already has a
217
+ * spelling of its own, so a second one could only ever disagree with it.
218
+ *
219
+ * Rejected for a `personal` account, and rejected when it ADDS a withdrawn
220
+ * id the account did not already carry — keeping or re-ordering one it has is
221
+ * always allowed.
222
+ */
223
+ accountCategories?: AccountCategoryId[];
192
224
  }
193
225
  /** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
194
226
  export interface ProvisionChannelInput {
@@ -226,7 +258,10 @@ export interface InviteAccountMemberInput {
226
258
  }
227
259
  /** Input accepted by `updateAccountMember`. The owner role cannot be assigned. */
228
260
  export interface UpdateAccountMemberInput {
229
- role: Exclude<AccountRole, 'owner'>;
261
+ role?: Exclude<AccountRole, 'owner'>;
262
+ inherit?: boolean;
263
+ permissionGrants?: string[];
264
+ permissionRevokes?: string[];
230
265
  }
231
266
  /** Input accepted by `transferAccountOwnership`. */
232
267
  export interface TransferAccountOwnershipInput {
@@ -560,6 +595,18 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
560
595
  /**
561
596
  * Update an account's mutable profile fields. Tree placement changes
562
597
  * (reparenting) go through the dedicated move endpoint, not here.
598
+ *
599
+ * An account IS a user, so this write changes identity — and a profile
600
+ * screen never reads `/accounts/<id>`. It reads `GET /users/<id>` and
601
+ * `GET /profiles/username/<handle>`, both cached for 5 minutes in the
602
+ * CALLER'S OWN process, so busting only the account-graph keys left every
603
+ * profile surface serving the pre-edit avatar and name for the full TTL
604
+ * with a perfectly healthy server (the cross-service `oxy:user:invalidate`
605
+ * signal does not help: it evicts BACKEND caches, and cannot reach a cache
606
+ * living in a browser tab). {@link evictOxyIdentityCache} owns that key
607
+ * list — see its docs for why the handle-keyed entries are prefix-swept
608
+ * (a RENAME leaves the old handle's entry unreachable by any targeted key).
609
+ *
563
610
  * @param accountId - The account's Mongo `_id`.
564
611
  * @param data - Subset of updatable profile fields.
565
612
  */
@@ -577,7 +624,27 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
577
624
  */
578
625
  listChildAccounts(accountId: string): Promise<AccountNode[]>;
579
626
  /**
580
- * 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
+ *
581
648
  * @param accountId - The account's Mongo `_id`.
582
649
  */
583
650
  listAccountMembers(accountId: string): Promise<AccountMember[]>;
@@ -702,28 +769,18 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
702
769
  */
703
770
  getAppUsage(applicationId: string, period?: ApplicationUsagePeriod): Promise<ApplicationUsageStats>;
704
771
  /**
705
- * Bust every cached account list. `listAccounts({tree?})` keys the flat list
706
- * as `GET:/accounts` and the tree variant as `GET:/accounts?tree=true` (the
707
- * query string is part of the URL path). A change to the accessible forest
708
- * (create/archive/ownership transfer) invalidates both, so we clear the
709
- * unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
710
- * `GET:/accounts?` matches only the query-string list variants, never the
711
- * `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.
712
776
  *
713
777
  * Internal helper (leading underscore); not part of the supported public
714
778
  * surface. Public rather than `private` because mixins compose into an
715
779
  * exported anonymous class, where TypeScript cannot represent a private
716
780
  * member in the emitted declaration file (TS4094).
717
- */
718
- _invalidateAccountLists(): void;
719
- /**
720
- * Bust the cached member list and detail for an account after a membership
721
- * mutation. The member list (`listAccountMembers`) and the detail
722
- * (`getAccount`, which can embed the caller's membership) both go stale when
723
- * the member set or a member's role changes.
724
781
  *
725
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
726
- * 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.
727
784
  */
728
785
  _invalidateAccountMembership(accountId: string): void;
729
786
  /**
@@ -735,8 +792,8 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
735
792
  * query-string list variants, never the `GET:/applications/<id>…`
736
793
  * detail/sub-resource keys.
737
794
  *
738
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
739
- * this is public rather than `private`.
795
+ * Internal helper (leading underscore); see `_invalidateAccountMembership`
796
+ * for why this is public rather than `private`.
740
797
  */
741
798
  _invalidateAppLists(): void;
742
799
  httpService: import("../HttpService").HttpService;
@@ -0,0 +1,211 @@
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
+ mode: "enabled" | "disabled";
82
+ }>;
83
+ /**
84
+ * Drop the override so this application follows the global relationship
85
+ * again. Distinct from setting `enabled`: inheriting means a later global
86
+ * change takes effect here, and an explicit `enabled` means it does not.
87
+ */
88
+ restoreFollowInheritance(relationshipId: string, applicationId?: string): Promise<{
89
+ ok: true;
90
+ }>;
91
+ /**
92
+ * Resolve a target by canonical URI, registering it the first time anyone
93
+ * asks. The call an application makes on the way into a screen, before it
94
+ * can render a button.
95
+ *
96
+ * Idempotent on the URI, which is what makes two applications describing
97
+ * the same thing — the same fediverse actor, the same topic — arrive at ONE
98
+ * row, and therefore at one relationship per user rather than one per app.
99
+ *
100
+ * `metadata` is a display snapshot (name, handle, icon) and is refreshed
101
+ * only for the application that provides the target: a second application
102
+ * passing its own idea of the name would make the display flip depending on
103
+ * which app last looked.
104
+ */
105
+ ensureFollowTarget(input: {
106
+ uri: string;
107
+ kind: string;
108
+ metadata?: Record<string, unknown>;
109
+ providerReference?: string;
110
+ localUserId?: string;
111
+ }): Promise<{
112
+ id: string;
113
+ uri: string;
114
+ kind: string;
115
+ created: boolean;
116
+ }>;
117
+ /**
118
+ * Claim a namespace for the calling application. First come, and idempotent
119
+ * for the holder — an application that registers on every boot must not
120
+ * fail the second time.
121
+ */
122
+ claimFollowNamespace(namespace: string): Promise<{
123
+ namespace: string;
124
+ created: boolean;
125
+ }>;
126
+ /**
127
+ * Declare what following a kind of thing MEANS: the verb clients render,
128
+ * whether reverse lookups are public, whether it federates.
129
+ *
130
+ * Declared once by the application that owns the concept, rather than
131
+ * passed per call site — otherwise two screens of one app can disagree
132
+ * about whether a store is followed or subscribed to.
133
+ */
134
+ registerFollowKind(input: {
135
+ kind: string;
136
+ label?: string;
137
+ capabilities?: {
138
+ verb?: "follow" | "subscribe" | "join";
139
+ reverse?: "public" | "private" | "aggregate" | "unavailable";
140
+ federated?: boolean;
141
+ };
142
+ }): Promise<{
143
+ kind: string;
144
+ created: boolean;
145
+ }>;
146
+ /**
147
+ * Everything the signed-in user follows, newest first.
148
+ *
149
+ * Owner-only by construction server-side — there is no parameter naming a
150
+ * user, so this cannot be pointed at somebody else's graph.
151
+ *
152
+ * Paginate by passing back `nextCursor`, never an offset: the list changes
153
+ * while it is being read, and an offset silently skips or repeats rows
154
+ * exactly when it does.
155
+ */
156
+ listFollows(params?: {
157
+ kind?: string;
158
+ cursor?: string;
159
+ limit?: number;
160
+ }): Promise<FollowListPage>;
161
+ httpService: import("../HttpService").HttpService;
162
+ cloudURL: string;
163
+ config: import("../OxyServices.base").OxyConfig;
164
+ __resetTokensForTests(): void;
165
+ makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
166
+ getBaseURL(): string;
167
+ getClient(): import("../HttpService").HttpService;
168
+ createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
169
+ getMetrics(): {
170
+ totalRequests: number;
171
+ successfulRequests: number;
172
+ failedRequests: number;
173
+ cacheHits: number;
174
+ cacheMisses: number;
175
+ averageResponseTime: number;
176
+ };
177
+ clearCache(): void;
178
+ clearCacheEntry(key: string): void;
179
+ clearCacheByPrefix(prefix: string): number;
180
+ getCacheStats(): {
181
+ size: number;
182
+ hits: number;
183
+ misses: number;
184
+ hitRate: number;
185
+ };
186
+ getCloudURL(): string;
187
+ setTokens(accessToken: string): void;
188
+ clearTokens(): void;
189
+ onTokensChanged(listener: (accessToken: string | null) => void): () => void;
190
+ _cachedUserId: string | null | undefined;
191
+ _cachedAccessToken: string | null;
192
+ getCurrentUserId(): string | null;
193
+ hasValidToken(): boolean;
194
+ getAccessToken(): string | null;
195
+ getAccessTokenExpiry(): number | null;
196
+ waitForAuth(timeoutMs?: number): Promise<boolean>;
197
+ withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
198
+ maxRetries?: number;
199
+ retryDelay?: number;
200
+ authTimeoutMs?: number;
201
+ }): Promise<T_1>;
202
+ validate(): Promise<boolean>;
203
+ handleError(error: unknown): Error;
204
+ healthCheck(): Promise<{
205
+ status: string;
206
+ users?: number;
207
+ timestamp?: string;
208
+ [key: string]: any;
209
+ }>;
210
+ };
211
+ } & T;
@@ -254,13 +254,16 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
254
254
  /**
255
255
  * Update user profile.
256
256
  *
257
- * Invalidates the SDK-side response cache for every endpoint that
258
- * returns the current user (`GET /users/me`, `GET /session/user/*`,
259
- * `GET /users/<id>`, `GET /profiles/username/*`) so the next read
260
- * doesn't return a stale snapshot. Without this, a follow-up
261
- * `getUserBySession` call inside the 2-minute cache window can return
262
- * the pre-update user most visibly during onboarding, where it
263
- * causes the username step to flicker back as if nothing was saved.
257
+ * Invalidates the SDK-side response cache for every endpoint that can
258
+ * return this user — the list is owned by {@link evictOxyIdentityCache}, so
259
+ * a new identity read is added in one place instead of to each writer
260
+ * separately (this method's own hand-written copy had already drifted from
261
+ * the server-side one, missing `GET /auth/lookup/*` and
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.
264
267
  *
265
268
  * TanStack Query handles offline queuing automatically.
266
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
@@ -1,4 +1,4 @@
1
- import type { AccountKind, OrganizationCategory, UserNameResponse, UserRelationship, ThemePreference } from '@oxyhq/contracts';
1
+ import type { AccountKind, AccountCategoryId, UserNameResponse, UserRelationship, ThemePreference } from '@oxyhq/contracts';
2
2
  export interface OxyConfig {
3
3
  baseURL: string;
4
4
  cloudURL?: string;
@@ -151,8 +151,16 @@ export interface User {
151
151
  };
152
152
  isManagedAccount?: boolean;
153
153
  managedBy?: string;
154
- /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
155
- organizationCategory?: OrganizationCategory;
154
+ /**
155
+ * What this account is about, for any NON-personal account. ORDERED — the
156
+ * first element is the primary category, and nothing may reorder it.
157
+ *
158
+ * Stable ids, not labels: render each through the
159
+ * `accounts.accountCategory.<id>` translation key so the reader sees their own
160
+ * language rather than the language of whoever chose it. Absent when the
161
+ * account has none.
162
+ */
163
+ accountCategories?: AccountCategoryId[];
156
164
  /**
157
165
  * The account's languages as full BCP-47 locales (`language-REGION`, e.g.
158
166
  * `en-US`, `es-MX`, `pt-BR`), ordered with the PRIMARY (UI) locale first.
@@ -25,7 +25,9 @@ export type { OxyCorsOptions } from './cors';
25
25
  export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders';
26
26
  export type { OxyCspDirective, OxyCspExtensions, OxyPagesHeadersOptions, OxySecurityHeadersOptions, } from './securityHeaders';
27
27
  export { verifySecret } from './verifySecret';
28
- export { createOxyUserInvalidationHandler, evictOxyIdentityCache, publishOxyUserInvalidation, } from './userInvalidation';
29
- export type { OxyIdentityCacheEvictor, OxyInvalidationPublisher, OxyUserInvalidationHandlerOptions, } from './userInvalidation';
28
+ export { createOxyUserInvalidationHandler, publishOxyUserInvalidation, } from './userInvalidation';
29
+ export type { OxyInvalidationPublisher, OxyUserInvalidationHandlerOptions, } from './userInvalidation';
30
+ export { evictOxyIdentityCache, oxyUserByIdCacheKey, OXY_IDENTITY_CACHE_PREFIXES } from '../utils/identityCacheSweep';
31
+ export type { OxyIdentityCacheEvictor } from '../utils/identityCacheSweep';
30
32
  export { registrableApex } from '../utils/registrableApex';
31
33
  export { isOfficialWebOrigin } from '../utils/officialOrigins';
@@ -6,10 +6,10 @@
6
6
  * Every Oxy backend caches Oxy identity, and none of them find out when it
7
7
  * changes. The `OxyServices` GET response cache holds `GET /users/:id` and
8
8
  * `GET /profiles/username/:name` for five minutes; it is swept when THIS process
9
- * writes the profile (see the `clearCacheEntry` calls in the user mixin) and
10
- * never when somebody else does — which is the normal case, since profiles are
11
- * edited in Oxy's own apps. So an avatar or display-name change is invisible to
12
- * every consuming backend for up to five minutes, per process.
9
+ * writes the profile (the `evictOxyIdentityCache` calls in the user and accounts
10
+ * mixins) and never when somebody else does — which is the normal case, since
11
+ * profiles are edited in Oxy's own apps. So an avatar or display-name change is
12
+ * invisible to every consuming backend for up to five minutes, per process.
13
13
  *
14
14
  * oxy-api broadcasts {@link OXY_USER_INVALIDATION_CHANNEL} on the shared Valkey
15
15
  * when a user's identity changes. This module is the consumer half: it parses
@@ -51,6 +51,7 @@
51
51
  * Node-only; exported solely from `@oxyhq/core/server`.
52
52
  */
53
53
  import { type OxyUserChangeReason, type OxyUserInvalidationEvent } from '@oxyhq/contracts';
54
+ import { type OxyIdentityCacheEvictor } from '../utils/identityCacheSweep';
54
55
  /**
55
56
  * The publish surface of a Redis client. Both `ioredis` and `node-redis`
56
57
  * satisfy this structurally, so neither library is a dependency here.
@@ -58,14 +59,6 @@ import { type OxyUserChangeReason, type OxyUserInvalidationEvent } from '@oxyhq/
58
59
  export interface OxyInvalidationPublisher {
59
60
  publish(channel: string, message: string): unknown;
60
61
  }
61
- /**
62
- * The cache-eviction surface of an {@link OxyServices} instance. Declared
63
- * structurally so this Node-only module does not pull in the client.
64
- */
65
- export interface OxyIdentityCacheEvictor {
66
- clearCacheEntry(key: string): void;
67
- clearCacheByPrefix(prefix: string): number;
68
- }
69
62
  /**
70
63
  * Broadcast that an Oxy user's record changed.
71
64
  *
@@ -119,15 +112,3 @@ export interface OxyUserInvalidationHandlerOptions {
119
112
  * fixes.
120
113
  */
121
114
  export declare function createOxyUserInvalidationHandler(options?: OxyUserInvalidationHandlerOptions): (raw: string) => void;
122
- /**
123
- * Sweep an `OxyServices` GET response cache of everything that could carry the
124
- * given user's identity.
125
- *
126
- * The by-id entry is exact. The by-username and resolve entries are keyed by
127
- * HANDLE, which cannot be derived from an id without the very lookup we are
128
- * invalidating, so those are swept by prefix — the same imprecision the SDK
129
- * already accepts when it sweeps its own cache after a local profile write, and
130
- * bounded by the fact that over-eviction costs a refetch and can never serve
131
- * wrong data.
132
- */
133
- export declare function evictOxyIdentityCache(oxy: OxyIdentityCacheEvictor, userId: string): void;