@oxyhq/core 20.0.0 → 21.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 (94) hide show
  1. package/NOTICE +10 -9
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +19 -2
  5. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  8. package/dist/cjs/index.js +50 -16
  9. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  12. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  13. package/dist/cjs/mixins/index.js +7 -0
  14. package/dist/cjs/server/rateLimit.js +15 -6
  15. package/dist/cjs/session/SessionClient.js +361 -1
  16. package/dist/cjs/session/accountDialogController.js +121 -147
  17. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  18. package/dist/cjs/session/deviceDirectory.js +143 -0
  19. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  20. package/dist/cjs/session/projectSessionState.js +8 -1
  21. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/boot/sessionColdBoot.js +107 -8
  24. package/dist/esm/i18n/locales/en-US.json +19 -2
  25. package/dist/esm/i18n/locales/es-ES.json +19 -2
  26. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  27. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  28. package/dist/esm/index.js +32 -10
  29. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  30. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  31. package/dist/esm/mixins/OxyServices.store.js +263 -0
  32. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  33. package/dist/esm/mixins/index.js +7 -0
  34. package/dist/esm/server/rateLimit.js +15 -6
  35. package/dist/esm/session/SessionClient.js +362 -2
  36. package/dist/esm/session/accountDialogController.js +121 -147
  37. package/dist/esm/session/accountSwitchTargets.js +71 -0
  38. package/dist/esm/session/deviceDirectory.js +135 -0
  39. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  40. package/dist/esm/session/projectSessionState.js +8 -2
  41. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  44. package/dist/types/index.d.ts +15 -3
  45. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  46. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  47. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  48. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  49. package/dist/types/mixins/index.d.ts +3 -1
  50. package/dist/types/models/session.d.ts +11 -0
  51. package/dist/types/session/SessionClient.d.ts +202 -1
  52. package/dist/types/session/accountDialogController.d.ts +76 -64
  53. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  54. package/dist/types/session/deviceDirectory.d.ts +182 -0
  55. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  56. package/dist/types/session/projectSessionState.d.ts +29 -0
  57. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  58. package/package.json +3 -3
  59. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  60. package/src/boot/sessionColdBoot.ts +133 -9
  61. package/src/i18n/locales/en-US.json +19 -2
  62. package/src/i18n/locales/es-ES.json +19 -2
  63. package/src/index.ts +105 -18
  64. package/src/mixins/OxyServices.auth.ts +67 -5
  65. package/src/mixins/OxyServices.chains.ts +134 -0
  66. package/src/mixins/OxyServices.store.ts +585 -0
  67. package/src/mixins/OxyServices.utility.ts +161 -108
  68. package/src/mixins/__tests__/chains.test.ts +113 -0
  69. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  70. package/src/mixins/__tests__/store.test.ts +304 -0
  71. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  72. package/src/mixins/index.ts +9 -0
  73. package/src/models/session.ts +11 -0
  74. package/src/server/__tests__/rateLimit.test.ts +47 -0
  75. package/src/server/rateLimit.ts +18 -8
  76. package/src/session/SessionClient.ts +386 -1
  77. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  78. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  79. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  80. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  81. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  82. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  83. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  84. package/src/session/accountDialogController.ts +141 -179
  85. package/src/session/accountSwitchTargets.ts +87 -0
  86. package/src/session/deviceDirectory.ts +269 -0
  87. package/src/session/deviceSwitcherRows.ts +145 -0
  88. package/src/session/projectSessionState.ts +9 -3
  89. package/src/session/sharedDeviceCredential.ts +349 -0
  90. package/dist/cjs/session/accountProjection.js +0 -213
  91. package/dist/esm/session/accountProjection.js +0 -207
  92. package/dist/types/session/accountProjection.d.ts +0 -198
  93. package/src/session/__tests__/accountProjection.test.ts +0 -447
  94. package/src/session/accountProjection.ts +0 -354
