@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
|
@@ -136,18 +136,41 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
136
136
|
* Uses server-side session validation for security (not just JWT decode).
|
|
137
137
|
*
|
|
138
138
|
* **Design note — jwtDecode vs jwt.verify:**
|
|
139
|
-
* This middleware
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* -
|
|
143
|
-
*
|
|
144
|
-
* -
|
|
145
|
-
*
|
|
146
|
-
*
|
|
139
|
+
* This middleware uses `jwtDecode()` (decode-only, NO signature check) for
|
|
140
|
+
* user tokens, because third-party apps mounting `oxy.auth()` do not hold
|
|
141
|
+
* the Oxy signing secret. **Every claim in a user token is therefore
|
|
142
|
+
* attacker-controlled and proves nothing on its own.** The identity comes
|
|
143
|
+
* from somewhere else entirely:
|
|
144
|
+
* - A user token MUST carry a `sessionId`. That session is validated
|
|
145
|
+
* server-side on every request via `validateSession()`, and the user id
|
|
146
|
+
* is read off the VALIDATED SESSION — never off the token. A token whose
|
|
147
|
+
* `userId` claim disagrees with the session is refused
|
|
148
|
+
* (`SESSION_USER_MISMATCH`); a token with no `sessionId` at all is
|
|
149
|
+
* refused outright (`SESSION_REQUIRED`). There is no local-claims path.
|
|
150
|
+
* - Service tokens (type: 'service') ARE stateless, so they use
|
|
151
|
+
* cryptographic HMAC verification via the `jwtSecret` option, and are
|
|
152
|
+
* additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
147
153
|
* cross-token-type confusion attacks.
|
|
148
154
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
149
155
|
* direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
|
|
150
156
|
*
|
|
157
|
+
* **Why session-less user tokens are refused rather than trusted:**
|
|
158
|
+
* every user access token the Oxy API issues carries a `sessionId` (see
|
|
159
|
+
* `packages/api/src/utils/sessionUtils.ts`, `generateSessionTokens` — the
|
|
160
|
+
* only mint site for user tokens, including the OAuth code exchange). So
|
|
161
|
+
* refusing session-less user tokens costs nothing legitimate, while
|
|
162
|
+
* accepting them let anyone authenticate as anyone by hand-rolling a JWT
|
|
163
|
+
* with a `userId` claim and a garbage signature.
|
|
164
|
+
*
|
|
165
|
+
* **Why the claimed user id is cross-checked against the session:**
|
|
166
|
+
* `GET /session/validate/:sessionId` is UNAUTHENTICATED and does not bind
|
|
167
|
+
* the bearer token — it returns whoever owns the session id it was handed.
|
|
168
|
+
* Trusting the token's `userId` claim after a successful validation would
|
|
169
|
+
* therefore let a caller holding ANY live session id (their own, for
|
|
170
|
+
* instance) pair it with a forged `userId` and be trusted as that user.
|
|
171
|
+
* `authSocket()` has always cross-checked this; the HTTP middleware now
|
|
172
|
+
* does too.
|
|
173
|
+
*
|
|
151
174
|
* **Service-token delegation (X-Oxy-User-Id):**
|
|
152
175
|
* When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
|
|
153
176
|
* `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
|
|
@@ -410,8 +433,10 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
410
433
|
}
|
|
411
434
|
return next();
|
|
412
435
|
}
|
|
413
|
-
|
|
414
|
-
|
|
436
|
+
// The CLAIMED user id. Never trusted as an identity — it is only ever
|
|
437
|
+
// compared against the id the validated session resolves to.
|
|
438
|
+
const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
|
|
439
|
+
if (!claimedUserId) {
|
|
415
440
|
if (optional) {
|
|
416
441
|
req.userId = null;
|
|
417
442
|
req.user = null;
|
|
@@ -445,111 +470,129 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
445
470
|
return onError(error);
|
|
446
471
|
return res.status(401).json(error);
|
|
447
472
|
}
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
req.user = null;
|
|
459
|
-
return next();
|
|
460
|
-
}
|
|
461
|
-
const error = {
|
|
462
|
-
error: 'INVALID_SESSION',
|
|
463
|
-
message: 'Session invalid or expired',
|
|
464
|
-
code: 'INVALID_SESSION',
|
|
465
|
-
status: 401
|
|
466
|
-
};
|
|
467
|
-
if (onError)
|
|
468
|
-
return onError(error);
|
|
469
|
-
return res.status(401).json(error);
|
|
470
|
-
}
|
|
471
|
-
// Use validated user data from session validation (already has full user)
|
|
472
|
-
req.userId = userId;
|
|
473
|
-
req.accessToken = token;
|
|
474
|
-
req.sessionId = decoded.sessionId;
|
|
475
|
-
if (loadUser && validationResult.user) {
|
|
476
|
-
// Session validation already returns full user data
|
|
477
|
-
req.user = validationResult.user;
|
|
478
|
-
}
|
|
479
|
-
else {
|
|
480
|
-
req.user = { id: userId };
|
|
481
|
-
}
|
|
482
|
-
if (debug) {
|
|
483
|
-
logger_1.logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
|
|
484
|
-
component: 'auth',
|
|
485
|
-
method: 'auth',
|
|
486
|
-
});
|
|
487
|
-
}
|
|
473
|
+
// A server-validated session is MANDATORY for a user token. The JWT
|
|
474
|
+
// signature is not verified on this path, so a bare decoded token
|
|
475
|
+
// proves nothing: without the session round-trip a forged token could
|
|
476
|
+
// claim any user id. Mirrors `authSocket()`, which has always
|
|
477
|
+
// required this.
|
|
478
|
+
const sessionId = readStringClaim(decoded.sessionId);
|
|
479
|
+
if (!sessionId) {
|
|
480
|
+
if (optional) {
|
|
481
|
+
req.userId = null;
|
|
482
|
+
req.user = null;
|
|
488
483
|
return next();
|
|
489
484
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
485
|
+
const error = {
|
|
486
|
+
error: 'SESSION_REQUIRED',
|
|
487
|
+
message: 'Access token is not bound to a session',
|
|
488
|
+
code: 'SESSION_REQUIRED',
|
|
489
|
+
status: 401
|
|
490
|
+
};
|
|
491
|
+
if (onError)
|
|
492
|
+
return onError(error);
|
|
493
|
+
return res.status(401).json(error);
|
|
494
|
+
}
|
|
495
|
+
// Validate the token against the Oxy API. This proves the session is
|
|
496
|
+
// real and unrevoked, AND yields the identity it belongs to.
|
|
497
|
+
try {
|
|
498
|
+
const validationResult = await oxyInstance.validateSession(sessionId, {
|
|
499
|
+
useHeaderValidation: true,
|
|
500
|
+
});
|
|
501
|
+
if (!validationResult || !validationResult.valid || !validationResult.user) {
|
|
497
502
|
if (optional) {
|
|
498
503
|
req.userId = null;
|
|
499
504
|
req.user = null;
|
|
500
505
|
return next();
|
|
501
506
|
}
|
|
502
507
|
const error = {
|
|
503
|
-
error: '
|
|
504
|
-
message: 'Session
|
|
505
|
-
code: '
|
|
508
|
+
error: 'INVALID_SESSION',
|
|
509
|
+
message: 'Session invalid or expired',
|
|
510
|
+
code: 'INVALID_SESSION',
|
|
506
511
|
status: 401
|
|
507
512
|
};
|
|
508
513
|
if (onError)
|
|
509
514
|
return onError(error);
|
|
510
515
|
return res.status(401).json(error);
|
|
511
516
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
try {
|
|
520
|
-
// Temporarily set token to make the API call
|
|
521
|
-
const prevToken = oxyInstance.getAccessToken();
|
|
522
|
-
oxyInstance.setTokens(token);
|
|
523
|
-
const fullUser = await oxyInstance.getCurrentUser();
|
|
524
|
-
// Restore previous token
|
|
525
|
-
if (prevToken) {
|
|
526
|
-
oxyInstance.setTokens(prevToken);
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
oxyInstance.clearTokens();
|
|
517
|
+
// The session — not the token — is the source of truth for identity.
|
|
518
|
+
const validatedUserId = getUserIdentityId(validationResult.user);
|
|
519
|
+
if (!validatedUserId) {
|
|
520
|
+
if (optional) {
|
|
521
|
+
req.userId = null;
|
|
522
|
+
req.user = null;
|
|
523
|
+
return next();
|
|
530
524
|
}
|
|
531
|
-
|
|
532
|
-
|
|
525
|
+
const error = {
|
|
526
|
+
error: 'INVALID_SESSION',
|
|
527
|
+
message: 'Session did not resolve to a usable identity',
|
|
528
|
+
code: 'INVALID_SESSION',
|
|
529
|
+
status: 401
|
|
530
|
+
};
|
|
531
|
+
if (onError)
|
|
532
|
+
return onError(error);
|
|
533
|
+
return res.status(401).json(error);
|
|
534
|
+
}
|
|
535
|
+
if (validatedUserId !== claimedUserId) {
|
|
536
|
+
// Session-id/claim confusion: the caller presented a live session
|
|
537
|
+
// that belongs to somebody else. Worth a warning — it has no
|
|
538
|
+
// benign cause. Ids only; never the token or the payload.
|
|
539
|
+
logger_1.logger.warn('[oxy.auth] Token rejected — claimed user does not own the session', {
|
|
540
|
+
component: 'auth',
|
|
541
|
+
method: 'auth',
|
|
542
|
+
claimedUserId,
|
|
543
|
+
validatedUserId,
|
|
544
|
+
});
|
|
545
|
+
if (optional) {
|
|
546
|
+
req.userId = null;
|
|
547
|
+
req.user = null;
|
|
548
|
+
return next();
|
|
533
549
|
}
|
|
550
|
+
const error = {
|
|
551
|
+
error: 'SESSION_USER_MISMATCH',
|
|
552
|
+
message: 'Token user does not match the session',
|
|
553
|
+
code: 'SESSION_USER_MISMATCH',
|
|
554
|
+
status: 401
|
|
555
|
+
};
|
|
556
|
+
if (onError)
|
|
557
|
+
return onError(error);
|
|
558
|
+
return res.status(401).json(error);
|
|
534
559
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
560
|
+
req.userId = validatedUserId;
|
|
561
|
+
req.accessToken = token;
|
|
562
|
+
req.sessionId = sessionId;
|
|
563
|
+
// Session validation already returned the full user, so `loadUser`
|
|
564
|
+
// costs no extra round-trip.
|
|
565
|
+
req.user = loadUser ? validationResult.user : { id: validatedUserId };
|
|
566
|
+
if (debug) {
|
|
567
|
+
logger_1.logger.debug(`[oxy.auth] OK user=${validatedUserId} session=${sessionId}`, {
|
|
540
568
|
component: 'auth',
|
|
541
|
-
method: 'auth
|
|
542
|
-
|
|
543
|
-
}, loadUserError);
|
|
569
|
+
method: 'auth',
|
|
570
|
+
});
|
|
544
571
|
}
|
|
572
|
+
return next();
|
|
545
573
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
574
|
+
catch (validationError) {
|
|
575
|
+
if (debug) {
|
|
576
|
+
logger_1.logger.debug('[oxy.auth] Session validation failed', {
|
|
577
|
+
component: 'auth',
|
|
578
|
+
method: 'auth',
|
|
579
|
+
}, validationError);
|
|
580
|
+
}
|
|
581
|
+
if (optional) {
|
|
582
|
+
req.userId = null;
|
|
583
|
+
req.user = null;
|
|
584
|
+
return next();
|
|
585
|
+
}
|
|
586
|
+
const error = {
|
|
587
|
+
error: 'SESSION_VALIDATION_ERROR',
|
|
588
|
+
message: 'Session validation failed',
|
|
589
|
+
code: 'SESSION_VALIDATION_ERROR',
|
|
590
|
+
status: 401
|
|
591
|
+
};
|
|
592
|
+
if (onError)
|
|
593
|
+
return onError(error);
|
|
594
|
+
return res.status(401).json(error);
|
|
551
595
|
}
|
|
552
|
-
next();
|
|
553
596
|
}
|
|
554
597
|
catch (error) {
|
|
555
598
|
const handled = oxyInstance.handleError(error);
|
|
@@ -618,7 +661,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
618
661
|
}
|
|
619
662
|
return next(new Error('Invalid token'));
|
|
620
663
|
}
|
|
621
|
-
const claimedUserId = decoded.userId
|
|
664
|
+
const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
|
|
622
665
|
if (!claimedUserId) {
|
|
623
666
|
return next(new Error('Invalid token payload'));
|
|
624
667
|
}
|
|
@@ -629,12 +672,13 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
629
672
|
// A server-validated session is mandatory. A bare decoded JWT proves
|
|
630
673
|
// nothing — the signature is not verified here, so without a session
|
|
631
674
|
// round-trip a forged token could claim any user id.
|
|
632
|
-
|
|
675
|
+
const sessionId = readStringClaim(decoded.sessionId);
|
|
676
|
+
if (!sessionId) {
|
|
633
677
|
return next(new Error('Session required'));
|
|
634
678
|
}
|
|
635
679
|
let userId = claimedUserId;
|
|
636
680
|
try {
|
|
637
|
-
const result = await oxyInstance.validateSession(
|
|
681
|
+
const result = await oxyInstance.validateSession(sessionId, {
|
|
638
682
|
useHeaderValidation: true,
|
|
639
683
|
});
|
|
640
684
|
if (!result || !result.valid || !result.user) {
|
|
@@ -664,9 +708,9 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
664
708
|
// reads from `socket.user.id`.
|
|
665
709
|
socket.data = socket.data || {};
|
|
666
710
|
socket.data.userId = userId;
|
|
667
|
-
socket.data.sessionId =
|
|
711
|
+
socket.data.sessionId = sessionId;
|
|
668
712
|
socket.data.token = token;
|
|
669
|
-
socket.user = { id: userId, userId, sessionId
|
|
713
|
+
socket.user = { id: userId, userId, sessionId };
|
|
670
714
|
if (debug) {
|
|
671
715
|
logger_1.logger.debug(`[oxy.authSocket] OK user=${userId}`, {
|
|
672
716
|
component: 'auth',
|
|
@@ -810,12 +854,16 @@ async function verifyServiceTokenSignature(token, secret) {
|
|
|
810
854
|
}
|
|
811
855
|
}
|
|
812
856
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
*
|
|
857
|
+
* Read a JWT claim that is only usable as a non-empty string.
|
|
858
|
+
*
|
|
859
|
+
* A decoded payload is attacker-controlled JSON: a claim the type declares as
|
|
860
|
+
* `string` can arrive as a number, an object, or `null`. Narrowing here keeps
|
|
861
|
+
* those values out of URL construction and identity comparison, so an
|
|
862
|
+
* unexpected shape becomes a 401 rather than a stringified surprise.
|
|
818
863
|
*/
|
|
864
|
+
function readStringClaim(value) {
|
|
865
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
866
|
+
}
|
|
819
867
|
/**
|
|
820
868
|
* Resolve the canonical user id from a validated session's user object.
|
|
821
869
|
*
|
|
@@ -828,6 +876,13 @@ function getUserIdentityId(user) {
|
|
|
828
876
|
?? user._id;
|
|
829
877
|
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
|
|
830
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* Verify that a decoded service-token payload carries the expected `aud`,
|
|
881
|
+
* `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
|
|
882
|
+
* This is the defence against the H4 vulnerability where a recovery / 2FA /
|
|
883
|
+
* access token signed by the same shared secret could be replayed as a
|
|
884
|
+
* service token because no claim binding existed.
|
|
885
|
+
*/
|
|
831
886
|
function verifyServiceTokenClaims(decoded, expected) {
|
|
832
887
|
if (decoded.type !== 'service') {
|
|
833
888
|
throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const OxyServices_reputation_1 = require("./OxyServices.reputation");
|
|
|
20
20
|
const OxyServices_assets_1 = require("./OxyServices.assets");
|
|
21
21
|
const OxyServices_accounts_1 = require("./OxyServices.accounts");
|
|
22
22
|
const OxyServices_connectedApps_1 = require("./OxyServices.connectedApps");
|
|
23
|
+
const OxyServices_store_1 = require("./OxyServices.store");
|
|
23
24
|
const OxyServices_location_1 = require("./OxyServices.location");
|
|
24
25
|
const OxyServices_analytics_1 = require("./OxyServices.analytics");
|
|
25
26
|
const OxyServices_devices_1 = require("./OxyServices.devices");
|
|
@@ -31,6 +32,7 @@ const OxyServices_contacts_1 = require("./OxyServices.contacts");
|
|
|
31
32
|
const OxyServices_notifications_1 = require("./OxyServices.notifications");
|
|
32
33
|
const OxyServices_appData_1 = require("./OxyServices.appData");
|
|
33
34
|
const OxyServices_civic_1 = require("./OxyServices.civic");
|
|
35
|
+
const OxyServices_chains_1 = require("./OxyServices.chains");
|
|
34
36
|
const OxyServices_nodes_1 = require("./OxyServices.nodes");
|
|
35
37
|
const OxyServices_links_1 = require("./OxyServices.links");
|
|
36
38
|
const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
|
|
@@ -70,6 +72,10 @@ const MIXIN_PIPELINE = [
|
|
|
70
72
|
// OAuth-consent surface (public app identity + connected-app grants). Kept
|
|
71
73
|
// separate from account ownership.
|
|
72
74
|
OxyServices_connectedApps_1.OxyServicesConnectedAppsMixin,
|
|
75
|
+
// The app store: the public storefront, the reviews on it, and the listing a
|
|
76
|
+
// publisher edits. A module OVER the platform — turn it off and OAuth still
|
|
77
|
+
// works — so it is its own surface rather than more of `accounts`.
|
|
78
|
+
OxyServices_store_1.OxyServicesStoreMixin,
|
|
73
79
|
OxyServices_location_1.OxyServicesLocationMixin,
|
|
74
80
|
OxyServices_analytics_1.OxyServicesAnalyticsMixin,
|
|
75
81
|
OxyServices_devices_1.OxyServicesDevicesMixin,
|
|
@@ -84,6 +90,7 @@ const MIXIN_PIPELINE = [
|
|
|
84
90
|
OxyServices_appData_1.OxyServicesAppDataMixin,
|
|
85
91
|
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
86
92
|
OxyServices_civic_1.OxyServicesCivicMixin,
|
|
93
|
+
OxyServices_chains_1.OxyServicesChainsMixin,
|
|
87
94
|
// User nodes / decentralization (Fase 5): register/read/revoke/manage the
|
|
88
95
|
// caller's personal data node + ingest hint.
|
|
89
96
|
OxyServices_nodes_1.OxyServicesNodesMixin,
|
|
@@ -7,6 +7,7 @@ exports.createOxyRateLimit = createOxyRateLimit;
|
|
|
7
7
|
const node_crypto_1 = require("node:crypto");
|
|
8
8
|
const node_net_1 = require("node:net");
|
|
9
9
|
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
|
10
|
+
const auth_1 = require("./auth");
|
|
10
11
|
/**
|
|
11
12
|
* Built-in exemptions. A media app's cover-art/avatar fan-out and HLS
|
|
12
13
|
* sub-requests must not consume the coarse global budget; health probes from
|
|
@@ -119,11 +120,12 @@ function hashAnonymousIp(ip) {
|
|
|
119
120
|
/**
|
|
120
121
|
* Resolve the trusted authenticated rate-limit key.
|
|
121
122
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
123
|
+
* Only identities that came from a server-validated session or a verified
|
|
124
|
+
* service token/delegation may pick a bucket. `req.sessionId` is the marker
|
|
125
|
+
* for the former: `oxy.auth()` sets it only after `validateSession()` came
|
|
126
|
+
* back valid, so requiring it here means an identity written by some OTHER
|
|
127
|
+
* middleware — which this package cannot vouch for — shares the anonymous
|
|
128
|
+
* per-IP bucket rather than getting the authenticated quota.
|
|
127
129
|
*/
|
|
128
130
|
function resolveTrustedAuthenticatedKey(req) {
|
|
129
131
|
const userId = req.userId ?? req.user?.id ?? req.user?._id;
|
|
@@ -159,7 +161,14 @@ function createOxyRateLimit(oxy, options = {}) {
|
|
|
159
161
|
const { authenticatedMax = 5000, anonymousMax = 600, windowMs = 15 * 60 * 1000, store, exempt, message = 'Too many requests, please try again later.', auth, } = options;
|
|
160
162
|
// Idempotent optional-auth resolver. Reuses the SAME session resolution as
|
|
161
163
|
// every protected route, so the limiter keys by the real user identity.
|
|
162
|
-
|
|
164
|
+
//
|
|
165
|
+
// `createOptionalOxyAuth` — NOT the raw `oxy.auth({ optional: true })` —
|
|
166
|
+
// because only the former skips resolution when a preceding middleware has
|
|
167
|
+
// already resolved a user. The raw middleware writes `req.userId = null` on
|
|
168
|
+
// every request it cannot authenticate, and because it mutates the shared
|
|
169
|
+
// `req` that erasure is visible to every handler downstream of the limiter,
|
|
170
|
+
// not just to the bucket calculation.
|
|
171
|
+
const resolveSession = (0, auth_1.createOptionalOxyAuth)(oxy, { auth });
|
|
163
172
|
const skip = (req) => isBuiltInExempt(req) || (exempt ? exempt(req) : false);
|
|
164
173
|
const limiter = (0, express_rate_limit_1.default)({
|
|
165
174
|
windowMs,
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
21
|
exports.isSwitchTargetAccount = isSwitchTargetAccount;
|
|
22
|
+
exports.canSwitchIntoAccount = canSwitchIntoAccount;
|
|
22
23
|
exports.projectSwitchableAccounts = projectSwitchableAccounts;
|
|
23
24
|
exports.switchableAccountIds = switchableAccountIds;
|
|
24
25
|
const contracts_1 = require("@oxyhq/contracts");
|
|
@@ -53,6 +54,30 @@ const userHandle_1 = require("../utils/userHandle");
|
|
|
53
54
|
function isSwitchTargetAccount(node) {
|
|
54
55
|
return node.relationship === 'self' || (0, contracts_1.isActAsEligibleKind)(node.kind);
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether the caller may switch INTO this account — the server-side
|
|
59
|
+
* `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
|
|
60
|
+
*
|
|
61
|
+
* `relationship: 'self'` always passes (returning to the caller's own personal
|
|
62
|
+
* account). Every other ground requires a switch-eligible kind AND
|
|
63
|
+
* `account:act_as` in the resolved membership permissions. When permissions are
|
|
64
|
+
* absent but the relationship is `owner`, the owner baseline is assumed — the
|
|
65
|
+
* API always resolves effective permissions for owned accounts, but test
|
|
66
|
+
* fixtures and stale rows may omit the membership blob.
|
|
67
|
+
*/
|
|
68
|
+
function canSwitchIntoAccount(node) {
|
|
69
|
+
if (node.relationship === 'self') {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (!isSwitchTargetAccount(node)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const permissions = node.callerMembership?.permissions;
|
|
76
|
+
if (permissions) {
|
|
77
|
+
return permissions.includes('account:act_as');
|
|
78
|
+
}
|
|
79
|
+
return node.relationship === 'owner';
|
|
80
|
+
}
|
|
56
81
|
/**
|
|
57
82
|
* Pure union of device sign-ins and account-graph nodes into the flat
|
|
58
83
|
* {@link SwitchableAccount}[] every switcher renders.
|
|
@@ -62,9 +87,9 @@ function isSwitchTargetAccount(node) {
|
|
|
62
87
|
* and a graph node is deduped into ONE device row enriched with the graph
|
|
63
88
|
* metadata (relationship / kind / parent / membership).
|
|
64
89
|
*
|
|
65
|
-
* Graph nodes
|
|
66
|
-
*
|
|
67
|
-
* below.
|
|
90
|
+
* Graph nodes the caller cannot switch into — a `channel`, or a managed account
|
|
91
|
+
* whose membership lacks `account:act_as` — are omitted.
|
|
92
|
+
* {@link canSwitchIntoAccount} is the rule; see the filter below.
|
|
68
93
|
*/
|
|
69
94
|
function projectSwitchableAccounts(input) {
|
|
70
95
|
const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
|
|
@@ -144,7 +169,7 @@ function projectSwitchableAccounts(input) {
|
|
|
144
169
|
// An account already on the device skipped this check via the branch above,
|
|
145
170
|
// and correctly: whatever its kind, the caller is signed into it, so
|
|
146
171
|
// switching is a local activation that asks the server for nothing.
|
|
147
|
-
if (!
|
|
172
|
+
if (!canSwitchIntoAccount(node)) {
|
|
148
173
|
continue;
|
|
149
174
|
}
|
|
150
175
|
remember(toRow(node.account, {
|
|
@@ -166,7 +191,7 @@ function projectSwitchableAccounts(input) {
|
|
|
166
191
|
* document, but including their ids lets the caller pass one id set and lets the
|
|
167
192
|
* projection prefer freshly-fetched profiles uniformly.
|
|
168
193
|
*
|
|
169
|
-
* Applies the SAME {@link
|
|
194
|
+
* Applies the SAME {@link canSwitchIntoAccount} filter as
|
|
170
195
|
* {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
|
|
171
196
|
* profile for a row the projection will drop — and, just as importantly, never
|
|
172
197
|
* SKIPS one the projection will keep, which would leave that row unrendered
|
|
@@ -180,7 +205,7 @@ function switchableAccountIds(state, graph) {
|
|
|
180
205
|
}
|
|
181
206
|
}
|
|
182
207
|
for (const node of graph) {
|
|
183
|
-
if (node.accountId &&
|
|
208
|
+
if (node.accountId && canSwitchIntoAccount(node)) {
|
|
184
209
|
ids.add(node.accountId);
|
|
185
210
|
}
|
|
186
211
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ErrorCodes = void 0;
|
|
4
|
+
exports.isHttpRequestError = isHttpRequestError;
|
|
5
|
+
exports.parseHttpErrorBody = parseHttpErrorBody;
|
|
4
6
|
exports.createApiError = createApiError;
|
|
5
7
|
exports.handleHttpError = handleHttpError;
|
|
6
8
|
exports.getErrorCodeFromStatus = getErrorCodeFromStatus;
|
|
@@ -37,6 +39,63 @@ exports.ErrorCodes = {
|
|
|
37
39
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
38
40
|
CONNECTION_FAILED: 'CONNECTION_FAILED'
|
|
39
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Narrow a caught value to {@link HttpRequestError}.
|
|
44
|
+
*
|
|
45
|
+
* Returns `false` for a plain {@link ApiError} object (those are objects, not
|
|
46
|
+
* `Error`s) — run an arbitrary thrown value through {@link handleHttpError}
|
|
47
|
+
* first if you need one normalized.
|
|
48
|
+
*/
|
|
49
|
+
function isHttpRequestError(value) {
|
|
50
|
+
if (!(value instanceof Error)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return typeof value.status === 'number';
|
|
54
|
+
}
|
|
55
|
+
const isPlainRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
const nonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Extract `message` / `code` / `details` from a parsed HTTP error response body.
|
|
59
|
+
*
|
|
60
|
+
* Handles every error envelope in use across the Oxy ecosystem:
|
|
61
|
+
*
|
|
62
|
+
* - `{ error: { code, message, details? } }` — nested envelope (CrowdSource and
|
|
63
|
+
* other Oxy services). Never stringify the nested object: `new Error(obj)`
|
|
64
|
+
* yields the literal message `"[object Object]"`.
|
|
65
|
+
* - `{ error: '<CODE>', message, details? }` — oxy-api's canonical shape
|
|
66
|
+
* (`ApiError.toJSON`), where the top-level `error` field IS the code.
|
|
67
|
+
* - `{ error: '<CODE>', error_description }` — RFC 6749 §5.2 / RFC 6750 §3, the
|
|
68
|
+
* OAuth token and userinfo endpoints. `error_description` is the human text
|
|
69
|
+
* and `error` is the machine code, so both survive.
|
|
70
|
+
* - `{ message, code }` — e.g. the API's CSRF rejections.
|
|
71
|
+
* - `{ error: '<human message>' }` — legacy hand-rolled routes. With no sibling
|
|
72
|
+
* `message`/`error_description` the string is the message, not a code: a bare
|
|
73
|
+
* `error` string is not machine-readable enough to promote to `code`.
|
|
74
|
+
*
|
|
75
|
+
* Anything else — a non-object body (`null`, `[]`, `"str"`, `42`), or an object
|
|
76
|
+
* carrying none of these fields — yields an empty result, leaving the caller on
|
|
77
|
+
* its status-based fallback message. Total function: never throws.
|
|
78
|
+
*/
|
|
79
|
+
function parseHttpErrorBody(body) {
|
|
80
|
+
if (!isPlainRecord(body)) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
const nested = isPlainRecord(body.error) ? body.error : undefined;
|
|
84
|
+
const errorString = nonEmptyString(body.error);
|
|
85
|
+
// A sibling that proves the top-level `error` is a CODE rather than prose.
|
|
86
|
+
const siblingMessage = nonEmptyString(body.message) ?? nonEmptyString(body.error_description);
|
|
87
|
+
return {
|
|
88
|
+
message: siblingMessage ?? (nested ? nonEmptyString(nested.message) : errorString),
|
|
89
|
+
code: (nested ? nonEmptyString(nested.code) : undefined) ??
|
|
90
|
+
nonEmptyString(body.code) ??
|
|
91
|
+
(siblingMessage ? errorString : undefined),
|
|
92
|
+
details: isPlainRecord(body.details)
|
|
93
|
+
? body.details
|
|
94
|
+
: nested && isPlainRecord(nested.details)
|
|
95
|
+
? nested.details
|
|
96
|
+
: undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
40
99
|
/**
|
|
41
100
|
* Create a standardized API error
|
|
42
101
|
*/
|
|
@@ -81,7 +140,12 @@ function handleHttpError(error) {
|
|
|
81
140
|
const fetchError = error;
|
|
82
141
|
const status = fetchError.response?.status || fetchError.status;
|
|
83
142
|
if (status) {
|
|
84
|
-
|
|
143
|
+
// `details` is carried through when present: a body may ship structured
|
|
144
|
+
// detail without a machine-readable `code` (which is what routes the
|
|
145
|
+
// error to the already-an-ApiError branch above), and dropping it here
|
|
146
|
+
// would make it unreachable to every caller that rethrows via
|
|
147
|
+
// `OxyServices.handleError`.
|
|
148
|
+
return createApiError(fetchError.message || `HTTP ${status} error`, getErrorCodeFromStatus(status), status, isPlainRecord(fetchError.details) ? fetchError.details : undefined);
|
|
85
149
|
}
|
|
86
150
|
}
|
|
87
151
|
// Handle standard errors
|