@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.
- package/LICENSE +202 -0
- package/NOTICE +15 -0
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +23 -18
- package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
- package/dist/cjs/i18n/accountRoleLabels.js +27 -0
- package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
- package/dist/cjs/i18n/trustTierLabels.js +19 -0
- package/dist/cjs/index.js +19 -9
- package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
- package/dist/cjs/session/accountProjection.js +31 -6
- package/dist/cjs/utils/errorUtils.js +65 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +24 -19
- package/dist/esm/i18n/accountCategoryLabels.js +37 -0
- package/dist/esm/i18n/accountRoleLabels.js +20 -0
- package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
- package/dist/esm/i18n/trustTierLabels.js +12 -0
- package/dist/esm/index.js +11 -8
- package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
- package/dist/esm/session/accountProjection.js +30 -6
- package/dist/esm/utils/errorUtils.js +63 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
- package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
- package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
- package/dist/types/i18n/trustTierLabels.d.ts +9 -0
- package/dist/types/index.d.ts +7 -2
- package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
- package/dist/types/session/accountProjection.d.ts +20 -4
- package/dist/types/utils/errorUtils.d.ts +67 -0
- package/package.json +7 -6
- package/src/HttpService.ts +29 -22
- package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
- package/src/__tests__/serverValueImportsDeclared.test.ts +7 -0
- package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
- package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
- package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
- package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
- package/src/i18n/accountCategoryLabels.ts +44 -0
- package/src/i18n/accountRoleLabels.ts +26 -0
- package/src/i18n/reputationCategoryLabels.ts +20 -0
- package/src/i18n/trustTierLabels.ts +18 -0
- package/src/index.ts +13 -6
- package/src/mixins/OxyServices.followGraph.ts +24 -0
- package/src/mixins/__tests__/followGraph.test.ts +19 -0
- package/src/session/__tests__/accountProjection.test.ts +98 -0
- package/src/session/accountProjection.ts +37 -6
- 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
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
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
|
|
180
|
-
*
|
|
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 (!
|
|
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
|
|
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 &&
|
|
349
|
+
if (node.accountId && canSwitchIntoAccount(node)) {
|
|
319
350
|
ids.add(node.accountId);
|
|
320
351
|
}
|
|
321
352
|
}
|
package/src/utils/errorUtils.ts
CHANGED
|
@@ -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
|
}
|