@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
@@ -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"
@@ -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/dist/cjs/index.js CHANGED
@@ -21,8 +21,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.AEAD_KEY_LENGTH = exports.decryptAead = exports.encryptAead = exports.hkdfSha256 = exports.RecoveryPhraseService = exports.SignatureService = exports.updateIdentityMarker = exports.readIdentityMarker = exports.IdentityUnavailableError = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = exports.SELECTABLE_ACCOUNT_CATEGORY_IDS = exports.MAX_ACCOUNT_CATEGORIES = exports.ACCOUNT_CATEGORY_IDS = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.commonsDeliveryPlatform = exports.pushTargetsFromDelivery = exports.selectCommonsDelivery = exports.parseCommonsApprovalExpiresAt = exports.getCommonsApprovalBlockingReason = exports.ServiceCredentialMismatchError = exports.oxyClient = exports.OXY_CLOUD_URL = exports.ServiceAssetMetadataError = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.AssetUrlResolutionError = exports.OxyServices = void 0;
22
22
  exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isWebBrowser = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.getPrimaryLanguage = exports.getUserLanguages = exports.isRTLLocale = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.isSupportedLocale = exports.normalizeLocale = exports.getBaseLanguage = exports.FALLBACK_LOCALE = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.deriveSharedSecret = exports.AEAD_NONCE_LENGTH = void 0;
23
23
  exports.resetLoggerConfig = exports.getLoggerConfig = exports.configureLogger = exports.createLogger = exports.logger = exports.normalizeMultilineText = exports.normalizeInlineText = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE = exports.DISPLAY_NAME_ORPHANED_MARK_SOURCE = exports.DISPLAY_NAME_DISALLOWED_SOURCE = exports.DISPLAY_NAME_ALLOWED_SCRIPTS = exports.isValidDisplayName = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.DISPLAY_NAME_INVALID_MESSAGE = exports.MAX_DISPLAY_NAME_LENGTH = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.buildQueryParams = exports.translate = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = void 0;
24
- exports.createNativeIdentityPinStore = exports.createWebIdentityPinStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = void 0;
25
- exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AccountNotOnDeviceError = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = void 0;
24
+ exports.createWebIdentityPinStore = exports.AUTH_STATE_STORAGE_KEY = exports.createMemoryAuthStateStore = exports.createNativeAuthStateStore = exports.createWebAuthStateStore = exports.createAccountDialogController = exports.AccountDialogController = exports.switchableAccountIds = exports.projectSwitchableAccounts = exports.isSwitchTargetAccount = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.consumeOAuthReturnPath = exports.persistOAuthReturnPath = exports.clearOAuthHandshake = exports.readOAuthHandshake = exports.persistOAuthHandshake = exports.canonicalizeOAuthRedirectUri = exports.normalizeOAuthRedirectUri = exports.OXY_OAUTH_RETURN_PATH_STORAGE_KEY = exports.OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = exports.OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = exports.OXY_OAUTH_STATE_STORAGE_KEY = exports.OXY_AUTHORIZE_URL = exports.DEFAULT_OAUTH_SCOPE = exports.generatePkcePair = exports.generateOAuthState = exports.computeCodeChallenge = exports.buildOAuthAuthorizeUrl = exports.runColdBoot = exports.isOxyRpOrigin = exports.CENTRAL_IDP_APEX = exports.registrableApex = exports.getAccountColor = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.isDev = exports.consoleSink = void 0;
25
+ exports.packageInfo = exports.runSessionColdBoot = exports.TOKEN_REFRESH_LEAD_MS = exports.startTokenRefreshScheduler = exports.installAuthRefreshHandler = exports.createAuthRefreshHandler = exports.refreshDeviceSecretArm = exports.refreshPersistedSession = exports.AccountNotOnDeviceError = exports.establishIdentitySession = exports.resolveIdentityPin = exports.IDENTITY_PIN_STORAGE_KEY = exports.identityPinMatches = exports.createMemoryIdentityPinStore = exports.createNativeIdentityPinStore = void 0;
26
26
  // Ensure crypto polyfills are loaded before anything else
27
27
  require("./crypto/polyfill");
28
28
  // ---------------------------------------------------------------------------
