tokentracker-cli 1.1.7 → 1.1.9

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 (108) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-BvHgbiVw.js → AchievementBadge-BeNHapqp.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-Bfp4s86S.js → AchievementsPage-vNH31cFv.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-D_0d-wue.js → AchievementsSection-ChGSmCUP.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-CswBdrxR.js → BadgeDetailModal-CN0tcpEE.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CiHx0-39.js → BotAnimated-CKKYEJt-.js} +1 -1
  6. package/dashboard/dist/assets/{Card-D_P_GpGV.js → Card-Dx3Pj7SE.js} +1 -1
  7. package/dashboard/dist/assets/{ClawdAnimated-DF8C0aFU.js → ClawdAnimated-ChJEskg4.js} +2 -2
  8. package/dashboard/dist/assets/{CommandPalette-AO3PXq3L.js → CommandPalette-D4-sgyxP.js} +1 -1
  9. package/dashboard/dist/assets/CommunityStatsModal-NUDCSEve.js +1 -0
  10. package/dashboard/dist/assets/{ConfirmModal-Dkes9XSh.js → ConfirmModal-q4EXjOda.js} +1 -1
  11. package/dashboard/dist/assets/{Controls-DanVQOJL.js → Controls-OjbHepPz.js} +1 -1
  12. package/dashboard/dist/assets/DashboardPage-F3swcQnu.js +1 -0
  13. package/dashboard/dist/assets/{DevicePage-DqHfz-mA.js → DevicePage-CghTfQLN.js} +1 -1
  14. package/dashboard/dist/assets/{DialogClose-bTErbUan.js → DialogClose-DSp5s5nL.js} +1 -1
  15. package/dashboard/dist/assets/DialogDescription-Ln30UAVH.js +1 -0
  16. package/dashboard/dist/assets/{DialogTitle-wJu8XVdR.js → DialogTitle-M5ojVj4-.js} +1 -1
  17. package/dashboard/dist/assets/{FadeIn-BKwC8Giy.js → FadeIn-CWDRIc_C.js} +1 -1
  18. package/dashboard/dist/assets/{HeaderGithubStar-Dc0pFvj1.js → HeaderGithubStar-DR4Px_eh.js} +1 -1
  19. package/dashboard/dist/assets/{HoverTooltip-B9vlFH-p.js → HoverTooltip-CsfynzUO.js} +1 -1
  20. package/dashboard/dist/assets/{IpCheckPage-CsJ1PgvF.js → IpCheckPage-C8Y1OFOn.js} +1 -1
  21. package/dashboard/dist/assets/{LandingPage-eF1J1hC5.js → LandingPage-BnmP1NUm.js} +2 -2
  22. package/dashboard/dist/assets/{LeaderboardAvatar-CFI7peYE.js → LeaderboardAvatar-DprHPy4v.js} +1 -1
  23. package/dashboard/dist/assets/{LeaderboardPage-PP4US4yQ.js → LeaderboardPage-BI-ryUMj.js} +2 -2
  24. package/dashboard/dist/assets/{LeaderboardProfileModal-C5QWkQji.js → LeaderboardProfileModal-r-wgLtCo.js} +3 -3
  25. package/dashboard/dist/assets/{LeaderboardProfilePage-BJURGX8H.js → LeaderboardProfilePage-qBvdPj6z.js} +3 -3
  26. package/dashboard/dist/assets/{LimitsPage-DUngDTwb.js → LimitsPage--ObDuhSD.js} +1 -1
  27. package/dashboard/dist/assets/{LocalOnlyNotice-CYguu0fl.js → LocalOnlyNotice-Cvg81MIm.js} +1 -1
  28. package/dashboard/dist/assets/{LoginCard-DXuCwYWd.js → LoginCard-T1vufQym.js} +1 -1
  29. package/dashboard/dist/assets/{LoginModal-Dz0m0fxu.js → LoginModal-CElUuzhg.js} +1 -1
  30. package/dashboard/dist/assets/{LoginPage-jOLmSJtM.js → LoginPage-Decz8Qyn.js} +1 -1
  31. package/dashboard/dist/assets/{PetPage-BrC2Vlkx.js → PetPage-CitsUTsW.js} +1 -1
  32. package/dashboard/dist/assets/{PopoverPopup-D1ZuZ6qF.js → PopoverPopup-wx5xna8c.js} +1 -1
  33. package/dashboard/dist/assets/{ProviderIcon-D6fA334Z.js → ProviderIcon-DeCQ9ytY.js} +1 -1
  34. package/dashboard/dist/assets/{ResetPasswordPage-K8qjox3z.js → ResetPasswordPage-BklHQ_i8.js} +1 -1
  35. package/dashboard/dist/assets/{SegmentedControl-D0lZmjJp.js → SegmentedControl-DqbXKZfk.js} +1 -1
  36. package/dashboard/dist/assets/{Select-DnXhoIcT.js → Select-BPhSzg5T.js} +1 -1
  37. package/dashboard/dist/assets/{SelectItemText-DaLHPEaI.js → SelectItemText-BEqonLIf.js} +1 -1
  38. package/dashboard/dist/assets/{ServiceStatusPage-B2Jwo3q0.js → ServiceStatusPage-DnAv3J6d.js} +1 -1
  39. package/dashboard/dist/assets/{SessionsPage-CqscAu03.js → SessionsPage-Fr5rFC4h.js} +1 -1
  40. package/dashboard/dist/assets/SettingsPage-DYB-pyw2.js +2 -0
  41. package/dashboard/dist/assets/{SkillsPage-XbU2V_2F.js → SkillsPage-B9aU8WEc.js} +1 -1
  42. package/dashboard/dist/assets/{TokenGalaxy-yPB_tjQb.js → TokenGalaxy-ybV8JSOT.js} +1 -1
  43. package/dashboard/dist/assets/{ToolbarRootContext-CVe7ayLE.js → ToolbarRootContext-DTAiJvnu.js} +1 -1
  44. package/dashboard/dist/assets/{TrendMonitor-CPGG-jEV.js → TrendMonitor-BCpOG6Vh.js} +2 -2
  45. package/dashboard/dist/assets/{WidgetsPage-DWRry8La.js → WidgetsPage-Ypvi7zXN.js} +1 -1
  46. package/dashboard/dist/assets/{WrappedPage-BQNXcJsy.js → WrappedPage-ZetHSH8Z.js} +1 -1
  47. package/dashboard/dist/assets/__vite-browser-external-C_FI6biS.js +45 -0
  48. package/dashboard/dist/assets/{agent-logos-ClPaafqy.js → agent-logos-BP-j-Uhx.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-D9iXcTSi.js → arrow-up-right-BDfZ3gqx.js} +1 -1
  50. package/dashboard/dist/assets/{check-ClpRkox0.js → check-DMSqoEnk.js} +1 -1
  51. package/dashboard/dist/assets/{composite-BeLJ-eoZ.js → composite-BdUpN78Q.js} +1 -1
  52. package/dashboard/dist/assets/{copy-SgX3AIm3.js → copy-CSp3uU1K.js} +1 -1
  53. package/dashboard/dist/assets/{download-nN_fUpW9.js → download-5KK5dq2E.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-C_KAZYfd.js → external-link-CetdvgSB.js} +1 -1
  55. package/dashboard/dist/assets/{flame-Y4jft3cb.js → flame-CkGhWtYT.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens-DgHV0o-Z.js → format-tokens-CnlGC8B4.js} +1 -1
  57. package/dashboard/dist/assets/{icons-OfFaXJDp.js → icons-BwUwzIE1.js} +1 -1
  58. package/dashboard/dist/assets/{index-BKJfXEXA.js → index-BLiO8Wso.js} +1 -1
  59. package/dashboard/dist/assets/{index-7QPrFzyL.js → index-DQV-HPM-.js} +1 -1
  60. package/dashboard/dist/assets/{index-BWdBrNhr.js → index-D_-EkG8M.js} +1 -1
  61. package/dashboard/dist/assets/{info-lrIngAOG.js → info-KSAnlMrp.js} +1 -1
  62. package/dashboard/dist/assets/{link-2-CX_TTpam.js → link-2-BGXe__2Q.js} +1 -1
  63. package/dashboard/dist/assets/{loader-circle-DAt8Rl6H.js → loader-circle-D7ZxwMME.js} +1 -1
  64. package/dashboard/dist/assets/{maximize-2-BhF9GJwe.js → maximize-2-D80lpJ50.js} +1 -1
  65. package/dashboard/dist/assets/{modifiers.esm-Ozg3867-.js → modifiers.esm-DStyqxn4.js} +1 -1
  66. package/dashboard/dist/assets/{monitor-smartphone-AjSSS90b.js → monitor-smartphone-D282iI2t.js} +1 -1
  67. package/dashboard/dist/assets/{plus-BW8P8uiQ.js → plus-Cn55gGq1.js} +1 -1
  68. package/dashboard/dist/assets/provider-display-DvH8Y5lo.js +42 -0
  69. package/dashboard/dist/assets/{react-DwTWkLQf.js → react-B-73Uvu6.js} +1 -1
  70. package/dashboard/dist/assets/{search-DbwR9JhN.js → search-DpOq2L2S.js} +1 -1
  71. package/dashboard/dist/assets/skills-api-CCIV2ZIq.js +1 -0
  72. package/dashboard/dist/assets/{sortable.esm-DSWAIKHA.js → sortable.esm-BjIamCRg.js} +1 -1
  73. package/dashboard/dist/assets/{store-BPfJGR9z.js → store-CmFrvUue.js} +1 -1
  74. package/dashboard/dist/assets/{terminal-U-gMhjaN.js → terminal-CqeGAXgs.js} +1 -1
  75. package/dashboard/dist/assets/{trash-Bu1AqpoX.js → trash-CMLW5F5L.js} +1 -1
  76. package/dashboard/dist/assets/{use-community-stats-DfSn2jNt.js → use-community-stats-BKS7Gd-z.js} +1 -1
  77. package/dashboard/dist/assets/{use-latest-request-guard-7m4DXn4B.js → use-latest-request-guard-I80QDt_F.js} +1 -1
  78. package/dashboard/dist/assets/{use-limits-display-prefs-mRd0OnhT.js → use-limits-display-prefs-CPLY7mBi.js} +1 -1
  79. package/dashboard/dist/assets/{use-ordered-list-CBae4AuZ.js → use-ordered-list-wlw260-2.js} +1 -1
  80. package/dashboard/dist/assets/use-reduced-motion-ClrSsBXW.js +1 -0
  81. package/dashboard/dist/assets/{use-session-efficiency-pref-DCweltax.js → use-session-efficiency-pref-BdVVDyew.js} +1 -1
  82. package/dashboard/dist/assets/{use-transform-BPegjDim.js → use-transform-Daiv5LsX.js} +1 -1
  83. package/dashboard/dist/assets/use-usage-limits-CK1zK4GT.js +1 -0
  84. package/dashboard/dist/assets/{useCurrency-5h4sZ7c9.js → useCurrency-yxDqAL1Q.js} +1 -1
  85. package/dashboard/dist/assets/{useTokenFormat-BELg6Qla.js → useTokenFormat-BTYDgiyJ.js} +1 -1
  86. package/dashboard/dist/assets/{zap-q4Z6ZRMI.js → zap-CfNiBLFJ.js} +1 -1
  87. package/dashboard/dist/index.html +1 -2
  88. package/dashboard/dist/ip-check.html +1 -2
  89. package/dashboard/dist/leaderboard.html +1 -2
  90. package/dashboard/dist/share.html +1 -1
  91. package/package.json +1 -1
  92. package/src/commands/device-login.js +3 -2
  93. package/src/commands/sync.js +34 -12
  94. package/src/lib/cloud-account.js +93 -39
  95. package/src/lib/function-url.js +27 -0
  96. package/src/lib/local-api.js +141 -31
  97. package/src/lib/pricing/seed-snapshot.json +1 -1
  98. package/src/lib/telemetry.js +3 -2
  99. package/src/lib/upload-throttle.js +6 -0
  100. package/dashboard/dist/assets/CommunityStatsModal-Dhx0AXIU.js +0 -1
  101. package/dashboard/dist/assets/DashboardPage-CtrTRoRf.js +0 -1
  102. package/dashboard/dist/assets/DialogDescription-HFssj1J8.js +0 -1
  103. package/dashboard/dist/assets/SettingsPage-CL2euJqr.js +0 -2
  104. package/dashboard/dist/assets/__vite-browser-external-CAVICAeZ.js +0 -45
  105. package/dashboard/dist/assets/provider-display-Dmqqa8F4.js +0 -42
  106. package/dashboard/dist/assets/skills-api-CdNR4QHm.js +0 -1
  107. package/dashboard/dist/assets/use-reduced-motion-uScStq9r.js +0 -1
  108. package/dashboard/dist/assets/use-usage-limits-DKO2kwUE.js +0 -1
