@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,34 @@
|
|
|
1
|
+
import type { AccountCategoryId } from '@oxyhq/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Every account category's English name, keyed by its stable id.
|
|
4
|
+
*
|
|
5
|
+
* **The annotation is the point.** The vocabulary lives in `@oxyhq/contracts`
|
|
6
|
+
* and the names live in `locales/en-US.json`, so they are two lists that must
|
|
7
|
+
* agree and nothing but a type can make them. Declaring the JSON node as a
|
|
8
|
+
* TOTAL `Record<AccountCategoryId, string>` turns "somebody added a category at
|
|
9
|
+
* Oxy and nobody wrote its English" into a `TS2741` naming the missing id, at
|
|
10
|
+
* build time, instead of a picker row that paints `accounts.accountCategory.<id>`
|
|
11
|
+
* at a user trying to choose one.
|
|
12
|
+
*
|
|
13
|
+
* That failure is not hypothetical. The screen previously wrote `t(key) || id`,
|
|
14
|
+
* whose author believed an unnamed id would degrade to its raw slug. It cannot:
|
|
15
|
+
* {@link translate} echoes the KEY when it resolves nothing, and a non-empty
|
|
16
|
+
* string is never falsy, so the `|| id` arm was unreachable and the output was
|
|
17
|
+
* the dotted key. A runtime fallback that cannot run is worse than none,
|
|
18
|
+
* because it reads as protection.
|
|
19
|
+
*
|
|
20
|
+
* Totality is over `ACCOUNT_CATEGORY_IDS`, which RETAINS withdrawn ids, so an
|
|
21
|
+
* account still carrying a retired category keeps rendering its name while no
|
|
22
|
+
* picker offers it again. Retired and unknown are different cases: only an id
|
|
23
|
+
* outside the union is unnameable, which is why this is keyed by
|
|
24
|
+
* `AccountCategoryId` and not by `string`.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Module-scoped, NOT re-exported from the package index: the annotation is the
|
|
28
|
+
* whole job, and it does that job without being public API. It carries no
|
|
29
|
+
* `Object.freeze` and no `Readonly<>` for the same reason — those existed only
|
|
30
|
+
* to make an exported reference safe from a consumer's stray write, and there
|
|
31
|
+
* is no such consumer. Exported from the MODULE so its own test can name it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const EN_ACCOUNT_CATEGORY_LABELS: Record<AccountCategoryId, string>;
|
|
34
|
+
export declare function accountCategoryLabel(locale: string | undefined, id: AccountCategoryId): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AccountRole } from '../mixins/OxyServices.accounts';
|
|
2
|
+
/**
|
|
3
|
+
* Every account member role's English name, keyed by its stable id.
|
|
4
|
+
*
|
|
5
|
+
* Totality is over the closed `AccountRole` union so a new role without an
|
|
6
|
+
* English label is a build error, not a members row that paints
|
|
7
|
+
* `accounts.roles.<role>.label`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const EN_ACCOUNT_ROLE_LABELS: Record<AccountRole, string>;
|
|
10
|
+
export declare function accountRoleLabel(locale: string | undefined, role: AccountRole): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReputationCategory } from '@oxyhq/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Every reputation rule category's English name, keyed by its stable id.
|
|
4
|
+
*
|
|
5
|
+
* Totality is over `REPUTATION_CATEGORIES` from `@oxyhq/contracts` so a new
|
|
6
|
+
* category added server-side without an English label is a build error, not a
|
|
7
|
+
* Trust Rules section title that paints `trust.rules.categories.<id>`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const EN_REPUTATION_CATEGORY_LABELS: Record<ReputationCategory, string>;
|
|
10
|
+
export declare function reputationCategoryLabel(locale: string | undefined, id: ReputationCategory): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TrustTier } from '@oxyhq/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Every trust tier's English name, keyed by its stable id.
|
|
4
|
+
*
|
|
5
|
+
* Totality is over `TRUST_TIERS` from `@oxyhq/contracts` so a new tier without
|
|
6
|
+
* an English label is a build error, not a chip that paints `trust.tiers.<id>`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const EN_TRUST_TIER_LABELS: Record<TrustTier, string>;
|
|
9
|
+
export declare function trustTierLabel(locale: string | undefined, tier: TrustTier): string;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -79,9 +79,14 @@ export { HttpStatus, getErrorStatus, getErrorMessage, isAlreadyRegisteredError,
|
|
|
79
79
|
export { DEFAULT_CIRCUIT_BREAKER_CONFIG, createCircuitBreakerState, calculateBackoffInterval, recordFailure, recordSuccess, shouldAllowRequest, delay, withRetry, } from './shared/utils/networkUtils';
|
|
80
80
|
export type { CircuitBreakerState, CircuitBreakerConfig } from './shared/utils/networkUtils';
|
|
81
81
|
export { translate } from './i18n';
|
|
82
|
+
export { accountCategoryLabel } from './i18n/accountCategoryLabels';
|
|
83
|
+
export { accountRoleLabel } from './i18n/accountRoleLabels';
|
|
84
|
+
export { reputationCategoryLabel } from './i18n/reputationCategoryLabels';
|
|
85
|
+
export { trustTierLabel } from './i18n/trustTierLabels';
|
|
82
86
|
export { buildQueryParams, buildSearchParams, buildUrl, buildPaginationParams, safeJsonParse, } from './utils/apiUtils';
|
|
83
87
|
export type { PaginationParams, FollowGraphParams, FollowGraphSort, ApiResponse, ErrorResponse, } from './utils/apiUtils';
|
|
84
|
-
export { ErrorCodes, createApiError, handleHttpError, validateRequiredFields, } from './utils/errorUtils';
|
|
88
|
+
export { ErrorCodes, createApiError, handleHttpError, isHttpRequestError, parseHttpErrorBody, validateRequiredFields, } from './utils/errorUtils';
|
|
89
|
+
export type { HttpRequestError, ParsedHttpErrorBody } from './utils/errorUtils';
|
|
85
90
|
export { retryAsync } from './utils/asyncUtils';
|
|
86
91
|
export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, MAX_DISPLAY_NAME_LENGTH, DISPLAY_NAME_INVALID_MESSAGE, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, DISPLAY_NAME_ALLOWED_SCRIPTS, DISPLAY_NAME_DISALLOWED_SOURCE, DISPLAY_NAME_ORPHANED_MARK_SOURCE, DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
|
|
87
92
|
export { normalizeInlineText, normalizeMultilineText, } from './utils/textNormalization';
|
|
@@ -104,7 +109,7 @@ export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
|
104
109
|
export { createSessionClientHost } from './session/sessionClientHost';
|
|
105
110
|
export { createSessionClient } from './session/createSessionClient';
|
|
106
111
|
export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
|
|
107
|
-
export { isSwitchTargetAccount, projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
|
|
112
|
+
export { isSwitchTargetAccount, canSwitchIntoAccount, projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
|
|
108
113
|
export type { SwitchableAccount, SwitchableAccountUser, ProjectSwitchableAccountsInput, } from './session/accountProjection';
|
|
109
114
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
|
|
110
115
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, PopupWindowHandle, SignInFlowPhase, SignInFlowState, SignInProgress, } from './session/accountDialogController';
|
|
@@ -122,6 +122,19 @@ export declare function OxyServicesFollowGraphMixin<T extends typeof OxyServices
|
|
|
122
122
|
namespace: string;
|
|
123
123
|
created: boolean;
|
|
124
124
|
}>;
|
|
125
|
+
/**
|
|
126
|
+
* Release a namespace the calling application holds, when nothing is
|
|
127
|
+
registered inside it yet.
|
|
128
|
+
*
|
|
129
|
+
* Idempotent when the namespace is already unowned (`released: false`).
|
|
130
|
+
* Exists because claims are first-come and registration runs on boot — a
|
|
131
|
+
* development build with the wrong client id can bind a name permanently
|
|
132
|
+
* unless the holder can give it back.
|
|
133
|
+
*/
|
|
134
|
+
releaseFollowNamespace(namespace: string): Promise<{
|
|
135
|
+
namespace: string;
|
|
136
|
+
released: boolean;
|
|
137
|
+
}>;
|
|
125
138
|
/**
|
|
126
139
|
* Declare what following a kind of thing MEANS: the verb clients render,
|
|
127
140
|
* whether reverse lookups are public, whether it federates.
|
|
@@ -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.0.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -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
|
+
});
|