@oxyhq/core 19.1.2 → 20.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +23 -18
- package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
- package/dist/cjs/i18n/accountRoleLabels.js +27 -0
- package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
- package/dist/cjs/i18n/trustTierLabels.js +19 -0
- package/dist/cjs/index.js +19 -9
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.followGraph.js +17 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.utility.js +159 -104
- package/dist/cjs/mixins/index.js +7 -0
- package/dist/cjs/server/rateLimit.js +15 -6
- package/dist/cjs/session/accountProjection.js +31 -6
- package/dist/cjs/utils/errorUtils.js +65 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +24 -19
- package/dist/esm/i18n/accountCategoryLabels.js +37 -0
- package/dist/esm/i18n/accountRoleLabels.js +20 -0
- package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
- package/dist/esm/i18n/trustTierLabels.js +12 -0
- package/dist/esm/index.js +11 -8
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.followGraph.js +17 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.utility.js +159 -104
- package/dist/esm/mixins/index.js +7 -0
- package/dist/esm/server/rateLimit.js +15 -6
- package/dist/esm/session/accountProjection.js +30 -6
- package/dist/esm/utils/errorUtils.js +63 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
- package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
- package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
- package/dist/types/i18n/trustTierLabels.d.ts +9 -0
- package/dist/types/index.d.ts +14 -2
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.followGraph.d.ts +13 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/session/accountProjection.d.ts +20 -4
- package/dist/types/utils/errorUtils.d.ts +67 -0
- package/package.json +7 -6
- package/src/HttpService.ts +29 -22
- package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
- package/src/__tests__/serverValueImportsDeclared.test.ts +7 -0
- package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
- package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
- package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
- package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
- package/src/i18n/accountCategoryLabels.ts +44 -0
- package/src/i18n/accountRoleLabels.ts +26 -0
- package/src/i18n/reputationCategoryLabels.ts +20 -0
- package/src/i18n/trustTierLabels.ts +18 -0
- package/src/index.ts +43 -6
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.followGraph.ts +24 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.utility.ts +161 -108
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/followGraph.test.ts +19 -0
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/index.ts +9 -0
- package/src/server/__tests__/rateLimit.test.ts +47 -0
- package/src/server/rateLimit.ts +18 -8
- package/src/session/__tests__/accountProjection.test.ts +98 -0
- package/src/session/accountProjection.ts +37 -6
- package/src/utils/errorUtils.ts +116 -5
package/src/mixins/index.ts
CHANGED
|
@@ -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'));
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -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 `
|
|
29
|
-
*
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
-
|
|
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
|
|
180
|
-
*
|
|
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 (!
|
|
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
|
|
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 &&
|
|
349
|
+
if (node.accountId && canSwitchIntoAccount(node)) {
|
|
319
350
|
ids.add(node.accountId);
|
|
320
351
|
}
|
|
321
352
|
}
|
package/src/utils/errorUtils.ts
CHANGED
|
@@ -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
|
}
|