@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.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/HttpService.js +23 -18
  5. package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
  6. package/dist/cjs/i18n/accountRoleLabels.js +27 -0
  7. package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
  8. package/dist/cjs/i18n/trustTierLabels.js +19 -0
  9. package/dist/cjs/index.js +19 -9
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
  12. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  13. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  14. package/dist/cjs/mixins/index.js +7 -0
  15. package/dist/cjs/server/rateLimit.js +15 -6
  16. package/dist/cjs/session/accountProjection.js +31 -6
  17. package/dist/cjs/utils/errorUtils.js +65 -1
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/HttpService.js +24 -19
  20. package/dist/esm/i18n/accountCategoryLabels.js +37 -0
  21. package/dist/esm/i18n/accountRoleLabels.js +20 -0
  22. package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
  23. package/dist/esm/i18n/trustTierLabels.js +12 -0
  24. package/dist/esm/index.js +11 -8
  25. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  26. package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
  27. package/dist/esm/mixins/OxyServices.store.js +263 -0
  28. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  29. package/dist/esm/mixins/index.js +7 -0
  30. package/dist/esm/server/rateLimit.js +15 -6
  31. package/dist/esm/session/accountProjection.js +30 -6
  32. package/dist/esm/utils/errorUtils.js +63 -1
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
  35. package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
  36. package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
  37. package/dist/types/i18n/trustTierLabels.d.ts +9 -0
  38. package/dist/types/index.d.ts +14 -2
  39. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  40. package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
  41. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  42. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  43. package/dist/types/mixins/index.d.ts +3 -1
  44. package/dist/types/session/accountProjection.d.ts +20 -4
  45. package/dist/types/utils/errorUtils.d.ts +67 -0
  46. package/package.json +7 -6
  47. package/src/HttpService.ts +29 -22
  48. package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
  49. package/src/__tests__/serverValueImportsDeclared.test.ts +7 -0
  50. package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
  51. package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
  52. package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
  53. package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
  54. package/src/i18n/accountCategoryLabels.ts +44 -0
  55. package/src/i18n/accountRoleLabels.ts +26 -0
  56. package/src/i18n/reputationCategoryLabels.ts +20 -0
  57. package/src/i18n/trustTierLabels.ts +18 -0
  58. package/src/index.ts +43 -6
  59. package/src/mixins/OxyServices.chains.ts +134 -0
  60. package/src/mixins/OxyServices.followGraph.ts +24 -0
  61. package/src/mixins/OxyServices.store.ts +585 -0
  62. package/src/mixins/OxyServices.utility.ts +161 -108
  63. package/src/mixins/__tests__/chains.test.ts +113 -0
  64. package/src/mixins/__tests__/followGraph.test.ts +19 -0
  65. package/src/mixins/__tests__/store.test.ts +304 -0
  66. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  67. package/src/mixins/index.ts +9 -0
  68. package/src/server/__tests__/rateLimit.test.ts +47 -0
  69. package/src/server/rateLimit.ts +18 -8
  70. package/src/session/__tests__/accountProjection.test.ts +98 -0
  71. package/src/session/accountProjection.ts +37 -6
  72. 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 intentionally uses `jwtDecode()` (decode-only, no signature
113
- * verification) for user tokens. This is by design, NOT a security gap:
114
- * - Third-party apps using `oxy.auth()` don't have the Oxy JWT secret
115
- * - Security comes from API-based session validation (`validateSession()`)
116
- * which checks the session server-side on every request
117
- * - Service tokens (type: 'service') DO use cryptographic HMAC verification
118
- * via the `jwtSecret` option, since they are stateless. Service tokens
119
- * are additionally checked for `aud`, `iss`, and `type` claims to prevent
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 that are not switch targets — a `channel`, which nobody may act
165
- * as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
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 isSwitchTargetAccount} filter as
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": "19.1.2",
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": "AGPL-3.0-only",
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.24.0",
119
- "@oxyhq/protocol": "^0.1.6",
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": {
@@ -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
- // Try to parse error response (handle empty/malformed JSON)
636
- let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
637
- const contentType = response.headers.get('content-type');
638
- if (contentType && contentType.includes('application/json')) {
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
- const errorData = await response.json() as {
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
- const error = new Error(errorMessage) as Error & {
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
- response?: { status: number; statusText: string }
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
+ });