@oxyhq/core 19.1.2 → 20.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 (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +15 -0
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/HttpService.js +23 -18
  5. package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
  6. package/dist/cjs/i18n/accountRoleLabels.js +27 -0
  7. package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
  8. package/dist/cjs/i18n/trustTierLabels.js +19 -0
  9. package/dist/cjs/index.js +19 -9
  10. package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
  11. package/dist/cjs/session/accountProjection.js +31 -6
  12. package/dist/cjs/utils/errorUtils.js +65 -1
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/HttpService.js +24 -19
  15. package/dist/esm/i18n/accountCategoryLabels.js +37 -0
  16. package/dist/esm/i18n/accountRoleLabels.js +20 -0
  17. package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
  18. package/dist/esm/i18n/trustTierLabels.js +12 -0
  19. package/dist/esm/index.js +11 -8
  20. package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
  21. package/dist/esm/session/accountProjection.js +30 -6
  22. package/dist/esm/utils/errorUtils.js +63 -1
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
  25. package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
  26. package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
  27. package/dist/types/i18n/trustTierLabels.d.ts +9 -0
  28. package/dist/types/index.d.ts +7 -2
  29. package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
  30. package/dist/types/session/accountProjection.d.ts +20 -4
  31. package/dist/types/utils/errorUtils.d.ts +67 -0
  32. package/package.json +7 -6
  33. package/src/HttpService.ts +29 -22
  34. package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
  35. package/src/__tests__/serverValueImportsDeclared.test.ts +7 -0
  36. package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
  37. package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
  38. package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
  39. package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
  40. package/src/i18n/accountCategoryLabels.ts +44 -0
  41. package/src/i18n/accountRoleLabels.ts +26 -0
  42. package/src/i18n/reputationCategoryLabels.ts +20 -0
  43. package/src/i18n/trustTierLabels.ts +18 -0
  44. package/src/index.ts +13 -6
  45. package/src/mixins/OxyServices.followGraph.ts +24 -0
  46. package/src/mixins/__tests__/followGraph.test.ts +19 -0
  47. package/src/session/__tests__/accountProjection.test.ts +98 -0
  48. package/src/session/accountProjection.ts +37 -6
  49. package/src/utils/errorUtils.ts +116 -5
@@ -0,0 +1,56 @@
1
+ import { REPUTATION_CATEGORIES } from '@oxyhq/contracts';
2
+ import {
3
+ EN_REPUTATION_CATEGORY_LABELS,
4
+ reputationCategoryLabel,
5
+ } from '../reputationCategoryLabels';
6
+
7
+ const SHIPPED_LOCALES = [
8
+ 'en-US',
9
+ 'es-ES',
10
+ 'ca-ES',
11
+ 'fr-FR',
12
+ 'de-DE',
13
+ 'it-IT',
14
+ 'pt-PT',
15
+ 'ja-JP',
16
+ 'ko-KR',
17
+ 'zh-CN',
18
+ 'ar-SA',
19
+ ] as const;
20
+ const REGION_VARIANT = 'es-MX';
21
+ const UNSHIPPED_LOCALE = 'nl-NL';
22
+
23
+ describe('reputationCategoryLabel', () => {
24
+ it('covers the whole vocabulary', () => {
25
+ expect(REPUTATION_CATEGORIES.length).toBeGreaterThanOrEqual(7);
26
+ expect(Object.keys(EN_REPUTATION_CATEGORY_LABELS)).toHaveLength(
27
+ REPUTATION_CATEGORIES.length,
28
+ );
29
+ });
30
+
31
+ it.each([...SHIPPED_LOCALES, REGION_VARIANT, UNSHIPPED_LOCALE])(
32
+ 'names every category in %s — never a key, never a slug, never empty',
33
+ (locale) => {
34
+ for (const id of REPUTATION_CATEGORIES) {
35
+ const label = reputationCategoryLabel(locale, id);
36
+ expect(label).not.toBe('');
37
+ expect(label).not.toBe(`trust.rules.categories.${id}`);
38
+ }
39
+ },
40
+ );
41
+
42
+ it('falls back to English for a language with no category translations', () => {
43
+ expect(reputationCategoryLabel('de-DE', 'physical')).toBe(
44
+ EN_REPUTATION_CATEGORY_LABELS.physical,
45
+ );
46
+ });
47
+
48
+ it('resolves a region variant through its base language', () => {
49
+ expect(reputationCategoryLabel('es-MX', 'content')).toBe(
50
+ reputationCategoryLabel('es-ES', 'content'),
51
+ );
52
+ expect(reputationCategoryLabel('es-MX', 'content')).not.toBe(
53
+ EN_REPUTATION_CATEGORY_LABELS.content,
54
+ );
55
+ });
56
+ });
@@ -0,0 +1,47 @@
1
+ import { TRUST_TIERS } from '@oxyhq/contracts';
2
+ import { EN_TRUST_TIER_LABELS, trustTierLabel } from '../trustTierLabels';
3
+
4
+ const SHIPPED_LOCALES = [
5
+ 'en-US',
6
+ 'es-ES',
7
+ 'ca-ES',
8
+ 'fr-FR',
9
+ 'de-DE',
10
+ 'it-IT',
11
+ 'pt-PT',
12
+ 'ja-JP',
13
+ 'ko-KR',
14
+ 'zh-CN',
15
+ 'ar-SA',
16
+ ] as const;
17
+ const REGION_VARIANT = 'es-MX';
18
+ const UNSHIPPED_LOCALE = 'nl-NL';
19
+
20
+ describe('trustTierLabel', () => {
21
+ it('covers the whole vocabulary', () => {
22
+ expect(TRUST_TIERS.length).toBe(5);
23
+ expect(Object.keys(EN_TRUST_TIER_LABELS)).toHaveLength(TRUST_TIERS.length);
24
+ });
25
+
26
+ it.each([...SHIPPED_LOCALES, REGION_VARIANT, UNSHIPPED_LOCALE])(
27
+ 'names every tier in %s — never a key, never a slug, never empty',
28
+ (locale) => {
29
+ for (const tier of TRUST_TIERS) {
30
+ const label = trustTierLabel(locale, tier);
31
+ expect(label).not.toBe('');
32
+ expect(label).not.toBe(`trust.tiers.${tier}`);
33
+ }
34
+ },
35
+ );
36
+
37
+ it('falls back to English for a language with no tier translations', () => {
38
+ expect(trustTierLabel('de-DE', 'high_trust')).toBe(
39
+ EN_TRUST_TIER_LABELS.high_trust,
40
+ );
41
+ });
42
+
43
+ it('resolves a region variant through its base language', () => {
44
+ expect(trustTierLabel('es-MX', 'verified')).toBe(trustTierLabel('es-ES', 'verified'));
45
+ expect(trustTierLabel('es-MX', 'verified')).not.toBe(EN_TRUST_TIER_LABELS.verified);
46
+ });
47
+ });
@@ -0,0 +1,44 @@
1
+ import type { AccountCategoryId } from '@oxyhq/contracts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every account category's English name, keyed by its stable id.
7
+ *
8
+ * **The annotation is the point.** The vocabulary lives in `@oxyhq/contracts`
9
+ * and the names live in `locales/en-US.json`, so they are two lists that must
10
+ * agree and nothing but a type can make them. Declaring the JSON node as a
11
+ * TOTAL `Record<AccountCategoryId, string>` turns "somebody added a category at
12
+ * Oxy and nobody wrote its English" into a `TS2741` naming the missing id, at
13
+ * build time, instead of a picker row that paints `accounts.accountCategory.<id>`
14
+ * at a user trying to choose one.
15
+ *
16
+ * That failure is not hypothetical. The screen previously wrote `t(key) || id`,
17
+ * whose author believed an unnamed id would degrade to its raw slug. It cannot:
18
+ * {@link translate} echoes the KEY when it resolves nothing, and a non-empty
19
+ * string is never falsy, so the `|| id` arm was unreachable and the output was
20
+ * the dotted key. A runtime fallback that cannot run is worse than none,
21
+ * because it reads as protection.
22
+ *
23
+ * Totality is over `ACCOUNT_CATEGORY_IDS`, which RETAINS withdrawn ids, so an
24
+ * account still carrying a retired category keeps rendering its name while no
25
+ * picker offers it again. Retired and unknown are different cases: only an id
26
+ * outside the union is unnameable, which is why this is keyed by
27
+ * `AccountCategoryId` and not by `string`.
28
+ */
29
+ /**
30
+ * Module-scoped, NOT re-exported from the package index: the annotation is the
31
+ * whole job, and it does that job without being public API. It carries no
32
+ * `Object.freeze` and no `Readonly<>` for the same reason — those existed only
33
+ * to make an exported reference safe from a consumer's stray write, and there
34
+ * is no such consumer. Exported from the MODULE so its own test can name it.
35
+ */
36
+ export const EN_ACCOUNT_CATEGORY_LABELS: Record<AccountCategoryId, string> =
37
+ enUS.accounts.accountCategory;
38
+
39
+ export function accountCategoryLabel(
40
+ locale: string | undefined,
41
+ id: AccountCategoryId,
42
+ ): string {
43
+ return translate(locale, `accounts.accountCategory.${id}`);
44
+ }
@@ -0,0 +1,26 @@
1
+ import type { AccountRole } from '../mixins/OxyServices.accounts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every account member role's English name, keyed by its stable id.
7
+ *
8
+ * Totality is over the closed `AccountRole` union so a new role without an
9
+ * English label is a build error, not a members row that paints
10
+ * `accounts.roles.<role>.label`.
11
+ */
12
+ export const EN_ACCOUNT_ROLE_LABELS: Record<AccountRole, string> = {
13
+ owner: enUS.accounts.roles.owner.label,
14
+ admin: enUS.accounts.roles.admin.label,
15
+ editor: enUS.accounts.roles.editor.label,
16
+ developer: enUS.accounts.roles.developer.label,
17
+ billing: enUS.accounts.roles.billing.label,
18
+ viewer: enUS.accounts.roles.viewer.label,
19
+ };
20
+
21
+ export function accountRoleLabel(
22
+ locale: string | undefined,
23
+ role: AccountRole,
24
+ ): string {
25
+ return translate(locale, `accounts.roles.${role}.label`);
26
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReputationCategory } from '@oxyhq/contracts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every reputation rule category's English name, keyed by its stable id.
7
+ *
8
+ * Totality is over `REPUTATION_CATEGORIES` from `@oxyhq/contracts` so a new
9
+ * category added server-side without an English label is a build error, not a
10
+ * Trust Rules section title that paints `trust.rules.categories.<id>`.
11
+ */
12
+ export const EN_REPUTATION_CATEGORY_LABELS: Record<ReputationCategory, string> =
13
+ enUS.trust.rules.categories;
14
+
15
+ export function reputationCategoryLabel(
16
+ locale: string | undefined,
17
+ id: ReputationCategory,
18
+ ): string {
19
+ return translate(locale, `trust.rules.categories.${id}`);
20
+ }
@@ -0,0 +1,18 @@
1
+ import type { TrustTier } from '@oxyhq/contracts';
2
+ import enUS from './locales/en-US.json';
3
+ import { translate } from './index';
4
+
5
+ /**
6
+ * Every trust tier's English name, keyed by its stable id.
7
+ *
8
+ * Totality is over `TRUST_TIERS` from `@oxyhq/contracts` so a new tier without
9
+ * an English label is a build error, not a chip that paints `trust.tiers.<id>`.
10
+ */
11
+ export const EN_TRUST_TIER_LABELS: Record<TrustTier, string> = enUS.trust.tiers;
12
+
13
+ export function trustTierLabel(
14
+ locale: string | undefined,
15
+ tier: TrustTier,
16
+ ): string {
17
+ return translate(locale, `trust.tiers.${tier}`);
18
+ }
package/src/index.ts CHANGED
@@ -453,6 +453,10 @@ export type { CircuitBreakerState, CircuitBreakerConfig } from './shared/utils/n
453
453
  // i18n
454
454
  // ---------------------------------------------------------------------------
455
455
  export { translate } from './i18n';
456
+ export { accountCategoryLabel } from './i18n/accountCategoryLabels';
457
+ export { accountRoleLabel } from './i18n/accountRoleLabels';
458
+ export { reputationCategoryLabel } from './i18n/reputationCategoryLabels';
459
+ export { trustTierLabel } from './i18n/trustTierLabels';
456
460
 
457
461
  // ---------------------------------------------------------------------------
458
462
  // API request / URL helpers
@@ -476,8 +480,11 @@ export {
476
480
  ErrorCodes,
477
481
  createApiError,
478
482
  handleHttpError,
483
+ isHttpRequestError,
484
+ parseHttpErrorBody,
479
485
  validateRequiredFields,
480
486
  } from './utils/errorUtils';
487
+ export type { HttpRequestError, ParsedHttpErrorBody } from './utils/errorUtils';
481
488
 
482
489
  export { retryAsync } from './utils/asyncUtils';
483
490
 
@@ -647,14 +654,14 @@ export {
647
654
  // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
648
655
  // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
649
656
  // `@oxyhq/services` and auth.oxy.so so the list can't diverge.
650
- // `isSwitchTargetAccount` is the switcher's own question ("can I become this
651
- // account?"), exported so a surface that renders `AccountNode`s rather than the
652
- // projection the Console's workspace switcher, the accounts app's
653
- // managed-accounts rows asks the SAME question instead of testing a kind
654
- // literal. It is NOT `isActAsEligibleKind`: that one is false for `personal`
655
- // too, so gating a switcher on it alone empties the list.
657
+ // `isSwitchTargetAccount` is the structural half ("is this kind switchable at
658
+ // all?"); `canSwitchIntoAccount` adds the caller's `account:act_as` permission.
659
+ // Both are exported so surfaces that render `AccountNode`s rather than the
660
+ // projection — the Console workspace switcher, managed-accounts rows ask the
661
+ // SAME questions instead of testing a kind literal.
656
662
  export {
657
663
  isSwitchTargetAccount,
664
+ canSwitchIntoAccount,
658
665
  projectSwitchableAccounts,
659
666
  switchableAccountIds,
660
667
  } from './session/accountProjection';
@@ -210,6 +210,30 @@ export function OxyServicesFollowGraphMixin<T extends typeof OxyServicesBase>(Ba
210
210
  }
211
211
  }
212
212
 
213
+ /**
214
+ * Release a namespace the calling application holds, when nothing is
215
+ registered inside it yet.
216
+ *
217
+ * Idempotent when the namespace is already unowned (`released: false`).
218
+ * Exists because claims are first-come and registration runs on boot — a
219
+ * development build with the wrong client id can bind a name permanently
220
+ * unless the holder can give it back.
221
+ */
222
+ async releaseFollowNamespace(
223
+ namespace: string,
224
+ ): Promise<{ namespace: string; released: boolean }> {
225
+ try {
226
+ return await this.makeRequest(
227
+ 'DELETE',
228
+ `/v2/follow-targets/namespaces/${encodeURIComponent(namespace)}`,
229
+ undefined,
230
+ { cache: false },
231
+ );
232
+ } catch (error) {
233
+ throw this.handleError(error);
234
+ }
235
+ }
236
+
213
237
  /**
214
238
  * Declare what following a kind of thing MEANS: the verb clients render,
215
239
  * whether reverse lookups are public, whether it federates.
@@ -105,6 +105,23 @@ describe('OxyServices.followGraph', () => {
105
105
  await oxy.followTarget('../../admin');
106
106
  expect(makeRequest.mock.calls[0][1]).toBe('/v2/follows/..%2F..%2Fadmin');
107
107
  });
108
+
109
+ it('claims a namespace with POST', async () => {
110
+ await oxy.claimFollowNamespace('mention');
111
+ expect(makeRequest.mock.calls[0].slice(0, 3)).toEqual([
112
+ 'POST',
113
+ '/v2/follow-targets/namespaces',
114
+ { namespace: 'mention' },
115
+ ]);
116
+ });
117
+
118
+ it('releases a namespace with DELETE and encodes the segment', async () => {
119
+ await oxy.releaseFollowNamespace('mention.dev');
120
+ expect(makeRequest.mock.calls[0].slice(0, 2)).toEqual([
121
+ 'DELETE',
122
+ '/v2/follow-targets/namespaces/mention.dev',
123
+ ]);
124
+ });
108
125
  });
109
126
 
110
127
  describe('caching', () => {
@@ -115,6 +132,8 @@ describe('OxyServices.followGraph', () => {
115
132
  await oxy.unfollowTarget('r');
116
133
  await oxy.setFollowApplicationMode('r', 'disabled');
117
134
  await oxy.restoreFollowInheritance('r');
135
+ await oxy.claimFollowNamespace('ns');
136
+ await oxy.releaseFollowNamespace('ns');
118
137
 
119
138
  // A status cached across a write is the "follow reverts after navigating
120
139
  // away and back" bug the legacy path had to fix with explicit
@@ -3,6 +3,7 @@ import { ACCOUNT_KINDS } from '@oxyhq/contracts';
3
3
  import type { User } from '../../models/interfaces';
4
4
  import type { AccountNode } from '../../mixins/OxyServices.accounts';
5
5
  import {
6
+ canSwitchIntoAccount,
6
7
  isSwitchTargetAccount,
7
8
  projectSwitchableAccounts,
8
9
  switchableAccountIds,
@@ -102,6 +103,71 @@ describe('isSwitchTargetAccount', () => {
102
103
  });
103
104
  });
104
105
 
106
+ describe('canSwitchIntoAccount', () => {
107
+ it('admits self without membership permissions', () => {
108
+ expect(canSwitchIntoAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
109
+ });
110
+
111
+ it('admits an owned switch target when membership is absent (owner baseline)', () => {
112
+ expect(canSwitchIntoAccount({ kind: 'organization', relationship: 'owner' })).toBe(true);
113
+ });
114
+
115
+ it('requires account:act_as for member relationships', () => {
116
+ expect(
117
+ canSwitchIntoAccount({
118
+ kind: 'organization',
119
+ relationship: 'member',
120
+ callerMembership: {
121
+ _id: 'm1',
122
+ accountId: 'org1',
123
+ memberUserId: 'u1',
124
+ role: 'billing',
125
+ status: 'active',
126
+ permissions: ['account:read', 'billing:manage'],
127
+ inherit: true,
128
+ source: 'direct',
129
+ },
130
+ }),
131
+ ).toBe(false);
132
+
133
+ expect(
134
+ canSwitchIntoAccount({
135
+ kind: 'organization',
136
+ relationship: 'member',
137
+ callerMembership: {
138
+ _id: 'm2',
139
+ accountId: 'org1',
140
+ memberUserId: 'u1',
141
+ role: 'admin',
142
+ status: 'active',
143
+ permissions: ['account:act_as', 'account:read'],
144
+ inherit: true,
145
+ source: 'direct',
146
+ },
147
+ }),
148
+ ).toBe(true);
149
+ });
150
+
151
+ it('refuses channels even with act_as permission', () => {
152
+ expect(
153
+ canSwitchIntoAccount({
154
+ kind: 'channel',
155
+ relationship: 'owner',
156
+ callerMembership: {
157
+ _id: 'm3',
158
+ accountId: 'chan1',
159
+ memberUserId: 'u1',
160
+ role: 'owner',
161
+ status: 'active',
162
+ permissions: ['account:act_as'],
163
+ inherit: true,
164
+ source: 'direct',
165
+ },
166
+ }),
167
+ ).toBe(false);
168
+ });
169
+ });
170
+
105
171
  describe('projectSwitchableAccounts', () => {
106
172
  it('returns [] for null state and empty graph', () => {
107
173
  expect(
@@ -235,6 +301,38 @@ describe('projectSwitchableAccounts', () => {
235
301
  ]);
236
302
  });
237
303
 
304
+ it('omits a graph-only member without account:act_as', () => {
305
+ const rows = projectSwitchableAccounts({
306
+ state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
307
+ graph: [
308
+ graphNode('org1', { kind: 'organization', relationship: 'member', callerMembership: {
309
+ _id: 'm1',
310
+ accountId: 'org1',
311
+ memberUserId: 'a1',
312
+ role: 'billing',
313
+ status: 'active',
314
+ permissions: ['account:read', 'billing:manage'],
315
+ inherit: true,
316
+ source: 'direct',
317
+ } }),
318
+ graphNode('org2', { kind: 'organization', relationship: 'member', callerMembership: {
319
+ _id: 'm2',
320
+ accountId: 'org2',
321
+ memberUserId: 'a1',
322
+ role: 'admin',
323
+ status: 'active',
324
+ permissions: ['account:act_as', 'account:read'],
325
+ inherit: true,
326
+ source: 'direct',
327
+ } }),
328
+ ],
329
+ profilesById: mapOf(user('a1'), user('org1'), user('org2')),
330
+ resolveAvatarUrl: noAvatar,
331
+ });
332
+
333
+ expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org2']);
334
+ });
335
+
238
336
  it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
239
337
  const rows = projectSwitchableAccounts({
240
338
  state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
@@ -135,6 +135,37 @@ export function isSwitchTargetAccount(
135
135
  return node.relationship === 'self' || isActAsEligibleKind(node.kind);
136
136
  }
137
137
 
138
+ /**
139
+ * Whether the caller may switch INTO this account — the server-side
140
+ * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
141
+ *
142
+ * `relationship: 'self'` always passes (returning to the caller's own personal
143
+ * account). Every other ground requires a switch-eligible kind AND
144
+ * `account:act_as` in the resolved membership permissions. When permissions are
145
+ * absent but the relationship is `owner`, the owner baseline is assumed — the
146
+ * API always resolves effective permissions for owned accounts, but test
147
+ * fixtures and stale rows may omit the membership blob.
148
+ */
149
+ export function canSwitchIntoAccount(
150
+ node: {
151
+ kind?: AccountKind | null;
152
+ relationship?: AccountRelationship;
153
+ callerMembership?: AccountMember | null;
154
+ },
155
+ ): boolean {
156
+ if (node.relationship === 'self') {
157
+ return true;
158
+ }
159
+ if (!isSwitchTargetAccount(node)) {
160
+ return false;
161
+ }
162
+ const permissions = node.callerMembership?.permissions;
163
+ if (permissions) {
164
+ return permissions.includes('account:act_as');
165
+ }
166
+ return node.relationship === 'owner';
167
+ }
168
+
138
169
  /** Input to {@link projectSwitchableAccounts}. */
139
170
  export interface ProjectSwitchableAccountsInput {
140
171
  /**
@@ -176,9 +207,9 @@ export interface ProjectSwitchableAccountsInput {
176
207
  * and a graph node is deduped into ONE device row enriched with the graph
177
208
  * metadata (relationship / kind / parent / membership).
178
209
  *
179
- * Graph nodes that are not switch targets — a `channel`, which nobody may act
180
- * as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
181
- * below.
210
+ * Graph nodes the caller cannot switch into — a `channel`, or a managed account
211
+ * whose membership lacks `account:act_as` — are omitted.
212
+ * {@link canSwitchIntoAccount} is the rule; see the filter below.
182
213
  */
183
214
  export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[] {
184
215
  const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
@@ -274,7 +305,7 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
274
305
  // An account already on the device skipped this check via the branch above,
275
306
  // and correctly: whatever its kind, the caller is signed into it, so
276
307
  // switching is a local activation that asks the server for nothing.
277
- if (!isSwitchTargetAccount(node)) {
308
+ if (!canSwitchIntoAccount(node)) {
278
309
  continue;
279
310
  }
280
311
  remember(toRow(node.account, {
@@ -298,7 +329,7 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
298
329
  * document, but including their ids lets the caller pass one id set and lets the
299
330
  * projection prefer freshly-fetched profiles uniformly.
300
331
  *
301
- * Applies the SAME {@link isSwitchTargetAccount} filter as
332
+ * Applies the SAME {@link canSwitchIntoAccount} filter as
302
333
  * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
303
334
  * profile for a row the projection will drop — and, just as importantly, never
304
335
  * SKIPS one the projection will keep, which would leave that row unrendered
@@ -315,7 +346,7 @@ export function switchableAccountIds(
315
346
  }
316
347
  }
317
348
  for (const node of graph) {
318
- if (node.accountId && isSwitchTargetAccount(node)) {
349
+ if (node.accountId && canSwitchIntoAccount(node)) {
319
350
  ids.add(node.accountId);
320
351
  }
321
352
  }
@@ -36,6 +36,110 @@ export const ErrorCodes = {
36
36
  CONNECTION_FAILED: 'CONNECTION_FAILED'
37
37
  } as const;
38
38
 
39
+ /**
40
+ * The `Error` shape the SDK rejects with when an HTTP request fails.
41
+ *
42
+ * `HttpService` throws this for every non-2xx response, and
43
+ * `OxyServices.handleError` (the wrapper the mixin methods rethrow through)
44
+ * preserves `message`, `status`, `code` and `details`. `response` only survives
45
+ * on the raw `HttpService`/`makeRequest` path, so treat it as optional.
46
+ *
47
+ * Narrow a caught value with {@link isHttpRequestError} instead of asserting.
48
+ */
49
+ export interface HttpRequestError extends Error {
50
+ /** HTTP status of the failed response. */
51
+ status: number;
52
+ /** Machine-readable code the server sent, when it sent one. */
53
+ code?: string;
54
+ /** Structured error detail the server sent, when it sent an object. */
55
+ details?: Record<string, unknown>;
56
+ /**
57
+ * Present on errors thrown directly by `HttpService`. `data` is the parsed
58
+ * JSON error body verbatim — the escape hatch for any server field the SDK
59
+ * does not lift onto `code`/`details`.
60
+ */
61
+ response?: {
62
+ status: number;
63
+ statusText: string;
64
+ data?: unknown;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Narrow a caught value to {@link HttpRequestError}.
70
+ *
71
+ * Returns `false` for a plain {@link ApiError} object (those are objects, not
72
+ * `Error`s) — run an arbitrary thrown value through {@link handleHttpError}
73
+ * first if you need one normalized.
74
+ */
75
+ export function isHttpRequestError(value: unknown): value is HttpRequestError {
76
+ if (!(value instanceof Error)) {
77
+ return false;
78
+ }
79
+ return typeof (value as Partial<HttpRequestError>).status === 'number';
80
+ }
81
+
82
+ /**
83
+ * The fields {@link parseHttpErrorBody} lifts off a parsed error response body.
84
+ */
85
+ export interface ParsedHttpErrorBody {
86
+ message?: string;
87
+ code?: string;
88
+ details?: Record<string, unknown>;
89
+ }
90
+
91
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
92
+ typeof value === 'object' && value !== null && !Array.isArray(value);
93
+
94
+ const nonEmptyString = (value: unknown): string | undefined =>
95
+ typeof value === 'string' && value.trim().length > 0 ? value : undefined;
96
+
97
+ /**
98
+ * Extract `message` / `code` / `details` from a parsed HTTP error response body.
99
+ *
100
+ * Handles every error envelope in use across the Oxy ecosystem:
101
+ *
102
+ * - `{ error: { code, message, details? } }` — nested envelope (CrowdSource and
103
+ * other Oxy services). Never stringify the nested object: `new Error(obj)`
104
+ * yields the literal message `"[object Object]"`.
105
+ * - `{ error: '<CODE>', message, details? }` — oxy-api's canonical shape
106
+ * (`ApiError.toJSON`), where the top-level `error` field IS the code.
107
+ * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
108
+ * OAuth token and userinfo endpoints. `error_description` is the human text
109
+ * and `error` is the machine code, so both survive.
110
+ * - `{ message, code }` — e.g. the API's CSRF rejections.
111
+ * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
112
+ * `message`/`error_description` the string is the message, not a code: a bare
113
+ * `error` string is not machine-readable enough to promote to `code`.
114
+ *
115
+ * Anything else — a non-object body (`null`, `[]`, `"str"`, `42`), or an object
116
+ * carrying none of these fields — yields an empty result, leaving the caller on
117
+ * its status-based fallback message. Total function: never throws.
118
+ */
119
+ export function parseHttpErrorBody(body: unknown): ParsedHttpErrorBody {
120
+ if (!isPlainRecord(body)) {
121
+ return {};
122
+ }
123
+
124
+ const nested = isPlainRecord(body.error) ? body.error : undefined;
125
+ const errorString = nonEmptyString(body.error);
126
+ // A sibling that proves the top-level `error` is a CODE rather than prose.
127
+ const siblingMessage = nonEmptyString(body.message) ?? nonEmptyString(body.error_description);
128
+
129
+ return {
130
+ message: siblingMessage ?? (nested ? nonEmptyString(nested.message) : errorString),
131
+ code:
132
+ (nested ? nonEmptyString(nested.code) : undefined) ??
133
+ nonEmptyString(body.code) ??
134
+ (siblingMessage ? errorString : undefined),
135
+ details: isPlainRecord(body.details)
136
+ ? body.details
137
+ : nested && isPlainRecord(nested.details)
138
+ ? nested.details
139
+ : undefined,
140
+ };
141
+ }
142
+
39
143
  /**
40
144
  * Create a standardized API error
41
145
  */
@@ -98,21 +202,28 @@ export function handleHttpError(error: unknown): ApiError {
98
202
 
99
203
  // Handle fetch Response errors - check if it has response property with status
100
204
  if (error && typeof error === 'object' && 'response' in error) {
101
- const fetchError = error as {
102
- response?: {
103
- status: number;
205
+ const fetchError = error as {
206
+ response?: {
207
+ status: number;
104
208
  statusText?: string;
105
209
  };
106
210
  status?: number;
107
211
  message?: string;
212
+ details?: unknown;
108
213
  };
109
-
214
+
110
215
  const status = fetchError.response?.status || fetchError.status;
111
216
  if (status) {
217
+ // `details` is carried through when present: a body may ship structured
218
+ // detail without a machine-readable `code` (which is what routes the
219
+ // error to the already-an-ApiError branch above), and dropping it here
220
+ // would make it unreachable to every caller that rethrows via
221
+ // `OxyServices.handleError`.
112
222
  return createApiError(
113
223
  fetchError.message || `HTTP ${status} error`,
114
224
  getErrorCodeFromStatus(status),
115
- status
225
+ status,
226
+ isPlainRecord(fetchError.details) ? fetchError.details : undefined
116
227
  );
117
228
  }
118
229
  }