tokentracker-cli 1.1.7 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/assets/{AchievementBadge-BvHgbiVw.js → AchievementBadge-xbiBA5i9.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-Bfp4s86S.js → AchievementsPage-Bas-dACH.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-D_0d-wue.js → AchievementsSection-NIkQkv1K.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-CswBdrxR.js → BadgeDetailModal-D3UgBwf8.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-CiHx0-39.js → BotAnimated-BNyhe5RX.js} +1 -1
- package/dashboard/dist/assets/{Card-D_P_GpGV.js → Card-B4qMgo_P.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-DF8C0aFU.js → ClawdAnimated-B_6oXLru.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-AO3PXq3L.js → CommandPalette-DR50aykL.js} +1 -1
- package/dashboard/dist/assets/CommunityStatsModal-DbWZ2hX1.js +1 -0
- package/dashboard/dist/assets/{ConfirmModal-Dkes9XSh.js → ConfirmModal-CApD9tLe.js} +1 -1
- package/dashboard/dist/assets/{Controls-DanVQOJL.js → Controls-UQr6lc0S.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-38JMPZYD.js +1 -0
- package/dashboard/dist/assets/{DevicePage-DqHfz-mA.js → DevicePage-B7n5TS6A.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-bTErbUan.js → DialogClose-BVYAi_Mb.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-CD1rCYqD.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-wJu8XVdR.js → DialogTitle-_gEgVrEF.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-BKwC8Giy.js → FadeIn-CqWPKBtn.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Dc0pFvj1.js → HeaderGithubStar-nJV8kqj7.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-B9vlFH-p.js → HoverTooltip-CkPO2nyc.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CsJ1PgvF.js → IpCheckPage-CBPPbv12.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-eF1J1hC5.js → LandingPage-B3kHRjIL.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-CFI7peYE.js → LeaderboardAvatar-C9UJap02.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-PP4US4yQ.js → LeaderboardPage-BaxbbkZ4.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-C5QWkQji.js → LeaderboardProfileModal-CSCpQGtj.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-BJURGX8H.js → LeaderboardProfilePage-Cr4oGPW3.js} +3 -3
- package/dashboard/dist/assets/{LimitsPage-DUngDTwb.js → LimitsPage-D80H3CN6.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-CYguu0fl.js → LocalOnlyNotice-B-IFH3nt.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-DXuCwYWd.js → LoginCard-D28bk46z.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-Dz0m0fxu.js → LoginModal-B3OIZlRl.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-jOLmSJtM.js → LoginPage-BUZcOhur.js} +1 -1
- package/dashboard/dist/assets/{PetPage-BrC2Vlkx.js → PetPage-B1ID7Hn_.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-D1ZuZ6qF.js → PopoverPopup-C4y60FZN.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-D6fA334Z.js → ProviderIcon-DINSm4-U.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-K8qjox3z.js → ResetPasswordPage-DuT61PlK.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-D0lZmjJp.js → SegmentedControl-CI97Hzlt.js} +1 -1
- package/dashboard/dist/assets/{Select-DnXhoIcT.js → Select-Br0lETb5.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-DaLHPEaI.js → SelectItemText-DkwRyFhO.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-B2Jwo3q0.js → ServiceStatusPage-CXmaHXok.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-CqscAu03.js → SessionsPage-D4QNmkZK.js} +1 -1
- package/dashboard/dist/assets/SettingsPage-B2m9w6u2.js +2 -0
- package/dashboard/dist/assets/{SkillsPage-XbU2V_2F.js → SkillsPage-BgwQrJN5.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-yPB_tjQb.js → TokenGalaxy-Gu8Ow73w.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-CVe7ayLE.js → ToolbarRootContext-BQ1me9_q.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-CPGG-jEV.js → TrendMonitor-CvgRQWnP.js} +2 -2
- package/dashboard/dist/assets/{WidgetsPage-DWRry8La.js → WidgetsPage-CUqcWQRH.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-BQNXcJsy.js → WrappedPage-C3UN0ODa.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-CU61PE6P.js +45 -0
- package/dashboard/dist/assets/{agent-logos-ClPaafqy.js → agent-logos-CqHJCc4t.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-D9iXcTSi.js → arrow-up-right-DOj31845.js} +1 -1
- package/dashboard/dist/assets/{check-ClpRkox0.js → check-B3DcIhj-.js} +1 -1
- package/dashboard/dist/assets/{composite-BeLJ-eoZ.js → composite-84597ykh.js} +1 -1
- package/dashboard/dist/assets/{copy-SgX3AIm3.js → copy-DHKp6LiG.js} +1 -1
- package/dashboard/dist/assets/{download-nN_fUpW9.js → download-8BXZUZ5E.js} +1 -1
- package/dashboard/dist/assets/{external-link-C_KAZYfd.js → external-link-MMLkzpJo.js} +1 -1
- package/dashboard/dist/assets/{flame-Y4jft3cb.js → flame-CS31bpav.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-DgHV0o-Z.js → format-tokens-Db794xGj.js} +1 -1
- package/dashboard/dist/assets/{icons-OfFaXJDp.js → icons-DbJFxYam.js} +1 -1
- package/dashboard/dist/assets/{index-7QPrFzyL.js → index-C43slbyq.js} +1 -1
- package/dashboard/dist/assets/{index-BKJfXEXA.js → index-DxRNZBk8.js} +1 -1
- package/dashboard/dist/assets/{index-BWdBrNhr.js → index-zvktTnpL.js} +1 -1
- package/dashboard/dist/assets/{info-lrIngAOG.js → info-BBYVrlZQ.js} +1 -1
- package/dashboard/dist/assets/{link-2-CX_TTpam.js → link-2-BAkJwRS7.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-DAt8Rl6H.js → loader-circle-qmDffs-C.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-BhF9GJwe.js → maximize-2-DsglOasI.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-Ozg3867-.js → modifiers.esm-EKtyGFrY.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-AjSSS90b.js → monitor-smartphone-i1wfcnRb.js} +1 -1
- package/dashboard/dist/assets/{plus-BW8P8uiQ.js → plus-Cu09y-uC.js} +1 -1
- package/dashboard/dist/assets/provider-display-CqsH0Sl4.js +42 -0
- package/dashboard/dist/assets/{react-DwTWkLQf.js → react-BhNxIPTw.js} +1 -1
- package/dashboard/dist/assets/{search-DbwR9JhN.js → search-CnD0oNqF.js} +1 -1
- package/dashboard/dist/assets/skills-api-BZjW22xy.js +1 -0
- package/dashboard/dist/assets/{sortable.esm-DSWAIKHA.js → sortable.esm-DKZHcMD3.js} +1 -1
- package/dashboard/dist/assets/{store-BPfJGR9z.js → store-DtFmZUKU.js} +1 -1
- package/dashboard/dist/assets/{terminal-U-gMhjaN.js → terminal-Ci6VJSXB.js} +1 -1
- package/dashboard/dist/assets/{trash-Bu1AqpoX.js → trash-DFtauQXa.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DfSn2jNt.js → use-community-stats-CtiK0rE3.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-7m4DXn4B.js → use-latest-request-guard-AUvY3uHN.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-mRd0OnhT.js → use-limits-display-prefs-BB8SjBZP.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-CBae4AuZ.js → use-ordered-list-CKNhZ510.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-BXh1HaOk.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-DCweltax.js → use-session-efficiency-pref-C0qtk6TK.js} +1 -1
- package/dashboard/dist/assets/{use-transform-BPegjDim.js → use-transform-DYicUvFj.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-DKO2kwUE.js → use-usage-limits-CWRjxEix.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-5h4sZ7c9.js → useCurrency-LH_2wEBf.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-BELg6Qla.js → useTokenFormat-DKsWnBvy.js} +1 -1
- package/dashboard/dist/assets/{zap-q4Z6ZRMI.js → zap-CAoJCLuz.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/commands/device-login.js +3 -2
- package/src/commands/sync.js +34 -12
- package/src/lib/cloud-account.js +93 -39
- package/src/lib/function-url.js +27 -0
- package/src/lib/local-api.js +141 -31
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/telemetry.js +3 -2
- package/src/lib/upload-throttle.js +6 -0
- package/dashboard/dist/assets/CommunityStatsModal-Dhx0AXIU.js +0 -1
- package/dashboard/dist/assets/DashboardPage-CtrTRoRf.js +0 -1
- package/dashboard/dist/assets/DialogDescription-HFssj1J8.js +0 -1
- package/dashboard/dist/assets/SettingsPage-CL2euJqr.js +0 -2
- package/dashboard/dist/assets/__vite-browser-external-CAVICAeZ.js +0 -45
- package/dashboard/dist/assets/provider-display-Dmqqa8F4.js +0 -42
- package/dashboard/dist/assets/skills-api-CdNR4QHm.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-uScStq9r.js +0 -1
package/src/lib/cloud-account.js
CHANGED
|
@@ -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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
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() +
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 };
|
package/src/lib/local-api.js
CHANGED
|
@@ -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(
|
|
1348
|
-
return `${baseUrl}\0${
|
|
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
|
|
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
|
|
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 (!
|
|
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 (
|
|
1423
|
-
|
|
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
|
}
|