@camstack/sdk 1.2.133 → 1.2.135

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/dist/index.cjs CHANGED
@@ -654,6 +654,119 @@ SuperJSON.registerCustom;
654
654
  SuperJSON.registerSymbol;
655
655
  SuperJSON.allowErrorProps;
656
656
  //#endregion
657
+ //#region src/session-keeper.ts
658
+ var DEFAULT_MARGIN_MS = 5 * 6e4;
659
+ var RETRY_MS = 3e4;
660
+ var MIN_DELAY_MS = 1e3;
661
+ /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
662
+ function jwtExpiresAt(token) {
663
+ const part = token.split(".")[1];
664
+ if (part === void 0) return null;
665
+ try {
666
+ const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
667
+ const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
668
+ const payload = JSON.parse(json);
669
+ if (payload === null || typeof payload !== "object") return null;
670
+ const exp = Reflect.get(payload, "exp");
671
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+ /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
677
+ function isSessionRefusal(error) {
678
+ if (error === null || typeof error !== "object") return false;
679
+ const data = Reflect.get(error, "data");
680
+ const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
681
+ return code === "UNAUTHORIZED" || code === "FORBIDDEN";
682
+ }
683
+ var SessionKeeper = class {
684
+ options;
685
+ current = null;
686
+ cancel = null;
687
+ inflight = null;
688
+ now;
689
+ setTimer;
690
+ constructor(options) {
691
+ this.options = options;
692
+ this.now = options.now ?? Date.now;
693
+ this.setTimer = options.setTimer ?? ((fn, ms) => {
694
+ const handle = setTimeout(fn, ms);
695
+ return () => clearTimeout(handle);
696
+ });
697
+ }
698
+ /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
699
+ adopt(tokens) {
700
+ this.current = tokens;
701
+ this.schedule();
702
+ }
703
+ /** The tokens the keeper holds now. */
704
+ tokens() {
705
+ return this.current;
706
+ }
707
+ /**
708
+ * Refresh now if the access token expires within `marginMs` (default: the
709
+ * keeper's margin) — call it before reconnecting after the app was in the
710
+ * background. Resolves when done; concurrent callers share one refresh.
711
+ */
712
+ async ensureFresh(marginMs = this.margin()) {
713
+ const current = this.current;
714
+ if (current?.refreshToken === void 0) return;
715
+ const expiresAt = this.expiresAtOf(current);
716
+ if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
717
+ await this.refreshNow();
718
+ }
719
+ /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
720
+ refreshNow() {
721
+ if (this.inflight !== null) return this.inflight;
722
+ const refreshToken = this.current?.refreshToken;
723
+ if (refreshToken === void 0) return Promise.resolve(null);
724
+ this.inflight = this.options.refresh(refreshToken).then((next) => {
725
+ this.current = next;
726
+ this.options.onTokens(next);
727
+ this.schedule();
728
+ return next;
729
+ }).catch((error) => {
730
+ if (isSessionRefusal(error)) {
731
+ this.stop();
732
+ this.current = null;
733
+ this.options.onExpired();
734
+ } else this.arm(RETRY_MS);
735
+ return null;
736
+ }).finally(() => {
737
+ this.inflight = null;
738
+ });
739
+ return this.inflight;
740
+ }
741
+ stop() {
742
+ this.cancel?.();
743
+ this.cancel = null;
744
+ }
745
+ margin() {
746
+ return this.options.marginMs ?? DEFAULT_MARGIN_MS;
747
+ }
748
+ expiresAtOf(tokens) {
749
+ return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
750
+ }
751
+ schedule() {
752
+ this.stop();
753
+ const current = this.current;
754
+ if (current?.refreshToken === void 0) return;
755
+ const expiresAt = this.expiresAtOf(current);
756
+ if (expiresAt === null) return;
757
+ const remaining = expiresAt - this.now();
758
+ const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
759
+ this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
760
+ }
761
+ arm(ms) {
762
+ this.stop();
763
+ this.cancel = this.setTimer(() => {
764
+ this.cancel = null;
765
+ this.refreshNow();
766
+ }, ms);
767
+ }
768
+ };
769
+ //#endregion
657
770
  //#region src/system.ts
658
771
  /**
659
772
  * System — single, unified entry point for the camstack client API.
@@ -673,6 +786,27 @@ function refreshFlag(options) {
673
786
  var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
674
787
  var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
675
788
  var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
789
+ /** Close code the hub sends a socket whose bearer resolved to nobody. */
790
+ var SESSION_ENDED_CLOSE_CODE = 4401;
791
+ /** A token this close to its `exp` is refreshed before it rides a handshake. */
792
+ var HANDSHAKE_EXPIRY_SKEW_MS = 15e3;
793
+ /**
794
+ * Procedures a client calls BEFORE it holds a usable credential (login legs,
795
+ * the refresh itself, the login-method list). They run over anonymous HTTP:
796
+ * the socket answers a dead bearer by closing, and these calls are exactly
797
+ * what recovers from one.
798
+ */
799
+ var PRE_AUTH_PATHS = new Set([
800
+ "auth.refreshSession",
801
+ "auth.listLoginMethods",
802
+ "auth.getServerName",
803
+ "auth.login",
804
+ "auth.loginVerifyTotp",
805
+ "auth.loginBeginPasskey",
806
+ "auth.loginVerifyPasskey",
807
+ "auth.passkeyLoginBegin",
808
+ "auth.passkeyLoginFinish"
809
+ ]);
676
810
  var WS_RECONNECT_MAX_RETRY_DELAY_MS = 3e4;
677
811
  var System = class {
678
812
  /** Active server base URL. Mutable via `switchServerUrl()` so the
@@ -685,6 +819,7 @@ var System = class {
685
819
  onConnectionChange;
686
820
  token;
687
821
  getLanguage;
822
+ refreshCredential;
688
823
  _trpcClient;
689
824
  _wsClient = null;
690
825
  /**
@@ -701,6 +836,13 @@ var System = class {
701
836
  mirror = null;
702
837
  mirrorInit = null;
703
838
  connected = false;
839
+ /**
840
+ * The token the current socket's handshake carried (`undefined` token =
841
+ * anonymous handshake); `null` until a handshake has been attempted.
842
+ */
843
+ _wsHandshake = null;
844
+ /** The token the hub closed a socket for (4401): never handshaken again. */
845
+ _rejectedToken;
704
846
  connectedPromise = null;
705
847
  _systemProxy;
706
848
  _connectionVersion = 0;
@@ -716,6 +858,7 @@ var System = class {
716
858
  this._serverUrl = config.serverUrl.replace(/\/+$/, "");
717
859
  this.token = config.token;
718
860
  this.getLanguage = config.getLanguage ?? (() => void 0);
861
+ this.refreshCredential = config.refreshCredential;
719
862
  const isBrowser = typeof window !== "undefined";
720
863
  this.useWs = config.useWebSocket ?? isBrowser;
721
864
  this.onConnectionChange = config.onConnectionChange;
@@ -853,6 +996,7 @@ var System = class {
853
996
  this.resetEventSubs();
854
997
  this.connected = false;
855
998
  this.connectedPromise = null;
999
+ this._wsHandshake = null;
856
1000
  this._trpcClient = this.buildTrpcClient();
857
1001
  this._systemProxy = (0, _camstack_types_client.createSystemProxy)(this._trpcClient);
858
1002
  }
@@ -1123,6 +1267,16 @@ var System = class {
1123
1267
  this.token = token;
1124
1268
  }
1125
1269
  /**
1270
+ * True when the live WebSocket was handshaken with a token other than the
1271
+ * current one (anonymous, or rotated since). The socket authenticates once,
1272
+ * at the handshake, so such a socket answers every protected call
1273
+ * UNAUTHORIZED until `reconnect()`. Always false for HTTP transport.
1274
+ */
1275
+ isSocketTokenStale() {
1276
+ if (!this.useWs || this._wsHandshake === null) return false;
1277
+ return this._wsHandshake.token !== this.token;
1278
+ }
1279
+ /**
1126
1280
  * Synchronous snapshot of every device matching the optional filters.
1127
1281
  * Backed by the `SystemMirror` warm-boot cache — call `init()` first
1128
1282
  * (or `awaitReady()`) before invoking. Returns an empty array if the
@@ -1352,6 +1506,27 @@ var System = class {
1352
1506
  get wsClient() {
1353
1507
  return this._wsClient;
1354
1508
  }
1509
+ /**
1510
+ * The token the next socket handshake may carry. An expired token, or one
1511
+ * the hub just closed with 4401, is never sent: the credential is refreshed
1512
+ * over HTTP first, and when that does not yield a usable token the
1513
+ * handshake is anonymous (the app learns of a refused refresh from its
1514
+ * session keeper and routes to login; a transient failure retries later).
1515
+ */
1516
+ async handshakeToken() {
1517
+ const dead = (token) => {
1518
+ if (token === this._rejectedToken) return true;
1519
+ const exp = jwtExpiresAt(token);
1520
+ return exp !== null && exp - Date.now() <= HANDSHAKE_EXPIRY_SKEW_MS;
1521
+ };
1522
+ const initial = this.token;
1523
+ if (initial === void 0 || !dead(initial)) return initial;
1524
+ if (this.refreshCredential !== void 0) try {
1525
+ await this.refreshCredential(initial === this._rejectedToken ? "rejected" : "expired");
1526
+ } catch {}
1527
+ const current = this.token;
1528
+ return current !== void 0 && !dead(current) ? current : void 0;
1529
+ }
1355
1530
  buildTrpcClient() {
1356
1531
  const headers = () => {
1357
1532
  const h = {};
@@ -1367,10 +1542,12 @@ var System = class {
1367
1542
  const epoch = this._wsEpoch;
1368
1543
  this._wsClient = (0, _trpc_client.createWSClient)({
1369
1544
  url: wsUrl,
1370
- connectionParams: () => {
1545
+ connectionParams: async () => {
1546
+ const token = await this.handshakeToken();
1547
+ if (epoch === this._wsEpoch) this._wsHandshake = { token };
1371
1548
  const lang = this.getLanguage();
1372
1549
  return {
1373
- ...this.token !== void 0 ? { token: this.token } : {},
1550
+ ...token !== void 0 ? { token } : {},
1374
1551
  ...lang ? { lang } : {}
1375
1552
  };
1376
1553
  },
@@ -1385,14 +1562,27 @@ var System = class {
1385
1562
  this._connectionVersion += 1;
1386
1563
  this.emitConnectionEvent("connected");
1387
1564
  },
1388
- onClose: () => {
1565
+ onClose: (cause) => {
1389
1566
  if (epoch !== this._wsEpoch) return;
1567
+ if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
1390
1568
  this.emitConnectionEvent("disconnected");
1391
1569
  }
1392
1570
  });
1393
- return (0, _trpc_client.createTRPCClient)({ links: [(0, _trpc_client.wsLink)({
1394
- client: this._wsClient,
1395
- transformer: SuperJSON
1571
+ const anonymousHeaders = () => {
1572
+ const lang = this.getLanguage();
1573
+ return lang ? { "x-camstack-lang": lang } : {};
1574
+ };
1575
+ return (0, _trpc_client.createTRPCClient)({ links: [(0, _trpc_client.splitLink)({
1576
+ condition: (op) => PRE_AUTH_PATHS.has(op.path),
1577
+ true: (0, _trpc_client.httpLink)({
1578
+ url: `${this._serverUrl}/trpc`,
1579
+ headers: anonymousHeaders,
1580
+ transformer: SuperJSON
1581
+ }),
1582
+ false: (0, _trpc_client.wsLink)({
1583
+ client: this._wsClient,
1584
+ transformer: SuperJSON
1585
+ })
1396
1586
  })] });
1397
1587
  }
1398
1588
  this._wsClient = null;
@@ -1461,119 +1651,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
1461
1651
  }
1462
1652
  }
1463
1653
  //#endregion
1464
- //#region src/session-keeper.ts
1465
- var DEFAULT_MARGIN_MS = 5 * 6e4;
1466
- var RETRY_MS = 3e4;
1467
- var MIN_DELAY_MS = 1e3;
1468
- /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
1469
- function jwtExpiresAt(token) {
1470
- const part = token.split(".")[1];
1471
- if (part === void 0) return null;
1472
- try {
1473
- const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
1474
- const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
1475
- const payload = JSON.parse(json);
1476
- if (payload === null || typeof payload !== "object") return null;
1477
- const exp = Reflect.get(payload, "exp");
1478
- return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
1479
- } catch {
1480
- return null;
1481
- }
1482
- }
1483
- /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
1484
- function isSessionRefusal(error) {
1485
- if (error === null || typeof error !== "object") return false;
1486
- const data = Reflect.get(error, "data");
1487
- const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
1488
- return code === "UNAUTHORIZED" || code === "FORBIDDEN";
1489
- }
1490
- var SessionKeeper = class {
1491
- options;
1492
- current = null;
1493
- cancel = null;
1494
- inflight = null;
1495
- now;
1496
- setTimer;
1497
- constructor(options) {
1498
- this.options = options;
1499
- this.now = options.now ?? Date.now;
1500
- this.setTimer = options.setTimer ?? ((fn, ms) => {
1501
- const handle = setTimeout(fn, ms);
1502
- return () => clearTimeout(handle);
1503
- });
1504
- }
1505
- /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
1506
- adopt(tokens) {
1507
- this.current = tokens;
1508
- this.schedule();
1509
- }
1510
- /** The tokens the keeper holds now. */
1511
- tokens() {
1512
- return this.current;
1513
- }
1514
- /**
1515
- * Refresh now if the access token expires within `marginMs` (default: the
1516
- * keeper's margin) — call it before reconnecting after the app was in the
1517
- * background. Resolves when done; concurrent callers share one refresh.
1518
- */
1519
- async ensureFresh(marginMs = this.margin()) {
1520
- const current = this.current;
1521
- if (current?.refreshToken === void 0) return;
1522
- const expiresAt = this.expiresAtOf(current);
1523
- if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
1524
- await this.refreshNow();
1525
- }
1526
- /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
1527
- refreshNow() {
1528
- if (this.inflight !== null) return this.inflight;
1529
- const refreshToken = this.current?.refreshToken;
1530
- if (refreshToken === void 0) return Promise.resolve(null);
1531
- this.inflight = this.options.refresh(refreshToken).then((next) => {
1532
- this.current = next;
1533
- this.options.onTokens(next);
1534
- this.schedule();
1535
- return next;
1536
- }).catch((error) => {
1537
- if (isSessionRefusal(error)) {
1538
- this.stop();
1539
- this.current = null;
1540
- this.options.onExpired();
1541
- } else this.arm(RETRY_MS);
1542
- return null;
1543
- }).finally(() => {
1544
- this.inflight = null;
1545
- });
1546
- return this.inflight;
1547
- }
1548
- stop() {
1549
- this.cancel?.();
1550
- this.cancel = null;
1551
- }
1552
- margin() {
1553
- return this.options.marginMs ?? DEFAULT_MARGIN_MS;
1554
- }
1555
- expiresAtOf(tokens) {
1556
- return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
1557
- }
1558
- schedule() {
1559
- this.stop();
1560
- const current = this.current;
1561
- if (current?.refreshToken === void 0) return;
1562
- const expiresAt = this.expiresAtOf(current);
1563
- if (expiresAt === null) return;
1564
- const remaining = expiresAt - this.now();
1565
- const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
1566
- this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
1567
- }
1568
- arm(ms) {
1569
- this.stop();
1570
- this.cancel = this.setTimer(() => {
1571
- this.cancel = null;
1572
- this.refreshNow();
1573
- }, ms);
1574
- }
1575
- };
1576
- //#endregion
1577
1654
  //#region src/detection.ts
1578
1655
  /**
1579
1656
  * Detection classes — shared between CamStack app and proxy.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { System, createSystem, raceFastestEndpoint } from "./system.js";
2
2
  export type { LoginOptions, LoginOutcome, RefreshedSession } from "./system.js";
3
3
  export { isSessionRefusal, jwtExpiresAt, SessionKeeper, type SessionKeeperOptions, type SessionTokens } from "./session-keeper.js";
4
- export type { SystemConfig, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind } from "./system.js";
4
+ export type { SystemConfig, CredentialRefreshReason, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind } from "./system.js";
5
5
  export type { DeviceInfo } from "@camstack/types";
6
6
  export type { BackendAppRouter } from "./backend-router.js";
7
7
  export type { BackendAppRouter as AppRouter } from "./backend-router.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createTRPCClient, createWSClient, httpLink, wsLink } from "@trpc/client";
1
+ import { createTRPCClient, createWSClient, httpLink, splitLink, wsLink } from "@trpc/client";
2
2
  import { SystemMirror, createSystemProxy } from "@camstack/types/client";
3
3
  //#region ../../node_modules/superjson/dist/double-indexed-kv.js
4
4
  var DoubleIndexedKV = class {
@@ -653,6 +653,119 @@ SuperJSON.registerCustom;
653
653
  SuperJSON.registerSymbol;
654
654
  SuperJSON.allowErrorProps;
655
655
  //#endregion
656
+ //#region src/session-keeper.ts
657
+ var DEFAULT_MARGIN_MS = 5 * 6e4;
658
+ var RETRY_MS = 3e4;
659
+ var MIN_DELAY_MS = 1e3;
660
+ /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
661
+ function jwtExpiresAt(token) {
662
+ const part = token.split(".")[1];
663
+ if (part === void 0) return null;
664
+ try {
665
+ const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
666
+ const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
667
+ const payload = JSON.parse(json);
668
+ if (payload === null || typeof payload !== "object") return null;
669
+ const exp = Reflect.get(payload, "exp");
670
+ return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
671
+ } catch {
672
+ return null;
673
+ }
674
+ }
675
+ /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
676
+ function isSessionRefusal(error) {
677
+ if (error === null || typeof error !== "object") return false;
678
+ const data = Reflect.get(error, "data");
679
+ const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
680
+ return code === "UNAUTHORIZED" || code === "FORBIDDEN";
681
+ }
682
+ var SessionKeeper = class {
683
+ options;
684
+ current = null;
685
+ cancel = null;
686
+ inflight = null;
687
+ now;
688
+ setTimer;
689
+ constructor(options) {
690
+ this.options = options;
691
+ this.now = options.now ?? Date.now;
692
+ this.setTimer = options.setTimer ?? ((fn, ms) => {
693
+ const handle = setTimeout(fn, ms);
694
+ return () => clearTimeout(handle);
695
+ });
696
+ }
697
+ /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
698
+ adopt(tokens) {
699
+ this.current = tokens;
700
+ this.schedule();
701
+ }
702
+ /** The tokens the keeper holds now. */
703
+ tokens() {
704
+ return this.current;
705
+ }
706
+ /**
707
+ * Refresh now if the access token expires within `marginMs` (default: the
708
+ * keeper's margin) — call it before reconnecting after the app was in the
709
+ * background. Resolves when done; concurrent callers share one refresh.
710
+ */
711
+ async ensureFresh(marginMs = this.margin()) {
712
+ const current = this.current;
713
+ if (current?.refreshToken === void 0) return;
714
+ const expiresAt = this.expiresAtOf(current);
715
+ if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
716
+ await this.refreshNow();
717
+ }
718
+ /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
719
+ refreshNow() {
720
+ if (this.inflight !== null) return this.inflight;
721
+ const refreshToken = this.current?.refreshToken;
722
+ if (refreshToken === void 0) return Promise.resolve(null);
723
+ this.inflight = this.options.refresh(refreshToken).then((next) => {
724
+ this.current = next;
725
+ this.options.onTokens(next);
726
+ this.schedule();
727
+ return next;
728
+ }).catch((error) => {
729
+ if (isSessionRefusal(error)) {
730
+ this.stop();
731
+ this.current = null;
732
+ this.options.onExpired();
733
+ } else this.arm(RETRY_MS);
734
+ return null;
735
+ }).finally(() => {
736
+ this.inflight = null;
737
+ });
738
+ return this.inflight;
739
+ }
740
+ stop() {
741
+ this.cancel?.();
742
+ this.cancel = null;
743
+ }
744
+ margin() {
745
+ return this.options.marginMs ?? DEFAULT_MARGIN_MS;
746
+ }
747
+ expiresAtOf(tokens) {
748
+ return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
749
+ }
750
+ schedule() {
751
+ this.stop();
752
+ const current = this.current;
753
+ if (current?.refreshToken === void 0) return;
754
+ const expiresAt = this.expiresAtOf(current);
755
+ if (expiresAt === null) return;
756
+ const remaining = expiresAt - this.now();
757
+ const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
758
+ this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
759
+ }
760
+ arm(ms) {
761
+ this.stop();
762
+ this.cancel = this.setTimer(() => {
763
+ this.cancel = null;
764
+ this.refreshNow();
765
+ }, ms);
766
+ }
767
+ };
768
+ //#endregion
656
769
  //#region src/system.ts
657
770
  /**
658
771
  * System — single, unified entry point for the camstack client API.
@@ -672,6 +785,27 @@ function refreshFlag(options) {
672
785
  var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
673
786
  var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
674
787
  var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
788
+ /** Close code the hub sends a socket whose bearer resolved to nobody. */
789
+ var SESSION_ENDED_CLOSE_CODE = 4401;
790
+ /** A token this close to its `exp` is refreshed before it rides a handshake. */
791
+ var HANDSHAKE_EXPIRY_SKEW_MS = 15e3;
792
+ /**
793
+ * Procedures a client calls BEFORE it holds a usable credential (login legs,
794
+ * the refresh itself, the login-method list). They run over anonymous HTTP:
795
+ * the socket answers a dead bearer by closing, and these calls are exactly
796
+ * what recovers from one.
797
+ */
798
+ var PRE_AUTH_PATHS = new Set([
799
+ "auth.refreshSession",
800
+ "auth.listLoginMethods",
801
+ "auth.getServerName",
802
+ "auth.login",
803
+ "auth.loginVerifyTotp",
804
+ "auth.loginBeginPasskey",
805
+ "auth.loginVerifyPasskey",
806
+ "auth.passkeyLoginBegin",
807
+ "auth.passkeyLoginFinish"
808
+ ]);
675
809
  var WS_RECONNECT_MAX_RETRY_DELAY_MS = 3e4;
676
810
  var System = class {
677
811
  /** Active server base URL. Mutable via `switchServerUrl()` so the
@@ -684,6 +818,7 @@ var System = class {
684
818
  onConnectionChange;
685
819
  token;
686
820
  getLanguage;
821
+ refreshCredential;
687
822
  _trpcClient;
688
823
  _wsClient = null;
689
824
  /**
@@ -700,6 +835,13 @@ var System = class {
700
835
  mirror = null;
701
836
  mirrorInit = null;
702
837
  connected = false;
838
+ /**
839
+ * The token the current socket's handshake carried (`undefined` token =
840
+ * anonymous handshake); `null` until a handshake has been attempted.
841
+ */
842
+ _wsHandshake = null;
843
+ /** The token the hub closed a socket for (4401): never handshaken again. */
844
+ _rejectedToken;
703
845
  connectedPromise = null;
704
846
  _systemProxy;
705
847
  _connectionVersion = 0;
@@ -715,6 +857,7 @@ var System = class {
715
857
  this._serverUrl = config.serverUrl.replace(/\/+$/, "");
716
858
  this.token = config.token;
717
859
  this.getLanguage = config.getLanguage ?? (() => void 0);
860
+ this.refreshCredential = config.refreshCredential;
718
861
  const isBrowser = typeof window !== "undefined";
719
862
  this.useWs = config.useWebSocket ?? isBrowser;
720
863
  this.onConnectionChange = config.onConnectionChange;
@@ -852,6 +995,7 @@ var System = class {
852
995
  this.resetEventSubs();
853
996
  this.connected = false;
854
997
  this.connectedPromise = null;
998
+ this._wsHandshake = null;
855
999
  this._trpcClient = this.buildTrpcClient();
856
1000
  this._systemProxy = createSystemProxy(this._trpcClient);
857
1001
  }
@@ -1122,6 +1266,16 @@ var System = class {
1122
1266
  this.token = token;
1123
1267
  }
1124
1268
  /**
1269
+ * True when the live WebSocket was handshaken with a token other than the
1270
+ * current one (anonymous, or rotated since). The socket authenticates once,
1271
+ * at the handshake, so such a socket answers every protected call
1272
+ * UNAUTHORIZED until `reconnect()`. Always false for HTTP transport.
1273
+ */
1274
+ isSocketTokenStale() {
1275
+ if (!this.useWs || this._wsHandshake === null) return false;
1276
+ return this._wsHandshake.token !== this.token;
1277
+ }
1278
+ /**
1125
1279
  * Synchronous snapshot of every device matching the optional filters.
1126
1280
  * Backed by the `SystemMirror` warm-boot cache — call `init()` first
1127
1281
  * (or `awaitReady()`) before invoking. Returns an empty array if the
@@ -1351,6 +1505,27 @@ var System = class {
1351
1505
  get wsClient() {
1352
1506
  return this._wsClient;
1353
1507
  }
1508
+ /**
1509
+ * The token the next socket handshake may carry. An expired token, or one
1510
+ * the hub just closed with 4401, is never sent: the credential is refreshed
1511
+ * over HTTP first, and when that does not yield a usable token the
1512
+ * handshake is anonymous (the app learns of a refused refresh from its
1513
+ * session keeper and routes to login; a transient failure retries later).
1514
+ */
1515
+ async handshakeToken() {
1516
+ const dead = (token) => {
1517
+ if (token === this._rejectedToken) return true;
1518
+ const exp = jwtExpiresAt(token);
1519
+ return exp !== null && exp - Date.now() <= HANDSHAKE_EXPIRY_SKEW_MS;
1520
+ };
1521
+ const initial = this.token;
1522
+ if (initial === void 0 || !dead(initial)) return initial;
1523
+ if (this.refreshCredential !== void 0) try {
1524
+ await this.refreshCredential(initial === this._rejectedToken ? "rejected" : "expired");
1525
+ } catch {}
1526
+ const current = this.token;
1527
+ return current !== void 0 && !dead(current) ? current : void 0;
1528
+ }
1354
1529
  buildTrpcClient() {
1355
1530
  const headers = () => {
1356
1531
  const h = {};
@@ -1366,10 +1541,12 @@ var System = class {
1366
1541
  const epoch = this._wsEpoch;
1367
1542
  this._wsClient = createWSClient({
1368
1543
  url: wsUrl,
1369
- connectionParams: () => {
1544
+ connectionParams: async () => {
1545
+ const token = await this.handshakeToken();
1546
+ if (epoch === this._wsEpoch) this._wsHandshake = { token };
1370
1547
  const lang = this.getLanguage();
1371
1548
  return {
1372
- ...this.token !== void 0 ? { token: this.token } : {},
1549
+ ...token !== void 0 ? { token } : {},
1373
1550
  ...lang ? { lang } : {}
1374
1551
  };
1375
1552
  },
@@ -1384,14 +1561,27 @@ var System = class {
1384
1561
  this._connectionVersion += 1;
1385
1562
  this.emitConnectionEvent("connected");
1386
1563
  },
1387
- onClose: () => {
1564
+ onClose: (cause) => {
1388
1565
  if (epoch !== this._wsEpoch) return;
1566
+ if (cause?.code === SESSION_ENDED_CLOSE_CODE) this._rejectedToken = this._wsHandshake?.token ?? this._rejectedToken;
1389
1567
  this.emitConnectionEvent("disconnected");
1390
1568
  }
1391
1569
  });
1392
- return createTRPCClient({ links: [wsLink({
1393
- client: this._wsClient,
1394
- transformer: SuperJSON
1570
+ const anonymousHeaders = () => {
1571
+ const lang = this.getLanguage();
1572
+ return lang ? { "x-camstack-lang": lang } : {};
1573
+ };
1574
+ return createTRPCClient({ links: [splitLink({
1575
+ condition: (op) => PRE_AUTH_PATHS.has(op.path),
1576
+ true: httpLink({
1577
+ url: `${this._serverUrl}/trpc`,
1578
+ headers: anonymousHeaders,
1579
+ transformer: SuperJSON
1580
+ }),
1581
+ false: wsLink({
1582
+ client: this._wsClient,
1583
+ transformer: SuperJSON
1584
+ })
1395
1585
  })] });
1396
1586
  }
1397
1587
  this._wsClient = null;
@@ -1460,119 +1650,6 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
1460
1650
  }
1461
1651
  }
1462
1652
  //#endregion
1463
- //#region src/session-keeper.ts
1464
- var DEFAULT_MARGIN_MS = 5 * 6e4;
1465
- var RETRY_MS = 3e4;
1466
- var MIN_DELAY_MS = 1e3;
1467
- /** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
1468
- function jwtExpiresAt(token) {
1469
- const part = token.split(".")[1];
1470
- if (part === void 0) return null;
1471
- try {
1472
- const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
1473
- const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
1474
- const payload = JSON.parse(json);
1475
- if (payload === null || typeof payload !== "object") return null;
1476
- const exp = Reflect.get(payload, "exp");
1477
- return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
1478
- } catch {
1479
- return null;
1480
- }
1481
- }
1482
- /** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
1483
- function isSessionRefusal(error) {
1484
- if (error === null || typeof error !== "object") return false;
1485
- const data = Reflect.get(error, "data");
1486
- const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
1487
- return code === "UNAUTHORIZED" || code === "FORBIDDEN";
1488
- }
1489
- var SessionKeeper = class {
1490
- options;
1491
- current = null;
1492
- cancel = null;
1493
- inflight = null;
1494
- now;
1495
- setTimer;
1496
- constructor(options) {
1497
- this.options = options;
1498
- this.now = options.now ?? Date.now;
1499
- this.setTimer = options.setTimer ?? ((fn, ms) => {
1500
- const handle = setTimeout(fn, ms);
1501
- return () => clearTimeout(handle);
1502
- });
1503
- }
1504
- /** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
1505
- adopt(tokens) {
1506
- this.current = tokens;
1507
- this.schedule();
1508
- }
1509
- /** The tokens the keeper holds now. */
1510
- tokens() {
1511
- return this.current;
1512
- }
1513
- /**
1514
- * Refresh now if the access token expires within `marginMs` (default: the
1515
- * keeper's margin) — call it before reconnecting after the app was in the
1516
- * background. Resolves when done; concurrent callers share one refresh.
1517
- */
1518
- async ensureFresh(marginMs = this.margin()) {
1519
- const current = this.current;
1520
- if (current?.refreshToken === void 0) return;
1521
- const expiresAt = this.expiresAtOf(current);
1522
- if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
1523
- await this.refreshNow();
1524
- }
1525
- /** Refresh immediately. `null` when there is nothing to refresh or it failed. */
1526
- refreshNow() {
1527
- if (this.inflight !== null) return this.inflight;
1528
- const refreshToken = this.current?.refreshToken;
1529
- if (refreshToken === void 0) return Promise.resolve(null);
1530
- this.inflight = this.options.refresh(refreshToken).then((next) => {
1531
- this.current = next;
1532
- this.options.onTokens(next);
1533
- this.schedule();
1534
- return next;
1535
- }).catch((error) => {
1536
- if (isSessionRefusal(error)) {
1537
- this.stop();
1538
- this.current = null;
1539
- this.options.onExpired();
1540
- } else this.arm(RETRY_MS);
1541
- return null;
1542
- }).finally(() => {
1543
- this.inflight = null;
1544
- });
1545
- return this.inflight;
1546
- }
1547
- stop() {
1548
- this.cancel?.();
1549
- this.cancel = null;
1550
- }
1551
- margin() {
1552
- return this.options.marginMs ?? DEFAULT_MARGIN_MS;
1553
- }
1554
- expiresAtOf(tokens) {
1555
- return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
1556
- }
1557
- schedule() {
1558
- this.stop();
1559
- const current = this.current;
1560
- if (current?.refreshToken === void 0) return;
1561
- const expiresAt = this.expiresAtOf(current);
1562
- if (expiresAt === null) return;
1563
- const remaining = expiresAt - this.now();
1564
- const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
1565
- this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
1566
- }
1567
- arm(ms) {
1568
- this.stop();
1569
- this.cancel = this.setTimer(() => {
1570
- this.cancel = null;
1571
- this.refreshNow();
1572
- }, ms);
1573
- }
1574
- };
1575
- //#endregion
1576
1653
  //#region src/detection.ts
1577
1654
  /**
1578
1655
  * Detection classes — shared between CamStack app and proxy.
package/dist/system.d.ts CHANGED
@@ -56,7 +56,18 @@ export interface SystemConfig {
56
56
  * from `Accept-Language`.
57
57
  */
58
58
  readonly getLanguage?: () => string | undefined;
59
+ /**
60
+ * Obtain a fresh access token (normally `SessionKeeper.refreshNow` /
61
+ * `ensureFresh`, which ends in `setToken`). Called BEFORE a socket handshake
62
+ * whose token is expired or was just refused with close code 4401. The
63
+ * refresh itself runs over HTTP (`refreshSession` never uses the socket). A
64
+ * rejection is swallowed: the handshake then goes out anonymous rather than
65
+ * carrying a token the hub is known to refuse.
66
+ */
67
+ readonly refreshCredential?: (reason: CredentialRefreshReason) => Promise<void>;
59
68
  }
69
+ /** Why `SystemConfig.refreshCredential` is asked: the token's own `exp`, or a 4401 close. */
70
+ export type CredentialRefreshReason = "expired" | "rejected";
60
71
  type ConnectionListener = (state: BackendConnectionState, version: number) => void;
61
72
  /** Options of every login leg. */
62
73
  export interface LoginOptions {
@@ -114,6 +125,7 @@ export declare class System {
114
125
  private readonly onConnectionChange;
115
126
  private token;
116
127
  private readonly getLanguage;
128
+ private readonly refreshCredential;
117
129
  private _trpcClient;
118
130
  private _wsClient;
119
131
  /**
@@ -130,6 +142,13 @@ export declare class System {
130
142
  private mirror;
131
143
  private mirrorInit;
132
144
  private connected;
145
+ /**
146
+ * The token the current socket's handshake carried (`undefined` token =
147
+ * anonymous handshake); `null` until a handshake has been attempted.
148
+ */
149
+ private _wsHandshake;
150
+ /** The token the hub closed a socket for (4401): never handshaken again. */
151
+ private _rejectedToken;
133
152
  private connectedPromise;
134
153
  private _systemProxy;
135
154
  private _connectionVersion;
@@ -388,6 +407,13 @@ export declare class System {
388
407
  /** Update the auth token (e.g. after login or token refresh). */
389
408
  setToken(token: string): void;
390
409
  /**
410
+ * True when the live WebSocket was handshaken with a token other than the
411
+ * current one (anonymous, or rotated since). The socket authenticates once,
412
+ * at the handshake, so such a socket answers every protected call
413
+ * UNAUTHORIZED until `reconnect()`. Always false for HTTP transport.
414
+ */
415
+ isSocketTokenStale(): boolean;
416
+ /**
391
417
  * Synchronous snapshot of every device matching the optional filters.
392
418
  * Backed by the `SystemMirror` warm-boot cache — call `init()` first
393
419
  * (or `awaitReady()`) before invoking. Returns an empty array if the
@@ -494,6 +520,14 @@ export declare class System {
494
520
  * (e.g. for keep-alive metrics). Rebuilt on `reconnect()`.
495
521
  */
496
522
  get wsClient(): TrpcWsClient | null;
523
+ /**
524
+ * The token the next socket handshake may carry. An expired token, or one
525
+ * the hub just closed with 4401, is never sent: the credential is refreshed
526
+ * over HTTP first, and when that does not yield a usable token the
527
+ * handshake is anonymous (the app learns of a refused refresh from its
528
+ * session keeper and routes to login; a transient failure retries later).
529
+ */
530
+ private handshakeToken;
497
531
  private buildTrpcClient;
498
532
  }
499
533
  /** Create a `System` instance. Convenience factory. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/sdk",
3
- "version": "1.2.133",
3
+ "version": "1.2.135",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",