@oxy.so/core 7.10.1 → 7.10.3

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.
@@ -58,6 +58,14 @@ function sameTokenSession(left, right) {
58
58
  }
59
59
  if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub))
60
60
  return false;
61
+ // A legacy bearer may omit deviceId; empty or non-string values also carry no
62
+ // usable device. Two known, different devices are distinct local contexts.
63
+ if (typeof a.deviceId === 'string' &&
64
+ a.deviceId.length > 0 &&
65
+ typeof b.deviceId === 'string' &&
66
+ b.deviceId.length > 0 &&
67
+ a.deviceId !== b.deviceId)
68
+ return false;
61
69
  // A refresh may rotate exp/iat/jti while preserving its account and session.
62
70
  // Opaque/malformed context fields cannot establish that equivalence.
63
71
  return [
@@ -71,6 +79,13 @@ function sameTokenSession(left, right) {
71
79
  'authMethodId',
72
80
  ].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
73
81
  }
82
+ /** Extract a known device for local ownership checks, never authorization. */
83
+ function knownTokenDeviceId(token) {
84
+ const claims = (0, tokenClaims_1.decodeTokenClaims)(token);
85
+ return claims?.type !== 'service' && typeof claims?.deviceId === 'string' && claims.deviceId
86
+ ? claims.deviceId
87
+ : null;
88
+ }
74
89
  /** Discard an unused body even when a platform's stream shim lacks cancel. */