@@ -370,7 +370,14 @@ Object.defineProperty(exports, "accountIdsOf", { enumerable: true, get: function
370
370
  // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
371
371
  // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
372
372
  // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
373
+ // `isSwitchTargetAccount` is the switcher's own question ("can I become this
374
+ // account?"), exported so a surface that renders `AccountNode`s rather than the
375
+ // projection — the Console's workspace switcher, the accounts app's
376
+ // managed-accounts rows — asks the SAME question instead of testing a kind
377
+ // literal. It is NOT `isActAsEligibleKind`: that one is false for `personal`
378
+ // too, so gating a switcher on it alone empties the list.
373
379
  var accountProjection_1 = require("./session/accountProjection");
380
+ Object.defineProperty(exports, "isSwitchTargetAccount", { enumerable: true, get: function () { return accountProjection_1.isSwitchTargetAccount; } });
374
381
  Object.defineProperty(exports, "projectSwitchableAccounts", { enumerable: true, get: function () { return accountProjection_1.projectSwitchableAccounts; } });
375
382
  Object.defineProperty(exports, "switchableAccountIds", { enumerable: true, get: function () { return accountProjection_1.switchableAccountIds; } });
376
383
  // Headless controller for the unified account dialog. Framework-agnostic
@@ -4,6 +4,7 @@ exports.kindAcceptsAccountCategories = exports.isSelectableAccountCategoryId = e
4
4
  exports.OxyServicesAccountsMixin = OxyServicesAccountsMixin;
5
5
  const userIdentity_1 = require("../utils/userIdentity");
6
6
  const identityCacheSweep_1 = require("../utils/identityCacheSweep");
7
+ const accountCacheSweep_1 = require("../utils/accountCacheSweep");
7
8
  const mixinHelpers_1 = require("./mixinHelpers");
8
9
  var contracts_1 = require("@oxyhq/contracts");
9
10
  Object.defineProperty(exports, "ACCOUNT_CATEGORY_IDS", { enumerable: true, get: function () { return contracts_1.ACCOUNT_CATEGORY_IDS; } });
@@ -124,7 +125,7 @@ function OxyServicesAccountsMixin(Base) {
124
125
  const res = await this.makeRequest('POST', '/accounts', data, { cache: false });
125
126
  // A new account changes the accessible forest — bust every cached list
126
127
  // (flat + tree) so it appears on the next `listAccounts()` read.
127
- this._invalidateAccountLists();
128
+ (0, accountCacheSweep_1.evictOxyAccountForestCache)(this);
128
129
  return res.account;
129
130
  }
130
131
  catch (error) {
@@ -190,8 +191,7 @@ function OxyServicesAccountsMixin(Base) {
190
191
  const res = await this.makeRequest('PATCH', `/accounts/${encodeURIComponent(accountId)}`, data, { cache: false });
191
192
  // Bust the cached detail and every list (which embeds account profile
192
193
  // data) so neither serves the pre-update snapshot.
193
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
194
- this._invalidateAccountLists();
194
+ (0, accountCacheSweep_1.evictOxyAccountForestCache)(this, accountId);
195
195
  // The parent's children list embeds this account's profile and is keyed
196
196
  // by the PARENT id, so it is reachable only from the response node.
197
197
  const parentAccountId = res.account?.parentAccountId;
@@ -216,10 +216,9 @@ function OxyServicesAccountsMixin(Base) {
216
216
  try {
217
217
  const result = await this.makeRequest('DELETE', `/accounts/${encodeURIComponent(accountId)}`, undefined, { cache: false });
218
218
  // Bust every cached representation of the archived account.
219
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
220
219
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
221
220
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
222
- this._invalidateAccountLists();
221
+ (0, accountCacheSweep_1.evictOxyAccountForestCache)(this, accountId);
223
222
  return result;
224
223
  }
225
224
  catch (error) {
@@ -243,7 +242,27 @@ function OxyServicesAccountsMixin(Base) {
243
242
  // Account members
244
243
  // =========================================================================
245
244
  /**
246
- * List members of an account (direct membership rows on the account).
245
+ * List the members of an account: the membership rows ON it, plus the rows
246
+ * on its ancestors that cascade into it. Each entry carries `source`
247
+ * (`direct` | `inherited`) saying which it is.
248
+ *
249
+ * Inherited entries are members in every sense the server enforces — an
250
+ * ancestor row with `inherit: true` resolves through
251
+ * `resolveEffectiveAccess` and confers every account permission on the
252
+ * descendant, `account:act_as` included — so a roster that omitted them
253
+ * answered `[]` for accounts several people could act on.
254
+ *
255
+ * Two things follow for a caller. An entry's `accountId` is the account its
256
+ * ROW lives on, so an inherited entry names an ancestor rather than the
257
+ * account you asked about; and the member-mutation endpoints only accept
258
+ * rows on the account in the path, so gate any edit/remove/transfer
259
+ * affordance on `source === 'direct'`.
260
+ *
261
+ * Asking what the CALLER holds over an account is a different question, and
262
+ * scanning this list for yourself is the wrong way to answer it — use
263
+ * {@link OxyServicesAccountsMixin.getAccount}, whose `callerMembership` is
264
+ * the server's own resolution.
265
+ *
247
266
  * @param accountId - The account's Mongo `_id`.
248
267
  */
249
268
  async listAccountMembers(accountId) {
@@ -314,7 +333,7 @@ function OxyServicesAccountsMixin(Base) {
314
333
  // Ownership change alters roles in the member list AND the detail, and
315
334
  // can change which accounts the caller "owns" in the list view.
316
335
  this._invalidateAccountMembership(accountId);
317
- this._invalidateAccountLists();
336
+ (0, accountCacheSweep_1.evictOxyAccountForestCache)(this);
318
337
  return result;
319
338
  }
320
339
  catch (error) {
@@ -558,35 +577,29 @@ function OxyServicesAccountsMixin(Base) {
558
577
  // Cache-invalidation helpers
559
578
  // =========================================================================
560
579
  /**
561
- * Bust every cached account list. `listAccounts({tree?})` keys the flat list
562
- * as `GET:/accounts` and the tree variant as `GET:/accounts?tree=true` (the
563
- * query string is part of the URL path). A change to the accessible forest
564
- * (create/archive/ownership transfer) invalidates both, so we clear the
565
- * unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
566
- * `GET:/accounts?` matches only the query-string list variants, never the
567
- * `GET:/accounts/<id>…` detail/sub-resource keys.
580
+ * Bust the cached member list and detail for an account after a membership
581
+ * mutation. The member list (`listAccountMembers`) and the detail
582
+ * (`getAccount`, which can embed the caller's membership) both go stale when
583
+ * the member set or a member's role changes.
568
584
  *
569
585
  * Internal helper (leading underscore); not part of the supported public
570
586
  * surface. Public rather than `private` because mixins compose into an
571
587
  * exported anonymous class, where TypeScript cannot represent a private
572
588
  * member in the emitted declaration file (TS4094).
573
- */
574
- _invalidateAccountLists() {
575
- this.clearCacheEntry('GET:/accounts');
576
- this.clearCacheByPrefix('GET:/accounts?');
577
- }
578
- /**
579
- * Bust the cached member list and detail for an account after a membership
580
- * mutation. The member list (`listAccountMembers`) and the detail
581
- * (`getAccount`, which can embed the caller's membership) both go stale when
582
- * the member set or a member's role changes.
583
589
  *
584
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
585
- * this is public rather than `private`.
590
+ * The forest keys themselves are NOT owned here — see
591
+ * `utils/accountCacheSweep`, which the user mixin has to reach as well.
586
592
  */
587
593
  _invalidateAccountMembership(accountId) {
588
594
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
589
- this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
595
+ this.clearCacheEntry((0, accountCacheSweep_1.oxyAccountDetailCacheKey)(accountId));
596
+ // Inherited rows on descendant rosters are derived from this account's
597
+ // membership table — a targeted clear of only the mutated account's keys
598
+ // leaves every other cached `…/members` list serving stale inherited
599
+ // roles until MEDIUM TTL. Sweep all per-account sub-resource keys instead;
600
+ // the forest list keys (`GET:/accounts`, `GET:/accounts?…`) are excluded
601
+ // by the trailing slash on the prefix (see accountCacheSweep).
602
+ this.clearCacheByPrefix(accountCacheSweep_1.OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX);
590
603
  }
591
604
  /**
592
605
  * Bust every cached application list. `listAccountApps(accountId)` keys each
@@ -597,8 +610,8 @@ function OxyServicesAccountsMixin(Base) {
597
610
  * query-string list variants, never the `GET:/applications/<id>…`
598
611
  * detail/sub-resource keys.
599
612
  *
600
- * Internal helper (leading underscore); see `_invalidateAccountLists` for why
601
- * this is public rather than `private`.
613
+ * Internal helper (leading underscore); see `_invalidateAccountMembership`
614
+ * for why this is public rather than `private`.
602
615
  */
603
616
  _invalidateAppLists() {
604
617
  this.clearCacheEntry('GET:/applications');
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ /**
3
+ * Follow Graph Mixin (`/v2/follows`)
4
+ *
5
+ * The user-owned follow graph: one relationship per user and target, shared by
6
+ * every application, with per-application context on top. This is the SDK half
7
+ * of #809 and the replacement for the per-app follow endpoints each application
8
+ * grew for itself.
9
+ *
10
+ * ## Why this is not `followUser` with more parameters
11
+ *
12
+ * `followUser` answers "does A follow B" and nothing else. This answers "what
13
+ * does this user follow, anywhere, and which applications act on it" — a
14
+ * different question with a different owner. The legacy methods stay for the
15
+ * Mongo-backed social graph they were written for; new kinds (topics, stores,
16
+ * artists, channels) come here, and users will migrate behind an adapter rather
17
+ * than through a flag day.
18
+ *
19
+ * ## Caching
20
+ *
21
+ * Every method is `cache: false`. A follow status is exactly the shape that
22
+ * must never be served stale: the SDK's GET cache is identity-scoped but
23
+ * time-based, and a status cached across a write is the "follow reverts after
24
+ * navigating away and back" bug — which the legacy `followUser` had to fix with
25
+ * explicit invalidation. Not caching at this layer means an app's own store
26
+ * (React Query, Zustand) is the single cache authority, which is the rule the
27
+ * ecosystem already follows for anything written and read in the same session.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.OxyServicesFollowGraphMixin = OxyServicesFollowGraphMixin;
31
+ const apiUtils_1 = require("../utils/apiUtils");
32
+ function OxyServicesFollowGraphMixin(Base) {
33
+ return class extends Base {
34
+ constructor(...args) {
35
+ super(...args);
36
+ }
37
+ /**
38
+ * Follow a target. Idempotent — following something already followed
39
+ * returns the same relationship with `created: false`.
40
+ *
41
+ * The follower and the acting application are BOTH derived server-side from
42
+ * the session. There is deliberately no parameter for either: a client that
43
+ * could name them could forge a follow on another user's behalf, or record
44
+ * one as coming from an application it is not.
45
+ *
46
+ * @param targetId - The registered target's id, not its URI. Registration is
47
+ * a separate operation precisely so following cannot silently create
48
+ * targets — a typo would otherwise become a permanent row nobody follows.
49
+ * @param options.expiresIn - Seconds until the follow lapses on its own. For
50
+ * an event, a trial, a topic followed for a week. The server bounds it.
51
+ */
52
+ async followTarget(targetId, options) {
53
+ try {
54
+ return await this.makeRequest('PUT', `/v2/follows/${encodeURIComponent(targetId)}`, options?.expiresIn !== undefined ? { expiresIn: options.expiresIn } : {}, { cache: false });
55
+ }
56
+ catch (error) {
57
+ throw this.handleError(error);
58
+ }
59
+ }
60
+ /**
61
+ * Unfollow everywhere.
62
+ *
63
+ * There is no "unfollow here" — that is `setFollowApplicationMode(...,
64
+ * 'disabled')`, and keeping the two distinct is the point of the design. An
65
+ * application that quietly turned a global unfollow into a local one would
66
+ * leave the user believing they had stopped following something they still
67
+ * follow everywhere else.
68
+ *
69
+ * Idempotent: `removed: false` when it was already gone, because the state
70
+ * the caller asked for is the state that holds.
71
+ */
72
+ async unfollowTarget(relationshipId) {
73
+ try {
74
+ return await this.makeRequest('DELETE', `/v2/follows/${encodeURIComponent(relationshipId)}`, undefined, { cache: false });
75
+ }
76
+ catch (error) {
77
+ throw this.handleError(error);
78
+ }
79
+ }
80
+ /**
81
+ * The three-part status: globally, in this application, and in effect.
82
+ *
83
+ * Render `effectiveState` on the button and keep the other two for the
84
+ * explanation. A UI that collapses them cannot tell the user why a follow
85
+ * they can see in their list is not showing up in this app's feed.
86
+ */
87
+ async getFollowTargetStatus(targetId) {
88
+ try {
89
+ return await this.makeRequest('GET', `/v2/follows/${encodeURIComponent(targetId)}/status`, undefined, { cache: false });
90
+ }
91
+ catch (error) {
92
+ throw this.handleError(error);
93
+ }
94
+ }
95
+ /**
96
+ * Turn a relationship off, or back on, in ONE application.
97
+ *
98
+ * Omit `applicationId` and it applies to the calling application, which is
99
+ * the only form an ordinary app should ever need. Naming a DIFFERENT
100
+ * application requires `follows:manage` server-side — acting on another
101
+ * app's behalf is exactly the cross-application authority this design
102
+ * otherwise refuses, so it is a distinct permission and not a parameter an
103
+ * app happens to fill in.
104
+ */
105
+ async setFollowApplicationMode(relationshipId, mode, applicationId) {
106
+ try {
107
+ return await this.makeRequest('PUT', `/v2/follows/${encodeURIComponent(relationshipId)}/context`, { mode, ...(applicationId ? { applicationId } : {}) }, { cache: false });
108
+ }
109
+ catch (error) {
110
+ throw this.handleError(error);
111
+ }
112
+ }
113
+ /**
114
+ * Drop the override so this application follows the global relationship
115
+ * again. Distinct from setting `enabled`: inheriting means a later global
116
+ * change takes effect here, and an explicit `enabled` means it does not.
117
+ */
118
+ async restoreFollowInheritance(relationshipId, applicationId) {
119
+ try {
120
+ const path = (0, apiUtils_1.buildUrl)(`/v2/follows/${encodeURIComponent(relationshipId)}/context`, applicationId ? { applicationId } : {});
121
+ return await this.makeRequest('DELETE', path, undefined, { cache: false });
122
+ }
123
+ catch (error) {
124
+ throw this.handleError(error);
125
+ }
126
+ }
127
+ /**
128
+ * Resolve a target by canonical URI, registering it the first time anyone
129
+ * asks. The call an application makes on the way into a screen, before it
130
+ * can render a button.
131
+ *
132
+ * Idempotent on the URI, which is what makes two applications describing
133
+ * the same thing — the same fediverse actor, the same topic — arrive at ONE
134
+ * row, and therefore at one relationship per user rather than one per app.
135
+ *
136
+ * `metadata` is a display snapshot (name, handle, icon) and is refreshed
137
+ * only for the application that provides the target: a second application
138
+ * passing its own idea of the name would make the display flip depending on
139
+ * which app last looked.
140
+ */
141
+ async ensureFollowTarget(input) {
142
+ try {
143
+ return await this.makeRequest('POST', '/v2/follow-targets', input, { cache: false });
144
+ }
145
+ catch (error) {
146
+ throw this.handleError(error);
147
+ }
148
+ }
149
+ /**
150
+ * Claim a namespace for the calling application. First come, and idempotent
151
+ * for the holder — an application that registers on every boot must not
152
+ * fail the second time.
153
+ */
154
+ async claimFollowNamespace(namespace) {
155
+ try {
156
+ return await this.makeRequest('POST', '/v2/follow-targets/namespaces', { namespace }, { cache: false });
157
+ }
158
+ catch (error) {
159
+ throw this.handleError(error);
160
+ }
161
+ }
162
+ /**
163
+ * Declare what following a kind of thing MEANS: the verb clients render,
164
+ * whether reverse lookups are public, whether it federates.
165
+ *
166
+ * Declared once by the application that owns the concept, rather than
167
+ * passed per call site — otherwise two screens of one app can disagree
168
+ * about whether a store is followed or subscribed to.
169
+ */
170
+ async registerFollowKind(input) {
171
+ try {
172
+ return await this.makeRequest('POST', '/v2/follow-targets/kinds', input, {
173
+ cache: false,
174
+ });
175
+ }
176
+ catch (error) {
177
+ throw this.handleError(error);
178
+ }
179
+ }
180
+ /**
181
+ * Everything the signed-in user follows, newest first.
182
+ *
183
+ * Owner-only by construction server-side — there is no parameter naming a
184
+ * user, so this cannot be pointed at somebody else's graph.
185
+ *
186
+ * Paginate by passing back `nextCursor`, never an offset: the list changes
187
+ * while it is being read, and an offset silently skips or repeats rows
188
+ * exactly when it does.
189
+ */
190
+ async listFollows(params) {
191
+ try {
192
+ const path = (0, apiUtils_1.buildUrl)('/v2/me/follows', {
193
+ ...(params?.kind ? { kind: params.kind } : {}),
194
+ ...(params?.cursor ? { cursor: params.cursor } : {}),
195
+ ...(params?.limit ? { limit: params.limit } : {}),
196
+ });
197
+ return await this.makeRequest('GET', path, undefined, { cache: false });
198
+ }
199
+ catch (error) {
200
+ throw this.handleError(error);
201
+ }
202
+ }
203
+ };
204
+ }
@@ -7,6 +7,7 @@ const keyManager_1 = require("../crypto/keyManager");
7
7
  const signatureService_1 = require("../crypto/signatureService");
8
8
  const userIdentity_1 = require("../utils/userIdentity");
9
9
  const identityCacheSweep_1 = require("../utils/identityCacheSweep");
10
+ const accountCacheSweep_1 = require("../utils/accountCacheSweep");
10
11
  const logger_1 = require("../logger");
11
12
  const errorUtils_1 = require("../utils/errorUtils");
12
13
  /**
@@ -353,10 +354,11 @@ function OxyServicesUserMixin(Base) {
353
354
  * a new identity read is added in one place instead of to each writer
354
355
  * separately (this method's own hand-written copy had already drifted from
355
356
  * the server-side one, missing `GET /auth/lookup/*` and
356
- * `GET /profiles/resolve`). Without the sweep a follow-up
357
- * `getUserBySession` inside the cache window returns the pre-update user
358
- * most visibly during onboarding, where the username step flickers back as
359
- * if nothing was saved.
357
+ * `GET /profiles/resolve`). The account forest (`GET /accounts` and the
358
+ * caller's own detail row) is swept too a personal account IS this user,
359
+ * and `AccountNode.account` embeds the whole profile from the list that
360
+ * {@link evictOxyAccountForestCache} owns, for the same reason: the accounts
361
+ * mixin writes those keys as well, and two hand-written copies drift.
360
362
  *
361
363
  * TanStack Query handles offline queuing automatically.
362
364
  */
@@ -364,6 +366,7 @@ function OxyServicesUserMixin(Base) {
364
366
  try {
365
367
  const result = (0, userIdentity_1.normalizeUserIdentity)(await this.makeRequest('PUT', '/users/me', updates, { cache: false }));
366
368
  (0, identityCacheSweep_1.evictOxyIdentityCache)(this, result?.id);
369
+ (0, accountCacheSweep_1.evictOxyAccountForestCache)(this, result?.id);
367
370
  return result;
368
371
  }
369
372
  catch (error) {
@@ -33,6 +33,7 @@ const OxyServices_appData_1 = require("./OxyServices.appData");
33
33
  const OxyServices_civic_1 = require("./OxyServices.civic");
34
34
  const OxyServices_nodes_1 = require("./OxyServices.nodes");
35
35
  const OxyServices_links_1 = require("./OxyServices.links");
36
+ const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
36
37
  const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
37
38
  const OxyServices_deviceTransfer_1 = require("./OxyServices.deviceTransfer");
38
39
  /**
@@ -89,6 +90,9 @@ const MIXIN_PIPELINE = [
89
90
  // Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
90
91
  // so apps stop scraping link metadata locally.
91
92
  OxyServices_links_1.OxyServicesLinksMixin,
93
+ // The user-owned follow graph (#809). One relationship per user and target,
94
+ // shared across applications, with per-application context on top.
95
+ OxyServices_followGraph_1.OxyServicesFollowGraphMixin,
92
96
  // Device-first token mint: the client half of the zero-cookie transport
93
97
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
94
98
  OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
@@ -18,11 +18,41 @@
18
18
  * atomic, so no cross-call current-row reconciliation is needed).
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.isSwitchTargetAccount = isSwitchTargetAccount;
21
22
  exports.projectSwitchableAccounts = projectSwitchableAccounts;
22
23
  exports.switchableAccountIds = switchableAccountIds;
23
24
  const contracts_1 = require("@oxyhq/contracts");
24
25
  const accountUtils_1 = require("../utils/accountUtils");
25
26
  const userHandle_1 = require("../utils/userHandle");
27
+ /**
28
+ * Whether the caller can BECOME this account — the one question every account
29
+ * switcher asks, answered here so no surface has to re-derive it.
30
+ *
31
+ * Two independent grounds, either of which suffices:
32
+ *
33
+ * - **It is already the caller's own identity** (`relationship: 'self'`).
34
+ * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
35
+ * is the HUMAN operator's personal account even while they are operating an
36
+ * org — never the operated account. Kind is irrelevant on this ground: the
37
+ * caller IS that account, so returning to it asks the server for nothing.
38
+ * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
39
+ * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
40
+ * on this ground is never a dead button.
41
+ *
42
+ * `isActAsEligibleKind` ALONE is not this question, and reaching for it
43
+ * directly is the mistake this function exists to prevent: it is false for
44
+ * `personal` as well as `channel`, so a switcher gated on it alone renders an
45
+ * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
46
+ * this question either — it silently admits every kind invented after it was
47
+ * written, which is the same trap `isActAsEligibleKind` was introduced to close
48
+ * on the server.
49
+ *
50
+ * Takes a structural subset rather than a whole {@link AccountNode} so a caller
51
+ * holding a projected {@link SwitchableAccount} can ask it too.
52
+ */
53
+ function isSwitchTargetAccount(node) {
54
+ return node.relationship === 'self' || (0, contracts_1.isActAsEligibleKind)(node.kind);
55
+ }
26
56
  /**
27
57
  * Pure union of device sign-ins and account-graph nodes into the flat
28
58
  * {@link SwitchableAccount}[] every switcher renders.
@@ -32,8 +62,9 @@ const userHandle_1 = require("../utils/userHandle");
32
62
  * and a graph node is deduped into ONE device row enriched with the graph
33
63
  * metadata (relationship / kind / parent / membership).
34
64
  *
35
- * Graph nodes of a kind nobody may act as (`channel`) are omitted see the
36
- * filter below.
65
+ * Graph nodes that are not switch targets a `channel`, which nobody may act
66
+ * as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
67
+ * below.
37
68
  */
38
69
  function projectSwitchableAccounts(input) {
39
70
  const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
@@ -108,10 +139,12 @@ function projectSwitchableAccounts(input) {
108
139
  // construction": the graph contributes accounts that have no device session
109
140
  // and no credentials at all, which is exactly how an org first becomes
110
141
  // switchable. So a kind that must never be switched into has to be filtered
111
- // HERE, and `isActAsEligibleKind` is the same predicate the server enforces
112
- // on `POST /accounts/:id/switch` — offering a row the server would 403 is a
113
- // dead button.
114
- if (!(0, contracts_1.isActAsEligibleKind)(node.kind)) {
142
+ // HERE offering a row the server would 403 is a dead button.
143
+ //
144
+ // An account already on the device skipped this check via the branch above,
145
+ // and correctly: whatever its kind, the caller is signed into it, so
146
+ // switching is a local activation that asks the server for nothing.
147
+ if (!isSwitchTargetAccount(node)) {
115
148
  continue;
116
149
  }
117
150
  remember(toRow(node.account, {
@@ -133,8 +166,11 @@ function projectSwitchableAccounts(input) {
133
166
  * document, but including their ids lets the caller pass one id set and lets the
134
167
  * projection prefer freshly-fetched profiles uniformly.
135
168
  *
136
- * Applies the SAME act-as filter as {@link projectSwitchableAccounts} to graph
137
- * nodes, so this never fetches a profile for a row the projection will drop.
169
+ * Applies the SAME {@link isSwitchTargetAccount} filter as
170
+ * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
171
+ * profile for a row the projection will drop — and, just as importantly, never
172
+ * SKIPS one the projection will keep, which would leave that row unrendered
173
+ * until some later fetch happened to resolve it.
138
174
  */
139
175
  function switchableAccountIds(state, graph) {
140
176
  const ids = new Set();
@@ -144,7 +180,7 @@ function switchableAccountIds(state, graph) {
144
180
  }
145
181
  }
146
182
  for (const node of graph) {
147
- if (node.accountId && (0, contracts_1.isActAsEligibleKind)(node.kind)) {
183
+ if (node.accountId && isSwitchTargetAccount(node)) {
148
184
  ids.add(node.accountId);
149
185
  }
150
186
  }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ /**
3
+ * THE enumeration of `OxyServices` GET-cache keys that serve the ACCOUNT FOREST
4
+ * — the caller's accessible accounts, as lists and as individual detail rows —
5
+ * and the one sweep that clears them.
6
+ *
7
+ * WHY THIS IS NOT A METHOD ON THE ACCOUNTS MIXIN
8
+ * ---------------------------------------------
9
+ * `AccountNode.account` is a whole `User`, so a forest read embeds the very
10
+ * profile the identity reads serve. That makes an IDENTITY write a writer of
11
+ * these keys too: `updateProfile` edits the caller's own personal account,
12
+ * which is a row in `GET /accounts` and is its own `GET /accounts/<id>`. Leave
13
+ * those cached and the account switcher keeps drawing the pre-edit name and
14
+ * picture for the full TTL, against a perfectly healthy server.
15
+ *
16
+ * The mixins compose into one class at runtime but are typed one at a time, so
17
+ * the user mixin cannot call a method the accounts mixin owns. The key list
18
+ * therefore lives here, once, and every writer calls {@link
19
+ * evictOxyAccountForestCache} — exactly like the identity key list in
20
+ * `identityCacheSweep`, which the accounts mixin already calls for the
21
+ * mirror-image case (an account write staling the identity reads). The
22
+ * alternative — a second hand-written copy of these keys in the other mixin —
23
+ * is the drift that shipped the two stale-profile bugs `identityCacheSweep`
24
+ * documents.
25
+ *
26
+ * WHY THE LIST NEEDS A PREFIX AND THE DETAIL DOES NOT
27
+ * --------------------------------------------------
28
+ * `listAccounts({tree?})` keys the flat list as `GET:/accounts` and every
29
+ * option variant as `GET:/accounts?<query>` (the query string is part of the
30
+ * URL, hence of the key), and a writer cannot enumerate which variants a caller
31
+ * has read. The detail key, by contrast, is derivable from the account id the
32
+ * writer already holds.
33
+ *
34
+ * The `GET:/accounts?` prefix matches ONLY the query-string list variants —
35
+ * never `GET:/accounts/<id>` or its `…/members`, `…/credentials`, `…/children`
36
+ * sub-resources, which are the accounts mixin's own business and stay there.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = exports.OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = exports.OXY_ACCOUNT_LIST_CACHE_KEY = void 0;
40
+ exports.oxyAccountDetailCacheKey = oxyAccountDetailCacheKey;
41
+ exports.evictOxyAccountForestCache = evictOxyAccountForestCache;
42
+ /** The cache key `listAccounts()` reads under with no options. */
43
+ exports.OXY_ACCOUNT_LIST_CACHE_KEY = 'GET:/accounts';
44
+ /**
45
+ * The prefix covering every option-carrying `listAccounts(opts)` variant
46
+ * (`?tree=true`, …), none of which a writer can enumerate.
47
+ */
48
+ exports.OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = 'GET:/accounts?';
49
+ /**
50
+ * Prefix covering every per-account sub-resource cache key
51
+ * (`GET:/accounts/<id>`, `…/members`, `…/credentials`, `…/children`). A
52
+ * membership mutation on an ancestor must sweep ALL of these, not only the
53
+ * account named in the path: descendant member rosters embed inherited rows
54
+ * resolved from that ancestor, and the writer cannot enumerate which descendant
55
+ * ids a caller has already read. The trailing slash deliberately excludes the
56
+ * forest list keys (`GET:/accounts`, `GET:/accounts?…`) documented above.
57
+ */
58
+ exports.OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = 'GET:/accounts/';
59
+ /**
60
+ * Build the exact cache key `getAccount(accountId)` reads under.
61
+ */
62
+ function oxyAccountDetailCacheKey(accountId) {
63
+ return `GET:/accounts/${encodeURIComponent(accountId)}`;
64
+ }
65
+ /**
66
+ * Sweep an `OxyServices` GET response cache of the account forest.
67
+ *
68
+ * @param oxy - Anything exposing the SDK's two eviction methods.
69
+ * @param accountId - The account whose detail row to drop as well. Optional: a
70
+ * writer that changed the SHAPE of the forest rather than one
71
+ * account in it (create, archive, ownership transfer) has no
72
+ * detail row to name, and clears only the lists.
73
+ */
74
+ function evictOxyAccountForestCache(oxy, accountId) {
75
+ oxy.clearCacheEntry(exports.OXY_ACCOUNT_LIST_CACHE_KEY);
76
+ oxy.clearCacheByPrefix(exports.OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX);
77
+ if (accountId) {
78
+ oxy.clearCacheEntry(oxyAccountDetailCacheKey(accountId));
79
+ }
80
+ }