@@ -12,6 +12,7 @@
12
12
 
13
13
  const { DEFAULT_BASE_URL, DEFAULT_ANON_KEY } = require("./runtime-config");
14
14
  const { expandHeatmapCompact } = require("./heatmap-compact");
15
+ const { functionUrlFor, fetchFunctionResponse } = require("./function-url");
15
16
 
16
17
  // usage-* (local CLI) → account-* (cloud) slug map. Only these have a
17
18
  // cross-device cloud equivalent; project-usage / usage-limits / category
@@ -107,24 +108,17 @@ let tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
107
108
  // intermittent "Activity silently dropped to this-machine data" symptom.
108
109
  const mintInflight = new Map();
109
110
 
110
- // Response cache for the account reads, mirroring the 30-second window the edge
111
- // functions already keep.
112
- //
113
- // Each `tokentracker-account-*` function caches the RPC snapshot it renders
114
- // from for 30 seconds (`COMPACT_TTL_MS` in dashboard/edge-patches/*), so two
115
- // reads inside one window render the same numbers from the same snapshot. That
116
- // cache only saves the edge-to-PostgREST hop, though: the full body still goes
117
- // out to every caller. And the callers repeat a lot — the Windows tray poller
118
- // re-reads the 52-week heatmap every ~49 seconds, and one popover refresh fans
119
- // out six reads at once. Holding the payload here for the same 30 seconds
120
- // returns exactly what the cloud would have returned, without paying for the
121
- // transfer again.
122
- //
123
- // Bodies are stored serialized and re-parsed on a hit so a caller mutating the
124
- // object it got back cannot corrupt what the next caller reads.
125
- const PAYLOAD_TTL_MS = 30_000;
111
+ // Ordinary cross-device reads may lag by two minutes for summaries and five
112
+ // minutes for charts. Uploads and explicit refreshes invalidate this window.
113
+ // Serialized copies prevent consumers from changing another consumer's data.
114
+ const SUMMARY_PAYLOAD_TTL_MS = 120_000;
115
+ const PAYLOAD_TTL_MS = 300_000;
126
116
  const PAYLOAD_CACHE_MAX = 64;