@@ -250,18 +250,41 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
250
250
  * Uses server-side session validation for security (not just JWT decode).
251
251
  *
252
252
  * **Design note — jwtDecode vs jwt.verify:**
253
- * This middleware intentionally uses `jwtDecode()` (decode-only, no signature
254
- * verification) for user tokens. This is by design, NOT a security gap:
255
- * - Third-party apps using `oxy.auth()` don't have the Oxy JWT secret
256
- * - Security comes from API-based session validation (`validateSession()`)
257
- * which checks the session server-side on every request
258
- * - Service tokens (type: 'service') DO use cryptographic HMAC verification
259
- * via the `jwtSecret` option, since they are stateless. Service tokens
260
- * are additionally checked for `aud`, `iss`, and `type` claims to prevent
253
+ * This middleware uses `jwtDecode()` (decode-only, NO signature check) for
254
+ * user tokens, because third-party apps mounting `oxy.auth()` do not hold
255
+ * the Oxy signing secret. **Every claim in a user token is therefore
256
+ * attacker-controlled and proves nothing on its own.** The identity comes
257
+ * from somewhere else entirely:
258
+ * - A user token MUST carry a `sessionId`. That session is validated
259
+ * server-side on every request via `validateSession()`, and the user id
260
+ * is read off the VALIDATED SESSION never off the token. A token whose
261
+ * `userId` claim disagrees with the session is refused
262
+ * (`SESSION_USER_MISMATCH`); a token with no `sessionId` at all is
263
+ * refused outright (`SESSION_REQUIRED`). There is no local-claims path.
264
+ * - Service tokens (type: 'service') ARE stateless, so they use
265
+ * cryptographic HMAC verification via the `jwtSecret` option, and are
266
+ * additionally checked for `aud`, `iss`, and `type` claims to prevent
261
267
  * cross-token-type confusion attacks.
262
268
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
263
269
  * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
264
270
  *
271
+ * **Why session-less user tokens are refused rather than trusted:**
272
+ * every user access token the Oxy API issues carries a `sessionId` (see
273
+ * `packages/api/src/utils/sessionUtils.ts`, `generateSessionTokens` — the
274
+ * only mint site for user tokens, including the OAuth code exchange). So
275
+ * refusing session-less user tokens costs nothing legitimate, while
276
+ * accepting them let anyone authenticate as anyone by hand-rolling a JWT
277
+ * with a `userId` claim and a garbage signature.
278
+ *
279
+ * **Why the claimed user id is cross-checked against the session:**
280
+ * `GET /session/validate/:sessionId` is UNAUTHENTICATED and does not bind
281
+ * the bearer token — it returns whoever owns the session id it was handed.
282
+ * Trusting the token's `userId` claim after a successful validation would
283
+ * therefore let a caller holding ANY live session id (their own, for
284
+ * instance) pair it with a forged `userId` and be trusted as that user.
285
+ * `authSocket()` has always cross-checked this; the HTTP middleware now
286
+ * does too.
287
+ *
265
288
  * **Service-token delegation (X-Oxy-User-Id):**
266
289
  * When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
