@oxyhq/core 19.1.2 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +15 -0
  3. package/dist/cjs/.tsbuildinfo +1 -1
  4. package/dist/cjs/HttpService.js +23 -18
  5. package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
  6. package/dist/cjs/i18n/accountRoleLabels.js +27 -0
  7. package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
  8. package/dist/cjs/i18n/trustTierLabels.js +19 -0
  9. package/dist/cjs/index.js +19 -9
  10. package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
  11. package/dist/cjs/session/accountProjection.js +31 -6
  12. package/dist/cjs/utils/errorUtils.js +65 -1
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/HttpService.js +24 -19
  15. package/dist/esm/i18n/accountCategoryLabels.js +37 -0
  16. package/dist/esm/i18n/accountRoleLabels.js +20 -0
  17. package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
  18. package/dist/esm/i18n/trustTierLabels.js +12 -0
  19. package/dist/esm/index.js +11 -8
  20. package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
  21. package/dist/esm/session/accountProjection.js +30 -6
  22. package/dist/esm/utils/errorUtils.js +63 -1
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
  25. package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
  26. package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
  27. package/dist/types/i18n/trustTierLabels.d.ts +9 -0
  28. package/dist/types/index.d.ts +7 -2
  29. package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
  30. package/dist/types/session/accountProjection.d.ts +20 -4
  31. package/dist/types/utils/errorUtils.d.ts +67 -0
  32. package/package.json +7 -6
  33. package/src/HttpService.ts +29 -22
  34. package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
  35. package/src/__tests__/serverValueImportsDeclared.test.ts +7 -0
  36. package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
  37. package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
  38. package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
  39. package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
  40. package/src/i18n/accountCategoryLabels.ts +44 -0
  41. package/src/i18n/accountRoleLabels.ts +26 -0
  42. package/src/i18n/reputationCategoryLabels.ts +20 -0
  43. package/src/i18n/trustTierLabels.ts +18 -0
  44. package/src/index.ts +13 -6
  45. package/src/mixins/OxyServices.followGraph.ts +24 -0
  46. package/src/mixins/__tests__/followGraph.test.ts +19 -0
  47. package/src/session/__tests__/accountProjection.test.ts +98 -0
  48. package/src/session/accountProjection.ts +37 -6
  49. package/src/utils/errorUtils.ts +116 -5
@@ -0,0 +1,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;
@@ -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 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.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": "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
+ });