127
117
  const payloadCache = new Map();
118
+ const payloadInflight = new Map();
119
+ const payloadVersions = new Map();
120
+ let payloadGeneration = 0;
121
+ let sessionGeneration = 0;
128
122
 
129
123
  // Mirrors the params `fetchAccountFunction` actually puts on the wire, so two
130
124
  // requests share an entry only when they would have produced the same URL.
@@ -132,7 +126,7 @@ function payloadCacheKey({ root, sub, slug, searchParams }) {
132
126
  const pairs = [];
133
127
  if (searchParams && typeof searchParams.entries === "function") {
134
128
  for (const [key, value] of searchParams.entries()) {
135
- if (key === "account" || key === "scope") continue;
129
+ if (key === "account" || key === "scope" || key === "refresh") continue;
136
130
  if (value == null || value === "") continue;
137
131
  pairs.push(`${key}=${String(value)}`);
138
132
  }
@@ -159,7 +153,7 @@ function payloadCacheGet(key, now) {
159
153
  }
160
154
  }
161
155
 
162
- function payloadCacheSet(key, data, now) {
156
+ function payloadCacheSet(key, data, now, ttlMs) {
163
157
  let body;
164
158
  try {
165
159
  body = JSON.stringify(data);
@@ -167,7 +161,7 @@ function payloadCacheSet(key, data, now) {
167
161
  return; // Not serializable: skip the cache rather than fail the read.
168
162
  }
169
163
  if (body === undefined) return;
170
- payloadCache.set(key, { expMs: now() + PAYLOAD_TTL_MS, body });
164
+ payloadCache.set(key, { expMs: now() + ttlMs, body });
171
165
  while (payloadCache.size > PAYLOAD_CACHE_MAX) {
172
166
  const oldest = payloadCache.keys().next();
173
167
  if (oldest.done) break;
@@ -175,10 +169,26 @@ function payloadCacheSet(key, data, now) {
175
169
  }
176
170
  }
177
171
 
172
+ function invalidateCloudAccountPayloadCache({ sessionChanged = false } = {}) {
173
+ if (sessionChanged) {
174
+ sessionGeneration += 1;
175
+ tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
176
+ mintInflight.clear();
177
+ }
178
+ payloadGeneration += 1;
179
+ payloadCache.clear();
180
+ payloadInflight.clear();
181
+ payloadVersions.clear();
182
+ }
183
+
184
+ function getCloudAccountSessionGeneration() {
185
+ return sessionGeneration;
186
+ }
187
+
178
188
  function __resetCloudAccountCacheForTests() {
179
189
  tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
180
190
  mintInflight.clear();
181
- payloadCache.clear();
191
+ invalidateCloudAccountPayloadCache({ sessionChanged: true });
182
192
  }
183
193
 
184
194
  // Failure classes surfaced to local-api so it can tell an intentional local
@@ -205,7 +215,8 @@ function csrfTokenFromRefreshPayload(data) {
205
215
  * every failure so callers can distinguish a transient outage (timeout,
206
216
  * offline, rotated-token rejection) from an intentionally local view.
207
217
  */
208
- async function performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, now, timeoutMs }) {
218
+ async function performMint({ root, anonKey, refreshToken, fetchImpl, now, timeoutMs }) {
219
+ const mintSessionGeneration = sessionGeneration;
209
220
  const headers = { "Content-Type": "application/json", Accept: "application/json" };
210
221
  if (anonKey) headers.apikey = anonKey;
211
222
 
@@ -260,9 +271,13 @@ async function performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, n
260
271
  }
261
272
 
262
273
  const expMs = decodeJwtExpMs(accessToken) || now() + 10 * 60_000;
263
- tokenCache = { cacheKey, accessToken, expMs };
264
-
274
+ if (mintSessionGeneration !== sessionGeneration) {
275
+ throw new AccountAuthError("auth_session_changed", "account session changed during token refresh");
276
+ }
265
277
  const rotated = refreshTokenFromRefreshPayload(data);
278
+ // The caller persists a rotation before its next account read. Key the
279
+ // still-valid access token by that current refresh token, not the consumed one.
280
+ tokenCache = { cacheKey: `${root}\0${rotated || refreshToken}`, accessToken, expMs };
266
281
  return {
267
282
  accessToken,
268
283
  refreshToken: rotated && rotated !== refreshToken ? rotated : null,
@@ -311,7 +326,7 @@ async function mintAccessToken({
311
326
 
312
327
  let inflight = mintInflight.get(cacheKey);
313
328
  if (!inflight) {
314
- inflight = performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, now, timeoutMs })
329
+ inflight = performMint({ root, anonKey, refreshToken, fetchImpl, now, timeoutMs })
315
330
  .finally(() => {
316
331
  // Only clear our own entry: a later caller may already have started a
317
332
  // fresh mint after this one settled.
@@ -342,10 +357,10 @@ async function fetchAccountFunction({
342
357
  timeoutMs,
343
358
  } = {}) {
344
359
  const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
345
- const url = new URL(`${root}/functions/${slug}`);
360
+ const url = new URL(functionUrlFor(root, slug));
346
361
  if (searchParams && typeof searchParams.entries === "function") {
347
362
  for (const [key, value] of searchParams.entries()) {
348
- if (key === "account" || key === "scope") continue;
363
+ if (key === "account" || key === "scope" || key === "refresh") continue;
349
364
  if (value != null && value !== "") url.searchParams.set(key, String(value));
350
365
  }
351
366
  }
@@ -364,13 +379,13 @@ async function fetchAccountFunction({
364
379
  }
365
380
 
366
381
  try {
367
- const res = await fetchImpl(url.toString(), { method: "GET", headers, signal });
382
+ const res = await fetchFunctionResponse(url.toString(), { method: "GET", headers, signal }, fetchImpl);
368
383
  if (!res || !res.ok) {
369
384
  const err = new Error(`Account fetch failed with HTTP ${res ? res.status : "?"}`);
370
385
  err.status = res ? res.status : 0;
371
386
  throw err;
372
387
  }
373
- const data = await res.json();
388
+ const data = await res.json();
374
389
  return slug === HEATMAP_ACCOUNT_SLUG ? expandHeatmapCompact(data) : data;
375
390
  } finally {
376
391
  if (timeoutId) {
@@ -402,6 +417,7 @@ async function fetchAccountUsage({
402
417
  if (!slug) return null;
403
418
  if (!refreshToken) return null;
404
419
 
420
+ const sessionAtStart = sessionGeneration;
405
421
  const startTime = now();
406
422
  const getRemainingTimeout = () => {
407
423
  if (!timeoutMs) return undefined;
@@ -423,6 +439,9 @@ async function fetchAccountUsage({
423
439
  throwOnFailure: true,
424
440
  });
425
441
  if (!minted) return null;
442
+ if (sessionAtStart !== sessionGeneration) {
443
+ throw new AccountAuthError("auth_session_changed", "account session changed during token refresh");
444
+ }
426
445
 
427
446
  // Keyed after the mint so rotation cannot invalidate the entry, and so a
428
447
  // revoked session fails here rather than being served from cache.
@@ -430,7 +449,8 @@ async function fetchAccountUsage({
430
449
  const cacheKey = sub
431
450
  ? payloadCacheKey({ root: String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ""), sub, slug, searchParams })
432
451
  : null;
433
- if (cacheKey) {
452
+ const force = searchParams?.get("refresh") === "1";
453
+ if (cacheKey && !force) {
434
454
  const cached = payloadCacheGet(cacheKey, now);
435
455
  if (cached !== null) {
436
456
  // Still surface a rotation from this mint: dropping it would leave the
@@ -443,16 +463,47 @@ async function fetchAccountUsage({
443
463
  }
444
464
  }
445
465
 
446
- const data = await fetchAccountFunction({
447
- baseUrl,
448
- anonKey,
449
- accessToken: minted.accessToken,
450
- slug,
451
- searchParams,
452
- fetchImpl,
453
- timeoutMs: getRemainingTimeout(),
454
- });
455
- if (cacheKey && data != null) payloadCacheSet(cacheKey, data, now);
466
+ const flightKey = cacheKey ? `${cacheKey}\0${payloadGeneration}\0${force ? "refresh" : `normal:${payloadVersions.get(cacheKey) || 0}`}` : null;
467
+ let pending = flightKey && payloadInflight.get(flightKey);
468
+ if (!pending) {
469
+ // A forced read supersedes an older ordinary read of the same account.
470
+ // Keep concurrent forced consumers together without sharing its old flight.
471
+ if (force && cacheKey) {
472
+ payloadCache.delete(cacheKey);
473
+ payloadVersions.set(cacheKey, (payloadVersions.get(cacheKey) || 0) + 1);
474
+ }
475
+ const generation = payloadGeneration;
476
+ const version = payloadVersions.get(cacheKey) || 0;
477
+ pending = (async () => {
478
+ try {
479
+ const data = await fetchAccountFunction({
480
+ baseUrl, anonKey, accessToken: minted.accessToken, slug, searchParams,
481
+ fetchImpl, timeoutMs: getRemainingTimeout(),
482
+ });
483
+ if (generation === payloadGeneration && version === (payloadVersions.get(cacheKey) || 0) && cacheKey && data != null) {
484
+ payloadCacheSet(cacheKey, data, now, slug === "tokentracker-account-summary" ? SUMMARY_PAYLOAD_TTL_MS : PAYLOAD_TTL_MS);
485
+ }
486
+ return JSON.stringify(data);
487
+ } catch (error) {
488
+ if (error.status === 401 || error.status === 403) {
489
+ invalidateCloudAccountPayloadCache({ sessionChanged: true });
490
+ }
491
+ throw error;
492
+ }
493
+ })();
494
+ if (flightKey) {
495
+ payloadInflight.set(flightKey, pending);
496
+ const cleanup = () => {
497
+ if (payloadInflight.get(flightKey) === pending) payloadInflight.delete(flightKey);
498
+ };
499
+ pending.then(cleanup, cleanup);
500
+ }
501
+ }
502
+ const body = await pending;
503
+ if (sessionAtStart !== sessionGeneration) {
504
+ throw new AccountAuthError("auth_session_changed", "account session changed during account read");
505
+ }
506
+ const data = body === undefined ? undefined : JSON.parse(body);
456
507
  return { data, rotatedRefreshToken: minted.refreshToken, rotatedCsrfToken: minted.csrfToken };
457
508
  }
458
509
 
@@ -460,6 +511,9 @@ module.exports = {
460
511
  AccountAuthError,
461
512
  USAGE_TO_ACCOUNT_SLUG,
462
513
  PAYLOAD_TTL_MS,
514
+ SUMMARY_PAYLOAD_TTL_MS,
515
+ invalidateCloudAccountPayloadCache,
516
+ getCloudAccountSessionGeneration,
463
517
  HEATMAP_ACCOUNT_SLUG,
464
518
  accountSlugFor,
465
519
  accessTokenFromRefreshPayload,
@@ -0,0 +1,27 @@
1
+ const { DEFAULT_BASE_URL } = require("./runtime-config");
2
+
3
+ const PROD_FUNCTIONS_URL = "https://srctyff5.function2.insforge.app";
4
+
5
+ // Use only the production deployment verified by the platform. A custom or
6
+ // self-hosted API keeps its proxy route instead of sending credentials elsewhere.
7
+ function functionUrlFor(baseUrl, slug) {
8
+ const root = String(baseUrl || "").trim().replace(/\/$/, "");
9
+ return root === DEFAULT_BASE_URL
10
+ ? `${PROD_FUNCTIONS_URL}/${slug}`
11
+ : `${root}/functions/${slug}`;
12
+ }
13
+
14
+ async function fetchFunctionResponse(url, init = {}, fetchImpl = fetch) {
15
+ const response = await fetchImpl(url, init);
16
+ const method = String(init.method || "GET").toUpperCase();
17
+ const target = new URL(String(url));
18
+ if (response.status === 404 && (method === "GET" || method === "HEAD")
19
+ && target.origin === PROD_FUNCTIONS_URL) {
20
+ const legacy = new URL(`${DEFAULT_BASE_URL}/functions${target.pathname}`);
21
+ legacy.search = target.search;
22
+ return fetchImpl(legacy.toString(), init);
23
+ }
24
+ return response;
25
+ }
26
+
27
+ module.exports = { functionUrlFor, fetchFunctionResponse };
@@ -10,8 +10,9 @@ const {
10
10
  listExcludedSources,
11
11
  normalizeUsageScope,
12
12
  } = require("./source-metadata");
13
- const { accountSlugFor, fetchAccountUsage, mintAccessToken } = require("./cloud-account");
13
+ const { accountSlugFor, fetchAccountUsage, mintAccessToken, invalidateCloudAccountPayloadCache, getCloudAccountSessionGeneration } = require("./cloud-account");
14
14
  const { getOrCreateMachineId, computeStableMachineId } = require("./machine-id");
15
+ const { functionUrlFor, fetchFunctionResponse } = require("./function-url");
15
16
 
16
17
  const SYNC_TIMEOUT_MS = 120_000;
17
18
  // A failed account-view request should not stall every dashboard refresh
@@ -1145,12 +1146,49 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1145
1146
  const cookiePath = path.join(trackerDataDir, "relay-cookies.json");
1146
1147
  const localSyncDeviceTokenCache = new Map();
1147
1148
  const localSyncDeviceTokenInflight = new Map();
1149
+ let localSyncDeviceTokenUserId = "";
1150
+ function clearLocalSyncDeviceTokenCache() {
1151
+ localSyncDeviceTokenCache.clear();
1152
+ localSyncDeviceTokenInflight.clear();
1153
+ localSyncDeviceTokenUserId = "";
1154
+ }
1155
+ function currentCloudUploadFailure(syncStartedAt, { backoff = false } = {}) {
1156
+ try {
1157
+ const state = JSON.parse(fs.readFileSync(path.join(path.dirname(qp), "upload.throttle.json"), "utf8"));
1158
+ if (Date.parse(state.lastErrorAt || "") >= syncStartedAt && Number(state.lastErrorStatus) > 0) {
1159
+ if (state.lastErrorCode === "CLOUD_DEVICE_TOKEN_REJECTED") clearLocalSyncDeviceTokenCache();
1160
+ return { code: state.lastErrorCode || "CLOUD_UPLOAD_FAILED", status: Number(state.lastErrorStatus) };
1161
+ }
1162
+ if (backoff && Number(state.backoffUntilMs || 0) > syncStartedAt) {
1163
+ return { code: "SYNC_UPLOAD_BACKOFF", status: 429 };
1164
+ }
1165
+ } catch { /* no upload failure was recorded */ }
1166
+ return null;
1167
+ }
1148
1168
  // A dashboard refresh fans out to several account-view endpoints. Keep the
1149
1169
  // circuit-breaker state per session instead of one global slot (a successful
1150
1170
  // request for one account must not clear another account's failure), and
1151
1171
  // suppress the duplicate fan-out while the first cloud probe is pending.
1152
1172
  const accountViewFailures = new Map();
1153
1173
  const accountViewInFlight = new Set();
1174
+ let accountUploadState = readAccountUploadState();
1175
+
1176
+ function readAccountUploadState() {
1177
+ try {
1178
+ const state = JSON.parse(fs.readFileSync(path.join(path.dirname(qp), "queue.state.json"), "utf8"));
1179
+ return JSON.stringify([Number(state.offset) || 0, state.updatedAt || ""]);
1180
+ } catch {
1181
+ return "";
1182
+ }
1183
+ }
1184
+
1185
+ function invalidateAfterAccountUpload() {
1186
+ const state = readAccountUploadState();
1187
+ if (state !== accountUploadState) {
1188
+ accountUploadState = state;
1189
+ invalidateCloudAccountPayloadCache();
1190
+ }
1191
+ }
1154
1192
 
1155
1193
  function accountViewFailureUntil(failureKey) {
1156
1194
  const until = accountViewFailures.get(failureKey) || 0;
@@ -1202,6 +1240,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1202
1240
  }
1203
1241
 
1204
1242
  function clearRelayCookies(reason) {
1243
+ clearLocalSyncDeviceTokenCache();
1244
+ invalidateCloudAccountPayloadCache({ sessionChanged: true });
1205
1245
  if (relayCookies.size === 0) return;
1206
1246
  relayCookies.clear();
1207
1247
  try {
@@ -1230,6 +1270,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1230
1270
 
1231
1271
  if (isDeletion) {
1232
1272
  if (relayCookies.has(name)) {
1273
+ if (name === "insforge_refresh_token") invalidateCloudAccountPayloadCache({ sessionChanged: true });
1233
1274
  relayCookies.delete(name);
1234
1275
  changed = true;
1235
1276
  console.log(`[LocalAPI] Cookie deleted: ${name}`);
@@ -1237,6 +1278,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1237
1278
  } else {
1238
1279
  const oldVal = relayCookies.get(name);
1239
1280
  if (oldVal !== raw.trim()) {
1281
+ if (name === "insforge_refresh_token") invalidateCloudAccountPayloadCache({ sessionChanged: true });
1240
1282
  relayCookies.set(name, raw.trim());
1241
1283
  changed = true;
1242
1284
  console.log(`[LocalAPI] Cookie captured: ${name}`);
@@ -1261,7 +1303,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1261
1303
  }
1262
1304
  }
1263
1305
 
1264
- function captureAuthTokensFromBody(bodyBuffer, contentType) {
1306
+ function captureAuthTokensFromBody(bodyBuffer, contentType, { newLogin = false } = {}) {
1265
1307
  if (!bodyBuffer || !String(contentType || "").toLowerCase().includes("application/json")) return;
1266
1308
  let parsed = null;
1267
1309
  try {
@@ -1280,8 +1322,10 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1280
1322
  }
1281
1323
  const refreshToken = typeof parsed?.refreshToken === "string" ? parsed.refreshToken.trim() : "";
1282
1324
  if (refreshToken) {
1325
+ if (newLogin) clearLocalSyncDeviceTokenCache();
1283
1326
  const cookie = `insforge_refresh_token=${encodeURIComponent(refreshToken)}; Path=/; HttpOnly; SameSite=Lax`;
1284
1327
  if (relayCookies.get("insforge_refresh_token") !== cookie) {
1328
+ invalidateCloudAccountPayloadCache({ sessionChanged: true });
1285
1329
  relayCookies.set("insforge_refresh_token", cookie);
1286
1330
  changed = true;
1287
1331
  }
@@ -1300,6 +1344,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1300
1344
  // takes effect for signed-in users; an explicit {enabled:false} still wins.
1301
1345
  const cloudSyncPrefPath = path.join(trackerDataDir, "cloud-sync-pref.json");
1302
1346
  let cloudSyncPrefCache;
1347
+ let cloudSyncPrefGeneration = 0;
1303
1348
  function getCloudSyncPref() {
1304
1349
  if (cloudSyncPrefCache === undefined) {
1305
1350
  try {
@@ -1311,6 +1356,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1311
1356
  return cloudSyncPrefCache;
1312
1357
  }
1313
1358
  function setCloudSyncPref(enabled) {
1359
+ if (getCloudSyncPref() !== Boolean(enabled)) {
1360
+ cloudSyncPrefGeneration += 1;
1361
+ clearLocalSyncDeviceTokenCache();
1362
+ invalidateCloudAccountPayloadCache();
1363
+ }
1314
1364
  cloudSyncPrefCache = Boolean(enabled);
1315
1365
  try {
1316
1366
  if (!fs.existsSync(trackerDataDir)) fs.mkdirSync(trackerDataDir, { recursive: true });
@@ -1344,8 +1394,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1344
1394
  }
1345
1395
  }
1346
1396
 
1347
- function localSyncDeviceTokenCacheKey(refreshToken, machineId, baseUrl) {
1348
- return `${baseUrl}\0${refreshToken}\0${machineId}`;
1397
+ function localSyncDeviceTokenCacheKey(userId, machineId, baseUrl) {
1398
+ return `${baseUrl}\0${userId}\0${machineId}`;
1349
1399
  }
1350
1400
 
1351
1401
  async function issueDeviceTokenForLocalSync(queuePathForMachineId, options = {}) {
@@ -1360,27 +1410,41 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1360
1410
  normalizeRemoteHttpBaseUrl(options.baseUrl) ||
1361
1411
  normalizeRemoteHttpBaseUrl(runtime.baseUrl) ||
1362
1412
  normalizeRemoteHttpBaseUrl(DEFAULT_BASE_URL);
1363
- const cacheKey = localSyncDeviceTokenCacheKey(refreshToken, machineId, baseUrl);
1413
+ const sessionAtStart = getCloudAccountSessionGeneration();
1414
+ const cloudPrefAtStart = cloudSyncPrefGeneration;
1415
+ const minted = await mintAccessToken({
1416
+ baseUrl,
1417
+ anonKey: runtime.anonKey,
1418
+ refreshToken,
1419
+ timeoutMs: runtime.httpTimeoutMs,
1420
+ });
1421
+ if (!minted?.accessToken || sessionAtStart !== getCloudAccountSessionGeneration()) return null;
1422
+ const rotatedRefreshToken =
1423
+ typeof minted.refreshToken === "string" && minted.refreshToken.trim()
1424
+ ? minted.refreshToken.trim()
1425
+ : "";
1426
+ if (rotatedRefreshToken) setRelayRefreshToken(rotatedRefreshToken);
1427
+ if (minted.csrfToken) setRelayCsrfToken(minted.csrfToken);
1428
+ if (!getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration) return null;
1429
+ // The token comes from a successful refresh, not from a caller-supplied JWT.
1430
+ // Token rotation keeps the same subject and therefore the same device key.
1431
+ let userId = "";
1432
+ try {
1433
+ const payload = JSON.parse(Buffer.from(minted.accessToken.split(".")[1], "base64url").toString("utf8"));
1434
+ userId = typeof payload.sub === "string" ? payload.sub : "";
1435
+ } catch { /* legacy opaque access tokens retain their isolated refresh key */ }
1436
+ if (userId && localSyncDeviceTokenUserId && userId !== localSyncDeviceTokenUserId) {
1437
+ clearLocalSyncDeviceTokenCache();
1438
+ }
1439
+ if (userId) localSyncDeviceTokenUserId = userId;
1440
+ const identity = userId || `refresh:${rotatedRefreshToken || refreshToken}`;
1441
+ const cacheKey = localSyncDeviceTokenCacheKey(identity, machineId, baseUrl);
1364
1442
  const cachedToken = localSyncDeviceTokenCache.get(cacheKey);
1365
1443
  if (cachedToken) return cachedToken;
1366
1444
  const inflightToken = localSyncDeviceTokenInflight.get(cacheKey);
1367
- if (inflightToken) return inflightToken;
1445
+ if (inflightToken?.sessionGeneration === sessionAtStart) return inflightToken.promise;
1368
1446
 
1369
1447
  const issuePromise = (async () => {
1370
- const minted = await mintAccessToken({
1371
- baseUrl,
1372
- anonKey: runtime.anonKey,
1373
- refreshToken,
1374
- timeoutMs: runtime.httpTimeoutMs,
1375
- });
1376
- if (!minted?.accessToken) return null;
1377
- const rotatedRefreshToken =
1378
- typeof minted.refreshToken === "string" && minted.refreshToken.trim()
1379
- ? minted.refreshToken.trim()
1380
- : "";
1381
- if (rotatedRefreshToken) setRelayRefreshToken(rotatedRefreshToken);
1382
- if (minted.csrfToken) setRelayCsrfToken(minted.csrfToken);
1383
-
1384
1448
  const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
1385
1449
  const headers = {
1386
1450
  "Content-Type": "application/json",
@@ -1401,7 +1465,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1401
1465
  process.platform === "linux" ? "Linux x86_64" :
1402
1466
  "web";
1403
1467
 
1404
- const res = await fetch(`${root}/functions/tokentracker-device-token-issue`, {
1468
+ const res = await fetchFunctionResponse(functionUrlFor(root, "tokentracker-device-token-issue"), {
1405
1469
  method: "POST",
1406
1470
  headers,
1407
1471
  signal: controller ? controller.signal : undefined,
@@ -1416,20 +1480,22 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1416
1480
  }).finally(() => {
1417
1481
  if (timeoutId) clearTimeout(timeoutId);
1418
1482
  });
1419
- if (!res.ok) return null;
1483
+ if (sessionAtStart !== getCloudAccountSessionGeneration() || !getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration) return null;
1484
+ if (!res.ok) {
1485
+ if (res.status === 401) clearLocalSyncDeviceTokenCache();
1486
+ return null;
1487
+ }
1420
1488
  const data = await res.json().catch(() => null);
1421
1489
  const token = typeof data?.token === "string" ? data.token.trim() : "";
1422
- if (token) {
1423
- const activeRefreshToken = rotatedRefreshToken || refreshToken;
1424
- localSyncDeviceTokenCache.set(localSyncDeviceTokenCacheKey(activeRefreshToken, machineId, baseUrl), token);
1425
- }
1490
+ if (sessionAtStart !== getCloudAccountSessionGeneration() || !getCloudSyncPref() || cloudPrefAtStart !== cloudSyncPrefGeneration) return null;
1491
+ if (token) localSyncDeviceTokenCache.set(cacheKey, token);
1426
1492
  return token || null;
1427
1493
  })();
1428
- localSyncDeviceTokenInflight.set(cacheKey, issuePromise);
1494
+ localSyncDeviceTokenInflight.set(cacheKey, { promise: issuePromise, sessionGeneration: sessionAtStart });
1429
1495
  try {
1430
1496
  return await issuePromise;
1431
1497
  } finally {
1432
- localSyncDeviceTokenInflight.delete(cacheKey);
1498
+ if (localSyncDeviceTokenInflight.get(cacheKey)?.promise === issuePromise) localSyncDeviceTokenInflight.delete(cacheKey);
1433
1499
  }
1434
1500
  }
1435
1501
 
@@ -1466,10 +1532,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1466
1532
  if (!getCloudSyncPref()) return ACCOUNT_FALLBACK_CLOUD_SYNC_OFF;
1467
1533
  const refreshToken = getRefreshTokenForCloud();
1468
1534
  if (!refreshToken) return ACCOUNT_FALLBACK_SIGNED_OUT;
1535
+ invalidateAfterAccountUpload();
1469
1536
  const runtime = resolveRuntimeConfig();
1470
1537
  const failureKey = `${runtime.baseUrl}\0${refreshToken}`;
1471
1538
  const requestKey = `${failureKey}\0${usageSlug}\0${url.searchParams.toString()}`;
1472
- if (accountViewFailureUntil(failureKey) > Date.now()) {
1539
+ if (url.searchParams.get("refresh") !== "1" && accountViewFailureUntil(failureKey) > Date.now()) {
1473
1540
  return "transient-backoff";
1474
1541
  }
1475
1542
  // Six native dashboard requests normally arrive together. If the cloud is
@@ -1483,6 +1550,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1483
1550
  ? parseInt(process.env.TOKENTRACKER_HTTP_TIMEOUT_MS, 10)
1484
1551
  : 0;
1485
1552
  const timeoutMs = envTimeout > 0 ? envTimeout : 4000;
1553
+ const sessionAtStart = getCloudAccountSessionGeneration();
1554
+ const cloudPrefAtStart = cloudSyncPrefGeneration;
1486
1555
  try {
1487
1556
  const out = await fetchAccountUsage({
1488
1557
  usageSlug,
@@ -1495,12 +1564,20 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1495
1564
  // `null` here means the slug has no cloud equivalent, or the session
1496
1565
  // vanished mid-request — a routing/session fact, not an outage.
1497
1566
  if (!out) return ACCOUNT_FALLBACK_SIGNED_OUT;
1567
+ if (sessionAtStart !== getCloudAccountSessionGeneration()) {
1568
+ throw Object.assign(new Error("Account session changed"), { code: "auth_session_changed" });
1569
+ }
1498
1570
  if (out.data == null) {
1499
1571
  rememberAccountViewFailure(failureKey);
1500
1572
  return "transient-upstream";
1501
1573
  }
1502
1574
  if (out.rotatedRefreshToken) setRelayRefreshToken(out.rotatedRefreshToken);
1503
1575
  if (out.rotatedCsrfToken) setRelayCsrfToken(out.rotatedCsrfToken);
1576
+ if (!getCloudSyncPref()) return ACCOUNT_FALLBACK_CLOUD_SYNC_OFF;
1577
+ if (cloudPrefAtStart !== cloudSyncPrefGeneration) {
1578
+ json(res, { error: "Cloud sync preference changed", code: "CLOUD_SYNC_CHANGED" }, 409);
1579
+ return "served";
1580
+ }
1504
1581
  accountViewFailures.delete(failureKey);
1505
1582
  res.writeHead(200, {
1506
1583
  "Content-Type": "application/json",
@@ -1510,6 +1587,10 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1510
1587
  res.end(JSON.stringify(out.data));
1511
1588
  return "served";
1512
1589
  } catch (e) {
1590
+ if (e.code === "auth_session_changed") {
1591
+ json(res, { error: "Account session changed", code: e.code }, 409);
1592
+ return "served";
1593
+ }
1513
1594
  // Signed in + cloud sync on, but the cloud read failed (offline, token
1514
1595
  // rejected, edge error, or timeout). Serve local data rather than
1515
1596
  // erroring, but say so: this is a *temporary* downgrade and the client
@@ -1685,6 +1766,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1685
1766
 
1686
1767
  // --- auth proxy: forward /api/auth/* to InsForge cloud ---
1687
1768
  if (p.startsWith("/api/auth/")) {
1769
+ const authSessionAtStart = getCloudAccountSessionGeneration();
1688
1770
  const runtime = resolveRuntimeConfig();
1689
1771
  const insforgeBase = runtime.baseUrl || DEFAULT_BASE_URL;
1690
1772
  try {
@@ -1782,6 +1864,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1782
1864
  // Error responses must not mutate relay state: a 403's deletion
1783
1865
  // set-cookie (insforge_refresh_token=; Expires=1970) would otherwise
1784
1866
  // destroy a still-valid persisted session.
1867
+ if ((p === "/api/auth/refresh" || p === "/api/auth/logout") &&
1868
+ authSessionAtStart !== getCloudAccountSessionGeneration()) {
1869
+ json(res, { error: "Account session changed", code: "auth_session_changed" }, 409);
1870
+ return true;
1871
+ }
1785
1872
  const allowRelayCapture = proxyRes.status < 400;
1786
1873
  const responseHeaders = [...proxyRes.headers.entries()]
1787
1874
  .filter(([k]) => !["transfer-encoding", "connection"].includes(k.toLowerCase()))
@@ -1798,7 +1885,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
1798
1885
  if (p === "/api/auth/logout") {
1799
1886
  clearRelayCookies("sign out");
1800
1887
  } else {
1801
- captureAuthTokensFromBody(resBody, proxyRes.headers.get("content-type"));
1888
+ captureAuthTokensFromBody(resBody, proxyRes.headers.get("content-type"), { newLogin: p !== "/api/auth/refresh" });
1802
1889
  }
1803
1890
  }
1804
1891
  if (
@@ -2027,6 +2114,10 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2027
2114
  extraEnv.TOKENTRACKER_WSL_MODE = "native-only";
2028
2115
  }
2029
2116
  if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
2117
+ if (!getCloudSyncPref() && !background) {
2118
+ json(res, { ok: false, error: "Cloud sync disabled", code: "CLOUD_SYNC_DISABLED" }, 409);
2119
+ return true;
2120
+ }
2030
2121
  extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
2031
2122
  }
2032
2123
  let localSyncBaseUrl = null;
@@ -2044,6 +2135,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2044
2135
  getCloudSyncPref() &&
2045
2136
  getRefreshTokenForCloud()) {
2046
2137
  let issuedToken = null;
2138
+ const issuerSessionAtStart = getCloudAccountSessionGeneration();
2139
+ const issuerCloudPrefAtStart = cloudSyncPrefGeneration;
2047
2140
  try {
2048
2141
  issuedToken = await issueDeviceTokenForLocalSync(qp, { baseUrl: localSyncBaseUrl });
2049
2142
  } catch (e) {
@@ -2051,6 +2144,14 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2051
2144
  console.warn("[LocalAPI] local sync device token issue failed:", e?.message || e);
2052
2145
  }
2053
2146
  }
2147
+ if (issuerSessionAtStart !== getCloudAccountSessionGeneration()) {
2148
+ json(res, { ok: false, error: "Account session changed", code: "auth_session_changed" }, 409);
2149
+ return true;
2150
+ }
2151
+ if (!getCloudSyncPref() || issuerCloudPrefAtStart !== cloudSyncPrefGeneration) {
2152
+ json(res, { ok: false, error: "Cloud sync preference changed", code: "CLOUD_SYNC_CHANGED" }, 409);
2153
+ return true;
2154
+ }
2054
2155
  if (!issuedToken) {
2055
2156
  if (drain) {
2056
2157
  json(res, { ok: false, error: "Unable to issue cloud device token for local sync" }, 502);
@@ -2060,6 +2161,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2060
2161
  extraEnv.TOKENTRACKER_DEVICE_TOKEN = issuedToken;
2061
2162
  }
2062
2163
  }
2164
+ const syncStartedAt = Date.now();
2063
2165
  const result = await runSyncCommand(extraEnv, {
2064
2166
  drain,
2065
2167
  auto,
@@ -2068,7 +2170,15 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2068
2170
  allLocalSources,
2069
2171
  waitForLock:
2070
2172
  !drain && !background && Boolean(extraEnv.TOKENTRACKER_DEVICE_TOKEN),
2173
+ }).catch((error) => {
2174
+ const failure = currentCloudUploadFailure(syncStartedAt, { backoff: auto && drain });
2175
+ if (failure) Object.assign(error, failure);
2176
+ throw error;
2071
2177
  });
2178
+ const failure = currentCloudUploadFailure(syncStartedAt);
2179
+ if (failure) throw Object.assign(new Error("Cloud upload failed"), failure);
2180
+ invalidateAfterAccountUpload();
2181
+ if (drain) invalidateCloudAccountPayloadCache();
2072
2182
  try {
2073
2183
  const { resetUsageLimitsCache } = require("./usage-limits");
2074
2184
  resetUsageLimitsCache();
@@ -2077,7 +2187,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null }) {
2077
2187
  }
2078
2188
  json(res, { ok: true, ...result });
2079
2189
  } catch (e) {
2080
- json(res, { ok: false, error: e?.message, code: e?.code ?? null, stdout: e?.stdout || "", stderr: e?.stderr || "" }, 500);
2190
+ json(res, { ok: false, error: e?.message, code: e?.code ?? null, stdout: e?.stdout || "", stderr: e?.stderr || "" }, e?.status || 500);
2081
2191
  }
2082
2192
  return true;
2083
2193
  }