tokentracker-cli 1.2.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/dashboard/dist/assets/AchievementBadge-BaqdyZaV.js +1 -0
  4. package/dashboard/dist/assets/AchievementsPage-B4Ju0guv.js +2 -0
  5. package/dashboard/dist/assets/{AchievementsSection-DoYMz2Rr.js → AchievementsSection-CDUUEfIi.js} +1 -1
  6. package/dashboard/dist/assets/BadgeDetailModal-B-XMJAvL.js +1 -0
  7. package/dashboard/dist/assets/{BotAnimated-C21PdYSf.js → BotAnimated-Dx_vvTtC.js} +1 -1
  8. package/dashboard/dist/assets/Card-D-5mABVt.js +1 -0
  9. package/dashboard/dist/assets/CloudCheckoutPage-BELoh56h.js +1 -0
  10. package/dashboard/dist/assets/CloudPage-CfBruNxe.js +1 -0
  11. package/dashboard/dist/assets/CloudUsageExport-BoFU7xCT.js +4 -0
  12. package/dashboard/dist/assets/CommandPalette-BmmKZWn-.js +1 -0
  13. package/dashboard/dist/assets/CommunityStatsModal-BF77zVhN.js +1 -0
  14. package/dashboard/dist/assets/{ConfirmModal-zUljpLNe.js → ConfirmModal-BQ-xB1S_.js} +1 -1
  15. package/dashboard/dist/assets/Controls-BnTeX7T4.js +1 -0
  16. package/dashboard/dist/assets/DashboardPage-BWulHa5J.js +1 -0
  17. package/dashboard/dist/assets/{DevicePage-CCIyAYH3.js → DevicePage-Dg6XkIaY.js} +1 -1
  18. package/dashboard/dist/assets/DialogClose-DAyilsH-.js +1 -0
  19. package/dashboard/dist/assets/DialogDescription-j4XnRy6y.js +1 -0
  20. package/dashboard/dist/assets/{DialogTitle-6q0jyME6.js → DialogTitle-B1iNGD2S.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-BUm1z22V.js → FadeIn-ChVf45WW.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-CxdmloMy.js → HeaderGithubStar-B740_G7a.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-CrMdGYZj.js → HoverTooltip-BzP5FudR.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-w6UUK_1K.js → IpCheckPage-BuZH3F14.js} +1 -1
  25. package/dashboard/dist/assets/LandingPage-Dtgq_caz.js +2 -0
  26. package/dashboard/dist/assets/LeaderboardPage-Di1kEg2O.js +2 -0
  27. package/dashboard/dist/assets/LeaderboardProBadge-h8FfR0au.js +1 -0
  28. package/dashboard/dist/assets/LeaderboardProfileModal-OUZvO6u-.js +73 -0
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-Fljho5RP.js → LeaderboardProfilePage-bHI1oTEK.js} +2 -2
  30. package/dashboard/dist/assets/{LimitsPage-Do_uJqdQ.js → LimitsPage-DKojB73_.js} +2 -2
  31. package/dashboard/dist/assets/{LocalOnlyNotice-RIj2XT_X.js → LocalOnlyNotice-BaEq6qN7.js} +1 -1
  32. package/dashboard/dist/assets/LoginCard-Dn5Xy4If.js +1 -0
  33. package/dashboard/dist/assets/LoginModal-C5h-5GOA.js +1 -0
  34. package/dashboard/dist/assets/LoginPage-BpX8Tgjj.js +1 -0
  35. package/dashboard/dist/assets/PetPage-BPBsUHyL.js +1 -0
  36. package/dashboard/dist/assets/PopoverPopup-CVTiEVHs.js +1 -0
  37. package/dashboard/dist/assets/{ProviderIcon-BSI-1Cy8.js → ProviderIcon-BshO2uER.js} +1 -1
  38. package/dashboard/dist/assets/ResetPasswordPage-RTFTD8Dr.js +1 -0
  39. package/dashboard/dist/assets/{Select-B6CM0ico.js → Select-CtTceC4T.js} +1 -1
  40. package/dashboard/dist/assets/SelectItemText-C7xi8iGp.js +1 -0
  41. package/dashboard/dist/assets/SelfHostPage-BlwTt8XM.js +1 -0
  42. package/dashboard/dist/assets/{ServiceStatusPage-B4lIs2fc.js → ServiceStatusPage-BkIcJpYK.js} +1 -1
  43. package/dashboard/dist/assets/SessionsPage-okHobk_L.js +3 -0
  44. package/dashboard/dist/assets/SettingsPage-C-cc6wcF.js +2 -0
  45. package/dashboard/dist/assets/SkillsPage-D2LlKKJN.js +1 -0
  46. package/dashboard/dist/assets/{TokenGalaxy-Bk3kGSGi.js → TokenGalaxy-DiXUpXKF.js} +2 -2
  47. package/dashboard/dist/assets/TrendMonitor-C88S-5aa.js +19 -0
  48. package/dashboard/dist/assets/WidgetsPage-WsEFPNwp.js +1 -0
  49. package/dashboard/dist/assets/{WrappedPage-DHRmP4Ey.js → WrappedPage-ClEo-RmR.js} +1 -1
  50. package/dashboard/dist/assets/__vite-browser-external-DFRX9dp-.css +1 -0
  51. package/dashboard/dist/assets/__vite-browser-external-DUJeOWgd.js +45 -0
  52. package/dashboard/dist/assets/agent-logos-BM9jlReI.js +1 -0
  53. package/dashboard/dist/assets/arrow-left-BEM-HVmC.js +1 -0
  54. package/dashboard/dist/assets/arrow-right-CwBz5dwb.js +1 -0
  55. package/dashboard/dist/assets/{arrow-up-right-c39fdiDQ.js → arrow-up-right-jPmRN562.js} +1 -1
  56. package/dashboard/dist/assets/{check-8LQ7IvkH.js → check-CKwHc4Hv.js} +1 -1
  57. package/dashboard/dist/assets/cloud-checkout-nYNHDFoR.js +1 -0
  58. package/dashboard/dist/assets/composite-B4Z3F65o.js +1 -0
  59. package/dashboard/dist/assets/{download-x3Gmv-wu.js → download-CJUnX1Bs.js} +1 -1
  60. package/dashboard/dist/assets/{external-link-daw-XTjZ.js → external-link-B5yXxdPy.js} +1 -1
  61. package/dashboard/dist/assets/{flame-B9o0_EF8.js → flame-CKjAluj_.js} +1 -1
  62. package/dashboard/dist/assets/{format-tokens-o9atQGtV.js → format-tokens-mk49THtv.js} +1 -1
  63. package/dashboard/dist/assets/{host-mode-GPY2DxE4.js → host-mode-efKlSYjX.js} +1 -1
  64. package/dashboard/dist/assets/{icons-CmM2z-Jt.js → icons-ZB1ZthjH.js} +1 -1
  65. package/dashboard/dist/assets/{index-CPcJP0uH.js → index-BImBG3eS.js} +1 -1
  66. package/dashboard/dist/assets/{index-CtQOew7C.js → index-BgdM7kBl.js} +1 -1
  67. package/dashboard/dist/assets/{index-Bfgblk6o.js → index-DweUMAUD.js} +1 -1
  68. package/dashboard/dist/assets/{info-BaK8Hoi2.js → info-CyDY7NYM.js} +1 -1
  69. package/dashboard/dist/assets/{link-2-CV3uvMH3.js → link-2-Dn2soiyd.js} +1 -1
  70. package/dashboard/dist/assets/{loader-circle-DYpa-xTZ.js → loader-circle-CNT630zn.js} +1 -1
  71. package/dashboard/dist/assets/{modifiers.esm-Ds4Uh7JO.js → modifiers.esm-WPx0bc2K.js} +1 -1
  72. package/dashboard/dist/assets/{monitor-smartphone-BbeYHvY-.js → monitor-smartphone-C-qC0RAt.js} +1 -1
  73. package/dashboard/dist/assets/os-DMmhX9tk.js +1 -0
  74. package/dashboard/dist/assets/{plus-C9qGVLFv.js → plus-Cx82jb7o.js} +1 -1
  75. package/dashboard/dist/assets/{provider-display-BWYNyitK.js → provider-display-CodMmiRb.js} +2 -2
  76. package/dashboard/dist/assets/{react-CO1HovF5.js → react-B7SkZTgg.js} +1 -1
  77. package/dashboard/dist/assets/safePolygon-CKpREa4K.js +1 -0
  78. package/dashboard/dist/assets/{search-DBnhkzG_.js → search-Ol3OZBm4.js} +1 -1
  79. package/dashboard/dist/assets/server-DFCbznC7.js +1 -0
  80. package/dashboard/dist/assets/shield-check-YcmLwVZ6.js +1 -0
  81. package/dashboard/dist/assets/skills-api-C-9vK884.js +1 -0
  82. package/dashboard/dist/assets/{sortable.esm-SVl6OixH.js → sortable.esm-CKC9NuR-.js} +1 -1
  83. package/dashboard/dist/assets/store-C9yWYHfg.js +1 -0
  84. package/dashboard/dist/assets/{terminal-kUMdPZhq.js → terminal-B3_fCLFs.js} +1 -1
  85. package/dashboard/dist/assets/{trash-Dd_vbrYo.js → trash-CZGfgbFa.js} +1 -1
  86. package/dashboard/dist/assets/{use-community-stats-Bmlmv4H-.js → use-community-stats-CW-qkYn7.js} +1 -1
  87. package/dashboard/dist/assets/{use-latest-request-guard-CqZwzrCW.js → use-latest-request-guard-DHv7vfFE.js} +1 -1
  88. package/dashboard/dist/assets/{use-limits-display-prefs-CDC0MZRY.js → use-limits-display-prefs-NZ-AAYD5.js} +1 -1
  89. package/dashboard/dist/assets/use-native-settings-CWiLqfZh.js +1 -0
  90. package/dashboard/dist/assets/{use-ordered-list-DvBI7mUU.js → use-ordered-list-Df8pw-WJ.js} +1 -1
  91. package/dashboard/dist/assets/use-pet-settings-Bg2CDprr.js +2 -0
  92. package/dashboard/dist/assets/use-reduced-motion-iDZEUyua.js +1 -0
  93. package/dashboard/dist/assets/{use-transform-uSKYTYaW.js → use-transform-BHwEAfmO.js} +1 -1
  94. package/dashboard/dist/assets/use-usage-limits-CSxFFn3_.js +1 -0
  95. package/dashboard/dist/assets/{useCurrency-t1iy_pC3.js → useCurrency-pwClnCZ5.js} +1 -1
  96. package/dashboard/dist/assets/usePositioner-B0j1orGj.js +1 -0
  97. package/dashboard/dist/assets/useTokenFormat-BMvVAe6V.js +1 -0
  98. package/dashboard/dist/assets/{zap-DFM6AsYo.js → zap-DvX2aYx5.js} +1 -1
  99. package/dashboard/dist/index.html +2 -2
  100. package/dashboard/dist/ip-check.html +2 -2
  101. package/dashboard/dist/leaderboard.html +2 -2
  102. package/dashboard/dist/pricing.html +6 -5
  103. package/dashboard/dist/privacy.html +3 -3
  104. package/dashboard/dist/share.html +2 -2
  105. package/dashboard/dist/terms.html +6 -6
  106. package/package.json +6 -2
  107. package/src/commands/device-login.js +60 -33
  108. package/src/commands/init.js +26 -7
  109. package/src/commands/serve.js +3 -3
  110. package/src/commands/sync.js +43 -22
  111. package/src/lib/atomic-json.js +27 -0
  112. package/src/lib/bearer-token.js +11 -0
  113. package/src/lib/cloud-account.js +4 -1
  114. package/src/lib/local-api.js +282 -69
  115. package/src/lib/openclaw-cli.js +104 -0
  116. package/src/lib/openclaw-hook.js +1 -46
  117. package/src/lib/openclaw-session-plugin.js +1 -59
  118. package/src/lib/pricing/seed-snapshot.json +1 -1
  119. package/src/lib/proxy-env.js +8 -3
  120. package/src/lib/rollout.js +15 -10
  121. package/src/lib/runtime-config.js +84 -14
  122. package/src/lib/skills-manager.js +8 -1
  123. package/src/lib/static-server.js +19 -4
  124. package/src/lib/upload-throttle.js +18 -4
  125. package/dashboard/dist/assets/AchievementBadge-DPlXNqoK.js +0 -1
  126. package/dashboard/dist/assets/AchievementsPage-B49wjdK_.js +0 -2
  127. package/dashboard/dist/assets/BadgeDetailModal-DUCS6K1n.js +0 -1
  128. package/dashboard/dist/assets/Card-BU2CqxfU.js +0 -1
  129. package/dashboard/dist/assets/CommandPalette-O3pOLyJo.js +0 -1
  130. package/dashboard/dist/assets/CommunityStatsModal-Bt82neQ7.js +0 -1
  131. package/dashboard/dist/assets/Controls-D56v-YJw.js +0 -1
  132. package/dashboard/dist/assets/DashboardPage-CKEuFzuc.js +0 -1
  133. package/dashboard/dist/assets/DialogClose-C-QK97qp.js +0 -1
  134. package/dashboard/dist/assets/DialogDescription-COXweCuc.js +0 -1
  135. package/dashboard/dist/assets/LandingPage-DLDE7wdR.js +0 -2
  136. package/dashboard/dist/assets/LeaderboardAvatar-BqD4dEvQ.js +0 -1
  137. package/dashboard/dist/assets/LeaderboardPage-kgHSryiT.js +0 -2
  138. package/dashboard/dist/assets/LeaderboardProfileModal-zLdeOiA3.js +0 -73
  139. package/dashboard/dist/assets/LoginCard-Dx6MmGjU.js +0 -1
  140. package/dashboard/dist/assets/LoginModal-BWaZVRnT.js +0 -1
  141. package/dashboard/dist/assets/LoginPage-BNkU-riS.js +0 -1
  142. package/dashboard/dist/assets/PetPage-CyEgU2Vm.js +0 -1
  143. package/dashboard/dist/assets/PopoverPopup-CmUIY8lF.js +0 -1
  144. package/dashboard/dist/assets/ResetPasswordPage-CUXKkLb4.js +0 -1
  145. package/dashboard/dist/assets/SelectItemText-CZ7puxuB.js +0 -1
  146. package/dashboard/dist/assets/SessionsPage-D99UfBgy.js +0 -3
  147. package/dashboard/dist/assets/SettingsPage-DT3rld91.js +0 -2
  148. package/dashboard/dist/assets/SkillsPage-Dv47kVCB.js +0 -1
  149. package/dashboard/dist/assets/TrendMonitor-mB1x3c_o.js +0 -19
  150. package/dashboard/dist/assets/WidgetsPage-BhUArowe.js +0 -1
  151. package/dashboard/dist/assets/__vite-browser-external-BW1rxvYq.js +0 -45
  152. package/dashboard/dist/assets/__vite-browser-external-gKkiQad5.css +0 -1
  153. package/dashboard/dist/assets/agent-logos-D7f7AMF-.js +0 -1
  154. package/dashboard/dist/assets/composite-BgLSXWKA.js +0 -1
  155. package/dashboard/dist/assets/skills-api-BSkbxC8-.js +0 -1
  156. package/dashboard/dist/assets/store-DrEEdO_u.js +0 -1
  157. package/dashboard/dist/assets/use-pet-settings-C_FrqySO.js +0 -2
  158. package/dashboard/dist/assets/use-reduced-motion-BEOENqBa.js +0 -1
  159. package/dashboard/dist/assets/use-session-efficiency-pref-D8cAb4A9.js +0 -1
  160. package/dashboard/dist/assets/use-usage-limits-DFgOKTtv.js +0 -1
  161. package/dashboard/dist/assets/useTokenFormat-0bPjtaaM.js +0 -1