267
290
  * `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
@@ -543,8 +566,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
543
566
  return next();
544
567
  }
545
568
 
546
- const userId = decoded.userId || decoded.id;
547
- if (!userId) {
569
+ // The CLAIMED user id. Never trusted as an identity — it is only ever
570
+ // compared against the id the validated session resolves to.
571
+ const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
572
+ if (!claimedUserId) {
548
573
  if (optional) {
549
574
  req.userId = null;
550
575
  req.user = null;
@@ -580,58 +605,82 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
580
605
  return res.status(401).json(error);
581
606
  }
582
607
 
583
- // Validate token against the Oxy API for session-based verification
584
- // This ensures the session hasn't been revoked server-side
585
- if (decoded.sessionId) {
586
- try {
587
- const validationResult = await oxyInstance.validateSession(decoded.sessionId, {
588
- useHeaderValidation: true,
589
- });
608
+ // A server-validated session is MANDATORY for a user token. The JWT
609
+ // signature is not verified on this path, so a bare decoded token
610
+ // proves nothing: without the session round-trip a forged token could
611
+ // claim any user id. Mirrors `authSocket()`, which has always
612
+ // required this.
613
+ const sessionId = readStringClaim(decoded.sessionId);
614
+ if (!sessionId) {
615
+ if (optional) {
616
+ req.userId = null;
617
+ req.user = null;
618
+ return next();
619
+ }
590
620
 
591
- if (!validationResult || !validationResult.valid) {
592
- if (optional) {
593
- req.userId = null;
594
- req.user = null;
595
- return next();
596
- }
621
+ const error = {
622
+ error: 'SESSION_REQUIRED',
623
+ message: 'Access token is not bound to a session',
624
+ code: 'SESSION_REQUIRED',
625
+ status: 401
626
+ };
627
+ if (onError) return onError(error);
628
+ return res.status(401).json(error);
629
+ }
597
630
 
598
- const error = {
599
- error: 'INVALID_SESSION',
600
- message: 'Session invalid or expired',
601
- code: 'INVALID_SESSION',
602
- status: 401
603
- };
604
- if (onError) return onError(error);
605
- return res.status(401).json(error);
631
+ // Validate the token against the Oxy API. This proves the session is
632
+ // real and unrevoked, AND yields the identity it belongs to.
633
+ try {
634
+ const validationResult = await oxyInstance.validateSession(sessionId, {
635
+ useHeaderValidation: true,
636
+ });
637
+
638
+ if (!validationResult || !validationResult.valid || !validationResult.user) {
639
+ if (optional) {
640
+ req.userId = null;
641
+ req.user = null;
642
+ return next();
606
643
  }
607
644
 
608
- // Use validated user data from session validation (already has full user)
609
- req.userId = userId;
610
- req.accessToken = token;
611
- req.sessionId = decoded.sessionId;
645
+ const error = {
646
+ error: 'INVALID_SESSION',
647
+ message: 'Session invalid or expired',
648
+ code: 'INVALID_SESSION',
649
+ status: 401
650
+ };
651
+ if (onError) return onError(error);
652
+ return res.status(401).json(error);
653
+ }
612
654
 
613
- if (loadUser && validationResult.user) {
614
- // Session validation already returns full user data
615
- req.user = validationResult.user;
616
- } else {
617
- req.user = { id: userId } as User;
655
+ // The session — not the token — is the source of truth for identity.
656
+ const validatedUserId = getUserIdentityId(validationResult.user);
657
+ if (!validatedUserId) {
658
+ if (optional) {
659
+ req.userId = null;
660
+ req.user = null;
661
+ return next();
618
662
  }
619
663
 
620
- if (debug) {
621
- logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
622
- component: 'auth',
623
- method: 'auth',
624
- });
625
- }
664
+ const error = {
665
+ error: 'INVALID_SESSION',
666
+ message: 'Session did not resolve to a usable identity',
667
+ code: 'INVALID_SESSION',
668
+ status: 401
669
+ };
670
+ if (onError) return onError(error);
671
+ return res.status(401).json(error);
672
+ }
626
673
 
627
- return next();
628
- } catch (validationError) {
629
- if (debug) {
630
- logger.debug('[oxy.auth] Session validation failed', {
631
- component: 'auth',
632
- method: 'auth',
633
- }, validationError);
634
- }
674
+ if (validatedUserId !== claimedUserId) {
675
+ // Session-id/claim confusion: the caller presented a live session
676
+ // that belongs to somebody else. Worth a warning — it has no
677
+ // benign cause. Ids only; never the token or the payload.
678
+ logger.warn('[oxy.auth] Token rejected — claimed user does not own the session', {
679
+ component: 'auth',
680
+ method: 'auth',
681
+ claimedUserId,
682
+ validatedUserId,
683
+ });
635
684
 
636
685
  if (optional) {
637
686
  req.userId = null;
@@ -640,58 +689,53 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
640
689
  }
641
690
 
642
691
  const error = {
643
- error: 'SESSION_VALIDATION_ERROR',
644
- message: 'Session validation failed',
645
- code: 'SESSION_VALIDATION_ERROR',
692
+ error: 'SESSION_USER_MISMATCH',
693
+ message: 'Token user does not match the session',
694
+ code: 'SESSION_USER_MISMATCH',
646
695
  status: 401
647
696
  };
648
697
  if (onError) return onError(error);
649
698
  return res.status(401).json(error);
650
699
  }
651
- }
652
700
 
653
- // Non-session token: use local validation only (userId from JWT)
654
- req.userId = userId;
655
- req.accessToken = token;
656
- req.user = { id: userId } as User;
701
+ req.userId = validatedUserId;
702
+ req.accessToken = token;
703
+ req.sessionId = sessionId;
704
+ // Session validation already returned the full user, so `loadUser`
705
+ // costs no extra round-trip.
706
+ req.user = loadUser ? validationResult.user : ({ id: validatedUserId } as User);
657
707
 
658
- // If loadUser requested with non-session token, fetch from API
659
- if (loadUser) {
660
- try {
661
- // Temporarily set token to make the API call
662
- const prevToken = oxyInstance.getAccessToken();
663
- oxyInstance.setTokens(token);
664
- const fullUser = await oxyInstance.getCurrentUser();
665
- // Restore previous token
666
- if (prevToken) {
667
- oxyInstance.setTokens(prevToken);
668
- } else {
669
- oxyInstance.clearTokens();
670
- }
708
+ if (debug) {
709
+ logger.debug(`[oxy.auth] OK user=${validatedUserId} session=${sessionId}`, {
710
+ component: 'auth',
711
+ method: 'auth',
712
+ });
713
+ }
671
714
 
672
- if (fullUser) {
673
- req.user = fullUser;
674
- }
675
- } catch (loadUserError) {
676
- // Loading the full user is best-effort here; the basic { id }
677
- // object is already attached. Log so misconfigured deployments
678
- // can be diagnosed instead of silently failing.
679
- logger.warn('[oxy.auth] loadUser fallback — could not fetch full profile', {
715
+ return next();
716
+ } catch (validationError) {
717
+ if (debug) {
718
+ logger.debug('[oxy.auth] Session validation failed', {
680
719
  component: 'auth',
681
- method: 'auth.loadUser',
682
- userId,
683
- }, loadUserError);
720
+ method: 'auth',
721
+ }, validationError);
684
722
  }
685
- }
686
723
 
687
- if (debug) {
688
- logger.debug(`[oxy.auth] OK user=${userId} (no session)`, {
689
- component: 'auth',
690
- method: 'auth',
691
- });
692
- }
724
+ if (optional) {
725
+ req.userId = null;
726
+ req.user = null;
727
+ return next();
728
+ }
693
729
 
694
- next();
730
+ const error = {
731
+ error: 'SESSION_VALIDATION_ERROR',
732
+ message: 'Session validation failed',
733
+ code: 'SESSION_VALIDATION_ERROR',
734
+ status: 401
735
+ };
736
+ if (onError) return onError(error);
737
+ return res.status(401).json(error);
738
+ }
695
739
  } catch (error) {
696
740
  const handled = oxyInstance.handleError(error) as Error & {
697
741
  code?: string;
@@ -768,7 +812,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
768
812
  return next(new Error('Invalid token'));
769
813
  }
770
814
 
771
- const claimedUserId = decoded.userId || decoded.id;
815
+ const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
772
816
  if (!claimedUserId) {
773
817
  return next(new Error('Invalid token payload'));
774
818
  }
@@ -781,13 +825,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
781
825
  // A server-validated session is mandatory. A bare decoded JWT proves
782
826
  // nothing — the signature is not verified here, so without a session
783
827
  // round-trip a forged token could claim any user id.
784
- if (!decoded.sessionId) {
828
+ const sessionId = readStringClaim(decoded.sessionId);
829
+ if (!sessionId) {
785
830
  return next(new Error('Session required'));
786
831
  }
787
832
 
788
833
  let userId = claimedUserId;
789
834
  try {
790
- const result = await oxyInstance.validateSession(decoded.sessionId, {
835
+ const result = await oxyInstance.validateSession(sessionId, {
791
836
  useHeaderValidation: true,
792
837
  });
793
838
  if (!result || !result.valid || !result.user) {
@@ -819,10 +864,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
819
864
  // reads from `socket.user.id`.
820
865
  socket.data = socket.data || {};
821
866
  socket.data.userId = userId;
822
- socket.data.sessionId = decoded.sessionId || null;
867
+ socket.data.sessionId = sessionId;
823
868
  socket.data.token = token;
824
869
 
825
- socket.user = { id: userId, userId, sessionId: decoded.sessionId };
870
+ socket.user = { id: userId, userId, sessionId };
826
871
 
827
872
  if (debug) {
828
873
  logger.debug(`[oxy.authSocket] OK user=${userId}`, {
@@ -978,12 +1023,17 @@ async function verifyServiceTokenSignature(token: string, secret: string): Promi
978
1023
  }
979
1024
 
980
1025
  /**
981
- * Verify that a decoded service-token payload carries the expected `aud`,
982
- * `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
983
- * This is the defence against the H4 vulnerability where a recovery / 2FA /
984
- * access token signed by the same shared secret could be replayed as a
985
- * service token because no claim binding existed.
1026
+ * Read a JWT claim that is only usable as a non-empty string.
1027
+ *
1028
+ * A decoded payload is attacker-controlled JSON: a claim the type declares as
1029
+ * `string` can arrive as a number, an object, or `null`. Narrowing here keeps
1030
+ * those values out of URL construction and identity comparison, so an
1031
+ * unexpected shape becomes a 401 rather than a stringified surprise.
986
1032
  */