75
90
  function discardResponseBody(response) {
76
91
  try {
@@ -328,6 +343,8 @@ class HttpService {
328
343
  * bearer — and through the token listeners, the account — back.
329
344
  */
330
345
  this.sessionEpoch = 0;
346
+ /** Preserve a known device across legacy rotations which omit the claim. */
347
+ this.sessionDeviceId = null;
331
348
  /**
332
349
  * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
333
350
  * native Commons-identity re-mint arm: after a sign-out a 401 is the expected
@@ -566,8 +583,12 @@ class HttpService {
566
583
  const authHeader = usesSessionAuth
567
584
  ? await (0, requestSignal_1.awaitRequestSignal)(this.getAuthHeader(), signal)
568
585
  : null;
569
- const isOriginalSession = () => this.sessionEpoch === contextEpoch &&
570
- sameTokenSession(contextToken, this.syncAccessTokenFromProvider());
586
+ const isOriginalSession = () => {
587
+ if (this.sessionEpoch !== contextEpoch)
588
+ return false;
589
+ const currentToken = this.syncAccessTokenFromProvider();
590
+ return this.sessionEpoch === contextEpoch && sameTokenSession(contextToken, currentToken);
591
+ };
571
592
  const newerRequestToken = () => {
572
593
  if (!contextToken || !authHeader?.startsWith('Bearer '))
573
594
  return null;
@@ -770,6 +791,7 @@ class HttpService {
770
791
  if (this.tokenStore.getAccessToken())
771
792
  this.sessionEpoch += 1;
772
793
  this.tokenStore.clearTokens();
794
+ this.sessionDeviceId = null;
773
795
  selfClearedEpoch = this.sessionEpoch;
774
796
  this.notifyTokenChange();
775
797
  }
@@ -1653,8 +1675,13 @@ class HttpService {
1653
1675
  // Token management
1654
1676
  setTokens(accessToken) {
1655
1677
  const current = this.tokenStore.getAccessToken();
1656
- if (current && !sameTokenSession(current, accessToken))
1678
+ const deviceId = knownTokenDeviceId(accessToken);
1679
+ const changed = current !== null &&
1680
+ (!sameTokenSession(current, accessToken) ||
1681
+ (this.sessionDeviceId !== null && deviceId !== null && this.sessionDeviceId !== deviceId));
1682
+ if (changed)
1657
1683
  this.sessionEpoch += 1;
1684
+ this.sessionDeviceId = !current || changed ? deviceId : (deviceId ?? this.sessionDeviceId);
1658
1685
  this.sessionEnded = false;
1659
1686
  this.tokenStore.setTokens(accessToken);
1660
1687
  this.notifyTokenChange();
@@ -1673,8 +1700,8 @@ class HttpService {
1673
1700
  this.clearTokens();
1674
1701
  }
1675
1702
  /**
1676
- * The current context epoch: account/session switches and bearer removal
1677
- * invalidate pending work; rotation within the same context preserves it.
1703
+ * The current context epoch: account/session switches, known device changes,
1704
+ * and bearer removal invalidate pending work; same-context rotation preserves it.
1678
1705
  * A re-mint lane that
1679
1706
  * plants tokens itself (the device-secret arm) compares it across its awaits.
1680
1707
  */
@@ -1705,6 +1732,7 @@ class HttpService {
1705
1732
  if (this.tokenStore.getAccessToken())
1706
1733
  this.sessionEpoch += 1;
1707
1734
  this.tokenStore.clearTokens();
1735
+ this.sessionDeviceId = null;
1708
1736
  // Drop the response cache on logout. The cache is identity-scoped, so a
1709
1737
  // different user could never read these entries, but a logged-out client
1710
1738
  // must not keep the previous session's personalized data resident in
@@ -1864,6 +1892,7 @@ class HttpService {
1864
1892
  __resetTokensForTests() {
1865
1893
  this.sessionEnded = false;
1866
1894
  this.tokenStore.clearTokens();
1895
+ this.sessionDeviceId = null;
1867
1896
  }
1868
1897
  }
1869
1898
  exports.HttpService = HttpService;
@@ -55,6 +55,14 @@ function sameTokenSession(left, right) {
55
55
  }
56
56
  if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub))
57
57
  return false;
58
+ // A legacy bearer may omit deviceId; empty or non-string values also carry no
59
+ // usable device. Two known, different devices are distinct local contexts.
60
+ if (typeof a.deviceId === 'string' &&
61
+ a.deviceId.length > 0 &&
62
+ typeof b.deviceId === 'string' &&
63
+ b.deviceId.length > 0 &&
64
+ a.deviceId !== b.deviceId)
65
+ return false;
58
66
  // A refresh may rotate exp/iat/jti while preserving its account and session.
59
67
  // Opaque/malformed context fields cannot establish that equivalence.
60
68
  return [
@@ -68,6 +76,13 @@ function sameTokenSession(left, right) {
68
76
  'authMethodId',
69
77
  ].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
70
78
  }
79
+ /** Extract a known device for local ownership checks, never authorization. */
80
+ function knownTokenDeviceId(token) {
81
+ const claims = decodeTokenClaims(token);
82
+ return claims?.type !== 'service' && typeof claims?.deviceId === 'string' && claims.deviceId
83
+ ? claims.deviceId
84
+ : null;
85
+ }
71
86
  /** Discard an unused body even when a platform's stream shim lacks cancel. */
72
87
  function discardResponseBody(response) {
73
88
  try {
@@ -325,6 +340,8 @@ export class HttpService {
325
340
  * bearer — and through the token listeners, the account — back.
326
341
  */
327
342
  this.sessionEpoch = 0;
343
+ /** Preserve a known device across legacy rotations which omit the claim. */
344
+ this.sessionDeviceId = null;
328
345
  /**
329
346
  * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
330
347
  * native Commons-identity re-mint arm: after a sign-out a 401 is the expected
@@ -563,8 +580,12 @@ export class HttpService {
563
580
  const authHeader = usesSessionAuth
564
581
  ? await awaitRequestSignal(this.getAuthHeader(), signal)
565
582
  : null;
566
- const isOriginalSession = () => this.sessionEpoch === contextEpoch &&
567
- sameTokenSession(contextToken, this.syncAccessTokenFromProvider());
583
+ const isOriginalSession = () => {
584
+ if (this.sessionEpoch !== contextEpoch)
585
+ return false;
586
+ const currentToken = this.syncAccessTokenFromProvider();
587
+ return this.sessionEpoch === contextEpoch && sameTokenSession(contextToken, currentToken);
588
+ };
568
589
  const newerRequestToken = () => {
569
590
  if (!contextToken || !authHeader?.startsWith('Bearer '))
570
591
  return null;
@@ -767,6 +788,7 @@ export class HttpService {
767
788
  if (this.tokenStore.getAccessToken())
768
789
  this.sessionEpoch += 1;
769
790
  this.tokenStore.clearTokens();
791
+ this.sessionDeviceId = null;
770
792
  selfClearedEpoch = this.sessionEpoch;
771
793
  this.notifyTokenChange();
772
794
  }
@@ -1650,8 +1672,13 @@ export class HttpService {
1650
1672
  // Token management
1651
1673
  setTokens(accessToken) {
1652
1674
  const current = this.tokenStore.getAccessToken();
1653
- if (current && !sameTokenSession(current, accessToken))
1675
+ const deviceId = knownTokenDeviceId(accessToken);
1676
+ const changed = current !== null &&
1677
+ (!sameTokenSession(current, accessToken) ||
1678
+ (this.sessionDeviceId !== null && deviceId !== null && this.sessionDeviceId !== deviceId));
1679
+ if (changed)
1654
1680
  this.sessionEpoch += 1;
1681
+ this.sessionDeviceId = !current || changed ? deviceId : (deviceId ?? this.sessionDeviceId);
1655
1682
  this.sessionEnded = false;
1656
1683
  this.tokenStore.setTokens(accessToken);
1657
1684
  this.notifyTokenChange();
@@ -1670,8 +1697,8 @@ export class HttpService {
1670
1697
  this.clearTokens();
1671
1698
  }
1672
1699
  /**
1673
- * The current context epoch: account/session switches and bearer removal
1674
- * invalidate pending work; rotation within the same context preserves it.
1700
+ * The current context epoch: account/session switches, known device changes,
1701
+ * and bearer removal invalidate pending work; same-context rotation preserves it.
1675
1702
  * A re-mint lane that
1676
1703
  * plants tokens itself (the device-secret arm) compares it across its awaits.
1677
1704
  */
@@ -1702,6 +1729,7 @@ export class HttpService {
1702
1729
  if (this.tokenStore.getAccessToken())
1703
1730
  this.sessionEpoch += 1;
1704
1731
  this.tokenStore.clearTokens();
1732
+ this.sessionDeviceId = null;
1705
1733
  // Drop the response cache on logout. The cache is identity-scoped, so a
1706
1734
  // different user could never read these entries, but a logged-out client
1707
1735
  // must not keep the previous session's personalized data resident in
@@ -1861,6 +1889,7 @@ export class HttpService {
1861
1889
  __resetTokensForTests() {
1862
1890
  this.sessionEnded = false;
1863
1891
  this.tokenStore.clearTokens();
1892
+ this.sessionDeviceId = null;
1864
1893
  }
1865
1894
  }
1866
1895
  /**
@@ -228,6 +228,8 @@ export declare class HttpService {
228
228
  * bearer — and through the token listeners, the account — back.
229
229
  */
230
230
  private sessionEpoch;
231
+ /** Preserve a known device across legacy rotations which omit the claim. */
232
+ private sessionDeviceId;
231
233
  /**
232
234
  * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
233
235
  * native Commons-identity re-mint arm: after a sign-out a 401 is the expected
@@ -509,8 +511,8 @@ export declare class HttpService {
509
511
  */
510
512
  endSession(): void;
511
513
  /**
512
- * The current context epoch: account/session switches and bearer removal
513
- * invalidate pending work; rotation within the same context preserves it.
514
+ * The current context epoch: account/session switches, known device changes,
515
+ * and bearer removal invalidate pending work; same-context rotation preserves it.
514
516
  * A re-mint lane that
515
517
  * plants tokens itself (the device-secret arm) compares it across its awaits.
516
518
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.10.1",
3
+ "version": "7.10.3",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -181,7 +181,7 @@
181
181
  "dependencies": {
182
182
  "@noble/ciphers": "^1.3.0",
183
183
  "@noble/hashes": "^1.8.0",
184
- "@oxy.so/contracts": "6.1.0",
184
+ "@oxy.so/contracts": "6.2.0",
185
185
  "@oxy.so/protocol": "^1.5.1",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^1.6.0",
@@ -74,6 +74,16 @@ function sameTokenSession(left: string | null, right: string | null): boolean {
74
74
  );
75
75
  }
76
76
  if (!(a.userId || a.id || a.sub) || !(b.userId || b.id || b.sub)) return false;
77
+ // A legacy bearer may omit deviceId; empty or non-string values also carry no
78
+ // usable device. Two known, different devices are distinct local contexts.
79
+ if (
80
+ typeof a.deviceId === 'string' &&
81
+ a.deviceId.length > 0 &&
82
+ typeof b.deviceId === 'string' &&
83
+ b.deviceId.length > 0 &&
84
+ a.deviceId !== b.deviceId
85
+ )
86
+ return false;
77
87
  // A refresh may rotate exp/iat/jti while preserving its account and session.
78
88
  // Opaque/malformed context fields cannot establish that equivalence.
79
89
  return [
@@ -88,6 +98,14 @@ function sameTokenSession(left: string | null, right: string | null): boolean {
88
98
  ].every((key) => (a[key] === undefined || typeof a[key] === 'string') && a[key] === b[key]);
89
99
  }
90
100
 
101
+ /** Extract a known device for local ownership checks, never authorization. */
102
+ function knownTokenDeviceId(token: string): string | null {
103
+ const claims = decodeTokenClaims(token);
104
+ return claims?.type !== 'service' && typeof claims?.deviceId === 'string' && claims.deviceId
105
+ ? claims.deviceId
106
+ : null;
107
+ }
108
+
91
109
  /** Discard an unused body even when a platform's stream shim lacks cancel. */
92
110
  function discardResponseBody(response: Response): Promise<void> {
93
111
  try {
@@ -529,6 +547,8 @@ export class HttpService {
529
547
  * bearer — and through the token listeners, the account — back.
530
548
  */
531
549
  private sessionEpoch = 0;
550
+ /** Preserve a known device across legacy rotations which omit the claim. */
551
+ private sessionDeviceId: string | null = null;
532
552
  /**
533
553
  * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
534
554
  * native Commons-identity re-mint arm: after a sign-out a 401 is the expected
@@ -825,9 +845,11 @@ export class HttpService {
825
845
  const authHeader = usesSessionAuth
826
846
  ? await awaitRequestSignal(this.getAuthHeader(), signal)
827
847
  : null;
828
- const isOriginalSession = () =>
829
- this.sessionEpoch === contextEpoch &&
830
- sameTokenSession(contextToken, this.syncAccessTokenFromProvider());
848
+ const isOriginalSession = () => {
849
+ if (this.sessionEpoch !== contextEpoch) return false;
850
+ const currentToken = this.syncAccessTokenFromProvider();
851
+ return this.sessionEpoch === contextEpoch && sameTokenSession(contextToken, currentToken);
852
+ };
831
853
  const newerRequestToken = () => {
832
854
  if (!contextToken || !authHeader?.startsWith('Bearer ')) return null;
833
855
  const requestToken = authHeader.slice('Bearer '.length);
@@ -1060,6 +1082,7 @@ export class HttpService {
1060
1082
  if (isOriginalSession()) {
1061
1083
  if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
1062
1084
  this.tokenStore.clearTokens();
1085
+ this.sessionDeviceId = null;
1063
1086
  selfClearedEpoch = this.sessionEpoch;
1064
1087
  this.notifyTokenChange();
1065
1088
  }
@@ -2080,7 +2103,13 @@ export class HttpService {
2080
2103
  // Token management
2081
2104
  setTokens(accessToken: string): void {
2082
2105
  const current = this.tokenStore.getAccessToken();
2083
- if (current && !sameTokenSession(current, accessToken)) this.sessionEpoch += 1;
2106
+ const deviceId = knownTokenDeviceId(accessToken);
2107
+ const changed =
2108
+ current !== null &&
2109
+ (!sameTokenSession(current, accessToken) ||
2110
+ (this.sessionDeviceId !== null && deviceId !== null && this.sessionDeviceId !== deviceId));
2111
+ if (changed) this.sessionEpoch += 1;
2112
+ this.sessionDeviceId = !current || changed ? deviceId : (deviceId ?? this.sessionDeviceId);
2084
2113
  this.sessionEnded = false;
2085
2114
  this.tokenStore.setTokens(accessToken);
2086
2115
  this.notifyTokenChange();
@@ -2100,8 +2129,8 @@ export class HttpService {
2100
2129
  }
2101
2130
 
2102
2131
  /**
2103
- * The current context epoch: account/session switches and bearer removal
2104
- * invalidate pending work; rotation within the same context preserves it.
2132
+ * The current context epoch: account/session switches, known device changes,
2133
+ * and bearer removal invalidate pending work; same-context rotation preserves it.
2105
2134
  * A re-mint lane that
2106
2135
  * plants tokens itself (the device-secret arm) compares it across its awaits.
2107
2136
  */
@@ -2136,6 +2165,7 @@ export class HttpService {
2136
2165
  clearTokens(): void {
2137
2166
  if (this.tokenStore.getAccessToken()) this.sessionEpoch += 1;
2138
2167
  this.tokenStore.clearTokens();
2168
+ this.sessionDeviceId = null;
2139
2169
  // Drop the response cache on logout. The cache is identity-scoped, so a
2140
2170
  // different user could never read these entries, but a logged-out client
2141
2171
  // must not keep the previous session's personalized data resident in
@@ -2309,5 +2339,6 @@ export class HttpService {
2309
2339
  __resetTokensForTests(): void {
2310
2340
  this.sessionEnded = false;
2311
2341
  this.tokenStore.clearTokens();
2342
+ this.sessionDeviceId = null;
2312
2343
  }
2313
2344
  }