@oxyhq/core 18.0.0 → 19.0.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 (43) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +48 -5
  3. package/dist/cjs/i18n/locales/es-ES.json +48 -5
  4. package/dist/cjs/i18n/locales/locales/en-US.json +48 -5
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +48 -5
  6. package/dist/cjs/index.js +10 -6
  7. package/dist/cjs/mixins/OxyServices.accounts.js +27 -2
  8. package/dist/cjs/mixins/OxyServices.user.js +14 -20
  9. package/dist/cjs/server/index.js +8 -2
  10. package/dist/cjs/server/userInvalidation.js +6 -28
  11. package/dist/cjs/utils/identityCacheSweep.js +97 -0
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/i18n/locales/en-US.json +48 -5
  14. package/dist/esm/i18n/locales/es-ES.json +48 -5
  15. package/dist/esm/i18n/locales/locales/en-US.json +48 -5
  16. package/dist/esm/i18n/locales/locales/es-ES.json +48 -5
  17. package/dist/esm/index.js +1 -1
  18. package/dist/esm/mixins/OxyServices.accounts.js +22 -1
  19. package/dist/esm/mixins/OxyServices.user.js +14 -20
  20. package/dist/esm/server/index.js +5 -1
  21. package/dist/esm/server/userInvalidation.js +5 -26
  22. package/dist/esm/utils/identityCacheSweep.js +92 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/index.d.ts +2 -2
  25. package/dist/types/mixins/OxyServices.accounts.d.ts +39 -7
  26. package/dist/types/mixins/OxyServices.user.d.ts +9 -7
  27. package/dist/types/models/interfaces.d.ts +11 -3
  28. package/dist/types/server/index.d.ts +4 -2
  29. package/dist/types/server/userInvalidation.d.ts +5 -24
  30. package/dist/types/utils/identityCacheSweep.d.ts +80 -0
  31. package/package.json +2 -2
  32. package/src/i18n/locales/en-US.json +48 -5
  33. package/src/i18n/locales/es-ES.json +48 -5
  34. package/src/index.ts +8 -2
  35. package/src/mixins/OxyServices.accounts.ts +58 -7
  36. package/src/mixins/OxyServices.user.ts +14 -20
  37. package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +370 -0
  38. package/src/models/interfaces.ts +11 -3
  39. package/src/server/__tests__/userInvalidation.test.ts +3 -20
  40. package/src/server/index.ts +5 -2
  41. package/src/server/userInvalidation.ts +8 -36
  42. package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
  43. 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';
@@ -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.
@@ -168,8 +168,17 @@ export interface CreateAccountInput {
168
168
  };
169
169
  bio?: string;
170
170
  avatar?: string;
171
- /** Meaningful only when `kind` is `organization`. */
172
- organizationCategory?: OrganizationCategory;
171
+ /**
172
+ * What the account is about. ORDERED — the FIRST element is the primary
173
+ * category, so a picker must submit them in the order the user arranged them
174
+ * and must not sort. Stable ids, never labels: render each one through the
175
+ * `accounts.accountCategory.<id>` translation key.
176
+ *
177
+ * Offer `SELECTABLE_ACCOUNT_CATEGORY_IDS`, not `ACCOUNT_CATEGORY_IDS` — the
178
+ * latter still contains withdrawn ids so that accounts already carrying one
179
+ * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
180
+ */
181
+ accountCategories?: AccountCategoryId[];
173
182
  }
174
183
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
175
184
  export interface UpdateAccountInput {
@@ -187,8 +196,19 @@ export interface UpdateAccountInput {
187
196
  };
188
197
  bio?: string | null;
189
198
  avatar?: string | null;
190
- /** Clears the category when `null`; only valid on `kind: 'organization'`. */
191
- organizationCategory?: OrganizationCategory | null;
199
+ /**
200
+ * Replaces the WHOLE list, in the order given — there is no add/remove verb,
201
+ * because a partial edit cannot express a re-ordering and the order is what
202
+ * names the primary category. `[]` clears it.
203
+ *
204
+ * Not nullable, unlike `bio` and `avatar`: the empty case already has a
205
+ * spelling of its own, so a second one could only ever disagree with it.
206
+ *
207
+ * Rejected for a `personal` account, and rejected when it ADDS a withdrawn
208
+ * id the account did not already carry — keeping or re-ordering one it has is
209
+ * always allowed.
210
+ */
211
+ accountCategories?: AccountCategoryId[];
192
212
  }