1033
+ function readStringClaim(value: unknown): string | null {
1034
+ return typeof value === 'string' && value.length > 0 ? value : null;
1035
+ }
1036
+
987
1037
  /**
988
1038
  * Resolve the canonical user id from a validated session's user object.
989
1039
  *
@@ -997,6 +1047,13 @@ function getUserIdentityId(user: User): string | null {
997
1047
  return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
998
1048
  }
999
1049
 
1050
+ /**
1051
+ * Verify that a decoded service-token payload carries the expected `aud`,
1052
+ * `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
1053
+ * This is the defence against the H4 vulnerability where a recovery / 2FA /
1054
+ * access token signed by the same shared secret could be replayed as a
1055
+ * service token because no claim binding existed.
1056
+ */
1000
1057
  function verifyServiceTokenClaims(
1001
1058
  decoded: JwtPayload,
1002
1059
  expected: { audience: string; issuer: string },
@@ -1062,9 +1119,5 @@ interface OxyAuthInstance {
1062
1119
  user?: User;
1063
1120
  [key: string]: unknown;
1064
1121
  } | null>;
1065
- getAccessToken(): string | null;
1066
- setTokens(accessToken: string): void;
1067
- clearTokens(): void;
1068
- getCurrentUser(): Promise<User | null>;
1069
1122
  handleError(error: unknown): Error;
1070
1123
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The chains client — what it puts on the wire.
3
+ *
4
+ * There is exactly one piece of real logic here and it is the read's query
5
+ * string: joining two lists, and including `since`/`limit` only when the caller
6
+ * gave them. Everything else is a pass-through, so these cases pin the part that
7
+ * can actually be wrong rather than restating the method bodies.
8
+ *
9
+ * `makeServiceRequest` is stubbed because it belongs to the auth mixin and has
10
+ * its own suites; what matters here is the method, path and payload it is handed.
11
+ */
12
+
13
+ import { OxyServicesChainsMixin } from '../OxyServices.chains';
14
+
15
+ type Call = { method: string; url: string; data?: unknown };
16
+
17
+ /** A minimal host carrying the mixin, with the service transport recorded. */
18
+ function client(): { calls: Call[]; api: any } {
19
+ const calls: Call[] = [];
20
+ class Base {
21
+ makeServiceRequest(method: string, url: string, data?: unknown) {
22
+ calls.push({ method, url, data });
23
+ return Promise.resolve({ records: [], nextCursor: null });
24
+ }
25
+ }
26
+ const Mixed = OxyServicesChainsMixin(Base as any);
27
+ return { calls, api: new (Mixed as any)() };
28
+ }
29
+
30
+ describe('appendChainRecord', () => {
31
+ it('POSTs the record to /chains/records untouched', async () => {
32
+ const { calls, api } = client();
33
+
34
+ await api.appendChainRecord({
35
+ oxyUserId: 'u1',
36
+ collection: 'app.mention.feed.post',
37
+ rkey: 'p1',
38
+ record: { text: 'hi' },
39
+ });
40
+
41
+ expect(calls).toEqual([
42
+ {
43
+ method: 'POST',
44
+ url: '/chains/records',
45
+ data: {
46
+ oxyUserId: 'u1',
47
+ collection: 'app.mention.feed.post',
48
+ rkey: 'p1',
49
+ record: { text: 'hi' },
50
+ },
51
+ },
52
+ ]);
53
+ });
54
+ });
55
+
56
+ describe('readChainRecords', () => {
57
+ it('joins authors and collections into one comma-separated query each', async () => {
58
+ const { calls, api } = client();
59
+
60
+ await api.readChainRecords({
61
+ oxyUserIds: ['u1', 'u2'],
62
+ collections: ['app.mention.feed.post', 'app.mention.feed.like'],
63
+ });
64
+
65
+ const url = new URL(`http://x${calls[0].url}`);
66
+ expect(calls[0].method).toBe('GET');
67
+ expect(url.pathname).toBe('/chains/records');
68
+ expect(url.searchParams.get('authors')).toBe('u1,u2');
69
+ expect(url.searchParams.get('collections')).toBe('app.mention.feed.post,app.mention.feed.like');
70
+ });
71
+
72
+ it('omits since and limit when the caller gave neither', async () => {
73
+ // A `since=` or `limit=` sent as an empty string is not the same request —
74
+ // the server validates both, so an always-present key would 400 a first page.
75
+ const { calls, api } = client();
76
+
77
+ await api.readChainRecords({ oxyUserIds: ['u1'], collections: ['app.mention.feed.post'] });
78
+
79
+ const url = new URL(`http://x${calls[0].url}`);
80
+ expect(url.searchParams.has('since')).toBe(false);
81
+ expect(url.searchParams.has('limit')).toBe(false);
82
+ });
83
+
84
+ it('sends since and limit when it did', async () => {
85
+ const { calls, api } = client();
86
+
87
+ await api.readChainRecords({
88
+ oxyUserIds: ['u1'],
89
+ collections: ['app.mention.feed.post'],
90
+ since: 'Y3Vyc29y',
91
+ limit: 25,
92
+ });
93
+
94
+ const url = new URL(`http://x${calls[0].url}`);
95
+ expect(url.searchParams.get('since')).toBe('Y3Vyc29y');
96
+ expect(url.searchParams.get('limit')).toBe('25');
97
+ });
98
+
99
+ it('escapes a cursor that is not URL-safe', async () => {
100
+ // Cursors are opaque to the caller, so this client must not assume the
101
+ // encoding the server happens to use today.
102
+ const { calls, api } = client();
103
+
104
+ await api.readChainRecords({
105
+ oxyUserIds: ['u1'],
106
+ collections: ['app.mention.feed.post'],
107
+ since: 'a+b/c=',
108
+ });
109
+
110
+ const url = new URL(`http://x${calls[0].url}`);
111
+ expect(url.searchParams.get('since')).toBe('a+b/c=');
112
+ });
113
+ });
@@ -158,7 +158,40 @@ describe('pre-session public endpoints use skipAuth', () => {
158
158
  });
159
159
  });
