@antondziuin/talos-sdk 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -240,6 +240,25 @@ so an anchor-only pin still works with no server to fetch one from. The verdict
240
240
  is kept, so later starts need nothing — `validate()` re-checks the stored file
241
241
  instead of reaching for the network, and `isActivated` is true.
242
242
 
243
+ A **portable** permanent file is issued directly by the vendor. Import it with
244
+ `loadOfflineLicense("license.talos")` on each computer; no request or machine
245
+ key is needed. Its signed `binding: "portable"` explicitly permits use on
246
+ different computers. Each computer keeps its own local state; carry the licence
247
+ file rather than copying machine-encrypted state. Portable files can be copied
248
+ and remain usable indefinitely. `createOfflineDeactivation()` refuses them
249
+ because they have no bound seat to return.
250
+
251
+ A valid offline import replaces any existing online activation locally, so
252
+ later `validate()` calls and restarts use the file without contacting Talos.
253
+ Rejected imports preserve the previous stored activation. Keep state
254
+ persistence enabled for automatic restore.
255
+
256
+ For trusted users, a perpetual policy can opt into permanent offline files with
257
+ `offline_checkout_max_days: 0`. The issued file has a signed `perpetual: true`
258
+ and explicit null file and licence expiries. It needs no renewal or server
259
+ connection; `graceUntil` and `licenseExpiresAt` are null. Import it using the
260
+ same calls above. Older SDK versions reject these files and must be upgraded.
261
+
243
262
  `loadOfflineLicense` **throws** only for a file that is not a licence: unreadable,
244
263
  not one of ours, addressed to another product, or a signature that does not
245
264
  verify. A file that is one but cannot license this machine comes back as a
@@ -255,9 +274,9 @@ receipt changes nothing.
255
274
  Two things to know before you offer this to customers. A file **cannot be
256
275
  recalled** — revoking it in the portal frees the seat so you can issue to a
257
276
  replacement machine, but the machine holding it keeps working until the file's
258
- own expiry, which is why the policy's horizon is short. And hardware changes are
259
- not forgiven offline: the drift matcher lives on the server, so a replaced disk
260
- means `machine_mismatch` and a new request.
277
+ own expiry, or indefinitely for a permanent file. Hardware changes to a
278
+ machine-bound install are not forgiven offline: the drift matcher lives on the
279
+ server, so a replaced disk means `machine_mismatch` and a new request.
261
280
 
262
281
  ## Heartbeats, monitoring, and cadence
263
282
 
package/dist/index.cjs CHANGED
@@ -288,7 +288,7 @@ var StateStore = class {
288
288
  };
289
289
 
290
290
  // src/version.ts
291
- var SDK_VERSION = "0.2.1";
291
+ var SDK_VERSION = "0.3.0";
292
292
  var SDK_ID = `node/${SDK_VERSION}`;
293
293
 
294
294
  // src/diagnostics.ts
