@oxyhq/core 19.1.2 → 20.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -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.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.utility.js +159 -104
- package/dist/cjs/mixins/index.js +7 -0
- package/dist/cjs/server/rateLimit.js +15 -6
- 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.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.utility.js +159 -104
- package/dist/esm/mixins/index.js +7 -0
- package/dist/esm/server/rateLimit.js +15 -6
- 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 +14 -2
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
- package/dist/types/mixins/index.d.ts +3 -1
- 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 +43 -6
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.followGraph.ts +24 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.utility.ts +161 -108
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/followGraph.test.ts +19 -0
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/index.ts +9 -0
- package/src/server/__tests__/rateLimit.test.ts +47 -0
- package/src/server/rateLimit.ts +18 -8
- package/src/session/__tests__/accountProjection.test.ts +98 -0
- package/src/session/accountProjection.ts +37 -6
- package/src/utils/errorUtils.ts +116 -5
|
@@ -109,18 +109,41 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
109
109
|
* Uses server-side session validation for security (not just JWT decode).
|
|
110
110
|
*
|
|
111
111
|
* **Design note — jwtDecode vs jwt.verify:**
|
|
112
|
-
* This middleware
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* -
|
|
116
|
-
*
|
|
117
|
-
* -
|
|
118
|
-
*
|
|
119
|
-
*
|
|
112
|
+
* This middleware uses `jwtDecode()` (decode-only, NO signature check) for
|
|
113
|
+
* user tokens, because third-party apps mounting `oxy.auth()` do not hold
|
|
114
|
+
* the Oxy signing secret. **Every claim in a user token is therefore
|
|
115
|
+
* attacker-controlled and proves nothing on its own.** The identity comes
|
|
116
|
+
* from somewhere else entirely:
|
|
117
|
+
* - A user token MUST carry a `sessionId`. That session is validated
|
|
118
|
+
* server-side on every request via `validateSession()`, and the user id
|
|
119
|
+
* is read off the VALIDATED SESSION — never off the token. A token whose
|
|
120
|
+
* `userId` claim disagrees with the session is refused
|
|
121
|
+
* (`SESSION_USER_MISMATCH`); a token with no `sessionId` at all is
|
|
122
|
+
* refused outright (`SESSION_REQUIRED`). There is no local-claims path.
|
|
123
|
+
* - Service tokens (type: 'service') ARE stateless, so they use
|
|
124
|
+
* cryptographic HMAC verification via the `jwtSecret` option, and are
|
|
125
|
+
* additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
120
126
|
* cross-token-type confusion attacks.
|
|
121
127
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
122
128
|
* direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
|
|
123
129
|
*
|
|
130
|
+
* **Why session-less user tokens are refused rather than trusted:**
|
|
131
|
+
* every user access token the Oxy API issues carries a `sessionId` (see
|
|
132
|
+
* `packages/api/src/utils/sessionUtils.ts`, `generateSessionTokens` — the
|
|
133
|
+
* only mint site for user tokens, including the OAuth code exchange). So
|
|
134
|
+
* refusing session-less user tokens costs nothing legitimate, while
|
|
135
|
+
* accepting them let anyone authenticate as anyone by hand-rolling a JWT
|
|
136
|
+
* with a `userId` claim and a garbage signature.
|
|
137
|
+
*
|
|
138
|
+
* **Why the claimed user id is cross-checked against the session:**
|
|
139
|
+
* `GET /session/validate/:sessionId` is UNAUTHENTICATED and does not bind
|
|
140
|
+
* the bearer token — it returns whoever owns the session id it was handed.
|
|
141
|
+
* Trusting the token's `userId` claim after a successful validation would
|
|
142
|
+
* therefore let a caller holding ANY live session id (their own, for
|
|
143
|
+
* instance) pair it with a forged `userId` and be trusted as that user.
|
|
144
|
+
* `authSocket()` has always cross-checked this; the HTTP middleware now
|
|
145
|
+
* does too.
|
|
146
|
+
*
|
|
124
147
|
* **Service-token delegation (X-Oxy-User-Id):**
|
|
125
148
|
* When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
|
|
126
149
|
* `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
|
|
@@ -16,6 +16,7 @@ import { OxyServicesReputationMixin } from './OxyServices.reputation';
|
|
|
16
16
|
import { OxyServicesAssetsMixin } from './OxyServices.assets';
|
|
17
17
|
import { OxyServicesAccountsMixin } from './OxyServices.accounts';
|
|
18
18
|
import { OxyServicesConnectedAppsMixin } from './OxyServices.connectedApps';
|
|
19
|
+
import { OxyServicesStoreMixin } from './OxyServices.store';
|
|
19
20
|
import { OxyServicesLocationMixin } from './OxyServices.location';
|
|
20
21
|
import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
|
|
21
22
|
import { OxyServicesDevicesMixin } from './OxyServices.devices';
|
|
@@ -27,6 +28,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
|
27
28
|
import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
|
|
28
29
|
import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
29
30
|
import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
31
|
+
import { OxyServicesChainsMixin } from './OxyServices.chains';
|
|
30
32
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
31
33
|
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
32
34
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
|
|
@@ -41,7 +43,7 @@ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
|
41
43
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
42
44
|
* are visible without a cast.
|
|
43
45
|
*/
|
|
44
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
46
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
45
47
|
/**
|
|
46
48
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
47
49
|
* a new constructor that augments its input; reducing across the pipeline
|
|
@@ -121,6 +121,22 @@ export declare function isSwitchTargetAccount(node: {
|
|
|
121
121
|
kind?: AccountKind | null;
|
|
122
122
|
relationship?: AccountRelationship;
|
|
123
123
|
}): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Whether the caller may switch INTO this account — the server-side
|
|
126
|
+
* `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
|
|
127
|
+
*
|
|
128
|
+
* `relationship: 'self'` always passes (returning to the caller's own personal
|
|
129
|
+
* account). Every other ground requires a switch-eligible kind AND
|
|
130
|
+
* `account:act_as` in the resolved membership permissions. When permissions are
|
|
131
|
+
* absent but the relationship is `owner`, the owner baseline is assumed — the
|
|
132
|
+
* API always resolves effective permissions for owned accounts, but test
|
|
133
|
+
* fixtures and stale rows may omit the membership blob.
|
|
134
|
+
*/
|
|
135
|
+
export declare function canSwitchIntoAccount(node: {
|
|
136
|
+
kind?: AccountKind | null;
|
|
137
|
+
relationship?: AccountRelationship;
|
|
138
|
+
callerMembership?: AccountMember | null;
|
|
139
|
+
}): boolean;
|
|
124
140
|
/** Input to {@link projectSwitchableAccounts}. */
|
|
125
141
|
export interface ProjectSwitchableAccountsInput {
|
|
126
142
|
/**
|
|
@@ -161,9 +177,9 @@ export interface ProjectSwitchableAccountsInput {
|
|
|
161
177
|
* and a graph node is deduped into ONE device row enriched with the graph
|
|
162
178
|
* metadata (relationship / kind / parent / membership).
|
|
163
179
|
*
|
|
164
|
-
* Graph nodes
|
|
165
|
-
*
|
|
166
|
-
* below.
|
|
180
|
+
* Graph nodes the caller cannot switch into — a `channel`, or a managed account
|
|
181
|
+
* whose membership lacks `account:act_as` — are omitted.
|
|
182
|
+
* {@link canSwitchIntoAccount} is the rule; see the filter below.
|
|
167
183
|
*/
|
|
168
184
|
export declare function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[];
|
|
169
185
|
/**
|
|
@@ -173,7 +189,7 @@ export declare function projectSwitchableAccounts(input: ProjectSwitchableAccoun
|
|
|
173
189
|
* document, but including their ids lets the caller pass one id set and lets the
|
|
174
190
|
* projection prefer freshly-fetched profiles uniformly.
|
|
175
191
|
*
|
|
176
|
-
* Applies the SAME {@link
|
|
192
|
+
* Applies the SAME {@link canSwitchIntoAccount} filter as
|
|
177
193
|
* {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
|
|
178
194
|
* profile for a row the projection will drop — and, just as importantly, never
|
|
179
195
|
* SKIPS one the projection will keep, which would leave that row unrendered
|
|
@@ -23,6 +23,73 @@ export declare const ErrorCodes: {
|
|
|
23
23
|
readonly NETWORK_ERROR: "NETWORK_ERROR";
|
|
24
24
|
readonly CONNECTION_FAILED: "CONNECTION_FAILED";
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* The `Error` shape the SDK rejects with when an HTTP request fails.
|
|
28
|
+
*
|
|
29
|
+
* `HttpService` throws this for every non-2xx response, and
|
|
30
|
+
* `OxyServices.handleError` (the wrapper the mixin methods rethrow through)
|
|
31
|
+
* preserves `message`, `status`, `code` and `details`. `response` only survives
|
|
32
|
+
* on the raw `HttpService`/`makeRequest` path, so treat it as optional.
|
|
33
|
+
*
|
|
34
|
+
* Narrow a caught value with {@link isHttpRequestError} instead of asserting.
|
|
35
|
+
*/
|
|
36
|
+
export interface HttpRequestError extends Error {
|
|
37
|
+
/** HTTP status of the failed response. */
|
|
38
|
+
status: number;
|
|
39
|
+
/** Machine-readable code the server sent, when it sent one. */
|
|
40
|
+
code?: string;
|
|
41
|
+
/** Structured error detail the server sent, when it sent an object. */
|
|
42
|
+
details?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Present on errors thrown directly by `HttpService`. `data` is the parsed
|
|
45
|
+
* JSON error body verbatim — the escape hatch for any server field the SDK
|
|
46
|
+
* does not lift onto `code`/`details`.
|
|
47
|
+
*/
|
|
48
|
+
response?: {
|
|
49
|
+
status: number;
|
|
50
|
+
statusText: string;
|
|
51
|
+
data?: unknown;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Narrow a caught value to {@link HttpRequestError}.
|
|
56
|
+
*
|
|
57
|
+
* Returns `false` for a plain {@link ApiError} object (those are objects, not
|
|
58
|
+
* `Error`s) — run an arbitrary thrown value through {@link handleHttpError}
|
|
59
|
+
* first if you need one normalized.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isHttpRequestError(value: unknown): value is HttpRequestError;
|
|
62
|
+
/**
|
|
63
|
+
* The fields {@link parseHttpErrorBody} lifts off a parsed error response body.
|
|
64
|
+
*/
|
|
65
|
+
export interface ParsedHttpErrorBody {
|
|
66
|
+
message?: string;
|
|
67
|
+
code?: string;
|
|
68
|
+
details?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract `message` / `code` / `details` from a parsed HTTP error response body.
|
|
72
|
+
*
|
|
73
|
+
* Handles every error envelope in use across the Oxy ecosystem:
|
|
74
|
+
*
|
|
75
|
+
* - `{ error: { code, message, details? } }` — nested envelope (CrowdSource and
|
|
76
|
+
* other Oxy services). Never stringify the nested object: `new Error(obj)`
|
|
77
|
+
* yields the literal message `"[object Object]"`.
|
|
78
|
+
* - `{ error: '<CODE>', message, details? }` — oxy-api's canonical shape
|
|
79
|
+
* (`ApiError.toJSON`), where the top-level `error` field IS the code.
|
|
80
|
+
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
81
|
+
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
82
|
+
* and `error` is the machine code, so both survive.
|
|
83
|
+
* - `{ message, code }` — e.g. the API's CSRF rejections.
|
|
84
|
+
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
85
|
+
* `message`/`error_description` the string is the message, not a code: a bare
|
|
86
|
+
* `error` string is not machine-readable enough to promote to `code`.
|
|
87
|
+
*
|
|
88
|
+
* Anything else — a non-object body (`null`, `[]`, `"str"`, `42`), or an object
|
|
89
|
+
* carrying none of these fields — yields an empty result, leaving the caller on
|
|
90
|
+
* its status-based fallback message. Total function: never throws.
|
|
91
|
+
*/
|
|
92
|
+
export declare function parseHttpErrorBody(body: unknown): ParsedHttpErrorBody;
|
|
26
93
|
/**
|
|
27
94
|
* Create a standardized API error
|
|
28
95
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "20.1.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",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"./package.json": "./package.json"
|
|
63
63
|
},
|
|
64
64
|
"files": [
|
|
65
|
+
"NOTICE",
|
|
65
66
|
"dist",
|
|
66
67
|
"src"
|
|
67
68
|
],
|
|
@@ -79,7 +80,7 @@
|
|
|
79
80
|
"directory": "packages/core"
|
|
80
81
|
},
|
|
81
82
|
"author": "OxyHQ",
|
|
82
|
-
"license": "
|
|
83
|
+
"license": "Apache-2.0",
|
|
83
84
|
"homepage": "https://oxy.so",
|
|
84
85
|
"engines": {
|
|
85
86
|
"node": ">=18.0.0"
|
|
@@ -115,12 +116,13 @@
|
|
|
115
116
|
"dependencies": {
|
|
116
117
|
"@noble/ciphers": "^1.3.0",
|
|
117
118
|
"@noble/hashes": "^1.8.0",
|
|
118
|
-
"@oxyhq/contracts": "^0.
|
|
119
|
-
"@oxyhq/protocol": "^0.
|
|
119
|
+
"@oxyhq/contracts": "^0.25.0",
|
|
120
|
+
"@oxyhq/protocol": "^0.2.0",
|
|
120
121
|
"@scure/bip39": "^1.6.0",
|
|
121
122
|
"@types/elliptic": "^6.4.18",
|
|
122
123
|
"buffer": "^6.0.3",
|
|
123
124
|
"elliptic": "^6.6.1",
|
|
125
|
+
"express-rate-limit": "^8.6.0",
|
|
124
126
|
"helmet": "^8.0.0",
|
|
125
127
|
"invariant": "^2.2.4",
|
|
126
128
|
"jwt-decode": "^4.0.0",
|
|
@@ -132,8 +134,7 @@
|
|
|
132
134
|
"@react-native-async-storage/async-storage": "*",
|
|
133
135
|
"expo-crypto": "*",
|
|
134
136
|
"expo-secure-store": "*",
|
|
135
|
-
"express": "^4.0.0"
|
|
136
|
-
"express-rate-limit": "^8.0.0"
|
|
137
|
+
"express": "^4.0.0"
|
|
137
138
|
},
|
|
138
139
|
"peerDependenciesMeta": {
|
|
139
140
|
"@react-native-async-storage/async-storage": {
|
package/src/HttpService.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { TTLCache, registerCacheForCleanup } from './utils/cache';
|
|
17
17
|
import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils';
|
|
18
18
|
import { retryAsync } from './utils/asyncUtils';
|
|
19
|
-
import { handleHttpError } from './utils/errorUtils';
|
|
19
|
+
import { handleHttpError, parseHttpErrorBody } from './utils/errorUtils';
|
|
20
20
|
import { jwtDecode } from 'jwt-decode';
|
|
21
21
|
import { isNative, getPlatformOS } from './utils/platform';
|
|
22
22
|
import { isReactNative } from '@oxyhq/protocol';
|
|
@@ -632,37 +632,44 @@ export class HttpService {
|
|
|
632
632
|
}
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
635
|
+
// Read the error body (may be absent, non-JSON, empty or malformed).
|
|
636
|
+
// Anything unreadable leaves `errorBody` undefined and degrades to the
|
|
637
|
+
// status-based message — an error path that throws its own error is
|
|
638
|
+
// worse than the error it was reporting.
|
|
639
|
+
let errorBody: unknown;
|
|
640
|
+
const errorContentType = response.headers.get('content-type');
|
|
641
|
+
if (errorContentType?.includes('application/json')) {
|
|
639
642
|
try {
|
|
640
|
-
|
|
641
|
-
message?: string;
|
|
642
|
-
error?: string;
|
|
643
|
-
error_description?: string;
|
|
644
|
-
} | null;
|
|
645
|
-
// Accept either structured error field from API responses.
|
|
646
|
-
if (errorData?.message) {
|
|
647
|
-
errorMessage = errorData.message;
|
|
648
|
-
} else if (errorData?.error_description) {
|
|
649
|
-
// RFC 6749 §5.2 / RFC 6750 §3 — OAuth endpoints surface human text here.
|
|
650
|
-
errorMessage = errorData.error_description;
|
|
651
|
-
} else if (errorData?.error) {
|
|
652
|
-
errorMessage = errorData.error;
|
|
653
|
-
}
|
|
643
|
+
errorBody = await response.json();
|
|
654
644
|
} catch (parseError) {
|
|
655
645
|
// Malformed JSON or empty response - use status text
|
|
656
646
|
this.logger.warn('Failed to parse error response JSON:', parseError);
|
|
657
647
|
}
|
|
658
648
|
}
|
|
659
649
|
|
|
660
|
-
|
|
650
|
+
// `parseHttpErrorBody` handles every envelope in use, including the
|
|
651
|
+
// nested `{ error: { code, message } }` shape — assigning that nested
|
|
652
|
+
// OBJECT as the message is what produced `"[object Object]"`.
|
|
653
|
+
const parsed = parseHttpErrorBody(errorBody);
|
|
654
|
+
const error = new Error(
|
|
655
|
+
parsed.message ?? `HTTP ${response.status}: ${response.statusText}`,
|
|
656
|
+
) as Error & {
|
|
661
657
|
status?: number;
|
|
662
|
-
|
|
658
|
+
code?: string;
|
|
659
|
+
details?: Record<string, unknown>;
|
|
660
|
+
response?: { status: number; statusText: string; data?: unknown };
|
|
663
661
|
};
|
|
664
662
|
error.status = response.status;
|
|
665
|
-
error.response = { status: response.status, statusText: response.statusText };
|
|
663
|
+
error.response = { status: response.status, statusText: response.statusText, data: errorBody };
|
|
664
|
+
// Only set `code`/`details` when the server actually sent them.
|
|
665
|
+
// Assigning `undefined` would still create the property, which changes
|
|
666
|
+
// how `handleHttpError` classifies the error downstream.
|
|
667
|
+
if (parsed.code !== undefined) {
|
|
668
|
+
error.code = parsed.code;
|
|
669
|
+
}
|
|
670
|
+
if (parsed.details !== undefined) {
|
|
671
|
+
error.details = parsed.details;
|
|
672
|
+
}
|
|
666
673
|
throw error;
|
|
667
674
|
}
|
|
668
675
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isHttpRequestError, parseHttpErrorBody } from '../utils/errorUtils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `parseHttpErrorBody` is the ONE place the SDK decides what a failed request's
|
|
5
|
+
* message, code and details are, across every error envelope the Oxy ecosystem
|
|
6
|
+
* emits. The bug it exists to prevent is silent: a nested
|
|
7
|
+
* `{ error: { code, message } }` assigned straight to `new Error(...)` yields
|
|
8
|
+
* the literal string `"[object Object]"`, which reads as a rendering bug in
|
|
9
|
+
* whichever app surfaces it, arbitrarily far from here.
|
|
10
|
+
*
|
|
11
|
+
* Each case below pins one envelope AND the discrimination it depends on — in
|
|
12
|
+
* particular, whether the top-level `error` string is a machine CODE or human
|
|
13
|
+
* prose is decided solely by whether a sibling human field is present, so both
|
|
14
|
+
* sides of that fork need a fixture or the rule is untested.
|
|
15
|
+
*/
|
|
16
|
+
describe('parseHttpErrorBody', () => {
|
|
17
|
+
it('reads the nested { error: { code, message, details } } envelope', () => {
|
|
18
|
+
expect(
|
|
19
|
+
parseHttpErrorBody({
|
|
20
|
+
error: { code: 'case_conflict', message: 'A case already exists', details: { caseId: 'c_1' } },
|
|
21
|
+
}),
|
|
22
|
+
).toEqual({
|
|
23
|
+
message: 'A case already exists',
|
|
24
|
+
code: 'case_conflict',
|
|
25
|
+
details: { caseId: 'c_1' },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reads oxy-api's { error: '<CODE>', message } shape — error IS the code", () => {
|
|
30
|
+
expect(parseHttpErrorBody({ error: 'VALIDATION_ERROR', message: 'username is taken' })).toEqual({
|
|
31
|
+
message: 'username is taken',
|
|
32
|
+
code: 'VALIDATION_ERROR',
|
|
33
|
+
details: undefined,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads RFC 6749 { error: '<CODE>', error_description } from the OAuth endpoints", () => {
|
|
38
|
+
// The OAuth token/userinfo endpoints are the one part of the API that
|
|
39
|
+
// does not use the `{ error, message }` envelope. Without this arm the
|
|
40
|
+
// human text is dropped and the raw code is shown to the user.
|
|
41
|
+
expect(
|
|
42
|
+
parseHttpErrorBody({ error: 'invalid_grant', error_description: 'Authorization code expired' }),
|
|
43
|
+
).toEqual({
|
|
44
|
+
message: 'Authorization code expired',
|
|
45
|
+
code: 'invalid_grant',
|
|
46
|
+
details: undefined,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("treats a LONE { error: '<text>' } as the message, never as a code", () => {
|
|
51
|
+
// The discriminator is the ABSENCE of a sibling human field. Promoting a
|
|
52
|
+
// bare string to `code` would put prose where callers switch on codes,
|
|
53
|
+
// so this case and the two above have to disagree.
|
|
54
|
+
expect(parseHttpErrorBody({ error: 'Something went wrong' })).toEqual({
|
|
55
|
+
message: 'Something went wrong',
|
|
56
|
+
code: undefined,
|
|
57
|
+
details: undefined,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reads the flat { message, code } shape (CSRF rejections)', () => {
|
|
62
|
+
expect(parseHttpErrorBody({ message: 'Invalid CSRF token', code: 'CSRF_TOKEN_INVALID' })).toEqual({
|
|
63
|
+
message: 'Invalid CSRF token',
|
|
64
|
+
code: 'CSRF_TOKEN_INVALID',
|
|
65
|
+
details: undefined,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('ignores whitespace-only strings rather than reporting them as a message', () => {
|
|
70
|
+
// A blank message is worse than none: it replaces the status fallback
|
|
71
|
+
// with an empty error surface the user cannot act on.
|
|
72
|
+
expect(parseHttpErrorBody({ message: ' ', error: '' })).toEqual({
|
|
73
|
+
message: undefined,
|
|
74
|
+
code: undefined,
|
|
75
|
+
details: undefined,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('ignores a non-object `details` instead of passing it through', () => {
|
|
80
|
+
expect(parseHttpErrorBody({ message: 'nope', details: 'not-an-object' }).details).toBeUndefined();
|
|
81
|
+
expect(parseHttpErrorBody({ message: 'nope', details: ['a'] }).details).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each([null, undefined, [], 'a string', 42, true])('returns {} for the non-object body %p', (body) => {
|
|
85
|
+
expect(parseHttpErrorBody(body)).toEqual({});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns {} for an object carrying none of the known fields', () => {
|
|
89
|
+
expect(parseHttpErrorBody({ unrelated: 'field' })).toEqual({
|
|
90
|
+
message: undefined,
|
|
91
|
+
code: undefined,
|
|
92
|
+
details: undefined,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('isHttpRequestError', () => {
|
|
98
|
+
it('accepts an Error carrying a numeric status', () => {
|
|
99
|
+
const error = Object.assign(new Error('boom'), { status: 409 });
|
|
100
|
+
expect(isHttpRequestError(error)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a plain ApiError object — those are objects, not Errors', () => {
|
|
104
|
+
// The distinction is load-bearing: an ApiError has to go through
|
|
105
|
+
// `handleHttpError` first, and a truthy answer here would let a caller
|
|
106
|
+
// read `.stack`/`.name` off something that has neither.
|
|
107
|
+
expect(isHttpRequestError({ message: 'boom', status: 409, code: 'CONFLICT' })).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects an Error whose status is a non-number', () => {
|
|
111
|
+
// `'409'` is the shape a hand-built error most plausibly carries, and
|
|
112
|
+
// the narrowing promises callers a number they can compare.
|
|
113
|
+
expect(isHttpRequestError(Object.assign(new Error('boom'), { status: '409' }))).toBe(false);
|
|
114
|
+
expect(isHttpRequestError(new Error('boom'))).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -103,6 +103,13 @@ describe('@oxyhq/core/server value imports are installable', () => {
|
|
|
103
103
|
expect(manifest.peerDependenciesMeta?.helmet?.optional).toBeUndefined();
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
it('keeps express-rate-limit installable rather than a peer', () => {
|
|
107
|
+
// Same class as helmet: `rateLimit.ts` value-imports it and the server
|
|
108
|
+
// barrel re-exports that module, so a missing install crashes at boot.
|
|
109
|
+
expect(dependencies.has('express-rate-limit')).toBe(true);
|
|
110
|
+
expect(manifest.peerDependencies?.['express-rate-limit']).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
106
113
|
it('leaves express as an optional peer, because it is type-only here', () => {
|
|
107
114
|
// The counter-example that stops this test from being read as "declare
|
|
108
115
|
// everything": type imports vanish at build time, so an optional peer is a
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ACCOUNT_CATEGORY_IDS, SELECTABLE_ACCOUNT_CATEGORY_IDS } from '@oxyhq/contracts';
|
|
2
|
+
import { accountCategoryLabel, EN_ACCOUNT_CATEGORY_LABELS } from '../accountCategoryLabels';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every locale the SDK ships a dictionary for, plus a region variant and an
|
|
6
|
+
* untranslated language, because the three take different paths through
|
|
7
|
+
* `translate`: exact dictionary, base-subtag dictionary, and English fallback.
|
|
8
|
+
*/
|
|
9
|
+
const SHIPPED_LOCALES = [
|
|
10
|
+
'en-US',
|
|
11
|
+
'es-ES',
|
|
12
|
+
'ca-ES',
|
|
13
|
+
'fr-FR',
|
|
14
|
+
'de-DE',
|
|
15
|
+
'it-IT',
|
|
16
|
+
'pt-PT',
|
|
17
|
+
'ja-JP',
|
|
18
|
+
'ko-KR',
|
|
19
|
+
'zh-CN',
|
|
20
|
+
'ar-SA',
|
|
21
|
+
] as const;
|
|
22
|
+
const REGION_VARIANT = 'es-MX';
|
|
23
|
+
const UNSHIPPED_LOCALE = 'nl-NL';
|
|
24
|
+
|
|
25
|
+
describe('accountCategoryLabel', () => {
|
|
26
|
+
// Vacuity floor: a traversal bug that yielded an empty id list would make
|
|
27
|
+
// every assertion below pass over nothing.
|
|
28
|
+
it('covers the whole vocabulary', () => {
|
|
29
|
+
expect(ACCOUNT_CATEGORY_IDS.length).toBeGreaterThanOrEqual(46);
|
|
30
|
+
expect(Object.keys(EN_ACCOUNT_CATEGORY_LABELS)).toHaveLength(ACCOUNT_CATEGORY_IDS.length);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it.each([...SHIPPED_LOCALES, REGION_VARIANT, UNSHIPPED_LOCALE])(
|
|
34
|
+
'names every category in %s — never a key, never a slug, never empty',
|
|
35
|
+
(locale) => {
|
|
36
|
+
for (const id of ACCOUNT_CATEGORY_IDS) {
|
|
37
|
+
const label = accountCategoryLabel(locale, id);
|
|
38
|
+
expect(label).not.toBe('');
|
|
39
|
+
// `translate` echoes the key when it resolves nothing, so the key IS the
|
|
40
|
+
// failure signal. This is what the screen's old `t(key) || id` believed
|
|
41
|
+
// it was catching and could not, since a non-empty string is truthy.
|
|
42
|
+
expect(label).not.toBe(`accounts.accountCategory.${id}`);
|
|
43
|
+
// The raw slug reaching a reader is the bug this whole indirection
|
|
44
|
+
// exists to prevent. `ai` is excluded: its English label legitimately
|
|
45
|
+
// equals its id.
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it('falls back to English for a language with no category translations', () => {
|
|
51
|
+
// German ships a dictionary but no category labels, so it exercises
|
|
52
|
+
// `translate`'s PER-KEY English fallback rather than its locale fallback.
|
|
53
|
+
expect(accountCategoryLabel('de-DE', 'cooperative')).toBe(
|
|
54
|
+
EN_ACCOUNT_CATEGORY_LABELS.cooperative,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('resolves a region variant through its base language', () => {
|
|
59
|
+
expect(accountCategoryLabel('es-MX', 'news')).toBe(accountCategoryLabel('es-ES', 'news'));
|
|
60
|
+
expect(accountCategoryLabel('es-MX', 'news')).not.toBe(EN_ACCOUNT_CATEGORY_LABELS.news);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AccountRole } from '../../mixins/OxyServices.accounts';
|
|
2
|
+
import { EN_ACCOUNT_ROLE_LABELS, accountRoleLabel } from '../accountRoleLabels';
|
|
3
|
+
|
|
4
|
+
const ACCOUNT_ROLES: AccountRole[] = [
|
|
5
|
+
'owner',
|
|
6
|
+
'admin',
|
|
7
|
+
'editor',
|
|
8
|
+
'developer',
|
|
9
|
+
'billing',
|
|
10
|
+
'viewer',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const SHIPPED_LOCALES = [
|
|
14
|
+
'en-US',
|
|
15
|
+
'es-ES',
|
|
16
|
+
'ca-ES',
|
|
17
|
+
'fr-FR',
|
|
18
|
+
'de-DE',
|
|
19
|
+
'it-IT',
|
|
20
|
+
'pt-PT',
|
|
21
|
+
'ja-JP',
|
|
22
|
+
'ko-KR',
|
|
23
|
+
'zh-CN',
|
|
24
|
+
'ar-SA',
|
|
25
|
+
] as const;
|
|
26
|
+
const REGION_VARIANT = 'es-MX';
|
|
27
|
+
const UNSHIPPED_LOCALE = 'nl-NL';
|
|
28
|
+
|
|
29
|
+
describe('accountRoleLabel', () => {
|
|
30
|
+
it('covers the whole vocabulary', () => {
|
|
31
|
+
expect(ACCOUNT_ROLES.length).toBe(6);
|
|
32
|
+
expect(Object.keys(EN_ACCOUNT_ROLE_LABELS)).toHaveLength(ACCOUNT_ROLES.length);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([...SHIPPED_LOCALES, REGION_VARIANT, UNSHIPPED_LOCALE])(
|
|
36
|
+
'names every role in %s — never a key, never a slug, never empty',
|
|
37
|
+
(locale) => {
|
|
38
|
+
for (const role of ACCOUNT_ROLES) {
|
|
39
|
+
const label = accountRoleLabel(locale, role);
|
|
40
|
+
expect(label).not.toBe('');
|
|
41
|
+
expect(label).not.toBe(`accounts.roles.${role}.label`);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it('falls back to English for a language with no role translations', () => {
|
|
47
|
+
expect(accountRoleLabel('de-DE', 'developer')).toBe(EN_ACCOUNT_ROLE_LABELS.developer);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('resolves a region variant through its base language', () => {
|
|
51
|
+
expect(accountRoleLabel('es-MX', 'admin')).toBe(accountRoleLabel('es-ES', 'admin'));
|
|
52
|
+
expect(accountRoleLabel('es-MX', 'admin')).not.toBe(EN_ACCOUNT_ROLE_LABELS.admin);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -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
|
+
});
|