160
160
 
161
- it('exchangeOAuthCode rejects a response without deviceSecret', async () => {
161
+ // A third-party grant is meant to be ISOLATED from the browser's shared
162
+ // DeviceSession, so the token endpoint must be free to return no device
163
+ // credential. Core used to require the pair, which made that omission
164
+ // unshippable: every third-party sign-in through the SDK would have collapsed
165
+ // into a silent `exchange-failed` (issue #954).
166
+ it('exchangeOAuthCode accepts a device-less grant and still plants the token', async () => {
167
+ makeRequest.mockResolvedValueOnce({
168
+ access_token: 'tok',
169
+ token_type: 'Bearer',
170
+ expires_in: 900,
171
+ session_id: 's1',
172
+ user: { id: 'u1', username: 'alice' },
173
+ });
174
+
175
+ const result = await oxy.exchangeOAuthCode({
176
+ code: 'code-1',
177
+ clientId: 'oxy_dk_test',
178
+ redirectUri: 'https://app.example/callback',
179
+ codeVerifier: 'verifier',
180
+ });
181
+
182
+ expect(result).toMatchObject({
183
+ sessionId: 's1',
184
+ accessToken: 'tok',
185
+ user: { id: 'u1', username: 'alice' },
186
+ });
187
+ // Absent, not `undefined`-valued: a device-less grant carries no device keys
188
+ // at all, so nothing downstream can read one and persist an empty credential.
189
+ expect(result).not.toHaveProperty('deviceId');
190
+ expect(result).not.toHaveProperty('deviceSecret');
191
+ expect(oxy.getAccessToken()).toBe('tok');
192
+ });
193
+
194
+ it('exchangeOAuthCode accepts a deviceId with no deviceSecret', async () => {
162
195
  makeRequest.mockResolvedValueOnce({
163
196
  access_token: 'tok',
164
197
  token_type: 'Bearer',
@@ -167,6 +200,26 @@ describe('pre-session public endpoints use skipAuth', () => {
167
200
  deviceId: 'd1',
168
201
  user: { id: 'u1' },
169
202
  });
203
+
204
+ const result = await oxy.exchangeOAuthCode({
205
+ code: 'code-1',
206
+ clientId: 'oxy_dk_test',
207
+ redirectUri: 'https://app.example/callback',
208
+ codeVerifier: 'verifier',
209
+ });
210
+
211
+ expect(result).toMatchObject({ sessionId: 's1', deviceId: 'd1' });
212
+ expect(result).not.toHaveProperty('deviceSecret');
213
+ });
214
+
215
+ // The device pair left the guard; what identifies the session did NOT. Without
216
+ // these two the whole guard could be deleted and every test above would stay
217
+ // green — `sessionId` would silently become `undefined` on the returned session.
218
+ it.each([
219
+ ['session_id', { access_token: 'tok', expires_in: 900, user: { id: 'u1' } }],
220
+ ['user', { access_token: 'tok', expires_in: 900, session_id: 's1' }],
221
+ ])('exchangeOAuthCode still rejects a response missing %s', async (_field, response) => {
222
+ makeRequest.mockResolvedValueOnce(response);
170
223
  await expect(
171
224
  oxy.exchangeOAuthCode({
172
225
  code: 'code-1',