@runfusion/fusion 0.74.0-beta.2 → 0.74.0-beta.4

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 (127) hide show
  1. package/dist/bin.js +5416 -3166
  2. package/dist/child-process-worker.js +3540 -1656
  3. package/dist/client/.vite/manifest.json +272 -272
  4. package/dist/client/assets/AgentDetailView-BGXTnuBR.js +13 -0
  5. package/dist/client/assets/AgentDetailView-CH11Ydji.css +1 -0
  6. package/dist/client/assets/{AgentPermissionPolicyEditor-BycC7HhP.js → AgentPermissionPolicyEditor-DswM202p.js} +1 -1
  7. package/dist/client/assets/AgentsView-CEpZDrmC.js +24 -0
  8. package/dist/client/assets/{ChatView-DAsZY1We.js → ChatView-BXXX0q6G.js} +3 -3
  9. package/dist/client/assets/CommandCenter-CqjN1-9X.js +97 -0
  10. package/dist/client/assets/DevServerView-BUO7JRi6.js +1 -0
  11. package/dist/client/assets/{DirectoryPicker-DsCk3Nsw.js → DirectoryPicker-CiP4h5C7.js} +1 -1
  12. package/dist/client/assets/{DocumentsView-8EI6KNBY.js → DocumentsView-DwLD0h2S.js} +1 -1
  13. package/dist/client/assets/EvalsView-C9juKffD.js +1 -0
  14. package/dist/client/assets/{ExperimentalAgentOnboardingModal-ce6DROKD.js → ExperimentalAgentOnboardingModal-DYvw9TEh.js} +1 -1
  15. package/dist/client/assets/{GoalsView-BVlTtw-u.js → GoalsView-BD1AkfVN.js} +1 -1
  16. package/dist/client/assets/{InsightsView-BKQeROkb.js → InsightsView-Q0mu759R.js} +1 -1
  17. package/dist/client/assets/{MemoryView-DCzTnxsV.js → MemoryView-DCTqPpCG.js} +1 -1
  18. package/dist/client/assets/{PiExtensionsManager-C94HwDz5.js → PiExtensionsManager-CSe_uGfa.js} +1 -1
  19. package/dist/client/assets/{PluginManager-C3DegklW.js → PluginManager-DPm8YQC0.js} +1 -1
  20. package/dist/client/assets/{PullRequestView-CrE89UL7.js → PullRequestView-BpJgNDLH.js} +2 -2
  21. package/dist/client/assets/{ReportModal-BD1ZUDdV.js → ReportModal-iGmTZqiF.js} +1 -1
  22. package/dist/client/assets/ResearchView-BfgKB-6z.js +2 -0
  23. package/dist/client/assets/{SecretsView-6qK8Idb7.js → SecretsView-CqvQLK7z.js} +1 -1
  24. package/dist/client/assets/SessionTerminal-A9di8GAo.js +2 -0
  25. package/dist/client/assets/SettingsModal-BsCUsAXe.js +21 -0
  26. package/dist/client/assets/{SettingsModal-BkOSlfyr.js → SettingsModal-D-iyPGZE.js} +1 -1
  27. package/dist/client/assets/SettingsModal-DmfMotcd.css +1 -0
  28. package/dist/client/assets/{SettingsTextareaRow-Btyx5kaR.js → SettingsTextareaRow-DwgHxMMY.js} +1 -1
  29. package/dist/client/assets/{SetupWizardModal-Blhx-dRt.js → SetupWizardModal-CrSnLN44.js} +2 -2
  30. package/dist/client/assets/{SkillsView-DGE5kYc3.js → SkillsView-C_STy7KI.js} +1 -1
  31. package/dist/client/assets/{TodoView-YSGI7ATd.js → TodoView-BJJNGNLq.js} +1 -1
  32. package/dist/client/assets/{WorkflowNodeEditor-CZiEThu2.js → WorkflowNodeEditor-Jp5i_Vxu.js} +3 -3
  33. package/dist/client/assets/{WorkflowNodeEditor-nCx4nqoL.css → WorkflowNodeEditor-sZ96V3Kd.css} +1 -1
  34. package/dist/client/assets/{agent-import-generation-DQeNZMAm.js → agent-import-generation-DbVrl8ou.js} +1 -1
  35. package/dist/client/assets/{app-Dn1u7zJ9.js → app-BYs5Cx-j.js} +2 -2
  36. package/dist/client/assets/{app-DaikOPcV.js → app-CTV8PCYh.js} +3 -3
  37. package/dist/client/assets/{app-DJIiVHdA.js → app-CwdZxrJI.js} +2 -2
  38. package/dist/client/assets/{app-hQw_x2KJ.js → app-DE-MPJxA.js} +2 -2
  39. package/dist/client/assets/{app-DxTbBEMH.js → app-e8c4wb5t.js} +2 -2
  40. package/dist/client/assets/{app-DMZ_RusZ.js → app-yKugq_Ld.js} +2 -2
  41. package/dist/client/assets/{architectureDiagram-3BPJPVTR-DhaJNKa4.js → architectureDiagram-3BPJPVTR-Ctz8GX1N.js} +1 -1
  42. package/dist/client/assets/{blockDiagram-GPEHLZMM-pKpg7gJU.js → blockDiagram-GPEHLZMM-WI_YPphw.js} +1 -1
  43. package/dist/client/assets/{c4Diagram-AAUBKEIU-DVPXKJnr.js → c4Diagram-AAUBKEIU-BLwS5Zfh.js} +1 -1
  44. package/dist/client/assets/channel-CMMrQrQu.js +1 -0
  45. package/dist/client/assets/{chunk-2J33WTMH-CHt90T5z.js → chunk-2J33WTMH-DeXlX12-.js} +1 -1
  46. package/dist/client/assets/{chunk-4BX2VUAB-DsWFYy0a.js → chunk-4BX2VUAB-zvCEraJR.js} +1 -1
  47. package/dist/client/assets/{chunk-55IACEB6-D8XiXEo8.js → chunk-55IACEB6-BgZq96DR.js} +1 -1
  48. package/dist/client/assets/{chunk-727SXJPM-DlUYBn_Y.js → chunk-727SXJPM-CayoVGXL.js} +1 -1
  49. package/dist/client/assets/{chunk-AQP2D5EJ-BSdPOr9q.js → chunk-AQP2D5EJ-BmaktpVP.js} +1 -1
  50. package/dist/client/assets/{chunk-FMBD7UC4-B9IAeBld.js → chunk-FMBD7UC4-qYWgZPF8.js} +1 -1
  51. package/dist/client/assets/{chunk-ND2GUHAM-CmxnYQrR.js → chunk-ND2GUHAM-DIovauCc.js} +1 -1
  52. package/dist/client/assets/{chunk-QZHKN3VN-Dxp6Po_j.js → chunk-QZHKN3VN-DXWnr9LE.js} +1 -1
  53. package/dist/client/assets/classDiagram-4FO5ZUOK-CqxyehbN.js +1 -0
  54. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CqxyehbN.js +1 -0
  55. package/dist/client/assets/common-13IRj_PO.js +1 -0
  56. package/dist/client/assets/common-BBynATth.js +1 -0
  57. package/dist/client/assets/common-BG-vw0tL.js +1 -0
  58. package/dist/client/assets/common-DSo46rnH.js +1 -0
  59. package/dist/client/assets/common-DbOCE-0i.js +1 -0
  60. package/dist/client/assets/common-DnXF4Jmj.js +1 -0
  61. package/dist/client/assets/{cose-bilkent-S5V4N54A-BNl3bhpS.js → cose-bilkent-S5V4N54A-Dee9mTjK.js} +1 -1
  62. package/dist/client/assets/{dagre-BM42HDAG-DFs7WCcz.js → dagre-BM42HDAG-CU-v2zoi.js} +1 -1
  63. package/dist/client/assets/{dashboard-view-DnoTcnIo.js → dashboard-view-BM790i02.js} +1 -1
  64. package/dist/client/assets/{dashboard-view-ZGxLEL9r.js → dashboard-view-CG4rzThC.js} +1 -1
  65. package/dist/client/assets/{dashboard-view-vc8kgLMs.js → dashboard-view-COary9sK.js} +1 -1
  66. package/dist/client/assets/{diagram-2AECGRRQ-C2PVozvu.js → diagram-2AECGRRQ-DIqdnJ5B.js} +1 -1
  67. package/dist/client/assets/{diagram-5GNKFQAL-DFJ5b9H8.js → diagram-5GNKFQAL-ChpdUlpb.js} +1 -1
  68. package/dist/client/assets/{diagram-KO2AKTUF-D335CW-c.js → diagram-KO2AKTUF-BxmzOp-c.js} +1 -1
  69. package/dist/client/assets/{diagram-LMA3HP47-DSfhroAW.js → diagram-LMA3HP47-DLEx-0rl.js} +1 -1
  70. package/dist/client/assets/{diagram-OG6HWLK6-8iGveM-Z.js → diagram-OG6HWLK6-DEpkJ3cG.js} +1 -1
  71. package/dist/client/assets/{erDiagram-TEJ5UH35-Xc56MEEi.js → erDiagram-TEJ5UH35-C3Rjr78a.js} +1 -1
  72. package/dist/client/assets/{flowDiagram-I6XJVG4X-DjCU9WbD.js → flowDiagram-I6XJVG4X-aPbBbeiH.js} +1 -1
  73. package/dist/client/assets/{folder-open-BRrB44UX.js → folder-open-BnNbfyzc.js} +1 -1
  74. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CXQE1bea.js → ganttDiagram-6RSMTGT7-BEzAwvJ4.js} +1 -1
  75. package/dist/client/assets/{gitGraphDiagram-PVQCEYII--mrSABLC.js → gitGraphDiagram-PVQCEYII-Y4hB2swQ.js} +1 -1
  76. package/dist/client/assets/index-8wtfjIkg.js +2675 -0
  77. package/dist/client/assets/index-Bp3UBLOz.css +1 -0
  78. package/dist/client/assets/{infoDiagram-5YYISTIA-CTS_nljP.js → infoDiagram-5YYISTIA-pd3J7fRT.js} +1 -1
  79. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CpV5x3gk.js → ishikawaDiagram-YF4QCWOH-B968vfOH.js} +1 -1
  80. package/dist/client/assets/{journeyDiagram-JHISSGLW-BK2QCMnt.js → journeyDiagram-JHISSGLW-V4crhYKX.js} +1 -1
  81. package/dist/client/assets/{kanban-definition-UN3LZRKU-B4srpspY.js → kanban-definition-UN3LZRKU-Cl8LxR6k.js} +1 -1
  82. package/dist/client/assets/{mermaid.core-B8DVAt21.js → mermaid.core-rb_bKdHH.js} +4 -4
  83. package/dist/client/assets/{mindmap-definition-RKZ34NQL-CsmmiPpA.js → mindmap-definition-RKZ34NQL-Cnu-LPHz.js} +1 -1
  84. package/dist/client/assets/{pieDiagram-4H26LBE5-BahLDqf4.js → pieDiagram-4H26LBE5-C6hPYepr.js} +1 -1
  85. package/dist/client/assets/{puzzle-BFPQsuIa.js → puzzle-C8AvcmuM.js} +1 -1
  86. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Bo3u0wTU.js → quadrantDiagram-W4KKPZXB-CUImgAi-.js} +1 -1
  87. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DYkYsz7d.js → requirementDiagram-4Y6WPE33-DzDLxIYz.js} +1 -1
  88. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BCbh0BSY.js → sankeyDiagram-5OEKKPKP-DSr_oIy8.js} +1 -1
  89. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-C2AP9GlE.js → sequenceDiagram-3UESZ5HK-DO2E98Hv.js} +1 -1
  90. package/dist/client/assets/{shield-alert-tlZOi02c.js → shield-alert-Byjz1VmT.js} +1 -1
  91. package/dist/client/assets/{standing-instructions-template-C0Rzv9Mr.js → standing-instructions-template-Cf9fZ8bv.js} +1 -1
  92. package/dist/client/assets/{stateDiagram-AJRCARHV-CARkH0ug.js → stateDiagram-AJRCARHV-BYPlDS4u.js} +1 -1
  93. package/dist/client/assets/{stateDiagram-v2-BHNVJYJU-C2He043y.js → stateDiagram-v2-BHNVJYJU-Ce_6vh1Q.js} +1 -1
  94. package/dist/client/assets/{timeline-definition-PNZ67QCA-MvbYneEM.js → timeline-definition-PNZ67QCA-CG1FeaKV.js} +1 -1
  95. package/dist/client/assets/{upload-DWK0F17Z.js → upload-yPsE_DRN.js} +1 -1
  96. package/dist/client/assets/{users-1AkDfM14.js → users-C_YmWx9d.js} +1 -1
  97. package/dist/client/assets/{vennDiagram-CIIHVFJN-DOjzzLqc.js → vennDiagram-CIIHVFJN-JLUXqFyb.js} +1 -1
  98. package/dist/client/assets/{wardley-L42UT6IY-Di70vhu2.js → wardley-L42UT6IY-D_UBzrZ2.js} +1 -1
  99. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-nK6ARhDv.js → wardleyDiagram-YWT4CUSO-BzwMrdpL.js} +1 -1
  100. package/dist/client/assets/{xychartDiagram-2RQKCTM6-C7_y76B7.js → xychartDiagram-2RQKCTM6-C5TjbAcZ.js} +1 -1
  101. package/dist/client/index.html +2 -2
  102. package/dist/client/sw.js +633 -13
  103. package/dist/client/version.json +1 -1
  104. package/dist/extension.js +3999 -2008
  105. package/package.json +1 -1
  106. package/skill/fusion/references/engine-tools.md +1 -1
  107. package/dist/client/assets/AgentDetailView-CoeSgMXS.css +0 -1
  108. package/dist/client/assets/AgentDetailView-DH4Cn5iG.js +0 -13
  109. package/dist/client/assets/AgentsView-Dkij8Sm8.js +0 -24
  110. package/dist/client/assets/CommandCenter-jTzBo-pg.js +0 -97
  111. package/dist/client/assets/DevServerView-CxlacYJp.js +0 -1
  112. package/dist/client/assets/EvalsView-kYnaIk11.js +0 -1
  113. package/dist/client/assets/ResearchView-eMiRw1EM.js +0 -2
  114. package/dist/client/assets/SessionTerminal-DtFLyUaK.js +0 -2
  115. package/dist/client/assets/SettingsModal-BTfZlghk.css +0 -1
  116. package/dist/client/assets/SettingsModal-DSq-_qwo.js +0 -21
  117. package/dist/client/assets/channel-xFBfK0sX.js +0 -1
  118. package/dist/client/assets/classDiagram-4FO5ZUOK-BFBVD968.js +0 -1
  119. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-BFBVD968.js +0 -1
  120. package/dist/client/assets/common-BDp2y6CF.js +0 -1
  121. package/dist/client/assets/common-BnEo5fLN.js +0 -1
  122. package/dist/client/assets/common-Bzr_Y8h4.js +0 -1
  123. package/dist/client/assets/common-CvwyFzfv.js +0 -1
  124. package/dist/client/assets/common-CztCXRkz.js +0 -1
  125. package/dist/client/assets/common-DPe2mbb7.js +0 -1
  126. package/dist/client/assets/index-C2xsZoNG.js +0 -2671
  127. package/dist/client/assets/index-Dv-95s50.css +0 -1
