@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
@@ -17,6 +17,7 @@ import { OxyServicesReputationMixin } from './OxyServices.reputation';
17
17
  import { OxyServicesAssetsMixin } from './OxyServices.assets';
18
18
  import { OxyServicesAccountsMixin } from './OxyServices.accounts';
19
19
  import { OxyServicesConnectedAppsMixin } from './OxyServices.connectedApps';
20
+ import { OxyServicesStoreMixin } from './OxyServices.store';
20
21
  import { OxyServicesLocationMixin } from './OxyServices.location';
21
22
  import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
22
23
  import { OxyServicesDevicesMixin } from './OxyServices.devices';
@@ -28,6 +29,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
28
29
  import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
29
30
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
30
31
  import { OxyServicesCivicMixin } from './OxyServices.civic';
32
+ import { OxyServicesChainsMixin } from './OxyServices.chains';
31
33
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
32
34
  import { OxyServicesLinksMixin } from './OxyServices.links';
33
35
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
@@ -55,6 +57,7 @@ type AllMixinInstances =
55
57
  & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
56
58
  & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>>
57
59
  & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>>
60
+ & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>>
58
61
  & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
59
62
  & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
60
63
  & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>>
@@ -65,6 +68,7 @@ type AllMixinInstances =
65
68
  & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>>
66
69
  & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
67
70
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
71
+ & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>>
68
72
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
69
73
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
70
74
  & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
@@ -123,6 +127,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
123
127
  // OAuth-consent surface (public app identity + connected-app grants). Kept
124
128
  // separate from account ownership.
125
129
  OxyServicesConnectedAppsMixin,
130
+ // The app store: the public storefront, the reviews on it, and the listing a
131
+ // publisher edits. A module OVER the platform — turn it off and OAuth still
132
+ // works — so it is its own surface rather than more of `accounts`.
133
+ OxyServicesStoreMixin,
126
134
  OxyServicesLocationMixin,
127
135
  OxyServicesAnalyticsMixin,
128
136
  OxyServicesDevicesMixin,
@@ -137,6 +145,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
137
145
  OxyServicesAppDataMixin,
138
146
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
139
147
  OxyServicesCivicMixin,
148
+ OxyServicesChainsMixin,
140
149
  // User nodes / decentralization (Fase 5): register/read/revoke/manage the
141
150
  // caller's personal data node + ingest hint.
142
151
  OxyServicesNodesMixin,
@@ -125,6 +125,53 @@ describe('@oxyhq/core/server rate limiter', () => {
125
125
  expect(req.observedKey).toBe('user:validated-user');
126
126
  });
127
127
 