193
213
  /** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
194
214
  export interface ProvisionChannelInput {
@@ -560,6 +580,18 @@ export declare function OxyServicesAccountsMixin<T extends typeof OxyServicesBas
560
580
  /**
561
581
  * Update an account's mutable profile fields. Tree placement changes
562
582
  * (reparenting) go through the dedicated move endpoint, not here.
583
+ *
584
+ * An account IS a user, so this write changes identity — and a profile
585
+ * screen never reads `/accounts/<id>`. It reads `GET /users/<id>` and
586
+ * `GET /profiles/username/<handle>`, both cached for 5 minutes in the
587
+ * CALLER'S OWN process, so busting only the account-graph keys left every
588
+ * profile surface serving the pre-edit avatar and name for the full TTL
589
+ * with a perfectly healthy server (the cross-service `oxy:user:invalidate`
590
+ * signal does not help: it evicts BACKEND caches, and cannot reach a cache
591
+ * living in a browser tab). {@link evictOxyIdentityCache} owns that key
592
+ * list — see its docs for why the handle-keyed entries are prefix-swept
593
+ * (a RENAME leaves the old handle's entry unreachable by any targeted key).
594
+ *
563
595
  * @param accountId - The account's Mongo `_id`.
564
596
  * @param data - Subset of updatable profile fields.
565
597
  */