package/dist/client/sw.js CHANGED
@@ -1,4 +1,489 @@
1
- const CACHE_NAME = "fusion-cache-v5";
1
+ /*
2
+ FNXC:PWAOffline 2026-07-26-18:05:
3
+ Bumped v6 -> v7 to EVACUATE already-persisted credentials. Until this change every successful GET /api/*
4
+ response was written to Cache Storage, including GET /api/settings and /api/settings/global, whose bodies
5
+ carry plaintext `daemonToken`, `githubAuthToken`, `gitlabAuthToken`, and `ntfyAccessToken`. Adding the
6
+ allow-list below stops NEW writes but cannot reach entries an installed worker already wrote; `activate`
7
+ deletes every cache whose key !== CACHE_NAME, so the rename is the only mechanism that removes them from
8
+ durable origin storage on an existing install. Any future change to what may be cached must bump this too.
9
+ */
10
+ const CACHE_NAME = "fusion-cache-v7";
11
+
12
+ /*
13
+ FNXC:PWAOffline 2026-07-26-10:12:
14
+ Mobile browsers (iOS Safari tabs, iOS installed PWAs, Chrome Android) discard backgrounded pages under memory pressure. When the user returns, the tab is re-navigated from scratch, so restore cost IS the perceived bug: a network-first bundle refetch pulls ~2.8MB raw / 750KB gzipped of entry chunk (14MB across ~130 chunks) over a just-waking radio before anything paints, producing the white splash.
15
+
16
+ Vite emits content-hashed asset filenames (`[name]-[hash].[ext]`), so a hashed URL's bytes can never change — a new build emits a NEW url. Those are therefore safe to serve cache-first: a cache hit is authoritative, and a post-deploy hash simply misses and is fetched. HASHED_ASSET_PATTERN is the conservative gate for that guarantee; anything under /assets/ that does not prove it carries a hash stays network-first.
17
+
18
+ Conservatism rules for the pattern: the trailing dash-delimited segment of the basename must be >=8 chars of Vite's base64url hash alphabet (dash permitted only as the final character, which keeps the segment genuinely trailing instead of letting the match span earlier dashes) AND must contain at least one uppercase letter or digit. Human-authored kebab-case basenames (`vendor-runtime.js`, `foo-bar-baz.css`) fail the mixed-alphabet check and stay network-first. A real hash fails the pattern only when it embeds a dash before its last character (~10% of hashes) or is all-lowercase-alpha ((28/64)^8, ~0.1%); both are harmless downgrades to the previous network-first behavior, never a stale-serve.
19
+ */
20
+ const HASHED_ASSET_PATTERN = /-(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_]{7,}[A-Za-z0-9_-]?\.[A-Za-z0-9]+$/;
21
+
22
+ /**
23
+ * FNXC:PWAOffline 2026-07-26-10:18:
24
+ * Cache-first eligibility. Two admissible classes:
25
+ * 1. Content-hashed build assets under /assets/ — immutable by construction (see HASHED_ASSET_PATTERN).
26
+ * 2. Font requests (`request.destination === "font"`, e.g. the preloaded /fonts/SymbolsNerdFontMono-Regular.ttf) — not hash-named, but immutable in practice; a replaced font is picked up on the next CACHE_NAME bump. Blocking first paint on a font refetch over a waking radio is not worth that staleness window.
27
+ *
28
+ * Deliberately NOT admissible to the CACHE-FIRST path: the navigation shell, /api/*, and non-hashed
29
+ * scripts/styles. (Corrected 2026-07-26-18:05: the previous wording — "Deliberately NOT admissible" —
30
+ * read as a blanket exclusion from the cache. It never was one: those classes are merely network-FIRST
31
+ * and were still written to the same cache as an offline fallback. That misreading is how credential-
32
+ * bearing /api/ responses sat in durable storage unnoticed. What may be WRITTEN for /api/ is decided
33
+ * solely by isCacheableApiUrl() below, not by this predicate.)
34
+ *
35
+ * @param {URL} url
36
+ * @param {Request} request
37
+ * @returns {boolean}
38
+ */
39
+ function isImmutableAssetRequest(url, request) {
40
+ if (request.destination === "font") {
41
+ return true;
42
+ }
43
+ return url.pathname.startsWith("/assets/") && HASHED_ASSET_PATTERN.test(url.pathname);
44
+ }
45
+
46
+ /*
47
+ FNXC:PWAOffline 2026-07-26-14:05:
48
+ Cache-first hashed assets made cache SIZE load-bearing, and nothing evicted within a generation — `activate` only drops caches whose key !== CACHE_NAME, and CACHE_NAME is a hand-bumped literal. Fusion is self-hosted and rebuilt constantly (Command Center "Rebuild + restart" reloads the page onto a new build), each build emitting ~130 fresh hashed chunks (~14MB). Every prior build's chunks stayed resident forever, so a week of daily rebuilds parks ~100MB of dead chunks on the origin. On iOS the origin quota is enforced per-origin and eviction is ALL-OR-NOTHING for the bucket: blowing it wipes localStorage too, taking out the SWR board snapshot and the `kb-dashboard-*` preferences that the rest of the restore work depends on. So the unbounded cache does not merely waste disk — it can destroy the very state that makes restore cheap.
49
+
50
+ Chosen bound: an insertion-ordered entry cap over hashed /assets/ entries only.
51
+ - Why not "keep only what the current index.html references": index.html names only the entry chunk; the other ~129 are lazy imports discovered inside JS. Reconstructing that graph in the SW means parsing bundles — fragile, and a mis-parse strands the user on a failed dynamic import.
52
+ - Why not a CACHE_NAME-per-build bump: correct in principle, but CACHE_NAME is a literal in a static, untemplated file. Doing it properly needs build-time templating of sw.js (a Vite plugin emitting the build hash) — real build wiring, not a one-line change, and out of scope here. Recorded as the eventual better answer rather than faked with a hardcoded hash.
53
+ - Why insertion order is the right recency proxy: hashed URLs are immutable, so an entry is only ever inserted once, at the moment its build first ran. Cache API `keys()` is specified to return entries in insertion order, so oldest-first == oldest-build-first. Evicting from the front removes dead builds before live ones, with no metadata bookkeeping to persist or corrupt.
54
+
55
+ Partial safety against evicting an asset the RUNNING build still needs: every hashed URL this service-worker SESSION has served (hit or miss) is recorded in `sessionReferencedAssets` and is exempt from eviction while it remains in that set (itself bounded — see MAX_SESSION_REFERENCED_ASSETS).
56
+
57
+ CORRECTION 2026-07-26-18:05 — the previous wording ("A chunk the current page has already loaded is thereby pinned") asserted a guarantee this code does not deliver, and the claim must not be reintroduced. `sessionReferencedAssets` is an in-memory Set in the worker's global scope, and browsers idle-terminate a service worker after ~30s of no events — the normal state of a BACKGROUNDED tab, i.e. exactly the scenario this feature exists for. The worker that wakes to serve the restored tab is a cold start with an EMPTY set, so on the first prune after every wake NOTHING is pinned.
58
+
59
+ What actually bounds the residual risk, and why it was left as-is rather than papered over:
60
+ - Eviction is oldest-INSERTED-first, and the running build's chunks are the most recently inserted, so the natural ordering already protects them in the common case. The pin set only adds protection in the uncommon inversion where a live build's chunks are older than some other build's — e.g. a long-lived tab on build N while another tab loaded build N+1.
61
+ - Durably persisting the set would re-pin a PREVIOUS build's chunks on every restart with no way to tell which build is now live, converting a bounded eviction risk into an unbounded pinning one. Not worth it for the inversion case.
62
+ - The failure mode is a re-fetch, not a break: an evicted chunk still on the server is fetched again. The only unrecoverable case (chunk gone from the server) is the stale-chunk case `versionCheck.ts`'s handleChunkLoadError/isStaleChunkError already handles, and it would occur with no cache at all.
63
+ */
64
+ const MAX_IMMUTABLE_CACHE_ENTRIES = 200;
65
+
66
+ /*
67
+ FNXC:PWAOffline 2026-07-26-14:05:
68
+ The eviction-exemption set must itself be bounded, or it re-opens the hole it exists to make safe: a
69
+ service worker that survives many rebuilds (they are cheap to keep alive under active use) would
70
+ accumulate every build's served chunks as permanently-exempt and the cap could never bite. Capping it
71
+ drop-oldest makes the resident set provably bounded at MAX_IMMUTABLE_CACHE_ENTRIES +
72
+ MAX_SESSION_REFERENCED_ASSETS in the worst case rather than unbounded. Dropping the oldest exemption
73
+ is safe for the same reason eviction is: a chunk evicted but still needed is re-fetched from the
74
+ network, and the only unrecoverable case (build gone from the server) is the stale-chunk case
75
+ versionCheck.ts handles and would occur with no cache at all.
76
+ */
77
+ const MAX_SESSION_REFERENCED_ASSETS = 200;
78
+
79
+ /** @type {Set<string>} URLs of hashed assets served during this SW session; exempt from eviction. Set iteration is insertion-ordered, so the first entry is the oldest. */
80
+ const sessionReferencedAssets = new Set();
81
+
82
+ /**
83
+ * @param {string} requestUrl
84
+ * @returns {void}
85
+ */
86
+ function rememberSessionReferencedAsset(requestUrl) {
87
+ sessionReferencedAssets.delete(requestUrl);
88
+ sessionReferencedAssets.add(requestUrl);
89
+ while (sessionReferencedAssets.size > MAX_SESSION_REFERENCED_ASSETS) {
90
+ const oldest = sessionReferencedAssets.values().next();
91
+ if (oldest.done) {
92
+ break;
93
+ }
94
+ sessionReferencedAssets.delete(oldest.value);
95
+ }
96
+ }
97
+
98
+ /*
99
+ FNXC:PWAOffline 2026-07-26-15:40:
100
+ The hashed-asset cap above left the OTHER unbounded writer in this file untouched: every GET /api/ response is put into the same CACHE_NAME and nothing ever evicted those entries. The dashboard issues a large and open-ended set of distinct /api/ URLs (per project, per task, per query string), so the API half grew without limit — the same iOS all-or-nothing per-origin quota hazard the asset cap exists to prevent, where blowing the bucket wipes localStorage (SWR board snapshot, `kb-dashboard-*` prefs) alongside the caches.
101
+
102
+ Size is only half the defect. This cache is the OFFLINE FALLBACK: on a network failure a cached entry is served transparently to the app, which cannot tell it apart from a live response. With no expiry, an hours-old task list could be handed to the board during a brief radio blip — a correctness bug worse than the failed fetch it papers over. So the API entries get BOTH a count cap and a freshness bound.
103
+
104
+ Why not "stop caching /api/ entirely": the fallback is cheap and does earn a narrow keep. Warm hydration is NOT what depends on it — the app hydrates from its own localStorage/session snapshots — but a mobile tab restored onto a waking radio issues its first API burst before the connection settles, and a seconds-to-minutes-old response there is strictly better than an error state. That value evaporates fast, hence a short TTL rather than an unbounded fallback.
105
+
106
+ Freshness accounting, in precedence order:
107
+ 1. `apiCacheTimestamps` — recorded at put time, same clock as the read, exact.
108
+ 2. the response's `Date` header — Fusion serves its own API from Node, which always sets it, so a service worker that restarted (the common case after a discard) can still prove age.
109
+ 3. otherwise UNKNOWN, which counts as stale. Fail-closed: an entry whose age cannot be proven is never served. The cost is one failed request that would have failed anyway without a cache; the alternative is exactly the unbounded-staleness bug being fixed.
110
+ A negative age (client clock behind the server's) is also treated as unprovable rather than clamped to fresh, so clock skew cannot mint an immortal entry.
111
+ */
112
+ const MAX_API_CACHE_ENTRIES = 100;
113
+
114
+ /** Freshness bound for the /api/ offline fallback. Long enough to cover a radio blip, short enough that no stale board can surface. */
115
+ const MAX_API_CACHE_AGE_MS = 5 * 60 * 1000;
116
+
117
+ /*
118
+ FNXC:PWAOffline 2026-07-26-18:05:
119
+ WHICH /api/ responses may be written at all. Previously: every successful GET /api/*. That included
120
+ GET /api/settings and /api/settings/global, which return `daemonToken`, `githubAuthToken`,
121
+ `gitlabAuthToken`, and `ntfyAccessToken` as plaintext — so dashboard credentials were persisted to
122
+ durable Cache Storage, survived logout, token rotation, and project switch (nothing in the app touched
123
+ the caches API until the purge added in swrCache.ts), and were readable by any script on the origin.
124
+ The entry cap and TTL added earlier bound size and staleness; neither is a confidentiality control.
125
+
126
+ ALLOW-list, not deny-list. A deny-list fails OPEN: every endpoint added later is cached until someone
127
+ remembers to exclude it, which is exactly how the credential endpoints got here. The allow-list fails
128
+ closed — a new endpoint is network-only until someone argues it into this list.
129
+
130
+ What earns a place: only reads whose offline fallback has real value on the mobile-restore path this
131
+ whole feature targets (a restored tab issues its first API burst before the radio settles, where a
132
+ seconds-old body beats an error state) AND whose bodies carry no credential, secret, or token.
133
+ - `/api/tasks` and `/api/tasks/<id>`: the board and the card the user was looking at.
134
+ - `/api/projects`: the project list the board is keyed by.
135
+ Everything else — settings, secrets, auth, agents, git, chat, artifacts, attachments — goes straight to
136
+ the network with no cache write and no fallback read. The cost is an error state on a blip for those
137
+ views; the alternative is deciding, endpoint by endpoint forever, whether a body is sensitive.
138
+ */
139
+ const API_CACHE_ALLOWLIST = [
140
+ /^\/api\/tasks$/,
141
+ /^\/api\/tasks\/[^/]+$/,
142
+ /^\/api\/projects$/,
143
+ ];
144
+
145
+ /*
146
+ FNXC:PWAOffline 2026-07-26-18:05:
147
+ Defense in depth over the allow-list, not a substitute for it. `appendTokenQuery` (auth.ts) puts the
148
+ dashboard bearer token in the URL as `fn_token=<token>` for transports that cannot set headers
149
+ (TaskDetailModal attachments, artifactMediaUrl, file downloads). A cache key IS the URL, so caching such
150
+ a request would persist the token itself as a durable key. None of those paths are allow-listed today;
151
+ this guard makes that a property of the code rather than of the current allow-list's shape.
152
+ */
153
+ const CREDENTIAL_QUERY_PARAMS = ["fn_token", "token", "ticket", "access_token"];
154
+
155
+ /**
156
+ * FNXC:PWAOffline 2026-07-26-18:05:
157
+ * @param {string} requestUrl
158
+ * @returns {boolean} true when this URL may be written to / read from the offline fallback cache.
159
+ */
160
+ function isCacheableApiUrl(requestUrl) {
161
+ try {
162
+ const url = new URL(requestUrl);
163
+ for (const param of CREDENTIAL_QUERY_PARAMS) {
164
+ if (url.searchParams.has(param)) {
165
+ return false;
166
+ }
167
+ }
168
+ return API_CACHE_ALLOWLIST.some((pattern) => pattern.test(url.pathname));
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /*
175
+ FNXC:PWAOffline 2026-07-26-18:05:
176
+ Durable put-time stamp. `apiCacheTimestamps` is a plain Map in the service worker's global scope, and
177
+ browsers idle-terminate a service worker after ~30s — which is precisely what happens while the tab is
178
+ backgrounded, the scenario this feature exists for. So on the next wake the SW cold-starts with that Map
179
+ EMPTY and every surviving entry's age had to be proven from the server `Date` header alone. That header
180
+ is a real fallback but it is the ORIGIN's send time, not our put time, and an intermediary or a response
181
+ replayed from an HTTP cache can make it arbitrarily older than the entry. Stamping our own header at put
182
+ time makes age provable across a worker restart with the same clock the read uses.
183
+
184
+ Fails soft by design: if `Response`/`Headers` are unavailable, the status cannot carry a body (204/304),
185
+ or the body read throws, the caller falls back to putting the plain clone and age falls back to `Date`.
186
+ A failure here must never cost the response itself.
187
+ */
188
+ const SW_CACHED_AT_HEADER = "x-fusion-sw-cached-at";
189
+
190
+ /**
191
+ * @param {Response} response
192
+ * @returns {Promise<Response|undefined>} a stamped copy, or undefined when stamping is not possible.
193
+ */
194
+ async function buildStampedResponse(response) {
195
+ if (typeof Response !== "function" || typeof Headers !== "function") {
196
+ return undefined;
197
+ }
198
+ if (!response || !response.headers || typeof response.clone !== "function") {
199
+ return undefined;
200
+ }
201
+ // Response's constructor rejects a body for these statuses; leave them to the plain-clone path.
202
+ if (response.status === 204 || response.status === 205 || response.status === 304 || !response.status) {
203
+ return undefined;
204
+ }
205
+ const headers = new Headers(response.headers);
206
+ headers.set(SW_CACHED_AT_HEADER, String(Date.now()));
207
+ const body = await response.clone().arrayBuffer();
208
+ return new Response(body, {
209
+ status: response.status,
210
+ statusText: response.statusText,
211
+ headers,
212
+ });
213
+ }
214
+
215
+ /*
216
+ FNXC:PWAOffline 2026-07-26-15:40:
217
+ Unlike hashed assets (written only on the cold path, when a new build arrives), EVERY api response is a
218
+ cache put, so scheduling a full keys() scan per put would put an O(cache) walk behind ordinary polling.
219
+ Pruning every Nth put bounds the API entry count at limit + N instead of limit exactly, which is fine:
220
+ the cap is a quota guard, not a contract. Expiry is NOT lazy in the way that matters — it is enforced on
221
+ every read of the fallback path; the prune-side expiry sweep only reclaims disk.
222
+ */
223
+ const API_PRUNE_PUT_INTERVAL = 10;
224
+
225
+ /** @type {Map<string, number>} api URL -> epoch ms of its cache put. Insertion-ordered, so the first entry is the oldest. */
226
+ const apiCacheTimestamps = new Map();
227
+
228
+ let apiPutsSincePrune = 0;
229
+
230
+ /**
231
+ * @param {string} requestUrl
232
+ * @returns {void}
233
+ */
234
+ function rememberApiCacheTimestamp(requestUrl) {
235
+ apiCacheTimestamps.delete(requestUrl);
236
+ apiCacheTimestamps.set(requestUrl, Date.now());
237
+ while (apiCacheTimestamps.size > MAX_API_CACHE_ENTRIES) {
238
+ const oldest = apiCacheTimestamps.keys().next();
239
+ if (oldest.done) {
240
+ break;
241
+ }
242
+ apiCacheTimestamps.delete(oldest.value);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @param {string} requestUrl
248
+ * @param {Response|undefined} cachedResponse
249
+ * @returns {number|undefined} epoch ms the entry was cached, or undefined when unprovable.
250
+ */
251
+ function readApiCachedAtMillis(requestUrl, cachedResponse) {
252
+ const remembered = apiCacheTimestamps.get(requestUrl);
253
+ if (typeof remembered === "number") {
254
+ return remembered;
255
+ }
256
+ try {
257
+ const headers = cachedResponse && cachedResponse.headers ? cachedResponse.headers : null;
258
+ // FNXC:PWAOffline 2026-07-26-18:05: our own put-time stamp outranks `Date` — it survives the
259
+ // SW idle-termination that empties apiCacheTimestamps and is on the same clock as this read.
260
+ const stamped = headers ? headers.get(SW_CACHED_AT_HEADER) : null;
261
+ if (stamped) {
262
+ const parsedStamp = Number(stamped);
263
+ if (Number.isFinite(parsedStamp)) {
264
+ return parsedStamp;
265
+ }
266
+ }
267
+ const dateHeader = headers ? headers.get("date") : null;
268
+ if (dateHeader) {
269
+ const parsed = Date.parse(dateHeader);
270
+ if (Number.isFinite(parsed)) {
271
+ return parsed;
272
+ }
273
+ }
274
+ } catch (headerError) {
275
+ console.warn("[sw] api cache date header read failed", headerError);
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ /**
281
+ * @param {number|undefined} cachedAtMillis
282
+ * @returns {boolean} true only when the entry's age is provable AND within the freshness bound.
283
+ */
284
+ function isFreshApiCacheEntry(cachedAtMillis) {
285
+ if (typeof cachedAtMillis !== "number" || !Number.isFinite(cachedAtMillis)) {
286
+ return false;
287
+ }
288
+ const age = Date.now() - cachedAtMillis;
289
+ return age >= 0 && age <= MAX_API_CACHE_AGE_MS;
290
+ }
291
+
292
+ /**
293
+ * @param {string} requestUrl
294
+ * @returns {boolean}
295
+ */
296
+ function isApiCacheUrl(requestUrl) {
297
+ try {
298
+ const pathname = new URL(requestUrl).pathname;
299
+ // /api/events* never reaches the cache (SSE bypasses the SW), so it is not a prunable class.
300
+ return pathname.startsWith("/api/") && pathname !== "/api/events" && !pathname.startsWith("/api/events/");
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ /** Prune runs are single-flight; overlapping cold-path misses must not scan/delete concurrently. */
307
+ let immutablePruneInFlight = false;
308
+
309
+ /**
310
+ * FNXC:PWAOffline 2026-07-26-14:05:
311
+ * URL-only classifier for prunable entries. Deliberately does NOT reuse isImmutableAssetRequest():
312
+ * that one consults `request.destination`, which is not guaranteed to survive a round trip through
313
+ * the Cache API on a `keys()` result. Fonts therefore fall outside the prunable set — there are a
314
+ * handful of them and they are not the growth term.
315
+ *
316
+ * @param {string} requestUrl
317
+ * @returns {boolean}
318
+ */
319
+ function isPrunableAssetUrl(requestUrl) {
320
+ try {
321
+ const pathname = new URL(requestUrl).pathname;
322
+ return pathname.startsWith("/assets/") && HASHED_ASSET_PATTERN.test(pathname);
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+
328
+ /*
329
+ FNXC:PWAOffline 2026-07-26-15:40:
330
+ Both bounded classes (hashed assets, /api/ responses) share ONE policy-driven sweep over ONE keys()
331
+ scan rather than two parallel implementations walking the cache separately. A policy declares which
332
+ URLs it owns, its entry cap, which entries are pinned, and which are provably expired.
333
+
334
+ `isExpired` is deliberately allowed to answer only for entries whose age is PROVABLE. An /api/ entry
335
+ with no recorded timestamp is never served (see isFreshApiCacheEntry) but is also not force-evicted
336
+ here; the cap reclaims it in insertion order. This keeps the sweep from deleting entries it cannot
337
+ reason about — e.g. those written by a previous SW session — as a side effect of an asset prune.
338
+
339
+ Amended 2026-07-26-18:05: be explicit that after an idle-termination cold start BOTH in-memory
340
+ structures are empty, so a prune then sees zero pinned assets and zero provably-expired API entries and
341
+ degrades to a pure oldest-first cap sweep. That is correct behavior, not a gap: the cap is the quota
342
+ guard, and the freshness guarantee lives on the READ path (isFreshApiCacheEntry via the durable
343
+ SW_CACHED_AT_HEADER stamp), which never depends on these maps surviving. `isExpired` deliberately does
344
+ not open each entry to read that header — that would turn the sweep into an O(cache) match() storm on
345
+ the hot path to reclaim disk it will reclaim by cap anyway.
346
+
347
+ Entries outside every policy (the navigation shell, unhashed assets, fonts, icons) are a small fixed
348
+ set and stay untouched, as before.
349
+
350
+ @type {Array<{name: string, limit: number, owns: (url: string) => boolean, isPinned: (url: string) => boolean, isExpired: (url: string) => boolean, forget: (url: string) => void}>}
351
+ */
352
+ const CACHE_PRUNE_POLICIES = [
353
+ {
354
+ name: "immutable-asset",
355
+ limit: MAX_IMMUTABLE_CACHE_ENTRIES,
356
+ owns: isPrunableAssetUrl,
357
+ isPinned: (url) => sessionReferencedAssets.has(url),
358
+ isExpired: () => false,
359
+ forget: () => undefined,
360
+ },
361
+ {
362
+ name: "api",
363
+ limit: MAX_API_CACHE_ENTRIES,
364
+ owns: isApiCacheUrl,
365
+ isPinned: () => false,
366
+ isExpired: (url) => {
367
+ const cachedAt = apiCacheTimestamps.get(url);
368
+ return typeof cachedAt === "number" && !isFreshApiCacheEntry(cachedAt);
369
+ },
370
+ forget: (url) => {
371
+ apiCacheTimestamps.delete(url);
372
+ },
373
+ },
374
+ ];
375
+
376
+ /**
377
+ * FNXC:PWAOffline 2026-07-26-14:05 (generalized 2026-07-26-15:40):
378
+ * Evict per policy until each bounded class is back under its cap: provably-expired entries first
379
+ * (they can never be served, so they are pure reclaimed quota), then oldest-inserted, skipping
380
+ * anything pinned. If every over-cap entry is pinned the sweep simply does less work than requested —
381
+ * never evicting a live chunk is more important than hitting the cap exactly. Fully defensive: it is
382
+ * invoked fire-and-forget so a throw, a rejected delete, or a slow keys() scan can never delay or
383
+ * fail the fetch response it was triggered from.
384
+ *
385
+ * @param {Cache} cache
386
+ * @returns {Promise<void>}
387
+ */
388
+ async function pruneBoundedCacheEntries(cache) {
389
+ if (immutablePruneInFlight) {
390
+ return;
391
+ }
392
+ immutablePruneInFlight = true;
393
+ try {
394
+ const keys = await cache.keys();
395
+ const buckets = CACHE_PRUNE_POLICIES.map(() => ({ total: 0, expired: [], evictable: [] }));
396
+
397
+ for (const cachedRequest of keys) {
398
+ if (!cachedRequest) {
399
+ continue;
400
+ }
401
+ for (let index = 0; index < CACHE_PRUNE_POLICIES.length; index += 1) {
402
+ const policy = CACHE_PRUNE_POLICIES[index];
403
+ if (!policy.owns(cachedRequest.url)) {
404
+ continue;
405
+ }
406
+ const bucket = buckets[index];
407
+ bucket.total += 1;
408
+ if (policy.isExpired(cachedRequest.url)) {
409
+ bucket.expired.push(cachedRequest);
410
+ } else if (!policy.isPinned(cachedRequest.url)) {
411
+ bucket.evictable.push(cachedRequest);
412
+ }
413
+ break;
414
+ }
415
+ }
416
+
417
+ for (let index = 0; index < CACHE_PRUNE_POLICIES.length; index += 1) {
418
+ const policy = CACHE_PRUNE_POLICIES[index];
419
+ const bucket = buckets[index];
420
+ let overflow = bucket.total - policy.limit;
421
+
422
+ // Expired entries are dropped regardless of overflow; they are unservable dead weight.
423
+ for (const cachedRequest of bucket.expired) {
424
+ try {
425
+ await cache.delete(cachedRequest);
426
+ policy.forget(cachedRequest.url);
427
+ overflow -= 1;
428
+ } catch (deleteError) {
429
+ console.warn(`[sw] ${policy.name} expired eviction failed`, deleteError);
430
+ }
431
+ }
432
+
433
+ for (const cachedRequest of bucket.evictable) {
434
+ if (overflow <= 0) {
435
+ break;
436
+ }
437
+ try {
438
+ await cache.delete(cachedRequest);
439
+ policy.forget(cachedRequest.url);
440
+ overflow -= 1;
441
+ } catch (deleteError) {
442
+ console.warn(`[sw] ${policy.name} eviction failed`, deleteError);
443
+ }
444
+ }
445
+ }
446
+ } catch (error) {
447
+ console.warn("[sw] cache prune failed", error);
448
+ } finally {
449
+ immutablePruneInFlight = false;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * FNXC:PWAOffline 2026-07-26-14:05:
455
+ * Fire-and-forget prune trigger. Runs on the cold path only (after a cache MISS populated a new
456
+ * entry, i.e. exactly when a new build is arriving) and on activate. Not awaited: awaiting would put
457
+ * an O(cache) keys() scan in front of each of a new build's ~130 first-load chunk responses.
458
+ *
459
+ * @param {Cache} cache
460
+ * @returns {void}
461
+ */
462
+ function schedulePrune(cache) {
463
+ try {
464
+ void pruneBoundedCacheEntries(cache);
465
+ } catch (error) {
466
+ console.warn("[sw] cache prune scheduling failed", error);
467
+ }
468
+ }
469
+
470
+ /**
471
+ * FNXC:PWAOffline 2026-07-26-15:40:
472
+ * Throttled prune trigger for the /api/ hot path — see API_PRUNE_PUT_INTERVAL. The counter advances
473
+ * before the guard so a throwing prune cannot wedge the interval.
474
+ *
475
+ * @param {Cache} cache
476
+ * @returns {void}
477
+ */
478
+ function scheduleApiPrune(cache) {
479
+ apiPutsSincePrune += 1;
480
+ if (apiPutsSincePrune < API_PRUNE_PUT_INTERVAL) {
481
+ return;
482
+ }
483
+ apiPutsSincePrune = 0;
484
+ schedulePrune(cache);
485
+ }
486
+
2
487
  const APP_SHELL_URLS = [
3
488
  "/",
4
489
  "/index.html",
@@ -8,10 +493,38 @@ const APP_SHELL_URLS = [
8
493
  "/icons/icon-512.png",
9
494
  ];
10
495
 
496
+ /*
497
+ FNXC:PWAOffline 2026-07-26-18:05:
498
+ Operator-triggered Cache Storage purge, the service-worker half of `clearAllLocalCache()` (swrCache.ts).
499
+ Settings -> "Clear all cached data" wipes localStorage and then immediately reloads the page, which can
500
+ abort an in-page `caches.delete()` mid-flight. The service worker is NOT torn down by that reload, so
501
+ routing the purge through it is what makes the delete actually land. The page still performs its own
502
+ direct purge as well: a page with no controlling worker (first load, dev without SW) would otherwise get
503
+ no purge at all. Both paths are idempotent — deleting an absent cache is a no-op.
504
+ */
505
+ const PURGE_CACHES_MESSAGE = "PURGE_CACHES";
506
+
11
507
  self.addEventListener("message", (event) => {
12
508
  if (event.data && event.data.type === "SKIP_WAITING") {
13
509
  self.skipWaiting();
14
510
  }
511
+ if (event.data && event.data.type === PURGE_CACHES_MESSAGE) {
512
+ const purge = (async () => {
513
+ try {
514
+ const keys = await caches.keys();
515
+ await Promise.all(keys.map((key) => caches.delete(key)));
516
+ // In-memory bookkeeping describes entries that no longer exist; leaving it would let a
517
+ // subsequent read believe a deleted URL still has a provable put time.
518
+ apiCacheTimestamps.clear();
519
+ sessionReferencedAssets.clear();
520
+ } catch (error) {
521
+ console.warn("[sw] cache purge failed", error);
522
+ }
523
+ })();
524
+ if (event.waitUntil) {
525
+ event.waitUntil(purge);
526
+ }
527
+ }
15
528
  });
16
529
 
17
530
  self.addEventListener("install", (event) => {
@@ -35,6 +548,14 @@ self.addEventListener("activate", (event) => {
35
548
  .filter((key) => key !== CACHE_NAME)
36
549
  .map((key) => caches.delete(key)),
37
550
  );
551
+ // FNXC:PWAOffline 2026-07-26-14:05: cross-generation eviction above only fires on a
552
+ // CACHE_NAME bump; this bounds the CURRENT generation on every SW activation too.
553
+ try {
554
+ const cache = await caches.open(CACHE_NAME);
555
+ await pruneBoundedCacheEntries(cache);
556
+ } catch (pruneError) {
557
+ console.warn("[sw] activate prune failed", pruneError);
558
+ }
38
559
  await self.clients.claim();
39
560
  } catch (error) {
40
561
  console.warn("[sw] activate cleanup failed", error);
@@ -64,8 +585,10 @@ self.addEventListener("fetch", (event) => {
64
585
  const isBuiltAssetRequest =
65
586
  url.pathname.startsWith("/assets/") ||
66
587
  request.destination === "script" ||
67
- request.destination === "style" ||
68
- request.destination === "font";
588
+ request.destination === "style";
589
+ // NOTE: `request.destination === "font"` used to be listed here. Fonts are
590
+ // now claimed by isImmutableAssetRequest() above, so repeating them would be
591
+ // an unreachable branch.
69
592
 
70
593
  // EventSource requests stay open indefinitely. Waiting on cache.put() for an
71
594
  // infinite response body prevents the browser from ever receiving the stream
@@ -75,9 +598,10 @@ self.addEventListener("fetch", (event) => {
75
598
  return;
76
599
  }
77
600
 
78
- // Always revalidate the HTML shell so navigation picks up the latest hashed
79
- // asset names instead of getting stuck on a cached index.html that points at
80
- // a stale bundle.
601
+ /*
602
+ FNXC:PWAOffline 2026-07-26-10:24:
603
+ The navigation shell MUST stay network-first. index.html is the only unhashed document in the graph, so it is the single source of truth for which hashed asset URLs are current. Keeping it fresh is precisely what makes cache-first hashed assets safe: after a deploy the fresh shell names new hashes, those miss the cache, and are fetched. Serving the shell from cache would pin the tab to a previous build's hashes indefinitely. Cache remains an offline fallback only.
604
+ */
81
605
  if (isNavigationRequest) {
82
606
  event.respondWith((async () => {
83
607
  try {
@@ -100,22 +624,76 @@ self.addEventListener("fetch", (event) => {
100
624
  return;
101
625
  }
102
626
 
627
+ /*
628
+ FNXC:PWAOffline 2026-07-26-15:40 (amended 2026-07-26-18:05):
629
+ /api/ stays network-first; the cache is a fallback for a failed request only, and ONLY for the
630
+ allow-listed, credential-free URLs isCacheableApiUrl() admits. Three bounds apply here
631
+ (rationale at MAX_API_CACHE_ENTRIES): the put records a timestamp and throttle-schedules the shared
632
+ prune so the entry count cannot grow without limit, and the fallback read refuses any entry whose
633
+ age is not provably within MAX_API_CACHE_AGE_MS — an expired or unprovable entry is deleted and the
634
+ original network error is rethrown, so the app sees a failed request instead of silently rendering
635
+ stale data it cannot distinguish from live data.
636
+ */
103
637
  if (isApiRequest) {
638
+ /*
639
+ FNXC:PWAOffline 2026-07-26-18:05:
640
+ Confidentiality gate, evaluated BEFORE any cache is opened: a non-allow-listed /api/ URL is passed
641
+ through untouched — no put, and no fallback read either. The read side matters as much as the write
642
+ side: an entry left behind by an older worker generation must not become servable just because this
643
+ generation stopped writing it. Returning without respondWith() lets the browser perform the request
644
+ directly, which is also the cheapest possible path.
645
+ */
646
+ if (!isCacheableApiUrl(request.url)) {
647
+ return;
648
+ }
104
649
  event.respondWith((async () => {
105
650
  try {
106
651
  const networkResponse = await fetch(request);
652
+ /*
653
+ FNXC:PWAOffline 2026-07-26-18:05:
654
+ Only `response.ok` is cached — the same rule the immutable-asset branch already applied, which
655
+ this branch was missing. Caching a failure body makes the offline fallback actively wrong rather
656
+ than merely stale: a 401 taken right after a token rotation, or a 500 from a restarting daemon,
657
+ would be replayed on the next network blip as a response the app cannot distinguish from live,
658
+ and an error body carries none of the freshness value the fallback exists for.
659
+ */
660
+ if (!networkResponse || !networkResponse.ok) {
661
+ return networkResponse;
662
+ }
107
663
  try {
108
664
  const cache = await caches.open(CACHE_NAME);
109
- await cache.put(request, networkResponse.clone());
665
+ // FNXC:PWAOffline 2026-07-26-18:05: stamp the put time into the stored entry so a
666
+ // cold-started worker (see buildStampedResponse) can still prove this entry's age.
667
+ let entryToCache = networkResponse.clone();
668
+ try {
669
+ const stamped = await buildStampedResponse(networkResponse);
670
+ if (stamped) {
671
+ entryToCache = stamped;
672
+ }
673
+ } catch (stampError) {
674
+ console.warn("[sw] api cache stamp failed", stampError);
675
+ }
676
+ await cache.put(request, entryToCache);
677
+ rememberApiCacheTimestamp(request.url);
678
+ scheduleApiPrune(cache);
110
679
  } catch (cacheError) {
111
680
  console.warn("[sw] api cache put failed", cacheError);
112
681
  }
113
682
  return networkResponse;
114
683
  } catch (networkError) {
115
684
  try {
116
- const cachedResponse = await caches.match(request);
685
+ const cache = await caches.open(CACHE_NAME);
686
+ const cachedResponse = await cache.match(request);
117
687
  if (cachedResponse) {
118
- return cachedResponse;
688
+ if (isFreshApiCacheEntry(readApiCachedAtMillis(request.url, cachedResponse))) {
689
+ return cachedResponse;
690
+ }
691
+ try {
692
+ await cache.delete(request);
693
+ apiCacheTimestamps.delete(request.url);
694
+ } catch (deleteError) {
695
+ console.warn("[sw] api stale fallback eviction failed", deleteError);
696
+ }
119
697
  }
120
698
  } catch (cacheError) {
121
699
  console.warn("[sw] api cache lookup failed", cacheError);
@@ -126,10 +704,52 @@ self.addEventListener("fetch", (event) => {
126
704
  return;
127
705
  }
128
706
 
129
- // Built assets are content-hashed, but an already-controlled browser can
130
- // keep old entries in this named cache across local rebuilds. Prefer the
131
- // server response so tabs cannot stay on stale JS/CSS and render a blank
132
- // shell after an update. The cache remains an offline fallback.
707
+ /*
708
+ FNXC:PWAOffline 2026-07-26-10:31:
709
+ Immutable assets are served CACHE-FIRST: a hit returns without touching the network, so a discarded-and-restored mobile tab repaints from local storage instead of re-downloading the bundle over a waking radio. On a miss we fetch, populate, and return.
710
+
711
+ This replaces the previous network-first-for-everything rule, which existed out of a stale-JS fear. That fear does not apply here: the URL is content-hashed, so its bytes are immutable and a hit can never be "stale" — a rebuild produces a different URL, which misses. The fear DOES apply to the navigation shell, which is why that branch above is left network-first.
712
+
713
+ Only `response.ok` is cached. A hashed URL that 404s (deploy mid-flight, partially uploaded build) must never be pinned into an immutable cache entry, because nothing would ever evict it before the next CACHE_NAME bump.
714
+ */
715
+ if (isImmutableAssetRequest(url, request)) {
716
+ event.respondWith((async () => {
717
+ // FNXC:PWAOffline 2026-07-26-14:05: recorded BEFORE the hit/miss branch so an asset the
718
+ // running build is using is eviction-exempt whether it came from cache or network.
719
+ rememberSessionReferencedAsset(request.url);
720
+ try {
721
+ const cache = await caches.open(CACHE_NAME);
722
+ const cachedResponse = await cache.match(request);
723
+ if (cachedResponse) {
724
+ return cachedResponse;
725
+ }
726
+
727
+ const networkResponse = await fetch(request);
728
+ if (networkResponse && networkResponse.ok) {
729
+ try {
730
+ await cache.put(request, networkResponse.clone());
731
+ schedulePrune(cache);
732
+ } catch (cacheError) {
733
+ console.warn("[sw] immutable asset cache put failed", cacheError);
734
+ }
735
+ }
736
+ return networkResponse;
737
+ } catch (error) {
738
+ console.warn("[sw] immutable asset cache flow failed", error);
739
+ const fallback = await caches.match(request);
740
+ if (fallback) {
741
+ return fallback;
742
+ }
743
+ return fetch(request);
744
+ }
745
+ })());
746
+ return;
747
+ }
748
+
749
+ // Non-hashed built assets (unhashed scripts/styles, anything under /assets/
750
+ // that cannot prove it carries a content hash) keep the network-first path:
751
+ // their URL is not a content identity, so a cached copy can genuinely go
752
+ // stale and blank the app after an update. Cache stays an offline fallback.
133
753
  if (isBuiltAssetRequest) {
134
754
  event.respondWith((async () => {
135
755
  try {