@camstack/sdk 1.2.127 → 1.2.128
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/__tests__/session-keeper.spec.d.ts +1 -0
- package/dist/index.cjs +169 -12
- package/dist/index.d.ts +2 -0
- package/dist/index.js +167 -13
- package/dist/session-keeper.d.ts +63 -0
- package/dist/system.d.ts +77 -18
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.cjs
CHANGED
|
@@ -667,6 +667,9 @@ SuperJSON.allowErrorProps;
|
|
|
667
667
|
* ui-library Provider can seed React Query with the same WS connection
|
|
668
668
|
* — a future phase will narrow the surface further.
|
|
669
669
|
*/
|
|
670
|
+
function refreshFlag(options) {
|
|
671
|
+
return options.refreshable === true ? { refreshable: true } : {};
|
|
672
|
+
}
|
|
670
673
|
var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
|
|
671
674
|
var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
|
|
672
675
|
var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
|
|
@@ -946,10 +949,11 @@ var System = class {
|
|
|
946
949
|
this.mirror = null;
|
|
947
950
|
this.mirrorInit = null;
|
|
948
951
|
}
|
|
949
|
-
async login(username, password) {
|
|
952
|
+
async login(username, password, options = {}) {
|
|
950
953
|
const result = await this._trpcClient.auth.login.mutate({
|
|
951
954
|
username,
|
|
952
|
-
password
|
|
955
|
+
password,
|
|
956
|
+
...refreshFlag(options)
|
|
953
957
|
});
|
|
954
958
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
955
959
|
const r = result;
|
|
@@ -966,10 +970,11 @@ var System = class {
|
|
|
966
970
|
* server mints the real session JWT, which we set as the active
|
|
967
971
|
* token on this client.
|
|
968
972
|
*/
|
|
969
|
-
async loginVerifyTotp(challengeToken, code) {
|
|
973
|
+
async loginVerifyTotp(challengeToken, code, options = {}) {
|
|
970
974
|
const result = await this._trpcClient.auth.loginVerifyTotp.mutate({
|
|
971
975
|
challengeToken,
|
|
972
|
-
code
|
|
976
|
+
code,
|
|
977
|
+
...refreshFlag(options)
|
|
973
978
|
});
|
|
974
979
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
975
980
|
const token = result.token;
|
|
@@ -990,10 +995,11 @@ var System = class {
|
|
|
990
995
|
* success the server mints the real session JWT, which we set as the
|
|
991
996
|
* active token on this client.
|
|
992
997
|
*/
|
|
993
|
-
async loginVerifyPasskey(challengeToken, response) {
|
|
998
|
+
async loginVerifyPasskey(challengeToken, response, options = {}) {
|
|
994
999
|
const result = await this._trpcClient.auth.loginVerifyPasskey.mutate({
|
|
995
1000
|
challengeToken,
|
|
996
|
-
response
|
|
1001
|
+
response,
|
|
1002
|
+
...refreshFlag(options)
|
|
997
1003
|
});
|
|
998
1004
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
999
1005
|
const token = result.token;
|
|
@@ -1022,8 +1028,11 @@ var System = class {
|
|
|
1022
1028
|
* `secondFactors`. That token is NOT persisted as the active session
|
|
1023
1029
|
* — the caller completes the remaining factor via `loginVerifyTotp`.
|
|
1024
1030
|
*/
|
|
1025
|
-
async passkeyLoginFinish(response) {
|
|
1026
|
-
const result = await this._trpcClient.auth.passkeyLoginFinish.mutate({
|
|
1031
|
+
async passkeyLoginFinish(response, options = {}) {
|
|
1032
|
+
const result = await this._trpcClient.auth.passkeyLoginFinish.mutate({
|
|
1033
|
+
response,
|
|
1034
|
+
...refreshFlag(options)
|
|
1035
|
+
});
|
|
1027
1036
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
1028
1037
|
const r = result;
|
|
1029
1038
|
const requiresSecondFactor = r.requiresTotp === true || Array.isArray(r.secondFactors) && r.secondFactors.length > 0;
|
|
@@ -1031,8 +1040,22 @@ var System = class {
|
|
|
1031
1040
|
}
|
|
1032
1041
|
return result;
|
|
1033
1042
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1043
|
+
/**
|
|
1044
|
+
* Swap a refresh token for a new access token and a ROTATED refresh token
|
|
1045
|
+
* (the old one stops working). Sets the new access token on this client.
|
|
1046
|
+
* Throws a tRPC UNAUTHORIZED when the session is over — sign in again.
|
|
1047
|
+
*/
|
|
1048
|
+
async refreshSession(refreshToken) {
|
|
1049
|
+
const result = await this._trpcClient.auth.refreshSession.mutate({ refreshToken });
|
|
1050
|
+
this.setToken(result.token);
|
|
1051
|
+
return result;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* End this session on the hub (its tokens stop working at once);
|
|
1055
|
+
* `everywhere: true` ends every session of the user.
|
|
1056
|
+
*/
|
|
1057
|
+
async logout(options = {}) {
|
|
1058
|
+
await this._trpcClient.auth.logout.mutate(options.everywhere === true ? { everywhere: true } : void 0);
|
|
1036
1059
|
}
|
|
1037
1060
|
async getMe() {
|
|
1038
1061
|
return await this._trpcClient.auth.me.query();
|
|
@@ -1046,8 +1069,25 @@ var System = class {
|
|
|
1046
1069
|
async confirmOwnTotp(input) {
|
|
1047
1070
|
return await this._trpcClient.auth.confirmOwnTotp.mutate(input);
|
|
1048
1071
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1072
|
+
/** Needs a fresh `reauthToken` from {@link reauthenticate}. */
|
|
1073
|
+
async disableOwnTotp(input) {
|
|
1074
|
+
return await this._trpcClient.auth.disableOwnTotp.mutate(input);
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Re-authentication, passkey path step 1: assertion options for the
|
|
1078
|
+
* caller's own passkeys. Feed `optionsJSON` to `startAuthentication`, then
|
|
1079
|
+
* pass the result to {@link reauthenticate} as `passkeyResponse`.
|
|
1080
|
+
*/
|
|
1081
|
+
async reauthBeginPasskey() {
|
|
1082
|
+
return await this._trpcClient.auth.reauthBeginPasskey.mutate();
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Prove it is you — the current password or a passkey assertion — for a
|
|
1086
|
+
* 5-minute `reauthToken`, required by `disableOwnTotp`, `removeOwnPasskey`
|
|
1087
|
+
* and `auth.beginExternalLink` (valid only in this session).
|
|
1088
|
+
*/
|
|
1089
|
+
async reauthenticate(input) {
|
|
1090
|
+
return await this._trpcClient.auth.reauthenticate.mutate(input);
|
|
1051
1091
|
}
|
|
1052
1092
|
async getOwnTotpStatus() {
|
|
1053
1093
|
return await this._trpcClient.auth.getOwnTotpStatus.query();
|
|
@@ -1061,6 +1101,7 @@ var System = class {
|
|
|
1061
1101
|
async listOwnPasskeys() {
|
|
1062
1102
|
return await this._trpcClient.auth.listOwnPasskeys.query();
|
|
1063
1103
|
}
|
|
1104
|
+
/** Needs a fresh `reauthToken` from {@link reauthenticate}. */
|
|
1064
1105
|
async removeOwnPasskey(input) {
|
|
1065
1106
|
return await this._trpcClient.auth.removeOwnPasskey.mutate(input);
|
|
1066
1107
|
}
|
|
@@ -1410,6 +1451,119 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
|
|
|
1410
1451
|
}
|
|
1411
1452
|
}
|
|
1412
1453
|
//#endregion
|
|
1454
|
+
//#region src/session-keeper.ts
|
|
1455
|
+
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
1456
|
+
var RETRY_MS = 3e4;
|
|
1457
|
+
var MIN_DELAY_MS = 1e3;
|
|
1458
|
+
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
1459
|
+
function jwtExpiresAt(token) {
|
|
1460
|
+
const part = token.split(".")[1];
|
|
1461
|
+
if (part === void 0) return null;
|
|
1462
|
+
try {
|
|
1463
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
1464
|
+
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
1465
|
+
const payload = JSON.parse(json);
|
|
1466
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
1467
|
+
const exp = Reflect.get(payload, "exp");
|
|
1468
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
1469
|
+
} catch {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
1474
|
+
function isSessionRefusal(error) {
|
|
1475
|
+
if (error === null || typeof error !== "object") return false;
|
|
1476
|
+
const data = Reflect.get(error, "data");
|
|
1477
|
+
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
1478
|
+
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
1479
|
+
}
|
|
1480
|
+
var SessionKeeper = class {
|
|
1481
|
+
options;
|
|
1482
|
+
current = null;
|
|
1483
|
+
cancel = null;
|
|
1484
|
+
inflight = null;
|
|
1485
|
+
now;
|
|
1486
|
+
setTimer;
|
|
1487
|
+
constructor(options) {
|
|
1488
|
+
this.options = options;
|
|
1489
|
+
this.now = options.now ?? Date.now;
|
|
1490
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
1491
|
+
const handle = setTimeout(fn, ms);
|
|
1492
|
+
return () => clearTimeout(handle);
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
1496
|
+
adopt(tokens) {
|
|
1497
|
+
this.current = tokens;
|
|
1498
|
+
this.schedule();
|
|
1499
|
+
}
|
|
1500
|
+
/** The tokens the keeper holds now. */
|
|
1501
|
+
tokens() {
|
|
1502
|
+
return this.current;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Refresh now if the access token expires within `marginMs` (default: the
|
|
1506
|
+
* keeper's margin) — call it before reconnecting after the app was in the
|
|
1507
|
+
* background. Resolves when done; concurrent callers share one refresh.
|
|
1508
|
+
*/
|
|
1509
|
+
async ensureFresh(marginMs = this.margin()) {
|
|
1510
|
+
const current = this.current;
|
|
1511
|
+
if (current?.refreshToken === void 0) return;
|
|
1512
|
+
const expiresAt = this.expiresAtOf(current);
|
|
1513
|
+
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
1514
|
+
await this.refreshNow();
|
|
1515
|
+
}
|
|
1516
|
+
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
1517
|
+
refreshNow() {
|
|
1518
|
+
if (this.inflight !== null) return this.inflight;
|
|
1519
|
+
const refreshToken = this.current?.refreshToken;
|
|
1520
|
+
if (refreshToken === void 0) return Promise.resolve(null);
|
|
1521
|
+
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
1522
|
+
this.current = next;
|
|
1523
|
+
this.options.onTokens(next);
|
|
1524
|
+
this.schedule();
|
|
1525
|
+
return next;
|
|
1526
|
+
}).catch((error) => {
|
|
1527
|
+
if (isSessionRefusal(error)) {
|
|
1528
|
+
this.stop();
|
|
1529
|
+
this.current = null;
|
|
1530
|
+
this.options.onExpired();
|
|
1531
|
+
} else this.arm(RETRY_MS);
|
|
1532
|
+
return null;
|
|
1533
|
+
}).finally(() => {
|
|
1534
|
+
this.inflight = null;
|
|
1535
|
+
});
|
|
1536
|
+
return this.inflight;
|
|
1537
|
+
}
|
|
1538
|
+
stop() {
|
|
1539
|
+
this.cancel?.();
|
|
1540
|
+
this.cancel = null;
|
|
1541
|
+
}
|
|
1542
|
+
margin() {
|
|
1543
|
+
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
1544
|
+
}
|
|
1545
|
+
expiresAtOf(tokens) {
|
|
1546
|
+
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
1547
|
+
}
|
|
1548
|
+
schedule() {
|
|
1549
|
+
this.stop();
|
|
1550
|
+
const current = this.current;
|
|
1551
|
+
if (current?.refreshToken === void 0) return;
|
|
1552
|
+
const expiresAt = this.expiresAtOf(current);
|
|
1553
|
+
if (expiresAt === null) return;
|
|
1554
|
+
const remaining = expiresAt - this.now();
|
|
1555
|
+
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
1556
|
+
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
1557
|
+
}
|
|
1558
|
+
arm(ms) {
|
|
1559
|
+
this.stop();
|
|
1560
|
+
this.cancel = this.setTimer(() => {
|
|
1561
|
+
this.cancel = null;
|
|
1562
|
+
this.refreshNow();
|
|
1563
|
+
}, ms);
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
//#endregion
|
|
1413
1567
|
//#region src/detection.ts
|
|
1414
1568
|
/**
|
|
1415
1569
|
* Detection classes — shared between CamStack app and proxy.
|
|
@@ -1724,6 +1878,7 @@ function getClassesForTimelinePreset(preset, customClasses) {
|
|
|
1724
1878
|
//#endregion
|
|
1725
1879
|
exports.DEFAULT_ENABLED_CLASSES = DEFAULT_ENABLED_CLASSES;
|
|
1726
1880
|
exports.DetectionClass = DetectionClass;
|
|
1881
|
+
exports.SessionKeeper = SessionKeeper;
|
|
1727
1882
|
exports.System = System;
|
|
1728
1883
|
exports.TIMELINE_PRESET_ALL = TIMELINE_PRESET_ALL;
|
|
1729
1884
|
exports.TIMELINE_PRESET_CRITICAL = TIMELINE_PRESET_CRITICAL;
|
|
@@ -1749,7 +1904,9 @@ exports.isPackageClassname = isPackageClassname;
|
|
|
1749
1904
|
exports.isPersonClassname = isPersonClassname;
|
|
1750
1905
|
exports.isPlateClassname = isPlateClassname;
|
|
1751
1906
|
exports.isSensorLabelClassname = isSensorLabelClassname;
|
|
1907
|
+
exports.isSessionRefusal = isSessionRefusal;
|
|
1752
1908
|
exports.isVehicleClassname = isVehicleClassname;
|
|
1909
|
+
exports.jwtExpiresAt = jwtExpiresAt;
|
|
1753
1910
|
exports.licensePlateClasses = licensePlateClasses;
|
|
1754
1911
|
exports.motionClasses = motionClasses;
|
|
1755
1912
|
exports.packageClasses = packageClasses;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { System, createSystem, raceFastestEndpoint } from './system.js';
|
|
2
|
+
export type { LoginOptions, LoginOutcome, RefreshedSession } from './system.js';
|
|
3
|
+
export { isSessionRefusal, jwtExpiresAt, SessionKeeper, type SessionKeeperOptions, type SessionTokens, } from './session-keeper.js';
|
|
2
4
|
export type { SystemConfig, UserInfo, SystemLiveEvent, SystemLiveEventListener, SecondFactorKind, } from './system.js';
|
|
3
5
|
export type { DeviceInfo } from '@camstack/types';
|
|
4
6
|
export type { BackendAppRouter } from './backend-router.js';
|
package/dist/index.js
CHANGED
|
@@ -666,6 +666,9 @@ SuperJSON.allowErrorProps;
|
|
|
666
666
|
* ui-library Provider can seed React Query with the same WS connection
|
|
667
667
|
* — a future phase will narrow the surface further.
|
|
668
668
|
*/
|
|
669
|
+
function refreshFlag(options) {
|
|
670
|
+
return options.refreshable === true ? { refreshable: true } : {};
|
|
671
|
+
}
|
|
669
672
|
var WS_KEEP_ALIVE_INTERVAL_MS = 1e4;
|
|
670
673
|
var WS_KEEP_ALIVE_PONG_TIMEOUT_MS = 6e3;
|
|
671
674
|
var WS_RECONNECT_RETRY_DELAY_MS = 2e3;
|
|
@@ -945,10 +948,11 @@ var System = class {
|
|
|
945
948
|
this.mirror = null;
|
|
946
949
|
this.mirrorInit = null;
|
|
947
950
|
}
|
|
948
|
-
async login(username, password) {
|
|
951
|
+
async login(username, password, options = {}) {
|
|
949
952
|
const result = await this._trpcClient.auth.login.mutate({
|
|
950
953
|
username,
|
|
951
|
-
password
|
|
954
|
+
password,
|
|
955
|
+
...refreshFlag(options)
|
|
952
956
|
});
|
|
953
957
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
954
958
|
const r = result;
|
|
@@ -965,10 +969,11 @@ var System = class {
|
|
|
965
969
|
* server mints the real session JWT, which we set as the active
|
|
966
970
|
* token on this client.
|
|
967
971
|
*/
|
|
968
|
-
async loginVerifyTotp(challengeToken, code) {
|
|
972
|
+
async loginVerifyTotp(challengeToken, code, options = {}) {
|
|
969
973
|
const result = await this._trpcClient.auth.loginVerifyTotp.mutate({
|
|
970
974
|
challengeToken,
|
|
971
|
-
code
|
|
975
|
+
code,
|
|
976
|
+
...refreshFlag(options)
|
|
972
977
|
});
|
|
973
978
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
974
979
|
const token = result.token;
|
|
@@ -989,10 +994,11 @@ var System = class {
|
|
|
989
994
|
* success the server mints the real session JWT, which we set as the
|
|
990
995
|
* active token on this client.
|
|
991
996
|
*/
|
|
992
|
-
async loginVerifyPasskey(challengeToken, response) {
|
|
997
|
+
async loginVerifyPasskey(challengeToken, response, options = {}) {
|
|
993
998
|
const result = await this._trpcClient.auth.loginVerifyPasskey.mutate({
|
|
994
999
|
challengeToken,
|
|
995
|
-
response
|
|
1000
|
+
response,
|
|
1001
|
+
...refreshFlag(options)
|
|
996
1002
|
});
|
|
997
1003
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
998
1004
|
const token = result.token;
|
|
@@ -1021,8 +1027,11 @@ var System = class {
|
|
|
1021
1027
|
* `secondFactors`. That token is NOT persisted as the active session
|
|
1022
1028
|
* — the caller completes the remaining factor via `loginVerifyTotp`.
|
|
1023
1029
|
*/
|
|
1024
|
-
async passkeyLoginFinish(response) {
|
|
1025
|
-
const result = await this._trpcClient.auth.passkeyLoginFinish.mutate({
|
|
1030
|
+
async passkeyLoginFinish(response, options = {}) {
|
|
1031
|
+
const result = await this._trpcClient.auth.passkeyLoginFinish.mutate({
|
|
1032
|
+
response,
|
|
1033
|
+
...refreshFlag(options)
|
|
1034
|
+
});
|
|
1026
1035
|
if (typeof result === "object" && result !== null && "token" in result) {
|
|
1027
1036
|
const r = result;
|
|
1028
1037
|
const requiresSecondFactor = r.requiresTotp === true || Array.isArray(r.secondFactors) && r.secondFactors.length > 0;
|
|
@@ -1030,8 +1039,22 @@ var System = class {
|
|
|
1030
1039
|
}
|
|
1031
1040
|
return result;
|
|
1032
1041
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1042
|
+
/**
|
|
1043
|
+
* Swap a refresh token for a new access token and a ROTATED refresh token
|
|
1044
|
+
* (the old one stops working). Sets the new access token on this client.
|
|
1045
|
+
* Throws a tRPC UNAUTHORIZED when the session is over — sign in again.
|
|
1046
|
+
*/
|
|
1047
|
+
async refreshSession(refreshToken) {
|
|
1048
|
+
const result = await this._trpcClient.auth.refreshSession.mutate({ refreshToken });
|
|
1049
|
+
this.setToken(result.token);
|
|
1050
|
+
return result;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* End this session on the hub (its tokens stop working at once);
|
|
1054
|
+
* `everywhere: true` ends every session of the user.
|
|
1055
|
+
*/
|
|
1056
|
+
async logout(options = {}) {
|
|
1057
|
+
await this._trpcClient.auth.logout.mutate(options.everywhere === true ? { everywhere: true } : void 0);
|
|
1035
1058
|
}
|
|
1036
1059
|
async getMe() {
|
|
1037
1060
|
return await this._trpcClient.auth.me.query();
|
|
@@ -1045,8 +1068,25 @@ var System = class {
|
|
|
1045
1068
|
async confirmOwnTotp(input) {
|
|
1046
1069
|
return await this._trpcClient.auth.confirmOwnTotp.mutate(input);
|
|
1047
1070
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1071
|
+
/** Needs a fresh `reauthToken` from {@link reauthenticate}. */
|
|
1072
|
+
async disableOwnTotp(input) {
|
|
1073
|
+
return await this._trpcClient.auth.disableOwnTotp.mutate(input);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Re-authentication, passkey path step 1: assertion options for the
|
|
1077
|
+
* caller's own passkeys. Feed `optionsJSON` to `startAuthentication`, then
|
|
1078
|
+
* pass the result to {@link reauthenticate} as `passkeyResponse`.
|
|
1079
|
+
*/
|
|
1080
|
+
async reauthBeginPasskey() {
|
|
1081
|
+
return await this._trpcClient.auth.reauthBeginPasskey.mutate();
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Prove it is you — the current password or a passkey assertion — for a
|
|
1085
|
+
* 5-minute `reauthToken`, required by `disableOwnTotp`, `removeOwnPasskey`
|
|
1086
|
+
* and `auth.beginExternalLink` (valid only in this session).
|
|
1087
|
+
*/
|
|
1088
|
+
async reauthenticate(input) {
|
|
1089
|
+
return await this._trpcClient.auth.reauthenticate.mutate(input);
|
|
1050
1090
|
}
|
|
1051
1091
|
async getOwnTotpStatus() {
|
|
1052
1092
|
return await this._trpcClient.auth.getOwnTotpStatus.query();
|
|
@@ -1060,6 +1100,7 @@ var System = class {
|
|
|
1060
1100
|
async listOwnPasskeys() {
|
|
1061
1101
|
return await this._trpcClient.auth.listOwnPasskeys.query();
|
|
1062
1102
|
}
|
|
1103
|
+
/** Needs a fresh `reauthToken` from {@link reauthenticate}. */
|
|
1063
1104
|
async removeOwnPasskey(input) {
|
|
1064
1105
|
return await this._trpcClient.auth.removeOwnPasskey.mutate(input);
|
|
1065
1106
|
}
|
|
@@ -1409,6 +1450,119 @@ async function raceFastestEndpoint(candidates, timeoutMs) {
|
|
|
1409
1450
|
}
|
|
1410
1451
|
}
|
|
1411
1452
|
//#endregion
|
|
1453
|
+
//#region src/session-keeper.ts
|
|
1454
|
+
var DEFAULT_MARGIN_MS = 5 * 6e4;
|
|
1455
|
+
var RETRY_MS = 3e4;
|
|
1456
|
+
var MIN_DELAY_MS = 1e3;
|
|
1457
|
+
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
1458
|
+
function jwtExpiresAt(token) {
|
|
1459
|
+
const part = token.split(".")[1];
|
|
1460
|
+
if (part === void 0) return null;
|
|
1461
|
+
try {
|
|
1462
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
1463
|
+
const json = typeof atob === "function" ? atob(base64) : Buffer.from(base64, "base64").toString("utf8");
|
|
1464
|
+
const payload = JSON.parse(json);
|
|
1465
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
1466
|
+
const exp = Reflect.get(payload, "exp");
|
|
1467
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1e3 : null;
|
|
1468
|
+
} catch {
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
1473
|
+
function isSessionRefusal(error) {
|
|
1474
|
+
if (error === null || typeof error !== "object") return false;
|
|
1475
|
+
const data = Reflect.get(error, "data");
|
|
1476
|
+
const code = data !== null && typeof data === "object" ? Reflect.get(data, "code") : void 0;
|
|
1477
|
+
return code === "UNAUTHORIZED" || code === "FORBIDDEN";
|
|
1478
|
+
}
|
|
1479
|
+
var SessionKeeper = class {
|
|
1480
|
+
options;
|
|
1481
|
+
current = null;
|
|
1482
|
+
cancel = null;
|
|
1483
|
+
inflight = null;
|
|
1484
|
+
now;
|
|
1485
|
+
setTimer;
|
|
1486
|
+
constructor(options) {
|
|
1487
|
+
this.options = options;
|
|
1488
|
+
this.now = options.now ?? Date.now;
|
|
1489
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => {
|
|
1490
|
+
const handle = setTimeout(fn, ms);
|
|
1491
|
+
return () => clearTimeout(handle);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
1495
|
+
adopt(tokens) {
|
|
1496
|
+
this.current = tokens;
|
|
1497
|
+
this.schedule();
|
|
1498
|
+
}
|
|
1499
|
+
/** The tokens the keeper holds now. */
|
|
1500
|
+
tokens() {
|
|
1501
|
+
return this.current;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Refresh now if the access token expires within `marginMs` (default: the
|
|
1505
|
+
* keeper's margin) — call it before reconnecting after the app was in the
|
|
1506
|
+
* background. Resolves when done; concurrent callers share one refresh.
|
|
1507
|
+
*/
|
|
1508
|
+
async ensureFresh(marginMs = this.margin()) {
|
|
1509
|
+
const current = this.current;
|
|
1510
|
+
if (current?.refreshToken === void 0) return;
|
|
1511
|
+
const expiresAt = this.expiresAtOf(current);
|
|
1512
|
+
if (expiresAt !== null && expiresAt - this.now() > marginMs) return;
|
|
1513
|
+
await this.refreshNow();
|
|
1514
|
+
}
|
|
1515
|
+
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
1516
|
+
refreshNow() {
|
|
1517
|
+
if (this.inflight !== null) return this.inflight;
|
|
1518
|
+
const refreshToken = this.current?.refreshToken;
|
|
1519
|
+
if (refreshToken === void 0) return Promise.resolve(null);
|
|
1520
|
+
this.inflight = this.options.refresh(refreshToken).then((next) => {
|
|
1521
|
+
this.current = next;
|
|
1522
|
+
this.options.onTokens(next);
|
|
1523
|
+
this.schedule();
|
|
1524
|
+
return next;
|
|
1525
|
+
}).catch((error) => {
|
|
1526
|
+
if (isSessionRefusal(error)) {
|
|
1527
|
+
this.stop();
|
|
1528
|
+
this.current = null;
|
|
1529
|
+
this.options.onExpired();
|
|
1530
|
+
} else this.arm(RETRY_MS);
|
|
1531
|
+
return null;
|
|
1532
|
+
}).finally(() => {
|
|
1533
|
+
this.inflight = null;
|
|
1534
|
+
});
|
|
1535
|
+
return this.inflight;
|
|
1536
|
+
}
|
|
1537
|
+
stop() {
|
|
1538
|
+
this.cancel?.();
|
|
1539
|
+
this.cancel = null;
|
|
1540
|
+
}
|
|
1541
|
+
margin() {
|
|
1542
|
+
return this.options.marginMs ?? DEFAULT_MARGIN_MS;
|
|
1543
|
+
}
|
|
1544
|
+
expiresAtOf(tokens) {
|
|
1545
|
+
return tokens.expiresAt ?? jwtExpiresAt(tokens.token);
|
|
1546
|
+
}
|
|
1547
|
+
schedule() {
|
|
1548
|
+
this.stop();
|
|
1549
|
+
const current = this.current;
|
|
1550
|
+
if (current?.refreshToken === void 0) return;
|
|
1551
|
+
const expiresAt = this.expiresAtOf(current);
|
|
1552
|
+
if (expiresAt === null) return;
|
|
1553
|
+
const remaining = expiresAt - this.now();
|
|
1554
|
+
const margin = Math.min(this.margin(), Math.max(0, remaining / 2));
|
|
1555
|
+
this.arm(Math.max(MIN_DELAY_MS, remaining - margin));
|
|
1556
|
+
}
|
|
1557
|
+
arm(ms) {
|
|
1558
|
+
this.stop();
|
|
1559
|
+
this.cancel = this.setTimer(() => {
|
|
1560
|
+
this.cancel = null;
|
|
1561
|
+
this.refreshNow();
|
|
1562
|
+
}, ms);
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
//#endregion
|
|
1412
1566
|
//#region src/detection.ts
|
|
1413
1567
|
/**
|
|
1414
1568
|
* Detection classes — shared between CamStack app and proxy.
|
|
@@ -1721,4 +1875,4 @@ function getClassesForTimelinePreset(preset, customClasses) {
|
|
|
1721
1875
|
}
|
|
1722
1876
|
}
|
|
1723
1877
|
//#endregion
|
|
1724
|
-
export { DEFAULT_ENABLED_CLASSES, DetectionClass, System, TIMELINE_PRESET_ALL, TIMELINE_PRESET_CRITICAL, TIMELINE_PRESET_IMPORTANT, animalClasses, audioClasses, audioLabelClasses, createSystem, defaultDetectionClasses, detectionClassesDefaultMap, doorbellClasses, faceClasses, getClassesForTimelinePreset, getParentClass, getParentDetectionClass, isAnimalClassname, isAudioClassname, isDoorbellClassname, isFaceClassname, isLabelDetection, isMotionClassname, isPackageClassname, isPersonClassname, isPlateClassname, isSensorLabelClassname, isVehicleClassname, licensePlateClasses, motionClasses, packageClasses, personClasses, raceFastestEndpoint, sensorLabelClasses, vehicleClasses };
|
|
1878
|
+
export { DEFAULT_ENABLED_CLASSES, DetectionClass, SessionKeeper, System, TIMELINE_PRESET_ALL, TIMELINE_PRESET_CRITICAL, TIMELINE_PRESET_IMPORTANT, animalClasses, audioClasses, audioLabelClasses, createSystem, defaultDetectionClasses, detectionClassesDefaultMap, doorbellClasses, faceClasses, getClassesForTimelinePreset, getParentClass, getParentDetectionClass, isAnimalClassname, isAudioClassname, isDoorbellClassname, isFaceClassname, isLabelDetection, isMotionClassname, isPackageClassname, isPersonClassname, isPlateClassname, isSensorLabelClassname, isSessionRefusal, isVehicleClassname, jwtExpiresAt, licensePlateClasses, motionClasses, packageClasses, personClasses, raceFastestEndpoint, sensorLabelClasses, vehicleClasses };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps a refreshing CamStack session alive without the user noticing.
|
|
3
|
+
*
|
|
4
|
+
* The hub hands a refreshing client a short access token (default 2 h) and a
|
|
5
|
+
* rotating refresh token. This schedules `auth.refreshSession` shortly before
|
|
6
|
+
* the access token expires, hands every rotated pair to the app to persist,
|
|
7
|
+
* and reports the end of the session only when the hub REFUSES the refresh —
|
|
8
|
+
* a network failure is retried, never treated as a sign-out.
|
|
9
|
+
*
|
|
10
|
+
* Transport-agnostic: the caller supplies `refresh` (normally
|
|
11
|
+
* `System.refreshSession`), so the keeper outlives a System that is rebuilt.
|
|
12
|
+
*/
|
|
13
|
+
export interface SessionTokens {
|
|
14
|
+
/** The access token (Bearer). */
|
|
15
|
+
readonly token: string;
|
|
16
|
+
/** The rotating refresh token, when the session is refreshable. */
|
|
17
|
+
readonly refreshToken?: string;
|
|
18
|
+
/** When the access token expires (epoch ms); decoded from the JWT when absent. */
|
|
19
|
+
readonly expiresAt?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SessionKeeperOptions {
|
|
22
|
+
/** Swap a refresh token for a new pair (`System.refreshSession`). */
|
|
23
|
+
readonly refresh: (refreshToken: string) => Promise<SessionTokens>;
|
|
24
|
+
/** Persist / apply a rotated pair (also called after `ensureFresh`). */
|
|
25
|
+
readonly onTokens: (tokens: SessionTokens) => void;
|
|
26
|
+
/** The hub refused the refresh: the session is over — sign the user out. */
|
|
27
|
+
readonly onExpired: () => void;
|
|
28
|
+
/** Refresh this long before the access token expires (default 5 min, at most half its life). */
|
|
29
|
+
readonly marginMs?: number;
|
|
30
|
+
readonly now?: () => number;
|
|
31
|
+
/** Timer hook (tests); returns a cancel function. */
|
|
32
|
+
readonly setTimer?: (fn: () => void, ms: number) => () => void;
|
|
33
|
+
}
|
|
34
|
+
/** Decode a JWT's `exp` (seconds) to epoch ms, or `null`. Never throws. */
|
|
35
|
+
export declare function jwtExpiresAt(token: string): number | null;
|
|
36
|
+
/** Is `error` the hub saying the session is over (as opposed to a transport failure)? */
|
|
37
|
+
export declare function isSessionRefusal(error: unknown): boolean;
|
|
38
|
+
export declare class SessionKeeper {
|
|
39
|
+
private readonly options;
|
|
40
|
+
private current;
|
|
41
|
+
private cancel;
|
|
42
|
+
private inflight;
|
|
43
|
+
private readonly now;
|
|
44
|
+
private readonly setTimer;
|
|
45
|
+
constructor(options: SessionKeeperOptions);
|
|
46
|
+
/** Take over `tokens` (a login, a restore from storage) and schedule its refresh. */
|
|
47
|
+
adopt(tokens: SessionTokens): void;
|
|
48
|
+
/** The tokens the keeper holds now. */
|
|
49
|
+
tokens(): SessionTokens | null;
|
|
50
|
+
/**
|
|
51
|
+
* Refresh now if the access token expires within `marginMs` (default: the
|
|
52
|
+
* keeper's margin) — call it before reconnecting after the app was in the
|
|
53
|
+
* background. Resolves when done; concurrent callers share one refresh.
|
|
54
|
+
*/
|
|
55
|
+
ensureFresh(marginMs?: number): Promise<void>;
|
|
56
|
+
/** Refresh immediately. `null` when there is nothing to refresh or it failed. */
|
|
57
|
+
refreshNow(): Promise<SessionTokens | null>;
|
|
58
|
+
stop(): void;
|
|
59
|
+
private margin;
|
|
60
|
+
private expiresAtOf;
|
|
61
|
+
private schedule;
|
|
62
|
+
private arm;
|
|
63
|
+
}
|
package/dist/system.d.ts
CHANGED
|
@@ -50,6 +50,38 @@ export interface SystemConfig {
|
|
|
50
50
|
readonly maxRetryDelayMs?: number;
|
|
51
51
|
}
|
|
52
52
|
type ConnectionListener = (state: BackendConnectionState, version: number) => void;
|
|
53
|
+
/** Options of every login leg. */
|
|
54
|
+
export interface LoginOptions {
|
|
55
|
+
/** Ask for a short access token + a rotating refresh token (recommended). */
|
|
56
|
+
readonly refreshable?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** What a login leg returns: a session, or a second-factor challenge. */
|
|
59
|
+
export interface LoginOutcome {
|
|
60
|
+
readonly token: string;
|
|
61
|
+
readonly requiresTotp?: boolean;
|
|
62
|
+
readonly secondFactors?: readonly SecondFactorKind[];
|
|
63
|
+
/** When the access token expires (epoch ms). */
|
|
64
|
+
readonly expiresAt?: number;
|
|
65
|
+
/** The server-side login session. */
|
|
66
|
+
readonly sessionId?: string;
|
|
67
|
+
/** When the session expires unless refreshed (epoch ms). */
|
|
68
|
+
readonly sessionExpiresAt?: number;
|
|
69
|
+
/** Only for `refreshable: true` — swap it at `refreshSession` before `expiresAt`. */
|
|
70
|
+
readonly refreshToken?: string;
|
|
71
|
+
}
|
|
72
|
+
/** What `refreshSession` returns. */
|
|
73
|
+
export interface RefreshedSession {
|
|
74
|
+
readonly token: string;
|
|
75
|
+
readonly refreshToken: string;
|
|
76
|
+
readonly expiresAt: number;
|
|
77
|
+
readonly sessionId: string;
|
|
78
|
+
readonly sessionExpiresAt: number;
|
|
79
|
+
readonly user: {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly username: string;
|
|
82
|
+
readonly isAdmin: boolean;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
53
85
|
export interface SystemLiveEvent<TData = unknown> {
|
|
54
86
|
readonly id: string;
|
|
55
87
|
readonly timestamp: Date | string;
|
|
@@ -207,11 +239,7 @@ export declare class System {
|
|
|
207
239
|
/** Tear down WS connection + mirror. The instance is unusable afterwards. */
|
|
208
240
|
close(): void;
|
|
209
241
|
private disposeMirror;
|
|
210
|
-
login(username: string, password: string): Promise<
|
|
211
|
-
token: string;
|
|
212
|
-
requiresTotp?: boolean;
|
|
213
|
-
secondFactors?: readonly SecondFactorKind[];
|
|
214
|
-
}>;
|
|
242
|
+
login(username: string, password: string, options?: LoginOptions): Promise<LoginOutcome>;
|
|
215
243
|
/**
|
|
216
244
|
* Second leg of the 2FA login. The caller passes the challenge
|
|
217
245
|
* token returned by `login` (when `requiresTotp: true`) plus the
|
|
@@ -219,9 +247,7 @@ export declare class System {
|
|
|
219
247
|
* server mints the real session JWT, which we set as the active
|
|
220
248
|
* token on this client.
|
|
221
249
|
*/
|
|
222
|
-
loginVerifyTotp(challengeToken: string, code: string): Promise<
|
|
223
|
-
token: string;
|
|
224
|
-
}>;
|
|
250
|
+
loginVerifyTotp(challengeToken: string, code: string, options?: LoginOptions): Promise<LoginOutcome>;
|
|
225
251
|
/**
|
|
226
252
|
* Passkey second leg — step 1. Exchanges the login challenge token for
|
|
227
253
|
* the WebAuthn assertion options; the caller feeds `optionsJSON` to
|
|
@@ -235,9 +261,7 @@ export declare class System {
|
|
|
235
261
|
* success the server mints the real session JWT, which we set as the
|
|
236
262
|
* active token on this client.
|
|
237
263
|
*/
|
|
238
|
-
loginVerifyPasskey(challengeToken: string, response: Record<string, unknown
|
|
239
|
-
token: string;
|
|
240
|
-
}>;
|
|
264
|
+
loginVerifyPasskey(challengeToken: string, response: Record<string, unknown>, options?: LoginOptions): Promise<LoginOutcome>;
|
|
241
265
|
/**
|
|
242
266
|
* Usernameless (passkey-FIRST) login — step 1. Fetches discoverable-
|
|
243
267
|
* credential assertion options (EMPTY `allowCredentials`); the caller
|
|
@@ -259,12 +283,20 @@ export declare class System {
|
|
|
259
283
|
* `secondFactors`. That token is NOT persisted as the active session
|
|
260
284
|
* — the caller completes the remaining factor via `loginVerifyTotp`.
|
|
261
285
|
*/
|
|
262
|
-
passkeyLoginFinish(response: Record<string, unknown
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
286
|
+
passkeyLoginFinish(response: Record<string, unknown>, options?: LoginOptions): Promise<LoginOutcome>;
|
|
287
|
+
/**
|
|
288
|
+
* Swap a refresh token for a new access token and a ROTATED refresh token
|
|
289
|
+
* (the old one stops working). Sets the new access token on this client.
|
|
290
|
+
* Throws a tRPC UNAUTHORIZED when the session is over — sign in again.
|
|
291
|
+
*/
|
|
292
|
+
refreshSession(refreshToken: string): Promise<RefreshedSession>;
|
|
293
|
+
/**
|
|
294
|
+
* End this session on the hub (its tokens stop working at once);
|
|
295
|
+
* `everywhere: true` ends every session of the user.
|
|
296
|
+
*/
|
|
297
|
+
logout(options?: {
|
|
298
|
+
readonly everywhere?: boolean;
|
|
299
|
+
}): Promise<void>;
|
|
268
300
|
getMe(): Promise<UserInfo>;
|
|
269
301
|
changeOwnPassword(input: {
|
|
270
302
|
currentPassword: string;
|
|
@@ -281,9 +313,34 @@ export declare class System {
|
|
|
281
313
|
}): Promise<{
|
|
282
314
|
success: true;
|
|
283
315
|
}>;
|
|
284
|
-
|
|
316
|
+
/** Needs a fresh `reauthToken` from {@link reauthenticate}. */
|
|
317
|
+
disableOwnTotp(input: {
|
|
318
|
+
reauthToken: string;
|
|
319
|
+
}): Promise<{
|
|
285
320
|
success: true;
|
|
286
321
|
}>;
|
|
322
|
+
/**
|
|
323
|
+
* Re-authentication, passkey path step 1: assertion options for the
|
|
324
|
+
* caller's own passkeys. Feed `optionsJSON` to `startAuthentication`, then
|
|
325
|
+
* pass the result to {@link reauthenticate} as `passkeyResponse`.
|
|
326
|
+
*/
|
|
327
|
+
reauthBeginPasskey(): Promise<{
|
|
328
|
+
optionsJSON: Record<string, unknown>;
|
|
329
|
+
}>;
|
|
330
|
+
/**
|
|
331
|
+
* Prove it is you — the current password or a passkey assertion — for a
|
|
332
|
+
* 5-minute `reauthToken`, required by `disableOwnTotp`, `removeOwnPasskey`
|
|
333
|
+
* and `auth.beginExternalLink` (valid only in this session).
|
|
334
|
+
*/
|
|
335
|
+
reauthenticate(input: {
|
|
336
|
+
readonly password: string;
|
|
337
|
+
} | {
|
|
338
|
+
readonly passkeyResponse: Record<string, unknown>;
|
|
339
|
+
}): Promise<{
|
|
340
|
+
reauthToken: string;
|
|
341
|
+
expiresAt: number;
|
|
342
|
+
method: 'password' | 'passkey';
|
|
343
|
+
}>;
|
|
287
344
|
getOwnTotpStatus(): Promise<{
|
|
288
345
|
enabled: boolean;
|
|
289
346
|
confirmedAt: number | null;
|
|
@@ -299,8 +356,10 @@ export declare class System {
|
|
|
299
356
|
credentialId: string;
|
|
300
357
|
}>;
|
|
301
358
|
listOwnPasskeys(): Promise<readonly PasskeySummary[]>;
|
|
359
|
+
/** Needs a fresh `reauthToken` from {@link reauthenticate}. */
|
|
302
360
|
removeOwnPasskey(input: {
|
|
303
361
|
credentialId: string;
|
|
362
|
+
reauthToken: string;
|
|
304
363
|
}): Promise<{
|
|
305
364
|
success: true;
|
|
306
365
|
}>;
|