128
+ it('does not clobber an identity a preceding middleware already resolved', () => {
129
+ // The limiter mutates the SHARED `req`, so the unconditional
130
+ // `req.userId = null` that `oxy.auth({ optional: true })` writes for every
131
+ // request it cannot authenticate is not merely a bucketing detail — it
132
+ // erases the identity for every handler downstream of the limiter too.
133
+ // The resolver must therefore skip entirely when a user is already present.
134
+ // This handler stands in for that erasure.
135
+ const clobberingAuth = jest.fn(
136
+ (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
137
+ req.userId = null;
138
+ req.user = null;
139
+ req.sessionId = null;
140
+ next();
141
+ },
142
+ );
143
+ const oxy = makeOxy(clobberingAuth as unknown as RequestHandler);
144
+ const req = makeRequest({
145
+ userId: 'resolved-by-the-app',
146
+ user: { id: 'resolved-by-the-app' },
147
+ sessionId: 'app-session',
148
+ });
149
+
150
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
151
+
152
+ expect(req.userId).toBe('resolved-by-the-app');
153
+ expect(req.user).toEqual({ id: 'resolved-by-the-app' });
154
+ expect(req.sessionId).toBe('app-session');
155
+ expect(req.observedKey).toBe('user:resolved-by-the-app');
156
+ expect(clobberingAuth).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('still resolves the session when no identity is present yet', () => {
160
+ const authHandler = jest.fn((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
161
+ req.userId = 'resolved-by-oxy';
162
+ req.user = { id: 'resolved-by-oxy' };
163
+ req.sessionId = 'oxy-session';
164
+ next();
165
+ });
166
+ const oxy = makeOxy(authHandler as unknown as RequestHandler);
167
+ const req = makeRequest();
168
+
169
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
170
+
171
+ expect(authHandler).toHaveBeenCalledTimes(1);
172
+ expect(req.observedKey).toBe('user:resolved-by-oxy');
173
+ });
174
+
128
175
  it('continues through the anonymous limiter if optional auth returns an error', () => {
129
176
  const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => {
130
177
  next(new Error('token rejected'));
@@ -3,6 +3,7 @@ import { isIPv4, isIPv6 } from 'node:net';
3
3
  import type { Request, RequestHandler } from 'express';
4
4
  import rateLimit, { type Store } from 'express-rate-limit';
5
5
  import type { OxyServices } from '../OxyServices';
6
+ import { createOptionalOxyAuth } from './auth';
6
7
 
7
8
  /**
8
9
  * Server-only rate limiting for Oxy backends.
@@ -25,8 +26,9 @@ import type { OxyServices } from '../OxyServices';
25
26
  * WHAT IT PROVIDES
26
27
  * ----------------
27
28
  * `createOxyRateLimit(oxy, options)` returns a SINGLE composed middleware that:
28
- * 1. Resolves the user via `oxy.auth({ optional: true })` (idempotent — it
29
- * skips re-verification if a prior middleware already set `req.user`).
29
+ * 1. Resolves the user via `createOptionalOxyAuth` (idempotent — it skips
30
+ * resolution entirely if a prior middleware already resolved a user, so
31
+ * the limiter can never erase an identity it did not create).
30
32
  * 2. Applies an `express-rate-limit` limiter keyed PER USER when
31
33
  * authenticated, falling back to the (IPv6-safe) IP otherwise, with
32
34
  * generous, media-app-realistic defaults and sensible exemptions.
@@ -203,11 +205,12 @@ function hashAnonymousIp(ip: string): string {
203
205
  /**
204
206
  * Resolve the trusted authenticated rate-limit key.
205
207
  *
206
- * `oxy.auth({ optional: true })` preserves legacy non-session user tokens by
207
- * decoding their JWT claims locally. Those claims are not cryptographically
208
- * verified and therefore MUST NOT influence abuse-control buckets. Only use
209
- * identities that came from a server-validated session or a verified service
210
- * token/delegation.
208
+ * Only identities that came from a server-validated session or a verified
209
+ * service token/delegation may pick a bucket. `req.sessionId` is the marker
210
+ * for the former: `oxy.auth()` sets it only after `validateSession()` came
211
+ * back valid, so requiring it here means an identity written by some OTHER
212
+ * middleware — which this package cannot vouch for — shares the anonymous
213
+ * per-IP bucket rather than getting the authenticated quota.
211
214
  */
212
215
  function resolveTrustedAuthenticatedKey(req: OxyAuthedRequest): string | null {
213
216
  const userId = req.userId ?? req.user?.id ?? req.user?._id;
@@ -260,7 +263,14 @@ export function createOxyRateLimit(
260
263
 
261
264
  // Idempotent optional-auth resolver. Reuses the SAME session resolution as
262
265
  // every protected route, so the limiter keys by the real user identity.
263
- const resolveSession = oxy.auth({ ...auth, optional: true });
266
+ //
267
+ // `createOptionalOxyAuth` — NOT the raw `oxy.auth({ optional: true })` —
268
+ // because only the former skips resolution when a preceding middleware has
269
+ // already resolved a user. The raw middleware writes `req.userId = null` on
270
+ // every request it cannot authenticate, and because it mutates the shared
271
+ // `req` that erasure is visible to every handler downstream of the limiter,
272
+ // not just to the bucket calculation.
273
+ const resolveSession = createOptionalOxyAuth(oxy, { auth });
264
274
 
265
275
  const skip = (req: Request): boolean =>
266
276
  isBuiltInExempt(req) || (exempt ? exempt(req) : false);
@@ -3,6 +3,7 @@ import { ACCOUNT_KINDS } from '@oxyhq/contracts';
3
3
  import type { User } from '../../models/interfaces';
4
4
  import type { AccountNode } from '../../mixins/OxyServices.accounts';
5
5
  import {
6
+ canSwitchIntoAccount,
6
7
  isSwitchTargetAccount,
7
8
  projectSwitchableAccounts,
8
9
  switchableAccountIds,
@@ -102,6 +103,71 @@ describe('isSwitchTargetAccount', () => {
102
103
  });
103
104
  });
104
105
 
106
+ describe('canSwitchIntoAccount', () => {
107
+ it('admits self without membership permissions', () => {
108
+ expect(canSwitchIntoAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
109
+ });
110
+
111
+ it('admits an owned switch target when membership is absent (owner baseline)', () => {
112
+ expect(canSwitchIntoAccount({ kind: 'organization', relationship: 'owner' })).toBe(true);
113
+ });
114
+
115
+ it('requires account:act_as for member relationships', () => {
116
+ expect(
117
+ canSwitchIntoAccount({
118
+ kind: 'organization',
119
+ relationship: 'member',
120
+ callerMembership: {
121
+ _id: 'm1',
122
+ accountId: 'org1',
123
+ memberUserId: 'u1',
124
+ role: 'billing',
125
+ status: 'active',
126
+ permissions: ['account:read', 'billing:manage'],
127
+ inherit: true,
128
+ source: 'direct',
129
+ },
130
+ }),
131
+ ).toBe(false);
132
+
133
+ expect(
134
+ canSwitchIntoAccount({
135
+ kind: 'organization',
136
+ relationship: 'member',
137
+ callerMembership: {
138
+ _id: 'm2',
139
+ accountId: 'org1',
140
+ memberUserId: 'u1',
141
+ role: 'admin',
142
+ status: 'active',
143
+ permissions: ['account:act_as', 'account:read'],
144
+ inherit: true,
145
+ source: 'direct',
146
+ },
147
+ }),
148
+ ).toBe(true);
149
+ });
150
+
151
+ it('refuses channels even with act_as permission', () => {
152
+ expect(
153
+ canSwitchIntoAccount({
154
+ kind: 'channel',
155
+ relationship: 'owner',
156
+ callerMembership: {
157
+ _id: 'm3',
158
+ accountId: 'chan1',
159
+ memberUserId: 'u1',
160
+ role: 'owner',
161
+ status: 'active',
162
+ permissions: ['account:act_as'],
163
+ inherit: true,
164
+ source: 'direct',
165
+ },
166
+ }),
167
+ ).toBe(false);
168
+ });
169
+ });
170
+
105
171
  describe('projectSwitchableAccounts', () => {
106
172
  it('returns [] for null state and empty graph', () => {
107
173
  expect(
@@ -235,6 +301,38 @@ describe('projectSwitchableAccounts', () => {
235
301
  ]);
236
302
  });
237
303
 
304
+ it('omits a graph-only member without account:act_as', () => {
305
+ const rows = projectSwitchableAccounts({
306
+ state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
307
+ graph: [
308
+ graphNode('org1', { kind: 'organization', relationship: 'member', callerMembership: {
309
+ _id: 'm1',
310
+ accountId: 'org1',
311
+ memberUserId: 'a1',
312
+ role: 'billing',
313
+ status: 'active',
314
+ permissions: ['account:read', 'billing:manage'],
315
+ inherit: true,
316
+ source: 'direct',
317
+ } }),
318
+ graphNode('org2', { kind: 'organization', relationship: 'member', callerMembership: {
319
+ _id: 'm2',
320
+ accountId: 'org2',
321
+ memberUserId: 'a1',
322
+ role: 'admin',
323
+ status: 'active',
324
+ permissions: ['account:act_as', 'account:read'],
325
+ inherit: true,
326
+ source: 'direct',
327
+ } }),
328
+ ],
329
+ profilesById: mapOf(user('a1'), user('org1'), user('org2')),
330
+ resolveAvatarUrl: noAvatar,
331
+ });
332
+
333
+ expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org2']);
334
+ });
335
+
238
336
  it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
239
337
  const rows = projectSwitchableAccounts({
240
338
  state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
@@ -135,6 +135,37 @@ export function isSwitchTargetAccount(
135
135
  return node.relationship === 'self' || isActAsEligibleKind(node.kind);
136
136
  }
137
137
 
138
+ /**
139
+ * Whether the caller may switch INTO this account — the server-side
140
+ * `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
141
+ *
142
+ * `relationship: 'self'` always passes (returning to the caller's own personal
143
+ * account). Every other ground requires a switch-eligible kind AND
144
+ * `account:act_as` in the resolved membership permissions. When permissions are
145
+ * absent but the relationship is `owner`, the owner baseline is assumed — the
146
+ * API always resolves effective permissions for owned accounts, but test
147
+ * fixtures and stale rows may omit the membership blob.
148
+ */
149
+ export function canSwitchIntoAccount(
150
+ node: {
151
+ kind?: AccountKind | null;
152
+ relationship?: AccountRelationship;
153
+ callerMembership?: AccountMember | null;
154
+ },
155
+ ): boolean {
156
+ if (node.relationship === 'self') {
157
+ return true;
158
+ }
159
+ if (!isSwitchTargetAccount(node)) {
160
+ return false;
161
+ }
162
+ const permissions = node.callerMembership?.permissions;
163
+ if (permissions) {
164
+ return permissions.includes('account:act_as');
165
+ }
166
+ return node.relationship === 'owner';
167
+ }
168
+
138
169
  /** Input to {@link projectSwitchableAccounts}. */
139
170
  export interface ProjectSwitchableAccountsInput {
140
171
  /**
@@ -176,9 +207,9 @@ export interface ProjectSwitchableAccountsInput {
176
207
  * and a graph node is deduped into ONE device row enriched with the graph
177
208
  * metadata (relationship / kind / parent / membership).
178
209
  *
179
- * Graph nodes that are not switch targets — a `channel`, which nobody may act
180
- * as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
181
- * below.
210
+ * Graph nodes the caller cannot switch into — a `channel`, or a managed account
211
+ * whose membership lacks `account:act_as` — are omitted.
212
+ * {@link canSwitchIntoAccount} is the rule; see the filter below.
182
213
  */
183
214
  export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[] {
184
215
  const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
@@ -274,7 +305,7 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
274
305
  // An account already on the device skipped this check via the branch above,
275
306
  // and correctly: whatever its kind, the caller is signed into it, so
276
307
  // switching is a local activation that asks the server for nothing.
277
- if (!isSwitchTargetAccount(node)) {
308
+ if (!canSwitchIntoAccount(node)) {
278
309
  continue;
279
310
  }
280
311
  remember(toRow(node.account, {
@@ -298,7 +329,7 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
298
329
  * document, but including their ids lets the caller pass one id set and lets the
299
330
  * projection prefer freshly-fetched profiles uniformly.
300
331
  *
301
- * Applies the SAME {@link isSwitchTargetAccount} filter as
332
+ * Applies the SAME {@link canSwitchIntoAccount} filter as
302
333
  * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
303
334
  * profile for a row the projection will drop — and, just as importantly, never
304
335
  * SKIPS one the projection will keep, which would leave that row unrendered
@@ -315,7 +346,7 @@ export function switchableAccountIds(
315
346
  }
316
347
  }
317
348
  for (const node of graph) {
318
- if (node.accountId && isSwitchTargetAccount(node)) {
349
+ if (node.accountId && canSwitchIntoAccount(node)) {
319
350
  ids.add(node.accountId);
320
351
  }
321
352
  }
@@ -36,6 +36,110 @@ export const ErrorCodes = {
36
36
  CONNECTION_FAILED: 'CONNECTION_FAILED'
37
37
  } as const;
38
38
 
39
+ /**
40
+ * The `Error` shape the SDK rejects with when an HTTP request fails.
41
+ *
42
+ * `HttpService` throws this for every non-2xx response, and
43
+ * `OxyServices.handleError` (the wrapper the mixin methods rethrow through)
44
+ * preserves `message`, `status`, `code` and `details`. `response` only survives
45
+ * on the raw `HttpService`/`makeRequest` path, so treat it as optional.
46
+ *
47
+ * Narrow a caught value with {@link isHttpRequestError} instead of asserting.
48
+ */
49
+ export interface HttpRequestError extends Error {
50
+ /** HTTP status of the failed response. */
51
+ status: number;
52
+ /** Machine-readable code the server sent, when it sent one. */
53
+ code?: string;
54
+ /** Structured error detail the server sent, when it sent an object. */
55
+ details?: Record<string, unknown>;
56
+ /**
57
+ * Present on errors thrown directly by `HttpService`. `data` is the parsed
58
+ * JSON error body verbatim — the escape hatch for any server field the SDK
59
+ * does not lift onto `code`/`details`.
60
+ */
61
+ response?: {
62
+ status: number;
63
+ statusText: string;
64
+ data?: unknown;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Narrow a caught value to {@link HttpRequestError}.
70
+ *
71
+ * Returns `false` for a plain {@link ApiError} object (those are objects, not
72
+ * `Error`s) — run an arbitrary thrown value through {@link handleHttpError}
73
+ * first if you need one normalized.
74
+ */
75
+ export function isHttpRequestError(value: unknown): value is HttpRequestError {
76
+ if (!(value instanceof Error)) {
77
+ return false;
78
+ }
79
+ return typeof (value as Partial<HttpRequestError>).status === 'number';
80
+ }
81
+
82
+ /**
83
+ * The fields {@link parseHttpErrorBody} lifts off a parsed error response body.
84
+ */
85
+ export interface ParsedHttpErrorBody {
86
+ message?: string;
87
+ code?: string;
88
+ details?: Record<string, unknown>;
89
+ }
90
+
91
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
92
+ typeof value === 'object' && value !== null && !Array.isArray(value);
93
+
94
+ const nonEmptyString = (value: unknown): string | undefined =>
95
+ typeof value === 'string' && value.trim().length > 0 ? value : undefined;
96
+
97
+ /**
98
+ * Extract `message` / `code` / `details` from a parsed HTTP error response body.
99
+ *
100
+ * Handles every error envelope in use across the Oxy ecosystem:
101
+ *
102
+ * - `{ error: { code, message, details? } }` — nested envelope (CrowdSource and
103
+ * other Oxy services). Never stringify the nested object: `new Error(obj)`
104
+ * yields the literal message `"[object Object]"`.
105
+ * - `{ error: '<CODE>', message, details? }` — oxy-api's canonical shape
106
+ * (`ApiError.toJSON`), where the top-level `error` field IS the code.
107
+ * - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
108
+ * OAuth token and userinfo endpoints. `error_description` is the human text
109
+ * and `error` is the machine code, so both survive.
110
+ * - `{ message, code }` — e.g. the API's CSRF rejections.
111
+ * - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
112
+ * `message`/`error_description` the string is the message, not a code: a bare
113
+ * `error` string is not machine-readable enough to promote to `code`.
114
+ *
115
+ * Anything else — a non-object body (`null`, `[]`, `"str"`, `42`), or an object
116
+ * carrying none of these fields — yields an empty result, leaving the caller on
117
+ * its status-based fallback message. Total function: never throws.
118
+ */
119
+ export function parseHttpErrorBody(body: unknown): ParsedHttpErrorBody {
120
+ if (!isPlainRecord(body)) {
121
+ return {};
122
+ }
123
+
124
+ const nested = isPlainRecord(body.error) ? body.error : undefined;
125
+ const errorString = nonEmptyString(body.error);
126
+ // A sibling that proves the top-level `error` is a CODE rather than prose.
127
+ const siblingMessage = nonEmptyString(body.message) ?? nonEmptyString(body.error_description);
128
+
129
+ return {
130
+ message: siblingMessage ?? (nested ? nonEmptyString(nested.message) : errorString),
131
+ code:
132
+ (nested ? nonEmptyString(nested.code) : undefined) ??
133
+ nonEmptyString(body.code) ??
134
+ (siblingMessage ? errorString : undefined),
135
+ details: isPlainRecord(body.details)
136
+ ? body.details
137
+ : nested && isPlainRecord(nested.details)
138
+ ? nested.details
139
+ : undefined,
140
+ };
141
+ }
142
+
39
143
  /**
40
144
  * Create a standardized API error
41
145
  */
@@ -98,21 +202,28 @@ export function handleHttpError(error: unknown): ApiError {
98
202
 
99
203
  // Handle fetch Response errors - check if it has response property with status
100
204
  if (error && typeof error === 'object' && 'response' in error) {
101
- const fetchError = error as {
102
- response?: {
103
- status: number;
205
+ const fetchError = error as {
206
+ response?: {
207
+ status: number;
104
208
  statusText?: string;
105
209
  };
106
210
  status?: number;
107
211
  message?: string;
212
+ details?: unknown;
108
213
  };
109
-
214
+
110
215
  const status = fetchError.response?.status || fetchError.status;
111
216
  if (status) {
217
+ // `details` is carried through when present: a body may ship structured
218
+ // detail without a machine-readable `code` (which is what routes the
219
+ // error to the already-an-ApiError branch above), and dropping it here
220
+ // would make it unreachable to every caller that rethrows via
221
+ // `OxyServices.handleError`.
112
222
  return createApiError(
113
223
  fetchError.message || `HTTP ${status} error`,
114
224
  getErrorCodeFromStatus(status),
115
- status
225
+ status,
226
+ isPlainRecord(fetchError.details) ? fetchError.details : undefined
116
227
  );
117
228
  }
118
229
  }