@@ -3,7 +3,7 @@ const os = require("node:os");
3
3
  const path = require("node:path");
4
4
  const { spawn } = require("node:child_process");
5
5
  const crypto = require("node:crypto");
6
- const { DEFAULT_BASE_URL, resolveRuntimeConfig } = require("./runtime-config");
6
+ const { DEFAULT_BASE_URL, resolveRuntimeConfig, assertInsforgeRuntime, normalizeInstanceBaseUrl, clearDeviceIdentity, resetInstanceState } = require("./runtime-config");
7
7
  const {
8
8
  filterRowsByUsageScope,
9
9
  getSourceScope,
@@ -14,6 +14,8 @@ const { accountSlugFor, fetchAccountUsage, mintAccessToken, invalidateCloudAccou
14
14
  const { getOrCreateMachineId, computeStableMachineId } = require("./machine-id");
15
15
  const { functionUrlFor, fetchFunctionResponse } = require("./function-url");
16
16
  const { createCloudDeviceTokenStore } = require("./cloud-device-token");
17
+ const { parseBearerToken } = require("./bearer-token");
18
+ const { writeJsonAtomicSync } = require("./atomic-json");
17
19
 
18
20
  const SYNC_TIMEOUT_MS = 120_000;
19
21
  // A failed account-view request should not stall every dashboard refresh
@@ -806,18 +808,11 @@ function normalizeRemoteHttpBaseUrl(value) {
806
808
  }
807
809
  }
808
810
 
809
- function resolveAllowedInsforgeBaseUrl(value) {
811
+ function resolveAllowedInsforgeBaseUrl(value, runtime = resolveRuntimeConfig()) {
810
812
  const requested = normalizeRemoteHttpBaseUrl(value);
811
813
  if (!requested) return null;
812
814
 
813
- const runtime = resolveRuntimeConfig();
814
- const allowed = new Set(
815
- [runtime.baseUrl, DEFAULT_BASE_URL]
816
- .map((entry) => normalizeRemoteHttpBaseUrl(entry))
817
- .filter(Boolean),
818
- );
819
-
820
- return allowed.has(requested) ? requested : null;
815
+ return requested === normalizeRemoteHttpBaseUrl(runtime.baseUrl) ? requested : null;
821
816
  }
822
817
 
823
818
  function parseCookieHeader(value) {
@@ -890,24 +885,29 @@ function readBodyLimited(req, maxBytes) {
890
885
 
891
886
  function runSyncCommand(extraEnv = {}, opts = {}) {
892
887
  return new Promise((resolve, reject) => {
893
- const args = [TRACKER_BIN, "sync"];
888
+ const args = opts.trackerDataDir
889
+ ? ["-e", `require(${JSON.stringify(path.resolve(__dirname, "../commands/sync.js"))}).cmdSync(process.argv.slice(1), ${JSON.stringify({ trackerDataDir: opts.trackerDataDir, home: path.dirname(opts.trackerDataDir), ...(opts.syncContext || {}) })}).catch(e => { console.error(e.message); process.exitCode = 1; })`, "--"]
890
+ : [TRACKER_BIN, "sync"];
894
891
  if (opts.auto === true) args.push("--auto");
895
892
  if (opts.background === true) args.push("--background");
896
893
  if (opts.publishAccount === true) args.push("--publish-account");
897
894
  if (opts.allLocalSources === true) args.push("--all-local-sources");
898
895
  if (opts.drain === true) args.push("--drain");
899
896
  if (opts.waitForLock === true) args.push("--wait-for-lock");
897
+ if (opts.signal?.aborted) { reject(Object.assign(new Error("Account session changed"), { code: "auth_session_changed", status: 409 })); return; }
900
898
  const child = spawn(process.execPath, args, {
901
899
  env: {
902
900
  ...process.env, ...extraEnv,
903
901
  ...(extraEnv.TOKENTRACKER_DEVICE_TOKEN ? {
904
- TOKENTRACKER_INSFORGE_BASE_URL: extraEnv.TOKENTRACKER_INSFORGE_BASE_URL || resolveRuntimeConfig().baseUrl,
905
- TOKENTRACKER_INSFORGE_ANON_KEY: resolveRuntimeConfig().anonKey,
902
+ TOKENTRACKER_INSFORGE_BASE_URL: extraEnv.TOKENTRACKER_INSFORGE_BASE_URL,
903
+ TOKENTRACKER_INSFORGE_ANON_KEY: extraEnv.TOKENTRACKER_INSFORGE_ANON_KEY || "",
906
904
  } : {}),
907
905
  TOKENTRACKER_LOCAL_SYNC_DEVICE_TOKEN: extraEnv.TOKENTRACKER_DEVICE_TOKEN || "",
908
906
  },
909
907
  stdio: ["ignore", "pipe", "pipe"],
910
908
  });
909
+ const cancel = () => child.kill("SIGTERM");
910
+ opts.signal?.addEventListener("abort", cancel, { once: true });
911
911
  let stdout = "";
912
912
  let stderr = "";
913
913
  let settled = false;
@@ -915,18 +915,13 @@ function runSyncCommand(extraEnv = {}, opts = {}) {
915
915
  if (settled) return;
916
916
  settled = true;
917
917
  clearTimeout(tid);
918
+ opts.signal?.removeEventListener("abort", cancel);
918
919
  fn(v);
919
920
  };
921
+ let timedOut = false;
920
922
  const tid = setTimeout(() => {
923
+ timedOut = true;
921
924
  child.kill("SIGTERM");
922
- finish(
923
- reject,
924
- Object.assign(new Error("Sync timed out"), {
925
- code: "SYNC_TIMEOUT",
926
- stdout: trimOutput(stdout),
927
- stderr: trimOutput(stderr),
928
- }),
929
- );
930
925
  }, SYNC_TIMEOUT_MS);
931
926
  child.stdout?.on("data", (c) => {
932
927
  stdout += c;
@@ -938,6 +933,8 @@ function runSyncCommand(extraEnv = {}, opts = {}) {
938
933
  finish(reject, Object.assign(e, { stdout: trimOutput(stdout), stderr: trimOutput(stderr) }));
939
934
  });
940
935
  child.on("close", (code) => {
936
+ if (timedOut) { finish(reject, Object.assign(new Error("Sync timed out"), { code: "SYNC_TIMEOUT", stdout: trimOutput(stdout), stderr: trimOutput(stderr) })); return; }
937
+ if (opts.signal?.aborted) { finish(reject, Object.assign(new Error("Account session changed"), { code: "auth_session_changed", status: 409 })); return; }
941
938
  const r = { code: code ?? 1, stdout: trimOutput(stdout), stderr: trimOutput(stderr) };
942
939
  if (code === 0) {
943
940
  finish(resolve, r);
@@ -1141,7 +1138,7 @@ function buildProxyHeaders(headers) {
1141
1138
  // Main handler factory
1142
1139
  // ---------------------------------------------------------------------------
1143
1140
 
1144
- function createLocalApiHandler({ queuePath, serverVersion = null }) {
1141
+ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir: isolatedTrackerDataDir = null, syncContext = null }) {
1145
1142
  const qp = queuePath || resolveQueuePath();
1146
1143
  const normalizedServerVersion = typeof serverVersion === "string" ? serverVersion.trim() : "";
1147
1144
 
@@ -1151,13 +1148,24 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1151
1148
  const csrfRelayCookieName = "insforge_csrf_token";
1152
1149
  let relayCookies = new Map();
1153
1150
  const localAuthToken = crypto.randomBytes(24).toString("hex");
1154
- const trackerDataDir = path.join(os.homedir(), ".tokentracker", "tracker");
1151
+ const trackerDataDir = isolatedTrackerDataDir || path.dirname(qp);
1155
1152
  const cookiePath = path.join(trackerDataDir, "relay-cookies.json");
1156
1153
  const localSyncDeviceTokenStore = createCloudDeviceTokenStore(path.join(trackerDataDir, "cloud-device-token.json"));
1157
1154
  const localSyncDeviceTokenCache = new Map();
1158
1155
  const localSyncDeviceTokenInflight = new Map();
1159
1156
  let localSyncDeviceTokenUserId = "";
1157
+ let localCloudSession = null;
1158
+ let localCloudGeneration = 0;
1159
+ const activeCloudSyncs = new Set();
1160
+ const localSyncDeviceMetadata = new Map();
1161
+ function invalidateLocalCloudSession() {
1162
+ localCloudGeneration += 1;
1163
+ localCloudSession = null;
1164
+ for (const controller of activeCloudSyncs) controller.abort();
1165
+ }
1160
1166
  function clearLocalSyncDeviceTokenCache() {
1167
+ invalidateLocalCloudSession();
1168
+ localSyncDeviceMetadata.clear();
1161
1169
  localSyncDeviceTokenCache.clear();
1162
1170
  localSyncDeviceTokenInflight.clear();
1163
1171
  localSyncDeviceTokenStore.clear();
@@ -1176,10 +1184,10 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1176
1184
  if (attemptId && state.lastErrorAttemptId === attemptId &&
1177
1185
  Date.parse(state.lastErrorAt || "") >= syncStartedAt && Number(state.lastErrorStatus) > 0) {
1178
1186
  if (state.lastErrorCode === "CLOUD_DEVICE_TOKEN_REJECTED") invalidateLocalSyncDeviceToken(deviceToken);
1179
- return { code: state.lastErrorCode || "CLOUD_UPLOAD_FAILED", status: Number(state.lastErrorStatus) };
1187
+ return { code: state.lastErrorCode || "CLOUD_UPLOAD_FAILED", status: Number(state.lastErrorStatus), retryAfterMs: Math.max(0, Math.max(Number(state.backoffUntilMs || 0), Number(state.nextAllowedAtMs || 0)) - Date.now()), nextAllowedAt: state.nextAllowedAtMs ? new Date(state.nextAllowedAtMs).toISOString() : null };
1180
1188
  }
1181
- if (backoff && Number(state.backoffUntilMs || 0) > syncStartedAt) {
1182
- return { code: "SYNC_UPLOAD_BACKOFF", status: 429 };
1189
+ if (backoff && Math.max(Number(state.backoffUntilMs || 0), Number(state.nextAllowedAtMs || 0)) > syncStartedAt) {
1190
+ return { code: "SYNC_UPLOAD_BACKOFF", status: 429, retryAfterMs: Math.max(0, Math.max(Number(state.backoffUntilMs || 0), Number(state.nextAllowedAtMs || 0)) - Date.now()), nextAllowedAt: state.nextAllowedAtMs ? new Date(state.nextAllowedAtMs).toISOString() : null };
1183
1191
  }
1184
1192
  } catch { /* no upload failure was recorded */ }
1185
1193
  return null;
@@ -1191,6 +1199,46 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1191
1199
  const accountViewFailures = new Map();
1192
1200
  const accountViewInFlight = new Map();
1193
1201
  let accountUploadState = readAccountUploadState();
1202
+ const runtimeConfigPath = path.join(path.dirname(qp), "config.json");
1203
+ const instancePath = path.join(trackerDataDir, "runtime-instance.json");
1204
+ const issuedAccessTokenInstances = new Map();
1205
+ let instanceFingerprint = "";
1206
+ try { instanceFingerprint = JSON.parse(fs.readFileSync(instancePath, "utf8")).fingerprint || ""; } catch { /* first start */ }
1207
+
1208
+ function fingerprintFor(runtime) {
1209
+ return crypto.createHash("sha256").update(`${runtime.baseUrl}\0${runtime.anonKey || ""}`).digest("hex");
1210
+ }
1211
+
1212
+ function getRuntime() {
1213
+ let config = {};
1214
+ let invalid = false;
1215
+ try { config = JSON.parse(fs.readFileSync(runtimeConfigPath, "utf8")); }
1216
+ catch (error) { invalid = error.code !== "ENOENT"; }
1217
+ if (!config || typeof config !== "object" || Array.isArray(config)) { config = {}; invalid = true; }
1218
+ const runtime = resolveRuntimeConfig({ config: config || {}, env: process.env });
1219
+ if (invalid) runtime.configurationError = "invalid_local_config";
1220
+ if (runtime.configurationError || (!fs.existsSync(runtimeConfigPath) && !fs.existsSync(qp))) return runtime;
1221
+ const fingerprint = fingerprintFor(runtime);
1222
+ if (fingerprint !== instanceFingerprint) {
1223
+ if (instanceFingerprint || runtime.baseUrl !== DEFAULT_BASE_URL) {
1224
+ clearRelayCookies("backend instance changed");
1225
+ _nativeAuthPending = false; _nativeAuthExpiry = 0;
1226
+ resetInstanceState(path.dirname(qp));
1227
+ if (trackerDataDir !== path.dirname(qp)) resetInstanceState(trackerDataDir);
1228
+ accountViewFailures.clear(); accountViewInFlight.clear();
1229
+ if (["deviceToken", "deviceId", "user_id"].some(key => config?.[key]) &&
1230
+ normalizeInstanceBaseUrl(config.deviceTokenBaseUrl) !== runtime.baseUrl) {
1231
+ config = clearDeviceIdentity(config);
1232
+ writeJsonAtomicSync(runtimeConfigPath, config);
1233
+ runtime.deviceToken = null;
1234
+ }
1235
+ }
1236
+ fs.mkdirSync(trackerDataDir, { recursive: true });
1237
+ fs.writeFileSync(instancePath, JSON.stringify({ fingerprint }), { mode: 0o600 });
1238
+ instanceFingerprint = fingerprint;
1239
+ }
1240
+ return runtime;
1241
+ }
1194
1242
 
1195
1243
  function readAccountUploadState() {
1196
1244
  try {
@@ -1322,7 +1370,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1322
1370
  }
1323
1371
  }
1324
1372
 
1325
- function captureAuthTokensFromBody(bodyBuffer, contentType, { newLogin = false } = {}) {
1373
+ function captureAuthTokensFromBody(bodyBuffer, contentType, { newLogin = false, sourceFingerprint } = {}) {
1326
1374
  if (!bodyBuffer || !String(contentType || "").toLowerCase().includes("application/json")) return;
1327
1375
  let parsed = null;
1328
1376
  try {
@@ -1330,6 +1378,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1330
1378
  } catch {
1331
1379
  return;
1332
1380
  }
1381
+ if (sourceFingerprint && typeof parsed?.accessToken === "string" && parsed.accessToken.length <= 8192) {
1382
+ const hash = crypto.createHash("sha256").update(parsed.accessToken).digest("hex");
1383
+ if (!issuedAccessTokenInstances.has(hash)) issuedAccessTokenInstances.set(hash, sourceFingerprint);
1384
+ while (issuedAccessTokenInstances.size > 64) issuedAccessTokenInstances.delete(issuedAccessTokenInstances.keys().next().value);
1385
+ }
1333
1386
  let changed = false;
1334
1387
  const token = typeof parsed?.csrfToken === "string" ? parsed.csrfToken.trim() : "";
1335
1388
  if (token) {
@@ -1422,23 +1475,17 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1422
1475
  async function issueDeviceTokenForLocalSync(queuePathForMachineId, options = {}) {
1423
1476
  if (!getCloudSyncPref()) return null;
1424
1477
  const refreshToken = getRefreshTokenForCloud();
1425
- if (!refreshToken) return null;
1478
+ if (!options.accessToken && !refreshToken) return null;
1426
1479
  const machineId = getOrCreateMachineId(queuePathForMachineId);
1427
1480
  if (!machineId) return null;
1428
-
1429
- const runtime = resolveRuntimeConfig();
1430
- const baseUrl =
1431
- normalizeRemoteHttpBaseUrl(options.baseUrl) ||
1432
- normalizeRemoteHttpBaseUrl(runtime.baseUrl) ||
1433
- normalizeRemoteHttpBaseUrl(DEFAULT_BASE_URL);
1481
+ const runtime = assertInsforgeRuntime(getRuntime());
1482
+ const baseUrl = normalizeRemoteHttpBaseUrl(options.baseUrl) ||
1483
+ normalizeRemoteHttpBaseUrl(runtime.baseUrl);
1434
1484
  const sessionAtStart = getCloudAccountSessionGeneration();
1435
1485
  const cloudPrefAtStart = cloudSyncPrefGeneration;
1436
- const minted = await mintAccessToken({
1437
- baseUrl,
1438
- anonKey: runtime.anonKey,
1439
- refreshToken,
1440
- timeoutMs: runtime.httpTimeoutMs,
1441
- throwOnFailure: true,
1486
+ const generationAtStart = localCloudGeneration;
1487
+ const minted = options.accessToken ? { accessToken: options.accessToken } : await mintAccessToken({
1488
+ baseUrl, anonKey: runtime.anonKey, refreshToken, timeoutMs: runtime.httpTimeoutMs, throwOnFailure: true,
1442
1489
  }).catch((error) => {
1443
1490
  if (error.status === 401 && sessionAtStart === getCloudAccountSessionGeneration()) clearLocalSyncDeviceTokenCache();
1444
1491
  throw error;
@@ -1451,13 +1498,16 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1451
1498
  if (rotatedRefreshToken) setRelayRefreshToken(rotatedRefreshToken);
1452
1499
  if (minted.csrfToken) setRelayCsrfToken(minted.csrfToken);
1453
1500
  if (!getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration) return null;
1454
- // The token comes from a successful refresh, not from a caller-supplied JWT.
1455
- // Token rotation keeps the same subject and therefore the same device key.
1501
+ // Refresh validates native ownership. A browser JWT must additionally pass
1502
+ // token issuance upstream before the mirror receives an upload binding.
1456
1503
  let userId = "";
1457
1504
  try {
1458
1505
  const payload = JSON.parse(Buffer.from(minted.accessToken.split(".")[1], "base64url").toString("utf8"));
1459
1506
  userId = typeof payload.sub === "string" ? payload.sub : "";
1460
1507
  } catch { /* legacy opaque access tokens retain their isolated refresh key */ }
1508
+ if (options.expectedOwnerId && userId !== options.expectedOwnerId) {
1509
+ throw Object.assign(new Error("Account owner mismatch"), { status: 409, code: "auth_owner_mismatch" });
1510
+ }
1461
1511
  if (userId && localSyncDeviceTokenUserId && userId !== localSyncDeviceTokenUserId) {
1462
1512
  clearLocalSyncDeviceTokenCache();
1463
1513
  }
@@ -1465,15 +1515,18 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1465
1515
  const identity = userId || `refresh:${rotatedRefreshToken || refreshToken}`;
1466
1516
  const cacheKey = localSyncDeviceTokenCacheKey(identity, machineId, baseUrl);
1467
1517
  const binding = { userId, baseUrl, machineId };
1468
- const cachedToken = localSyncDeviceTokenCache.get(cacheKey) ||
1469
- (userId ? localSyncDeviceTokenStore.get(binding) : null);
1518
+ const cachedToken = !options.accessToken && (localSyncDeviceTokenCache.get(cacheKey) ||
1519
+ (userId ? localSyncDeviceTokenStore.get(binding) : null));
1470
1520
  if (cachedToken) {
1471
1521
  localSyncDeviceTokenCache.set(cacheKey, cachedToken);
1472
1522
  if (userId) localSyncDeviceTokenStore.set(binding, cachedToken);
1473
- return cachedToken;
1523
+ return options.returnSession ? localSyncDeviceMetadata.get(cachedToken) || null : cachedToken;
1474
1524
  }
1475
1525
  const inflightToken = localSyncDeviceTokenInflight.get(cacheKey);
1476
- if (inflightToken?.sessionGeneration === sessionAtStart) return inflightToken.promise;
1526
+ if (inflightToken?.sessionGeneration === sessionAtStart && inflightToken.generation === generationAtStart) {
1527
+ const token = await inflightToken.promise;
1528
+ return options.returnSession ? localSyncDeviceMetadata.get(token) || null : token;
1529
+ }
1477
1530
 
1478
1531
  const issuePromise = (async () => {
1479
1532
  const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
@@ -1511,26 +1564,34 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1511
1564
  }).finally(() => {
1512
1565
  if (timeoutId) clearTimeout(timeoutId);
1513
1566
  });
1514
- if (sessionAtStart !== getCloudAccountSessionGeneration() || !getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration) return null;
1567
+ getRuntime();
1568
+ if (sessionAtStart !== getCloudAccountSessionGeneration() || !getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration || generationAtStart !== localCloudGeneration) return null;
1515
1569
  if (!res.ok) {
1516
1570
  if (res.status === 401 && sessionAtStart === getCloudAccountSessionGeneration()) {
1517
1571
  localSyncDeviceTokenCache.delete(cacheKey);
1518
1572
  localSyncDeviceTokenStore.clear(binding);
1519
1573
  }
1574
+ const denied = await res.json().catch(() => ({}));
1575
+ if (typeof denied?.code === "string" && denied.code.startsWith("cloud_")) {
1576
+ throw Object.assign(new Error(denied.code), { code: denied.code, status: res.status,
1577
+ recoveryUrl: denied.recovery_url });
1578
+ }
1520
1579
  return null;
1521
1580
  }
1522
1581
  const data = await res.json().catch(() => null);
1523
1582
  const token = typeof data?.token === "string" ? data.token.trim() : "";
1524
- if (sessionAtStart !== getCloudAccountSessionGeneration() || !getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration) return null;
1583
+ if (sessionAtStart !== getCloudAccountSessionGeneration() || !getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration || generationAtStart !== localCloudGeneration) return null;
1525
1584
  if (token) {
1585
+ localSyncDeviceMetadata.set(token, { token, deviceId: data.device_id, issuedAt: data.created_at || new Date().toISOString() });
1526
1586
  localSyncDeviceTokenCache.set(cacheKey, token);
1527
1587
  if (userId) localSyncDeviceTokenStore.set(binding, token);
1528
1588
  }
1529
1589
  return token || null;
1530
1590
  })();
1531
- localSyncDeviceTokenInflight.set(cacheKey, { promise: issuePromise, sessionGeneration: sessionAtStart });
1591
+ localSyncDeviceTokenInflight.set(cacheKey, { promise: issuePromise, sessionGeneration: sessionAtStart, generation: generationAtStart });
1532
1592
  try {
1533
- return await issuePromise;
1593
+ const token = await issuePromise;
1594
+ return options.returnSession ? localSyncDeviceMetadata.get(token) || null : token;
1534
1595
  } finally {
1535
1596
  if (localSyncDeviceTokenInflight.get(cacheKey)?.promise === issuePromise) localSyncDeviceTokenInflight.delete(cacheKey);
1536
1597
  }
@@ -1553,6 +1614,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1553
1614
  if (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "auth_timeout") {
1554
1615
  return "transient-timeout";
1555
1616
  }
1617
+ if (typeof err.code === "string" && err.code.startsWith("cloud_")) return err.code.replaceAll("_", "-");
1556
1618
  if (err.code === "auth_rejected" || err.code === "auth_invalid" || err.status === 401 || err.status === 403) {
1557
1619
  return "transient-auth";
1558
1620
  }
@@ -1570,7 +1632,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1570
1632
  const refreshToken = getRefreshTokenForCloud();
1571
1633
  if (!refreshToken) return ACCOUNT_FALLBACK_SIGNED_OUT;
1572
1634
  invalidateAfterAccountUpload();
1573
- const runtime = resolveRuntimeConfig();
1635
+ const runtime = getRuntime();
1636
+ if (runtime.configurationError) return ACCOUNT_FALLBACK_SIGNED_OUT;
1574
1637
  const failureKey = `${runtime.baseUrl}\0${refreshToken}`;
1575
1638
  const sessionAtStart = getCloudAccountSessionGeneration();
1576
1639
  const cloudPrefAtStart = cloudSyncPrefGeneration;
@@ -1598,6 +1661,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1598
1661
  refreshToken,
1599
1662
  timeoutMs,
1600
1663
  onSessionRefreshed: (session) => {
1664
+ getRuntime();
1601
1665
  if (sessionAtStart !== getCloudAccountSessionGeneration()) {
1602
1666
  throw Object.assign(new Error("Account session changed"), { code: "auth_session_changed" });
1603
1667
  }
@@ -1612,6 +1676,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1612
1676
  pending.then(cleanup, cleanup);
1613
1677
  }
1614
1678
  const out = await pending;
1679
+ getRuntime();
1615
1680
  // `null` here means the slug has no cloud equivalent, or the session
1616
1681
  // vanished mid-request — a routing/session fact, not an outage.
1617
1682
  if (!out) return ACCOUNT_FALLBACK_SIGNED_OUT;
@@ -1652,7 +1717,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1652
1717
  // erroring, but say so: this is a *temporary* downgrade and the client
1653
1718
  // must not treat it as the user's real data scope.
1654
1719
  const reason = classifyAccountFallback(e);
1655
- if (resolveRuntimeConfig().debug) {
1720
+ if (getRuntime().debug) {
1656
1721
  console.warn(`[LocalAPI] account view fallback (${reason}) for ${usageSlug}:`, e?.message || e);
1657
1722
  }
1658
1723
  rememberAccountViewFailure(failureKey);
@@ -1707,6 +1772,45 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1707
1772
 
1708
1773
  return async function handleLocalApi(req, res, url) {
1709
1774
  const p = url.pathname;
1775
+ // Session recovery and the one-time native callback marker belong to local
1776
+ // clients. Reject foreign requests before consuming either, including preflights.
1777
+ const nativeMarkerRead = p === "/api/auth-bridge/verifier" && String(req.method).toUpperCase() === "GET";
1778
+ if (p.startsWith("/api/auth/") || nativeMarkerRead) {
1779
+ if (!hasAllowedLoopbackOrigin(req.headers || {}) || req.headers?.["sec-fetch-site"] === "cross-site") {
1780
+ json(res, { code: "untrusted_auth_origin", error: "Authentication requests must come from the local dashboard." }, 403);
1781
+ return true;
1782
+ }
1783
+ if (String(req.method).toUpperCase() === "OPTIONS") {
1784
+ res.writeHead(204, {
1785
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1786
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
1787
+ });
1788
+ res.end();
1789
+ return true;
1790
+ }
1791
+ }
1792
+ const selectedRuntime = getRuntime();
1793
+ const callerInstance = req.headers?.["x-tokentracker-instance"];
1794
+ if (callerInstance && normalizeInstanceBaseUrl(callerInstance) !== selectedRuntime.baseUrl) {
1795
+ json(res, { code: "instance_changed", error: "The configured backend instance changed. Reload and sign in again." }, 409);
1796
+ return true;
1797
+ }
1798
+ const bearer = parseBearerToken(req.headers?.authorization);
1799
+ const issuedFor = bearer && issuedAccessTokenInstances.get(crypto.createHash("sha256").update(bearer).digest("hex"));
1800
+ if (issuedFor && issuedFor !== fingerprintFor(selectedRuntime)) {
1801
+ json(res, { code: "instance_changed", error: "This session belongs to another backend instance. Sign in again." }, 409);
1802
+ return true;
1803
+ }
1804
+ if (p === "/api/runtime-config.js") {
1805
+ if (String(req.method).toUpperCase() !== "GET") { json(res, { error: "Method Not Allowed" }, 405); return true; }
1806
+ const descriptor = { baseUrl: normalizeInstanceBaseUrl(selectedRuntime.baseUrl) || "",
1807
+ anonKey: selectedRuntime.configurationError ? null : selectedRuntime.anonKey,
1808
+ dashboardUrl: normalizeInstanceBaseUrl(selectedRuntime.dashboardUrl) || "", configurationError: selectedRuntime.configurationError };
1809
+ const value = JSON.stringify(descriptor).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
1810
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
1811
+ res.end(`window.__TOKENTRACKER_RUNTIME_CONFIG__=${value};`);
1812
+ return true;
1813
+ }
1710
1814
 
1711
1815
  if (p === "/api/local-auth") {
1712
1816
  if (String(req.method || "GET").toUpperCase() !== "GET") {
@@ -1820,12 +1924,32 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1820
1924
 
1821
1925
  // --- auth proxy: forward /api/auth/* to InsForge cloud ---
1822
1926
  if (p.startsWith("/api/auth/")) {
1927
+ if (String(req.method).toUpperCase() === "POST" && /^\/api\/auth\/(?:logout|sessions|sign-in|oauth\/exchange)$/.test(p)) {
1928
+ clearLocalSyncDeviceTokenCache();
1929
+ invalidateCloudAccountPayloadCache({ sessionChanged: true });
1930
+ }
1823
1931
  const authSessionAtStart = getCloudAccountSessionGeneration();
1824
- const runtime = resolveRuntimeConfig();
1932
+ const runtime = getRuntime();
1933
+ try { assertInsforgeRuntime(runtime); } catch (error) { json(res, { code: error.code, error: error.message }, 503); return true; }
1825
1934
  const insforgeBase = runtime.baseUrl || DEFAULT_BASE_URL;
1935
+ if (req.headers.apikey && req.headers.apikey !== runtime.anonKey) {
1936
+ json(res, { code: "instance_changed", error: "The public client key belongs to another backend instance." }, 409);
1937
+ return true;
1938
+ }
1826
1939
  try {
1827
1940
  const targetUrl = `${insforgeBase.replace(/\/$/, "")}${p}${url.search || ""}`;
1828
1941
  const proxyHeaders = buildProxyHeaders(req.headers);
1942
+ // Browser cookies survive changes to the loopback server's backend.
1943
+ // Only this instance's captured cookies can be relayed, including when
1944
+ // switching from a custom instance back to the official service.
1945
+ const hadBrowserCookies = Boolean(normalizeCookieHeader(proxyHeaders.cookie).trim());
1946
+ proxyHeaders.cookie = hadBrowserCookies ? buildRelayCookieHeader("") : "";
1947
+ if (proxyHeaders["x-csrf-token"] !== getRelayCookieValue(csrfRelayCookieName)) delete proxyHeaders["x-csrf-token"];
1948
+ if (insforgeBase !== DEFAULT_BASE_URL && proxyHeaders.authorization && !callerInstance) {
1949
+ json(res, { code: "instance_changed", error: "Reload the local dashboard before using a custom backend session." }, 409);
1950
+ return true;
1951
+ }
1952
+ proxyHeaders.apikey = runtime.anonKey;
1829
1953
  const hasClientCookie = normalizeCookieHeader(proxyHeaders["cookie"]).trim().length > 0;
1830
1954
  const hasCsrfHeader = typeof proxyHeaders["x-csrf-token"] === "string" && proxyHeaders["x-csrf-token"].trim().length > 0;
1831
1955
  const relayCsrfToken = getRelayCookieValue(csrfRelayCookieName);
@@ -1865,6 +1989,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1865
1989
  const injectedRelayCookies =
1866
1990
  shouldInjectRelayCookies && relayCookies.size > 0 && mergedCookie !== originalCookieHeader;
1867
1991
  if (mergedCookie) proxyHeaders["cookie"] = mergedCookie;
1992
+ else delete proxyHeaders["cookie"];
1868
1993
 
1869
1994
  const bodyChunks = [];
1870
1995
  for await (const chunk of req) bodyChunks.push(chunk);
@@ -1915,6 +2040,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1915
2040
  }
1916
2041
  }
1917
2042
 
2043
+ const latestRuntime = getRuntime();
2044
+ if (latestRuntime.configurationError || latestRuntime.baseUrl !== runtime.baseUrl || latestRuntime.anonKey !== runtime.anonKey) {
2045
+ json(res, { code: "instance_changed", error: "The configured backend changed during authentication. Reload and sign in again." }, 409);
2046
+ return true;
2047
+ }
1918
2048
  // Error responses must not mutate relay state: a 403's deletion
1919
2049
  // set-cookie (insforge_refresh_token=; Expires=1970) would otherwise
1920
2050
  // destroy a still-valid persisted session.
@@ -1939,7 +2069,9 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1939
2069
  if (p === "/api/auth/logout") {
1940
2070
  clearRelayCookies("sign out");
1941
2071
  } else {
1942
- captureAuthTokensFromBody(resBody, proxyRes.headers.get("content-type"), { newLogin: p !== "/api/auth/refresh" });
2072
+ captureAuthTokensFromBody(resBody, proxyRes.headers.get("content-type"), {
2073
+ newLogin: p !== "/api/auth/refresh", sourceFingerprint: fingerprintFor(runtime),
2074
+ });
1943
2075
  }
1944
2076
  }
1945
2077
  if (
@@ -2134,6 +2266,36 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2134
2266
  return true;
2135
2267
  }
2136
2268
 
2269
+ // The browser supplies its current JWT only to prove ownership upstream.
2270
+ // Uploads use this server-held binding, never a caller's opaque device token.
2271
+ if (p === "/functions/tokentracker-cloud-session") {
2272
+ if (String(req.method).toUpperCase() !== "POST") { json(res, { error: "Method Not Allowed" }, 405); return true; }
2273
+ if (!isAuthorizedLocalMutation(req)) { json(res, { error: "Unauthorized" }, 401); return true; }
2274
+ try {
2275
+ const body = await readJsonBody(req);
2276
+ assertInsforgeRuntime(getRuntime());
2277
+ if (body.clear === true) {
2278
+ if (body.session_id && body.session_id === localCloudSession?.id) clearLocalSyncDeviceTokenCache();
2279
+ json(res, { ok: true }); return true;
2280
+ }
2281
+ if (!getCloudSyncPref()) { json(res, { code: "CLOUD_SYNC_DISABLED" }, 409); return true; }
2282
+ const accessToken = String(req.headers.authorization || "").replace(/^Bearer /i, "").trim();
2283
+ const ownerId = typeof body.expectedOwnerId === "string" ? body.expectedOwnerId.trim() : "";
2284
+ if (!accessToken || !ownerId) { json(res, { code: "auth_required" }, 401); return true; }
2285
+ const baseUrl = resolveAllowedInsforgeBaseUrl(body.insforgeBaseUrl || getRuntime().baseUrl, getRuntime());
2286
+ if (!baseUrl) { json(res, { error: "Unsupported insforgeBaseUrl override" }, 400); return true; }
2287
+ clearLocalSyncDeviceTokenCache();
2288
+ const generation = localCloudGeneration;
2289
+ await Promise.all([...activeCloudSyncs].map(controller => controller.done).filter(Boolean));
2290
+ const issued = await issueDeviceTokenForLocalSync(qp, { baseUrl, accessToken, expectedOwnerId: ownerId, returnSession: true });
2291
+ if (generation !== localCloudGeneration) { json(res, { code: "auth_session_changed" }, 409); return true; }
2292
+ if (!issued?.token || !issued.deviceId) { json(res, { code: "auth_rejected" }, 401); return true; }
2293
+ localCloudSession = { id: crypto.randomUUID(), ownerId, baseUrl, generation, token: issued.token };
2294
+ json(res, { session_id: localCloudSession.id, device_id: issued.deviceId, created_at: issued.issuedAt });
2295
+ } catch (error) { json(res, { error: error.message, code: error.code || null, recovery_url: error.recoveryUrl || null }, error.status || 502); }
2296
+ return true;
2297
+ }
2298
+
2137
2299
  // --- local-sync (POST) ---
2138
2300
  if (p === "/functions/tokentracker-local-sync") {
2139
2301
  if (String(req.method || "GET").toUpperCase() !== "POST") {
@@ -2151,8 +2313,15 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2151
2313
  } catch {
2152
2314
  body = {};
2153
2315
  }
2154
- const extraEnv = {};
2316
+ const currentRuntime = getRuntime();
2317
+ if (body.drain === true || body.cloudSessionId || body.insforgeBaseUrl || body.expectedOwnerId) assertInsforgeRuntime(currentRuntime);
2318
+ const extraEnv = { TOKENTRACKER_CLOUD_RECHECK: "0", TOKENTRACKER_INSFORGE_BASE_URL: currentRuntime.baseUrl, TOKENTRACKER_INSFORGE_ANON_KEY: currentRuntime.anonKey || "" };
2155
2319
  const drain = body.drain === true;
2320
+ // A user action after membership/resume may probe the server again.
2321
+ // The server still enforces membership, paused slots and its deadline.
2322
+ if (drain && body.recheckCloudAccess === true && body.auto !== true) {
2323
+ extraEnv.TOKENTRACKER_CLOUD_RECHECK = "1";
2324
+ }
2156
2325
  // Native Sync Now combines an incremental background scan with a full
2157
2326
  // cloud drain. A plain {drain:true} request remains an exhaustive sync.
2158
2327
  const auto = body.auto === true;
@@ -2167,16 +2336,20 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2167
2336
  if (background && body.nativeOnlyWsl === true) {
2168
2337
  extraEnv.TOKENTRACKER_WSL_MODE = "native-only";
2169
2338
  }
2170
- if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
2171
- if (!getCloudSyncPref() && !background) {
2172
- json(res, { ok: false, error: "Cloud sync disabled", code: "CLOUD_SYNC_DISABLED" }, 409);
2173
- return true;
2174
- }
2175
- extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
2339
+ if (body.deviceToken != null && !background) {
2340
+ json(res, { ok: false, error: "Use the current cloud session", code: getCloudSyncPref() ? "auth_session_required" : "CLOUD_SYNC_DISABLED" }, 409);
2341
+ return true;
2342
+ }
2343
+ const browserSession = body.cloudSessionId ? localCloudSession : null;
2344
+ if (body.cloudSessionId === "" || (body.expectedOwnerId && !body.cloudSessionId)) { json(res, { ok: false, code: "auth_session_required" }, 409); return true; }
2345
+ if (body.cloudSessionId && (!browserSession || body.cloudSessionId !== browserSession.id ||
2346
+ body.expectedOwnerId !== browserSession.ownerId || browserSession.generation !== localCloudGeneration || !getCloudSyncPref())) {
2347
+ json(res, { ok: false, code: "auth_session_changed" }, 409); return true;
2176
2348
  }
2349
+ if (browserSession) extraEnv.TOKENTRACKER_DEVICE_TOKEN = browserSession.token;
2177
2350
  let localSyncBaseUrl = null;
2178
2351
  if (body.insforgeBaseUrl != null) {
2179
- const allowedBaseUrl = resolveAllowedInsforgeBaseUrl(body.insforgeBaseUrl);
2352
+ const allowedBaseUrl = resolveAllowedInsforgeBaseUrl(body.insforgeBaseUrl, getRuntime());
2180
2353
  if (!allowedBaseUrl) {
2181
2354
  json(res, { ok: false, error: "Unsupported insforgeBaseUrl override" }, 400);
2182
2355
  return true;
@@ -2184,6 +2357,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2184
2357
  extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = allowedBaseUrl;
2185
2358
  localSyncBaseUrl = allowedBaseUrl;
2186
2359
  }
2360
+ if (browserSession) {
2361
+ extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = browserSession.baseUrl;
2362
+ localSyncBaseUrl = browserSession.baseUrl;
2363
+ }
2364
+ const uploadGeneration = localCloudGeneration;
2187
2365
  if ((!background || publishAccount) &&
2188
2366
  !extraEnv.TOKENTRACKER_DEVICE_TOKEN &&
2189
2367
  getCloudSyncPref() &&
@@ -2192,9 +2370,10 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2192
2370
  const issuerSessionAtStart = getCloudAccountSessionGeneration();
2193
2371
  const issuerCloudPrefAtStart = cloudSyncPrefGeneration;
2194
2372
  try {
2195
- issuedToken = await issueDeviceTokenForLocalSync(qp, { baseUrl: localSyncBaseUrl });
2373
+ issuedToken = await issueDeviceTokenForLocalSync(qp, { baseUrl: localSyncBaseUrl, expectedOwnerId: localCloudSession?.ownerId });
2196
2374
  } catch (e) {
2197
- if (resolveRuntimeConfig().debug) {
2375
+ if (e?.code?.startsWith("cloud_") || e?.code === "auth_owner_mismatch") throw e;
2376
+ if (getRuntime().debug) {
2198
2377
  console.warn("[LocalAPI] local sync device token issue failed:", e?.message || e);
2199
2378
  }
2200
2379
  }
@@ -2215,9 +2394,30 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2215
2394
  extraEnv.TOKENTRACKER_DEVICE_TOKEN = issuedToken;
2216
2395
  }
2217
2396
  }
2397
+ if (extraEnv.TOKENTRACKER_DEVICE_TOKEN && uploadGeneration !== localCloudGeneration) {
2398
+ json(res, { ok: false, code: "auth_session_changed" }, 409); return true;
2399
+ }
2400
+ await Promise.all([...activeCloudSyncs].filter(controller => controller.signal.aborted).map(controller => controller.done).filter(Boolean));
2401
+ if (extraEnv.TOKENTRACKER_DEVICE_TOKEN && uploadGeneration !== localCloudGeneration) throw Object.assign(new Error("Account session changed"), { code: "auth_session_changed", status: 409 });
2402
+ if (extraEnv.TOKENTRACKER_DEVICE_TOKEN && localSyncDeviceTokenUserId) {
2403
+ const binding = JSON.stringify({ ownerId: localSyncDeviceTokenUserId, baseUrl: localSyncBaseUrl || getRuntime().baseUrl });
2404
+ const ownerPath = path.join(path.dirname(qp), "cloud-upload-owner.json");
2405
+ let previous = "";
2406
+ try { previous = fs.readFileSync(ownerPath, "utf8"); } catch { /* an unbound legacy cursor cannot prove its previous owner */ }
2407
+ if (previous !== binding) {
2408
+ fs.writeFileSync(path.join(path.dirname(qp), "queue.state.json"), JSON.stringify({ offset: 0, updatedAt: new Date().toISOString() }), { mode: 0o600 });
2409
+ try { fs.unlinkSync(path.join(path.dirname(qp), "upload.throttle.json")); } catch { /* no cooldown yet */ }
2410
+ }
2411
+ fs.writeFileSync(ownerPath, binding, { mode: 0o600 });
2412
+ }
2413
+ const controller = new AbortController();
2414
+ if (extraEnv.TOKENTRACKER_DEVICE_TOKEN) activeCloudSyncs.add(controller);
2218
2415
  const syncStartedAt = Date.now();
2219
2416
  extraEnv.TOKENTRACKER_LOCAL_SYNC_ATTEMPT_ID = crypto.randomUUID();
2220
- const result = await runSyncCommand(extraEnv, {
2417
+ const syncPromise = runSyncCommand(extraEnv, {
2418
+ signal: controller.signal,
2419
+ trackerDataDir: isolatedTrackerDataDir,
2420
+ syncContext,
2221
2421
  drain,
2222
2422
  auto,
2223
2423
  background,
@@ -2225,11 +2425,15 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2225
2425
  allLocalSources,
2226
2426
  waitForLock:
2227
2427
  !drain && !background && Boolean(extraEnv.TOKENTRACKER_DEVICE_TOKEN),
2228
- }).catch((error) => {
2428
+ });
2429
+ controller.done = syncPromise.catch(() => {});
2430
+ const result = await syncPromise.catch((error) => {
2431
+ if (uploadGeneration !== localCloudGeneration) throw Object.assign(new Error("Account session changed"), { code: "auth_session_changed", status: 409 });
2229
2432
  const failure = currentCloudUploadFailure(syncStartedAt, { backoff: auto && drain, deviceToken: extraEnv.TOKENTRACKER_DEVICE_TOKEN, attemptId: extraEnv.TOKENTRACKER_LOCAL_SYNC_ATTEMPT_ID });
2230
2433
  if (failure) Object.assign(error, failure);
2231
2434
  throw error;
2232
- });
2435
+ }).finally(() => activeCloudSyncs.delete(controller));
2436
+ if (extraEnv.TOKENTRACKER_DEVICE_TOKEN && uploadGeneration !== localCloudGeneration) throw Object.assign(new Error("Account session changed"), { code: "auth_session_changed", status: 409 });
2233
2437
  const failure = currentCloudUploadFailure(syncStartedAt, { deviceToken: extraEnv.TOKENTRACKER_DEVICE_TOKEN, attemptId: extraEnv.TOKENTRACKER_LOCAL_SYNC_ATTEMPT_ID });
2234
2438
  if (failure) throw Object.assign(new Error("Cloud upload failed"), failure);
2235
2439
  invalidateAfterAccountUpload();
@@ -2240,9 +2444,18 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2240
2444
  } catch (_e) {
2241
2445
  // ignore if module load fails
2242
2446
  }
2243
- json(res, { ok: true, ...result });
2447
+ let cloudAccess = { uploaded: false, pending: true };
2448
+ try {
2449
+ const state = JSON.parse(fs.readFileSync(path.join(path.dirname(qp), "upload.throttle.json"), "utf8"));
2450
+ if (state.nextAllowedAtMs) cloudAccess.next_allowed_at = new Date(state.nextAllowedAtMs).toISOString();
2451
+ cloudAccess.uploaded = state.lastSuccessAttemptId === extraEnv.TOKENTRACKER_LOCAL_SYNC_ATTEMPT_ID &&
2452
+ Number(state.lastSuccessMs || 0) >= syncStartedAt;
2453
+ const queueState = JSON.parse(fs.readFileSync(path.join(path.dirname(qp), "queue.state.json"), "utf8"));
2454
+ cloudAccess.pending = fs.statSync(qp).size > Number(queueState.offset || 0);
2455
+ } catch { /* no upload has been attempted yet */ }
2456
+ json(res, { ok: true, ...result, ...cloudAccess });
2244
2457
  } catch (e) {
2245
- json(res, { ok: false, error: e?.message, code: e?.code ?? null, stdout: e?.stdout || "", stderr: e?.stderr || "" }, e?.status || 500);
2458
+ json(res, { ok: false, error: e?.message, code: e?.code ?? null, recovery_url: e?.recoveryUrl ?? null, next_allowed_at: e?.nextAllowedAt ?? null, retry_after_seconds: e?.retryAfterMs ? Math.ceil(e.retryAfterMs / 1000) : null, stdout: e?.stdout || "", stderr: e?.stderr || "" }, e?.status || 500);
2246
2459
  }
2247
2460
  return true;
2248
2461
  }