@@ -516,6 +516,9 @@ var TalosClient = class {
516
516
  * for weeks. A key revoked in the portal would never reach it.
517
517
  */
518
518
  keysetIssuedAt = 0;
519
+ keysetToken = null;
520
+ /** Invalidates online work started before a local licensing-mode change. */
521
+ licensingGeneration = 0;
519
522
  /** The policy's heartbeat cadence, from the last heartbeat's signed claims. */
520
523
  heartbeatInterval = null;
521
524
  checkInTimer = null;
@@ -638,12 +641,14 @@ var TalosClient = class {
638
641
  */
639
642
  async refreshKeyset() {
640
643
  if (!this.anchorKey) return;
644
+ const generation = this.licensingGeneration;
641
645
  const body = await this.post("/v1/client/keyset", {
642
646
  product_token: this.options.productToken
643
647
  });
648
+ if (generation !== this.licensingGeneration) return;
644
649
  this.adoptKeyset(body["keyset"]);
645
650
  this.keysetFromCache = false;
646
- if (typeof body["keyset"] === "string") this.persist({ keyset: body["keyset"] });
651
+ if (this.keysetToken) this.persist({ keyset: this.keysetToken });
647
652
  }
648
653
  /**
649
654
  * Make sure a verification key exists before a response has to be verified.
@@ -694,7 +699,8 @@ var TalosClient = class {
694
699
  if (!this.anchorKey || typeof token !== "string") return;
695
700
  const claims = verifyKeyset(this.anchorKey, this.options.productToken, token);
696
701
  const iat = claims["iat"];
697
- this.keysetIssuedAt = typeof iat === "number" ? iat : 0;
702
+ const issuedAt = typeof iat === "number" ? iat : 0;
703
+ if (issuedAt < this.keysetIssuedAt || issuedAt === this.keysetIssuedAt && this.keysetToken !== null && token !== this.keysetToken) return;
698
704
  const licenseKeys = [];
699
705
  const releaseKeys = [];
700
706
  for (const entry of asArray(claims["keys"])) {
@@ -706,6 +712,8 @@ var TalosClient = class {
706
712
  }
707
713
  this.keysetLicenseKeys = licenseKeys;
708
714
  this.keysetReleaseKeys = releaseKeys;
715
+ this.keysetIssuedAt = issuedAt;
716
+ this.keysetToken = token;
709
717
  }
710
718
  /** Keys to verify license tokens with: the keyset if loaded, else the direct pin. */
711
719
  licenseVerifyKeys() {
@@ -737,62 +745,71 @@ var TalosClient = class {
737
745
  * elsewhere.
738
746
  */
739
747
  async activate(licenseKey) {
740
- if (this.isActivated && this.licenseKeyHash === hashLicenseKey(licenseKey)) {
741
- try {
742
- const state = await this.validate();
743
- if (state.decision !== "deactivated") return state;
744
- } catch (e) {
745
- const stale = e instanceof TalosApiError && (e.statusCode === 401 || e.statusCode === 404);
746
- if (!stale) throw e;
748
+ const generation = this.licensingGeneration;
749
+ try {
750
+ if (this.isActivated && this.licenseKeyHash === hashLicenseKey(licenseKey)) {
751
+ try {
752
+ const state = await this.validate();
753
+ if (state.decision !== "deactivated") return state;
754
+ } catch (e) {
755
+ const stale = e instanceof TalosApiError && (e.statusCode === 401 || e.statusCode === 404);
756
+ if (!stale) throw e;
757
+ }
758
+ this.forgetBinding();
747
759
  }
748
- this.forgetBinding();
760
+ const nonce = newNonce();
761
+ const body = await this.post("/v1/client/activate", {
762
+ product_token: this.options.productToken,
763
+ license_key: licenseKey,
764
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
765
+ nonce,
766
+ hostname: this.reportedHostname(),
767
+ os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
768
+ arch: import_node_os3.default.arch(),
769
+ app_version: this.options.appVersion,
770
+ sdk_version: SDK_ID
771
+ });
772
+ const switched = this.offlineSince(generation);
773
+ if (switched) return switched;
774
+ this.adoptKeyset(body["keyset"]);
775
+ const claims = verifyToken(
776
+ this.licenseVerifyKeys(),
777
+ "activation",
778
+ this.options.productToken,
779
+ nonce,
780
+ body["activation_token"]
781
+ );
782
+ this.licenseKeyHash = hashLicenseKey(licenseKey);
783
+ this.activationSecret = body["activation_secret"];
784
+ this.machineId = body["machine_id"] ?? null;
785
+ this.binding = {
786
+ licenseKeyHash: this.licenseKeyHash,
787
+ activationSecret: this.activationSecret,
788
+ machineId: this.machineId ?? "",
789
+ fingerprint: this.fingerprint,
790
+ // A fresh activation invalidates any cached verdict for the previous
791
+ // binding; keep only the keyset, which is per product, not per machine.
792
+ keyset: this.keysetToken ?? this.binding?.keyset,
793
+ timeHighWater: this.binding?.timeHighWater,
794
+ // The machine's own identity survives an online activation, because it is
795
+ // an identity and not a verdict: an install that reaches the network once
796
+ // must not have to repeat the whole air-gapped ceremony with its vendor
797
+ // the next time it is unplugged. The clock mark survives for the same
798
+ // reason a mark exists at all — it only moves forward.
799
+ machineKey: this.binding?.machineKey,
800
+ machinePublicKey: this.binding?.machinePublicKey,
801
+ offlineSeen: this.binding?.offlineSeen
802
+ // The offline *file* does not: a live activation supersedes it, and
803
+ // keeping it would leave two answers to "what licenses this machine".
804
+ };
805
+ this.advanceClock(claims);
806
+ this.store?.save(this.binding);
807
+ return this.adopt(stateFromClaims(claims, "valid", false));
808
+ } catch (e) {
809
+ const switched = this.offlineSince(generation);
810
+ if (switched) return switched;
811
+ throw e;
749
812
  }
750
- const nonce = newNonce();
751
- const body = await this.post("/v1/client/activate", {
752
- product_token: this.options.productToken,
753
- license_key: licenseKey,
754
- fingerprint: { composite: this.fingerprint, components: this.fp.components },
755
- nonce,
756
- hostname: this.reportedHostname(),
757
- os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
758
- arch: import_node_os3.default.arch(),
759
- app_version: this.options.appVersion,
760
- sdk_version: SDK_ID
761
- });
762
- this.adoptKeyset(body["keyset"]);
763
- const claims = verifyToken(
764
- this.licenseVerifyKeys(),
765
- "activation",
766
- this.options.productToken,
767
- nonce,
768
- body["activation_token"]
769
- );
770
- this.licenseKeyHash = hashLicenseKey(licenseKey);
771
- this.activationSecret = body["activation_secret"];
772
- this.machineId = body["machine_id"] ?? null;
773
- this.binding = {
774
- licenseKeyHash: this.licenseKeyHash,
775
- activationSecret: this.activationSecret,
776
- machineId: this.machineId ?? "",
777
- fingerprint: this.fingerprint,
778
- // A fresh activation invalidates any cached verdict for the previous
779
- // binding; keep only the keyset, which is per product, not per machine.
780
- keyset: typeof body["keyset"] === "string" ? body["keyset"] : this.binding?.keyset,
781
- timeHighWater: this.binding?.timeHighWater,
782
- // The machine's own identity survives an online activation, because it is
783
- // an identity and not a verdict: an install that reaches the network once
784
- // must not have to repeat the whole air-gapped ceremony with its vendor
785
- // the next time it is unplugged. The clock mark survives for the same
786
- // reason a mark exists at all — it only moves forward.
787
- machineKey: this.binding?.machineKey,
788
- machinePublicKey: this.binding?.machinePublicKey,
789
- offlineSeen: this.binding?.offlineSeen
790
- // The offline *file* does not: a live activation supersedes it, and
791
- // keeping it would leave two answers to "what licenses this machine".
792
- };
793
- this.advanceClock(claims);
794
- this.store?.save(this.binding);
795
- return this.adopt(stateFromClaims(claims, "valid", false));
796
813
  }
797
814
  /**
798
815
  * This machine's own keypair, generated once and kept in the state file.
@@ -867,13 +884,13 @@ var TalosClient = class {
867
884
  });
868
885
  }
869
886
  /**
870
- * Adopt an air-gapped licence file issued for this machine.
887
+ * Adopt a machine-bound or portable offline licence file.
871
888
  *
872
889
  * Everything is verified locally, because there is nothing to ask: the
873
890
  * anchor-signed keyset travels inside the file, the verdict is signed by a key
874
- * that keyset lists, and the file names both halves of this machine's identity
875
- * — its fingerprint and the public key it generated. The verdict is then kept,
876
- * so later starts are licensed with no further ceremony.
891
+ * that keyset lists. Machine-bound files name this machine's fingerprint and
892
+ * public key; explicitly portable files need no machine request. The verdict
893
+ * is kept, so later starts are licensed with no further ceremony.
877
894
  *
878
895
  * **Throws** only for a file that is not a licence: unreadable, not one of
879
896
  * ours, addressed to another product, or a broken signature. A file that *is*
@@ -896,8 +913,27 @@ var TalosClient = class {
896
913
  if (doc["kind"] !== LICENSE_FILE_KIND || typeof doc["token"] !== "string") {
897
914
  throw new TalosError("not a Talos offline licence file");
898
915
  }
899
- if (typeof doc["keyset"] === "string") this.adoptKeyset(doc["keyset"]);
900
- return this.adoptOfflineToken(doc["token"], doc["keyset"]);
916
+ const previous = {
917
+ license: this.keysetLicenseKeys,
918
+ release: this.keysetReleaseKeys,
919
+ iat: this.keysetIssuedAt,
920
+ token: this.keysetToken
921
+ };
922
+ const restore = () => {
923
+ this.keysetLicenseKeys = previous.license;
924
+ this.keysetReleaseKeys = previous.release;
925
+ this.keysetIssuedAt = previous.iat;
926
+ this.keysetToken = previous.token;
927
+ };
928
+ try {
929
+ if (typeof doc["keyset"] === "string") this.adoptKeyset(doc["keyset"]);
930
+ const state = this.adoptOfflineToken(doc["token"], this.keysetToken);
931
+ if (!state.isValid) restore();
932
+ return state;
933
+ } catch (e) {
934
+ restore();
935
+ throw e;
936
+ }
901
937
  }
902
938
  /**
903
939
  * Hand this machine's air-gapped seat back.
@@ -919,9 +955,7 @@ var TalosClient = class {
919
955
  */
920
956
  createOfflineDeactivation() {
921
957
  const token = this.binding?.offlineToken;
922
- const key = this.binding?.machineKey;
923
- const pub = this.binding?.machinePublicKey;
924
- if (!token || !key || !pub) {
958
+ if (!token) {
925
959
  throw new TalosError("no offline licence to hand back");
926
960
  }
927
961
  let claims;
@@ -935,6 +969,12 @@ var TalosClient = class {
935
969
  } catch {
936
970
  throw new TalosError("the stored offline licence no longer verifies");
937
971
  }
972
+ if (claims["binding"] === "portable") {
973
+ throw new TalosError("portable offline licences have no bound seat to return");
974
+ }
975
+ const key = this.binding?.machineKey;
976
+ const pub = this.binding?.machinePublicKey;
977
+ if (!key || !pub) throw new TalosError("no offline licence to hand back");
938
978
  this.binding = { ...this.bindingBase(), offlineToken: void 0 };
939
979
  this.current = null;
940
980
  this.store?.save(this.binding);
@@ -959,38 +999,58 @@ var TalosClient = class {
959
999
  token
960
1000
  );
961
1001
  const mismatch = () => this.adopt(new LicenseState("machine_mismatch", {}, null, true, null, null));
962
- if (typeof claims["mpk"] !== "string" || claims["mpk"] !== this.binding?.machinePublicKey) {
1002
+ const lic = claims["lic"] ?? {};
1003
+ const features = lic["features"] ?? {};
1004
+ const licExp = num(lic["lic_exp"]);
1005
+ const exp = num(claims["exp"]);
1006
+ const perpetual = claims["perpetual"] === true && claims["exp"] === null && lic["lic_exp"] === null;
1007
+ const portable = claims["binding"] === "portable";
1008
+ if (portable) {
1009
+ const present = (key) => Object.hasOwn(claims, key);
1010
+ const named = (key) => typeof claims[key] === "string" && claims[key].trim().length > 0;
1011
+ if (!perpetual || claims["decision"] !== "valid" || present("fph") || present("mpk") || present("mid") || !named("fid") || !named("sub")) return mismatch();
1012
+ } else if (claims["binding"] !== void 0 && claims["binding"] !== "machine") {
1013
+ return mismatch();
1014
+ }
1015
+ if (!portable && (typeof claims["mpk"] !== "string" || claims["mpk"] !== this.binding?.machinePublicKey)) {
963
1016
  return mismatch();
964
1017
  }
965
- if (claims["fph"] !== this.fingerprint) return mismatch();
1018
+ if (!portable && claims["fph"] !== this.fingerprint) return mismatch();
966
1019
  const now = nowSeconds();
967
1020
  const mark = Math.max(this.binding?.timeHighWater ?? 0, this.binding?.offlineSeen ?? 0);
968
1021
  if (mark > 0 && now + CLOCK_SKEW_SECONDS2 < mark) {
969
1022
  return this.adopt(new LicenseState("clock_tampered", {}, null, true, null, null));
970
1023
  }
971
- const lic = claims["lic"] ?? {};
972
- const features = lic["features"] ?? {};
973
- const licExp = num(lic["lic_exp"]);
974
- const exp = num(claims["exp"]);
975
- this.machineId = typeof claims["mid"] === "string" ? claims["mid"] : this.machineId;
1024
+ if (!perpetual && (exp === null || now > exp)) {
1025
+ return this.adopt(
1026
+ new LicenseState("offline_file_expired", features, licExp, true, null, exp)
1027
+ );
1028
+ }
1029
+ if (licExp !== null && now > licExp) {
1030
+ return this.adopt(new LicenseState("expired", features, licExp, true, null, exp));
1031
+ }
1032
+ this.licensingGeneration += 1;
1033
+ this.licenseKeyHash = null;
1034
+ this.activationSecret = null;
1035
+ this.heartbeatInterval = null;
1036
+ this.leaseGeneration += 1;
1037
+ this.stopRenew();
1038
+ this.lease = null;
1039
+ this.machineId = !portable && typeof claims["mid"] === "string" ? claims["mid"] : null;
976
1040
  const base = this.bindingBase();
977
1041
  this.binding = {
978
1042
  ...base,
979
- machineId: this.machineId ?? base.machineId,
1043
+ licenseKeyHash: "",
1044
+ activationSecret: "",
1045
+ validation: void 0,
1046
+ machineId: this.machineId ?? "",
1047
+ fingerprint: this.fingerprint,
980
1048
  offlineToken: token,
981
1049
  offlineSeen: Math.max(base.offlineSeen ?? 0, now),
982
1050
  ...typeof keyset === "string" ? { keyset } : {}
983
1051
  };
984
1052
  this.advanceClock(claims);
985
1053
  this.store?.save(this.binding);
986
- if (exp === null || now > exp) {
987
- return this.adopt(
988
- new LicenseState("offline_file_expired", features, licExp, true, null, exp)
989
- );
990
- }
991
- if (licExp !== null && now > licExp) {
992
- return this.adopt(new LicenseState("expired", features, licExp, true, null, exp));
993
- }
994
1054
  return this.adopt(new LicenseState("valid", features, licExp, true, null, exp));
995
1055
  }
996
1056
  /**
@@ -1103,6 +1163,7 @@ var TalosClient = class {
1103
1163
  }
1104
1164
  /** Drop the machine binding, in memory and on disk. */
1105
1165
  forgetBinding() {
1166
+ this.licensingGeneration += 1;
1106
1167
  this.licenseKeyHash = null;
1107
1168
  this.activationSecret = null;
1108
1169
  this.machineId = null;
@@ -1136,8 +1197,11 @@ var TalosClient = class {
1136
1197
  return this.adoptOfflineToken(this.binding.offlineToken);
1137
1198
  }
1138
1199
  this.requireActivated();
1200
+ const generation = this.licensingGeneration;
1139
1201
  try {
1140
1202
  await this.ensureKeyset();
1203
+ const switchedBeforeRequest = this.offlineSince(generation);
1204
+ if (switchedBeforeRequest) return switchedBeforeRequest;
1141
1205
  const nonce = newNonce();
1142
1206
  const body = await this.post("/v1/client/validate", {
1143
1207
  product_token: this.options.productToken,
@@ -1151,6 +1215,8 @@ var TalosClient = class {
1151
1215
  app_version: this.options.appVersion,
1152
1216
  sdk_version: SDK_ID
1153
1217
  });
1218
+ const switched = this.offlineSince(generation);
1219
+ if (switched) return switched;
1154
1220
  const token = body["validation_token"];
1155
1221
  const claims = verifyToken(
1156
1222
  this.licenseVerifyKeys(),
@@ -1164,6 +1230,8 @@ var TalosClient = class {
1164
1230
  this.persist({ validation: token, timeHighWater: this.binding?.timeHighWater });
1165
1231
  return this.adopt(stateFromClaims(claims, decision, false));
1166
1232
  } catch (e) {
1233
+ const switched = this.offlineSince(generation);
1234
+ if (switched) return switched;
1167
1235
  if (e instanceof TalosApiError) throw e;
1168
1236
  const offline = this.offlineDecision();
1169
1237
  if (!offline) throw e;
@@ -1236,12 +1304,13 @@ var TalosClient = class {
1236
1304
  }
1237
1305
  async deactivate() {
1238
1306
  this.requireActivated();
1307
+ const generation = this.licensingGeneration;
1239
1308
  await this.post("/v1/client/deactivate", {
1240
1309
  product_token: this.options.productToken,
1241
1310
  fingerprint: { composite: this.fingerprint, components: this.fp.components },
1242
1311
  activation_secret: this.activationSecret
1243
1312
  });
1244
- this.forgetBinding();
1313
+ if (generation === this.licensingGeneration) this.forgetBinding();
1245
1314
  }
1246
1315
  /**
1247
1316
  * Send a heartbeat: refreshes this machine's last-seen state, feeds the
@@ -1265,46 +1334,59 @@ var TalosClient = class {
1265
1334
  }
1266
1335
  async heartbeat() {
1267
1336
  this.requireActivated();
1268
- await this.ensureKeyset();
1269
- const nonce = newNonce();
1270
- const body = await this.post("/v1/client/heartbeat", {
1271
- product_token: this.options.productToken,
1272
- fingerprint: { composite: this.fingerprint, components: this.fp.components },
1273
- activation_secret: this.activationSecret,
1274
- nonce,
1275
- app_version: this.options.appVersion,
1276
- os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
1277
- arch: import_node_os3.default.arch(),
1278
- sdk_version: SDK_ID,
1279
- // Naming the channel is what asks the server for the update hint, and it
1280
- // costs two queries there — so it is asked for only when somebody is
1281
- // listening for the answer.
1282
- update_channel: this.listeners.get("updateAvailable")?.size ? this.updateChannel() : void 0
1283
- });
1284
- const claims = verifyToken(
1285
- this.licenseVerifyKeys(),
1286
- "heartbeat",
1287
- this.options.productToken,
1288
- nonce,
1289
- body["heartbeat_token"]
1290
- );
1291
- const decision = typeof claims["decision"] === "string" ? claims["decision"] : "invalid";
1292
- const interval = num(claims["heartbeat_interval_s"]);
1293
- if (interval !== null && interval > 0) this.heartbeatInterval = interval;
1294
- this.advanceClock(claims);
1295
- this.persist({ timeHighWater: this.binding?.timeHighWater });
1296
- const state = this.adopt(
1297
- new LicenseState(
1298
- decision,
1299
- this.current?.features ?? {},
1300
- this.current?.licenseExpiresAt ?? null,
1301
- false,
1302
- this.current?.revalidateAfter ?? null,
1303
- this.current?.graceUntil ?? null
1304
- )
1305
- );
1306
- await this.announceUpdate(claims);
1307
- return state;
1337
+ const generation = this.licensingGeneration;
1338
+ try {
1339
+ await this.ensureKeyset();
1340
+ const switchedBeforeRequest = this.offlineSince(generation);
1341
+ if (switchedBeforeRequest) return switchedBeforeRequest;
1342
+ const nonce = newNonce();
1343
+ const body = await this.post("/v1/client/heartbeat", {
1344
+ product_token: this.options.productToken,
1345
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
1346
+ activation_secret: this.activationSecret,
1347
+ nonce,
1348
+ app_version: this.options.appVersion,
1349
+ os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
1350
+ arch: import_node_os3.default.arch(),
1351
+ sdk_version: SDK_ID,
1352
+ // Naming the channel is what asks the server for the update hint, and it
1353
+ // costs two queries there — so it is asked for only when somebody is
1354
+ // listening for the answer.
1355
+ update_channel: this.listeners.get("updateAvailable")?.size ? this.updateChannel() : void 0
1356
+ });
1357
+ const switched = this.offlineSince(generation);
1358
+ if (switched) return switched;
1359
+ const claims = verifyToken(
1360
+ this.licenseVerifyKeys(),
1361
+ "heartbeat",
1362
+ this.options.productToken,
1363
+ nonce,
1364
+ body["heartbeat_token"]
1365
+ );
1366
+ const decision = typeof claims["decision"] === "string" ? claims["decision"] : "invalid";
1367
+ const interval = num(claims["heartbeat_interval_s"]);
1368
+ if (interval !== null && interval > 0) this.heartbeatInterval = interval;
1369
+ this.advanceClock(claims);
1370
+ this.persist({ timeHighWater: this.binding?.timeHighWater });
1371
+ const state = this.adopt(
1372
+ new LicenseState(
1373
+ decision,
1374
+ this.current?.features ?? {},
1375
+ this.current?.licenseExpiresAt ?? null,
1376
+ false,
1377
+ this.current?.revalidateAfter ?? null,
1378
+ this.current?.graceUntil ?? null
1379
+ )
1380
+ );
1381
+ await this.announceUpdate(claims);
1382
+ const switchedAfterUpdate = this.offlineSince(generation);
1383
+ if (switchedAfterUpdate) return switchedAfterUpdate;
1384
+ return state;
1385
+ } catch (e) {
1386
+ const switched = this.offlineSince(generation);
1387
+ if (switched) return switched;
1388
+ throw e;
1389
+ }
1308
1390
  }
1309
1391
  /** The channel this install follows; `stable` unless set. */
1310
1392
  updateChannel() {
@@ -1689,6 +1771,13 @@ var TalosClient = class {
1689
1771
  throw new TalosError("not activated: call activate() first");
1690
1772
  }
1691
1773
  }
1774
+ /** Keep a newly imported offline licence authoritative over older requests. */
1775
+ offlineSince(generation) {
1776
+ if (generation === this.licensingGeneration || this.activationSecret || !this.binding?.offlineToken) {
1777
+ return null;
1778
+ }
1779
+ return this.adoptOfflineToken(this.binding.offlineToken);
1780
+ }
1692
1781
  post(path, body) {
1693
1782
  return this.request("POST", path, body);
1694
1783
  }