@@ -254,13 +254,15 @@ 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`). 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.
264
266
  *
265
267
  * TanStack Query handles offline queuing automatically.
266
268
  */
@@ -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;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * THE enumeration of `OxyServices` GET-cache keys that can carry a single
3
+ * account's identity, and the one sweep that clears them.
4
+ *
5
+ * WHY THIS IS ONE LIST
6
+ * --------------------
7
+ * An Oxy account is readable under SEVERAL cache keys, and a write that only
8
+ * busts the key it happens to know about leaves every other one serving the
9
+ * pre-write snapshot for up to its TTL — from the caller's OWN in-memory cache,
10
+ * with a perfectly healthy server. That failure has already shipped twice with
11
+ * two different sets of keys:
12
+ *
13
+ * - `updateAccount` busted `GET:/accounts/<id>` and the account lists, but a
14
+ * profile screen reads `GET:/profiles/username/<name>` and
15
+ * `GET:/users/<id>`, so a channel's new picture stayed invisible for the
16
+ * full 5-minute profile TTL.
17
+ * - `updateProfile` busted four of the six keys below, missing
18
+ * `GET:/auth/lookup/` (the login-flow avatar/display-name lookup) and
19
+ * `GET:/profiles/resolve` (handle resolution) — two independently-drifted
20
+ * copies of a list that has to agree.
21
+ *
22
+ * So the list lives here, once, and every writer calls
23
+ * {@link evictOxyIdentityCache}. Adding a new identity read means adding its key
24
+ * HERE and every writer inherits it.
25
+ *
26
+ * WHERE THE LINE IS DRAWN
27
+ * -----------------------
28
+ * These are the SINGLE-PROFILE reads — the account is the subject of the
29
+ * response and is addressable by id, handle, or session. Reads that merely
30
+ * CONTAIN an account among many (`GET:/profiles/search`,
31
+ * `GET:/users/<other>/followers`, `GET:/profiles/<other>/similar`) are
32
+ * deliberately NOT swept: an account cannot be located in them without the very
33
+ * lookup being invalidated, so sweeping them means sweeping the whole namespace
34
+ * on every identity change — a real cost on a backend consuming the
35
+ * cross-service invalidation signal, for a surface where a stale thumbnail
36
+ * expires on its own in ~2 minutes.
37
+ *
38
+ * WHY PREFIXES RATHER THAN EXACT KEYS
39
+ * -----------------------------------
40
+ * Only the by-id key can be built from a user id. The handle-keyed and
41
+ * session-keyed entries cannot — deriving a handle from an id needs the lookup
42
+ * we are invalidating, and the SDK never tracks active session ids centrally.
43
+ * Prefix sweeping is also what makes a USERNAME CHANGE correct: the entry under
44
+ * the OLD handle is unreachable by construction (nothing in the write response
45
+ * carries it), and a sweep targeted at the new handle alone would leave the old
46
+ * one serving the pre-rename profile until its TTL. Over-eviction costs a
47
+ * refetch; under-eviction serves wrong data.
48
+ *
49
+ * Platform-neutral by construction (no imports, no `OxyServices` reference) so
50
+ * the client mixins and the Node-only `@oxyhq/core/server` invalidation
51
+ * subscriber can share it without either pulling in the other.
52
+ */
53
+ /**
54
+ * The cache-eviction surface of an `OxyServices` instance. Declared
55
+ * structurally so this module stays free of any client import.
56
+ */
57
+ export interface OxyIdentityCacheEvictor {
58
+ clearCacheEntry(key: string): void;
59
+ clearCacheByPrefix(prefix: string): number;
60
+ }
61
+ /**
62
+ * Cache-key PREFIXES under which an account's identity can be served, for the
63
+ * reads whose key cannot be derived from a user id. Swept wholesale.
64
+ */
65
+ export declare const OXY_IDENTITY_CACHE_PREFIXES: readonly string[];
66
+ /**
67
+ * Build the exact cache key `getUserById` reads under. The only identity key
68
+ * derivable from a user id, so the only one that does not need a prefix sweep.
69
+ */
70
+ export declare function oxyUserByIdCacheKey(userId: string): string;
71
+ /**
72
+ * Sweep an `OxyServices` GET response cache of everything that could carry the
73
+ * given account's identity.
74
+ *
75
+ * @param oxy - Anything exposing the SDK's two eviction methods.
76
+ * @param userId - The account whose by-id entry to drop. Optional: a caller
77
+ * that does not know the id still clears every handle-, session-
78
+ * and self-keyed entry, which is the majority of the surface.
79
+ */
80
+ export declare function evictOxyIdentityCache(oxy: OxyIdentityCacheEvictor, userId?: string): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "18.0.0",
3
+ "version": "19.0.0",
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.22.0",
118
+ "@oxyhq/contracts": "^0.23.0",
119
119
  "@oxyhq/protocol": "^0.1.6",
120
120
  "@scure/bip39": "^1.6.0",
121
121
  "@types/elliptic": "^6.4.18",
@@ -1748,11 +1748,53 @@
1748
1748
  "description": "A programmatic account with service credentials"
1749
1749
  }
1750
1750
  },
1751
- "organizationCategory": {
1751
+ "accountCategory": {
1752
+ "news": "News",
1753
+ "politics": "Politics & policy",
1754
+ "business": "Business",
1755
+ "startup": "Startups & entrepreneurship",
1756
+ "finance": "Finance & investing",
1757
+ "crypto": "Crypto & web3",
1758
+ "marketplace": "Marketplace & classifieds",
1759
+ "retail": "Retail & ecommerce",
1760
+ "real_estate": "Real estate",
1752
1761
  "agency": "Real estate agency",
1762
+ "landlord": "Landlord & property management",
1753
1763
  "cooperative": "Housing cooperative",
1754
- "landlord": "Landlord / property manager",
1755
- "other": "Other organization"
1764
+ "architecture": "Architecture & construction",
1765
+ "technology": "Technology",
1766
+ "software": "Software & development",
1767
+ "ai": "AI & machine learning",
1768
+ "security": "Security & privacy",
1769
+ "automation": "Automation & tools",
1770
+ "science": "Science & research",
1771
+ "education": "Education & training",
1772
+ "books": "Books & writing",
1773
+ "health": "Health & medicine",
1774
+ "fitness": "Fitness & wellbeing",
1775
+ "sports": "Sports",
1776
+ "gaming": "Gaming & esports",
1777
+ "music": "Music",
1778
+ "film": "Film & TV",
1779
+ "podcast": "Podcasts & audio",
1780
+ "art": "Art & design",
1781
+ "photography": "Photography",
1782
+ "comedy": "Comedy",
1783
+ "food": "Food & drink",
1784
+ "travel": "Travel & places",
1785
+ "fashion": "Fashion & beauty",
1786
+ "home_garden": "Home & garden",
1787
+ "diy": "DIY & making",
1788
+ "automotive": "Cars & motoring",
1789
+ "animals": "Animals & pets",
1790
+ "family": "Family & parenting",
1791
+ "nonprofit": "Non-profit & charity",
1792
+ "government": "Government & public sector",
1793
+ "community": "Local community",
1794
+ "activism": "Activism & advocacy",
1795
+ "environment": "Environment & sustainability",
1796
+ "religion": "Religion & spirituality",
1797
+ "other": "Other"
1756
1798
  },
1757
1799
  "create": {
1758
1800
  "title": "Create account",
@@ -1772,8 +1814,9 @@
1772
1814
  "invalidChars": "Only letters, numbers, hyphens, and underscores",
1773
1815
  "checkFailed": "Could not check availability"
1774
1816
  },
1775
- "organizationCategory": {
1776
- "label": "Organization type"
1817
+ "accountCategory": {
1818
+ "label": "Categories",
1819
+ "hint": "Pick up to {{max}}. The first one is your main category."
1777
1820
  },
1778
1821
  "toasts": {
1779
1822
  "success": "Account created",
@@ -1748,11 +1748,53 @@
1748
1748
  "description": "Una cuenta programática con credenciales de servicio"
1749
1749
  }
1750
1750
  },
1751
- "organizationCategory": {
1751
+ "accountCategory": {
1752
+ "news": "Noticias",
1753
+ "politics": "Política",
1754
+ "business": "Negocios",
1755
+ "startup": "Startups y emprendimiento",
1756
+ "finance": "Finanzas e inversión",
1757
+ "crypto": "Cripto y web3",
1758
+ "marketplace": "Marketplace y anuncios",
1759
+ "retail": "Comercio y ecommerce",
1760
+ "real_estate": "Inmobiliaria",
1752
1761
  "agency": "Agencia inmobiliaria",
1762
+ "landlord": "Casero y gestión de fincas",
1753
1763
  "cooperative": "Cooperativa de vivienda",
1754
- "landlord": "Propietario / administrador de fincas",
1755
- "other": "Otra organización"
1764
+ "architecture": "Arquitectura y construcción",
1765
+ "technology": "Tecnología",
1766
+ "software": "Software y desarrollo",
1767
+ "ai": "IA y aprendizaje automático",
1768
+ "security": "Seguridad y privacidad",
1769
+ "automation": "Automatización y herramientas",
1770
+ "science": "Ciencia e investigación",
1771
+ "education": "Educación y formación",
1772
+ "books": "Libros y escritura",
1773
+ "health": "Salud y medicina",
1774
+ "fitness": "Deporte y bienestar",
1775
+ "sports": "Deportes",
1776
+ "gaming": "Videojuegos y esports",
1777
+ "music": "Música",
1778
+ "film": "Cine y TV",
1779
+ "podcast": "Pódcast y audio",
1780
+ "art": "Arte y diseño",
1781
+ "photography": "Fotografía",
1782
+ "comedy": "Comedia",
1783
+ "food": "Comida y bebida",
1784
+ "travel": "Viajes y lugares",
1785
+ "fashion": "Moda y belleza",
1786
+ "home_garden": "Hogar y jardín",
1787
+ "diy": "Bricolaje y manualidades",
1788
+ "automotive": "Coches y motor",
1789
+ "animals": "Animales y mascotas",
1790
+ "family": "Familia y crianza",
1791
+ "nonprofit": "ONG y benéficas",
1792
+ "government": "Gobierno y sector público",
1793
+ "community": "Comunidad local",
1794
+ "activism": "Activismo y defensa",
1795
+ "environment": "Medio ambiente y sostenibilidad",
1796
+ "religion": "Religión y espiritualidad",
1797
+ "other": "Otra"
1756
1798
  },
1757
1799
  "create": {
1758
1800
  "title": "Crear cuenta",
@@ -1772,8 +1814,9 @@
1772
1814
  "invalidChars": "Solo letras, números, guiones y guiones bajos",
1773
1815
  "checkFailed": "No se pudo comprobar la disponibilidad"
1774
1816
  },
1775
- "organizationCategory": {
1776
- "label": "Tipo de organización"
1817
+ "accountCategory": {
1818
+ "label": "Categorías",
1819
+ "hint": "Elige hasta {{max}}. La primera es tu categoría principal."
1777
1820
  },
1778
1821
  "toasts": {
1779
1822
  "success": "Cuenta creada",
package/src/index.ts CHANGED
@@ -122,7 +122,7 @@ export type {
122
122
  // ---------------------------------------------------------------------------
123
123
  export type {
124
124
  AccountKind,
125
- OrganizationCategory,
125
+ AccountCategoryId,
126
126
  AccountRelationship,
127
127
  AccountRole,
128
128
  AccountMemberStatus,
@@ -167,7 +167,13 @@ export type {
167
167
  ApplicationUsageStats,
168
168
  } from './mixins/OxyServices.accounts';
169
169
 
170
- export { ORGANIZATION_CATEGORIES } from './mixins/OxyServices.accounts';
170
+ export {
171
+ ACCOUNT_CATEGORY_IDS,
172
+ MAX_ACCOUNT_CATEGORIES,
173
+ SELECTABLE_ACCOUNT_CATEGORY_IDS,
174
+ isSelectableAccountCategoryId,
175
+ kindAcceptsAccountCategories,
176
+ } from './mixins/OxyServices.accounts';
171
177
 
172
178
  // ---------------------------------------------------------------------------
173
179
  // Reputation (Oxy Trust: ledger, balances, disputes, rules, influence).
@@ -33,10 +33,11 @@
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
  import { normalizeUserIdentity } from '../utils/userIdentity';
40
+ import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
40
41
  import { CACHE_TIMES } from './mixinHelpers';
41
42
 
42
43
  // ---------------------------------------------------------------------------
@@ -52,8 +53,16 @@ import { CACHE_TIMES } from './mixinHelpers';
52
53
  *
53
54
  * Single source of truth is `@oxyhq/contracts`.
54
55
  */
55
- export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
56
- export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
56
+ export type { AccountCategoryId, AccountKind } from '@oxyhq/contracts';
57
+ export {
58
+ ACCOUNT_CATEGORY_IDS,
59
+ ACCOUNT_KINDS,
60
+ MAX_ACCOUNT_CATEGORIES,
61
+ SELECTABLE_ACCOUNT_CATEGORY_IDS,
62
+ isActAsEligibleKind,
63
+ isSelectableAccountCategoryId,
64
+ kindAcceptsAccountCategories,
65
+ } from '@oxyhq/contracts';
57
66
 
58
67
  /**
59
68
  * The calling user's relationship to an account node, as resolved by the API:
@@ -179,8 +188,17 @@ export interface CreateAccountInput {
179
188
  name?: { first?: string; last?: string; displayName?: string };
180
189
  bio?: string;
181
190
  avatar?: string;
182
- /** Meaningful only when `kind` is `organization`. */
183
- organizationCategory?: OrganizationCategory;
191
+ /**
192
+ * What the account is about. ORDERED — the FIRST element is the primary
193
+ * category, so a picker must submit them in the order the user arranged them
194
+ * and must not sort. Stable ids, never labels: render each one through the
195
+ * `accounts.accountCategory.<id>` translation key.
196
+ *
197
+ * Offer `SELECTABLE_ACCOUNT_CATEGORY_IDS`, not `ACCOUNT_CATEGORY_IDS` — the
198
+ * latter still contains withdrawn ids so that accounts already carrying one
199
+ * keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
200
+ */
201
+ accountCategories?: AccountCategoryId[];
184
202
  }
185
203
 
186
204
  /** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
@@ -195,8 +213,19 @@ export interface UpdateAccountInput {
195
213
  name?: { first?: string; last?: string; displayName?: string };
196
214
  bio?: string | null;
197
215
  avatar?: string | null;
198
- /** Clears the category when `null`; only valid on `kind: 'organization'`. */
199
- organizationCategory?: OrganizationCategory | null;
216
+ /**
217
+ * Replaces the WHOLE list, in the order given — there is no add/remove verb,
218
+ * because a partial edit cannot express a re-ordering and the order is what
219
+ * names the primary category. `[]` clears it.
220
+ *
221
+ * Not nullable, unlike `bio` and `avatar`: the empty case already has a
222
+ * spelling of its own, so a second one could only ever disagree with it.
223
+ *
224
+ * Rejected for a `personal` account, and rejected when it ADDS a withdrawn
225
+ * id the account did not already carry — keeping or re-ordering one it has is
226
+ * always allowed.
227
+ */
228
+ accountCategories?: AccountCategoryId[];
200
229
  }
201
230
 
202
231
  /** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
@@ -736,6 +765,18 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
736
765
  /**
737
766
  * Update an account's mutable profile fields. Tree placement changes
738
767
  * (reparenting) go through the dedicated move endpoint, not here.
768
+ *
769
+ * An account IS a user, so this write changes identity — and a profile
770
+ * screen never reads `/accounts/<id>`. It reads `GET /users/<id>` and
771
+ * `GET /profiles/username/<handle>`, both cached for 5 minutes in the
772
+ * CALLER'S OWN process, so busting only the account-graph keys left every
773
+ * profile surface serving the pre-edit avatar and name for the full TTL
774
+ * with a perfectly healthy server (the cross-service `oxy:user:invalidate`
775
+ * signal does not help: it evicts BACKEND caches, and cannot reach a cache
776
+ * living in a browser tab). {@link evictOxyIdentityCache} owns that key
777
+ * list — see its docs for why the handle-keyed entries are prefix-swept
778
+ * (a RENAME leaves the old handle's entry unreachable by any targeted key).
779
+ *
739
780
  * @param accountId - The account's Mongo `_id`.
740
781
  * @param data - Subset of updatable profile fields.
741
782
  */
@@ -754,6 +795,16 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
754
795
  // data) so neither serves the pre-update snapshot.
755
796
  this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
756
797
  this._invalidateAccountLists();
798
+ // The parent's children list embeds this account's profile and is keyed
799
+ // by the PARENT id, so it is reachable only from the response node.
800
+ const parentAccountId = res.account?.parentAccountId;
801
+ if (parentAccountId) {
802
+ this.clearCacheEntry(
803
+ `GET:/accounts/${encodeURIComponent(parentAccountId)}/children`,
804
+ );
805
+ }
806
+ // Every identity read of this account, whichever key it lands under.
807
+ evictOxyIdentityCache(this, accountId);
757
808
  return res.account;
758
809
  } catch (error) {
759
810
  throw this.handleError(error);
@@ -28,6 +28,7 @@ import {
28
28
  import { KeyManager } from '../crypto/keyManager';
29
29
  import { SignatureService } from '../crypto/signatureService';
30
30
  import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
31
+ import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
31
32
  import { logger } from '../logger';
32
33
  import { extractErrorStatus } from '../utils/errorUtils';
33
34
 
@@ -534,13 +535,15 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
534
535
  /**
535
536
  * Update user profile.
536
537
  *
537
- * Invalidates the SDK-side response cache for every endpoint that
538
- * returns the current user (`GET /users/me`, `GET /session/user/*`,
539
- * `GET /users/<id>`, `GET /profiles/username/*`) so the next read
540
- * doesn't return a stale snapshot. Without this, a follow-up
541
- * `getUserBySession` call inside the 2-minute cache window can return
542
- * the pre-update user most visibly during onboarding, where it
543
- * causes the username step to flicker back as if nothing was saved.
538
+ * Invalidates the SDK-side response cache for every endpoint that can
539
+ * return this user — the list is owned by {@link evictOxyIdentityCache}, so
540
+ * a new identity read is added in one place instead of to each writer
541
+ * separately (this method's own hand-written copy had already drifted from
542
+ * the server-side one, missing `GET /auth/lookup/*` and
543
+ * `GET /profiles/resolve`). Without the sweep a follow-up
544
+ * `getUserBySession` inside the cache window returns the pre-update user
545
+ * most visibly during onboarding, where the username step flickers back as
546
+ * if nothing was saved.
544
547
  *
545
548
  * TanStack Query handles offline queuing automatically.
546
549
  */
@@ -550,15 +553,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
550
553
  await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false }),
551
554
  );
552
555
 
553
- // Bust every cached representation of the current user. We use a
554
- // prefix sweep rather than an enumeration because the SDK never
555
- // tracks the set of active session IDs centrally.
556
- this.clearCacheByPrefix('GET:/session/user/');
557
- this.clearCacheByPrefix('GET:/users/me');
558
- this.clearCacheByPrefix('GET:/profiles/username/');
559
- if (result?.id) {
560
- this.clearCacheEntry(`GET:/users/${result.id}`);
561
- }
556
+ evictOxyIdentityCache(this, result?.id);
562
557
 
563
558
  return result;
564
559
  } catch (error) {
@@ -615,10 +610,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
615
610
  const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
616
611
  cache: false,
617
612
  });
618
- this.clearCacheByPrefix('GET:/session/user/');
619
- this.clearCacheByPrefix('GET:/users/me');
620
- this.clearCacheByPrefix('GET:/profiles/username/');
621
- this.clearCacheEntry(`GET:/users/${id}`);
613
+ // Privacy settings ride the user DTO, so every identity read goes stale
614
+ // too — same key list as any other profile write.
615
+ evictOxyIdentityCache(this, id);
622
616
  this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
623
617
  return result;
624
618
  } catch (error) {