@lowdefy/server-dev 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (625) hide show
  1. package/client/App.jsx +52 -24
  2. package/client/Inspector.jsx +73 -30
  3. package/client/JourneyObserver.jsx +47 -0
  4. package/client/Page.jsx +54 -22
  5. package/client/Recorder.jsx +73 -0
  6. package/client/Routing.jsx +37 -13
  7. package/client/clientBuildImports.test.mjs +63 -0
  8. package/client/devStreamConnections.test.mjs +40 -0
  9. package/client/feedback/FeedbackMount.jsx +2 -2
  10. package/client/feedback/FeedbackOverlay.jsx +7 -4
  11. package/client/feedback/feedbackStyles.js +3 -0
  12. package/client/journeyObserver/startJourneyObserver.js +101 -0
  13. package/client/journeyObserver/startJourneyObserver.test.mjs +149 -0
  14. package/client/openInEditor/OpenInEditorListener.jsx +2 -0
  15. package/client/recorder/buildEngineRecord.js +37 -0
  16. package/client/recorder/buildInteractionRecord.js +64 -0
  17. package/client/recorder/buildTraceEvent.js +57 -0
  18. package/client/recorder/buildTraceEvent.test.mjs +81 -0
  19. package/client/recorder/chunkRecords.js +69 -0
  20. package/client/recorder/chunkRecords.test.mjs +59 -0
  21. package/client/recorder/collectEventActions.js +30 -0
  22. package/client/recorder/collectEventEndpoints.js +40 -0
  23. package/client/recorder/collectEventRequests.js +39 -0
  24. package/client/recorder/createPairingBuffer.js +187 -0
  25. package/client/recorder/createPairingBuffer.test.mjs +405 -0
  26. package/client/recorder/createPasswordRedactor.js +84 -0
  27. package/client/recorder/createRecordBatcher.js +54 -0
  28. package/client/recorder/createRecordBatcher.test.mjs +76 -0
  29. package/client/recorder/createRecorder.js +216 -0
  30. package/client/recorder/createRecorder.test.mjs +318 -0
  31. package/client/recorder/createRecordingSession.js +61 -0
  32. package/client/recorder/createRecordingSession.test.mjs +72 -0
  33. package/client/recorder/diffStateWrites.js +77 -0
  34. package/client/recorder/diffStateWrites.test.mjs +66 -0
  35. package/client/recorder/isEventCall.js +30 -0
  36. package/client/recorder/jsonType.js +39 -0
  37. package/client/recorder/keyChord.js +47 -0
  38. package/client/recorder/keyChord.test.mjs +50 -0
  39. package/client/recorder/sendRecords.js +40 -0
  40. package/client/recorder/toJsonSafe.js +29 -0
  41. package/lib/client/BuildingPage.jsx +3 -1
  42. package/lib/client/ErrorBar.jsx +17 -13
  43. package/lib/client/RedirectingPage.jsx +56 -0
  44. package/lib/client/auth/Auth.jsx +11 -6
  45. package/lib/client/auth/AuthConfigured.jsx +224 -39
  46. package/lib/client/auth/AuthNotConfigured.js +45 -4
  47. package/lib/client/auth/authNotConfiguredParity.test.mjs +61 -0
  48. package/lib/client/auth/authTemplateSync.test.mjs +40 -0
  49. package/lib/client/auth/betterAuthSessionContract.test.mjs +181 -0
  50. package/lib/client/auth/createFetchCheckingBuild.js +45 -0
  51. package/lib/client/bundleBuildId.js +25 -0
  52. package/lib/client/collectVisibleBlockIds.js +51 -0
  53. package/lib/client/getCspNonce.js +25 -0
  54. package/lib/client/getPagePath.js +36 -0
  55. package/lib/client/getPagePath.test.mjs +51 -0
  56. package/lib/client/utils/formatErrorsForCopy.js +30 -0
  57. package/lib/client/utils/formatErrorsForCopy.test.mjs +39 -0
  58. package/lib/client/utils/getErrorBarColor.js +25 -0
  59. package/lib/client/utils/getErrorBarColor.test.mjs +34 -0
  60. package/lib/client/utils/request.js +8 -1
  61. package/lib/client/utils/usePageConfig.js +89 -25
  62. package/lib/client/utils/usePageConfig.test.mjs +198 -13
  63. package/lib/configTree/buildConfigTree.mjs +58 -0
  64. package/lib/configTree/buildConfigTree.test.mjs +77 -0
  65. package/lib/configTree/collectConfigText.mjs +101 -0
  66. package/lib/configTree/collectConfigText.test.mjs +116 -0
  67. package/lib/configTree/prepareScratchServer.mjs +46 -0
  68. package/lib/configTree/runConfigTreeBuild.mjs +88 -0
  69. package/lib/configTree/runConfigTreeBuild.skipPluginChecks.test.mjs +74 -0
  70. package/lib/configTree/runConfigTreeBuild.test.mjs +202 -0
  71. package/lib/docs/JourneyStepError.js +30 -0
  72. package/lib/docs/acquireBrowserSlot.js +30 -0
  73. package/lib/docs/addNestedEndpoints.js +54 -0
  74. package/lib/docs/addNestedEndpoints.test.mjs +185 -0
  75. package/lib/docs/addWriteFlags.js +77 -0
  76. package/lib/docs/buildEditedPages.js +50 -0
  77. package/lib/docs/buildWaitHeader.js +21 -0
  78. package/lib/docs/captureAnnotatedScreenshot.js +23 -12
  79. package/lib/docs/checkAgentFiles.js +148 -0
  80. package/lib/docs/checkAgentFiles.test.mjs +225 -0
  81. package/lib/docs/checkStepReferences.js +239 -0
  82. package/lib/docs/checkStepReferences.test.mjs +173 -0
  83. package/lib/docs/checkpointStore.js +3 -0
  84. package/lib/docs/clientErrorStore.js +18 -1
  85. package/lib/docs/clientErrorStore.test.mjs +46 -0
  86. package/lib/docs/collectExercised.js +52 -0
  87. package/lib/docs/createBrowserLifecycle.js +102 -0
  88. package/lib/docs/createBrowserLifecycle.test.mjs +212 -0
  89. package/lib/docs/createDocsMcpServer.js +345 -388
  90. package/lib/docs/createDocsMcpServer.test.mjs +644 -4
  91. package/lib/docs/createDocsMcpServerRunJourney.test.mjs +148 -0
  92. package/lib/docs/createJourneyActors.chromium.test.mjs +227 -0
  93. package/lib/docs/createJourneyActors.js +213 -0
  94. package/lib/docs/createJourneyActors.test.mjs +79 -0
  95. package/lib/docs/createJourneyObservations.js +80 -0
  96. package/lib/docs/createLeftOriginError.js +26 -0
  97. package/lib/docs/createNetworkCounter.js +101 -0
  98. package/lib/docs/dataSets/checkSharedCollectionIds.js +67 -0
  99. package/lib/docs/dataSets/dataSessionRegistry.js +25 -0
  100. package/lib/docs/dataSets/dataSetJourneys.chromium.mongodb.test.mjs +679 -0
  101. package/lib/docs/dataSets/describeDataSetResult.js +25 -0
  102. package/lib/docs/dataSets/describeDatabaseTarget.js +46 -0
  103. package/lib/docs/dataSets/deserializeFixture.js +34 -0
  104. package/lib/docs/dataSets/drainSessionWork.js +37 -0
  105. package/lib/docs/dataSets/drainSessionWork.test.mjs +64 -0
  106. package/lib/docs/dataSets/duplicateIndexName.js +24 -0
  107. package/lib/docs/dataSets/findCollectionCollisions.js +52 -0
  108. package/lib/docs/dataSets/getDataStore.js +106 -0
  109. package/lib/docs/dataSets/getDataStore.mongodb.test.mjs +63 -0
  110. package/lib/docs/dataSets/getDataStore.test.mjs +195 -0
  111. package/lib/docs/dataSets/groupDataSetByCollection.js +44 -0
  112. package/lib/docs/dataSets/insertFixture.js +57 -0
  113. package/lib/docs/dataSets/insertGeneratedDocuments.js +41 -0
  114. package/lib/docs/dataSets/isDataStoreGone.js +29 -0
  115. package/lib/docs/dataSets/loadDataSetCollection.js +42 -0
  116. package/lib/docs/dataSets/mergeCollectionIndexes.js +52 -0
  117. package/lib/docs/dataSets/mergeCollectionIndexes.test.mjs +59 -0
  118. package/lib/docs/dataSets/openDataSession.js +119 -0
  119. package/lib/docs/dataSets/openDataSession.mongodb.test.mjs +343 -0
  120. package/lib/docs/dataSets/pickDataStorePort.js +58 -0
  121. package/lib/docs/dataSets/pickDataStorePort.test.mjs +63 -0
  122. package/lib/docs/dataSets/readConnectionArtifacts.js +46 -0
  123. package/lib/docs/dataSets/readDataSession.js +41 -0
  124. package/lib/docs/dataSets/readDataSession.test.mjs +57 -0
  125. package/lib/docs/dataSets/readDataSet.js +68 -0
  126. package/lib/docs/dataSets/readDataSet.test.mjs +220 -0
  127. package/lib/docs/dataSets/registerDataStoreShutdown.js +47 -0
  128. package/lib/docs/dataSets/removeDataStoreFiles.js +32 -0
  129. package/lib/docs/dataSets/resolveDataSetCollection.js +46 -0
  130. package/lib/docs/dataSets/resolveJourneyDataSet.js +106 -0
  131. package/lib/docs/dataSets/resolveJourneyDataSet.test.mjs +130 -0
  132. package/lib/docs/dataSets/sweepOrphanDatabases.js +33 -0
  133. package/lib/docs/dataSets/test/app/lowdefy.yaml +203 -0
  134. package/lib/docs/dataSets/test/app/modules/archive/module.lowdefy.yaml +30 -0
  135. package/lib/docs/dataSets/test/app/tests/data/shop.yaml +8 -0
  136. package/lib/docs/dataSets/test/buildTestApp.mjs +43 -0
  137. package/lib/docs/devEventBus.js +136 -0
  138. package/lib/docs/devEventBus.test.mjs +269 -0
  139. package/lib/docs/devToolDefinitions.js +529 -0
  140. package/lib/docs/docSources.js +21 -0
  141. package/lib/docs/docs.test.mjs +171 -15
  142. package/lib/docs/docsIndex.test.mjs +377 -0
  143. package/lib/docs/evalOperator.js +5 -3
  144. package/lib/docs/evalOperatorHeadless.js +84 -12
  145. package/lib/docs/evalOperatorHeadless.test.mjs +7 -1
  146. package/lib/docs/evalOperatorInTab.js +2 -1
  147. package/lib/docs/findConfig.js +95 -49
  148. package/lib/docs/fitResponse.js +81 -0
  149. package/lib/docs/fitResponse.test.mjs +128 -0
  150. package/lib/docs/formatFeedback.js +26 -5
  151. package/lib/docs/formatFeedback.test.mjs +48 -0
  152. package/lib/docs/getAppMap.js +7 -1
  153. package/lib/docs/getBrowser.js +150 -80
  154. package/lib/docs/getBrowser.test.mjs +407 -16
  155. package/lib/docs/getBuildId.js +61 -0
  156. package/lib/docs/getBuildId.test.mjs +84 -0
  157. package/lib/docs/getBuildStatus.js +54 -6
  158. package/lib/docs/getBuildStatus.test.mjs +133 -0
  159. package/lib/docs/getBuildStatusAfterEdits.js +35 -0
  160. package/lib/docs/getBuildStatusAfterEdits.test.mjs +76 -0
  161. package/lib/docs/getCoreDoc.js +9 -1
  162. package/lib/docs/getDoc.js +100 -0
  163. package/lib/docs/getDocsIndex.js +152 -0
  164. package/lib/docs/getHazards.js +115 -0
  165. package/lib/docs/getHazards.test.mjs +175 -0
  166. package/lib/docs/getMailOutboxDirectory.js +26 -0
  167. package/lib/docs/getOverview.js +79 -13
  168. package/lib/docs/getOverviewBasePath.test.mjs +37 -0
  169. package/lib/docs/getPageBuildStatus.js +46 -0
  170. package/lib/docs/getPluginDoc.js +21 -23
  171. package/lib/docs/getSchema.js +33 -1
  172. package/lib/docs/getStaleStatus.js +36 -0
  173. package/lib/docs/getStaleStatus.test.mjs +74 -0
  174. package/lib/docs/guardJourneyOrigin.js +39 -0
  175. package/lib/docs/inspectState.js +5 -3
  176. package/lib/docs/inspectStateFromTab.js +2 -2
  177. package/lib/docs/inspectStateHeadless.js +89 -27
  178. package/lib/docs/inspectStateHeadless.test.mjs +20 -1
  179. package/lib/docs/installHeadlessShell.failure.test.mjs +45 -0
  180. package/lib/docs/installHeadlessShell.js +123 -0
  181. package/lib/docs/installHeadlessShell.test.mjs +70 -0
  182. package/lib/docs/installHeadlessShell.timeout.test.mjs +72 -0
  183. package/lib/docs/isHeadlessShellIncomplete.js +34 -0
  184. package/lib/docs/isHeadlessShellIncomplete.test.mjs +63 -0
  185. package/lib/docs/isPageReady.js +77 -0
  186. package/lib/docs/isPageReady.test.mjs +235 -0
  187. package/lib/docs/isRunErrorEntry.js +25 -0
  188. package/lib/docs/journeyMutantCookie.chromium.test.mjs +150 -0
  189. package/lib/docs/journeyRunRecording.js +35 -0
  190. package/lib/docs/journeyRunRecording.test.mjs +39 -0
  191. package/lib/docs/keepEventStreamAlive.js +68 -0
  192. package/lib/docs/keepEventStreamAlive.test.mjs +77 -0
  193. package/lib/docs/launchBrowser.js +83 -0
  194. package/lib/docs/launchBrowser.test.mjs +175 -0
  195. package/lib/docs/listModuleDocEntries.js +69 -0
  196. package/lib/docs/listPackageDocFiles.js +67 -0
  197. package/lib/docs/listPluginDocEntries.js +111 -0
  198. package/lib/docs/listPlugins.js +9 -58
  199. package/lib/docs/listTypes.js +14 -6
  200. package/lib/docs/loadFrameworkHazards.js +40 -0
  201. package/lib/docs/loadFrameworkHazards.test.mjs +43 -0
  202. package/lib/docs/loadState.js +103 -35
  203. package/lib/docs/makeModuleManifestDoc.js +174 -0
  204. package/lib/docs/matchExercisedPages.js +35 -0
  205. package/lib/docs/matchJourneyRoute.js +69 -0
  206. package/lib/docs/matchJourneyRoute.test.mjs +178 -0
  207. package/lib/docs/mergeNetworkSnapshots.js +49 -0
  208. package/lib/docs/nextJourneySequence.js +30 -0
  209. package/lib/docs/noBrowserError.js +22 -0
  210. package/lib/docs/observe/TRANSIENT_SELECTOR.js +21 -0
  211. package/lib/docs/observe/claimExpectedErrors.js +67 -0
  212. package/lib/docs/observe/createJourneyAppErrors.js +236 -0
  213. package/lib/docs/observe/createJourneyAppErrors.test.mjs +385 -0
  214. package/lib/docs/observe/evaluateInvariants.js +183 -0
  215. package/lib/docs/observe/evaluateInvariants.test.mjs +299 -0
  216. package/lib/docs/observe/findingKey.js +29 -0
  217. package/lib/docs/observe/hasNoEffect.js +31 -0
  218. package/lib/docs/observe/hasNoEffect.test.mjs +37 -0
  219. package/lib/docs/observe/installStepObserver.js +72 -0
  220. package/lib/docs/observe/isAppApiUrl.js +33 -0
  221. package/lib/docs/observe/parseAppApiUrl.js +37 -0
  222. package/lib/docs/observe/readStepObserver.js +42 -0
  223. package/lib/docs/observe/relativeSource.js +29 -0
  224. package/lib/docs/observe/resolveConfigKeySource.js +33 -0
  225. package/lib/docs/observe/takeJourneyEvents.js +36 -0
  226. package/lib/docs/observe/toAppErrorFailure.js +61 -0
  227. package/lib/docs/observe/usesSearchStage.js +45 -0
  228. package/lib/docs/observe/waitForClientErrorReports.js +31 -0
  229. package/lib/docs/observe/watchJourneyContext.js +75 -0
  230. package/lib/docs/observe/withAppErrors.js +31 -0
  231. package/lib/docs/observe/withAppErrors.test.mjs +101 -0
  232. package/lib/docs/openJourney.js +94 -0
  233. package/lib/docs/openJourneyEmail.js +41 -0
  234. package/lib/docs/openJourneyEmail.test.mjs +187 -0
  235. package/lib/docs/pageBlocks.js +47 -0
  236. package/lib/docs/pageBuildStatus.test.mjs +103 -0
  237. package/lib/{client/setPageId.js → docs/parseModuleSource.js} +11 -12
  238. package/lib/docs/rails/authEngineActions.js +53 -0
  239. package/lib/docs/rails/authEngineActions.test.mjs +69 -0
  240. package/lib/docs/rails/authEngineMethods.js +52 -0
  241. package/lib/docs/rails/authEngineMethods.test.mjs +36 -0
  242. package/lib/docs/rails/checkAuthActionsRan.js +66 -0
  243. package/lib/docs/rails/checkAuthActionsRan.test.mjs +149 -0
  244. package/lib/docs/rails/checkClickRails.js +56 -0
  245. package/lib/docs/rails/createDataSetRails.js +82 -0
  246. package/lib/docs/rails/createDataSetRails.test.mjs +172 -0
  247. package/lib/docs/rails/findExternalBlocks.js +147 -0
  248. package/lib/docs/rails/findExternalBlocks.test.mjs +254 -0
  249. package/lib/docs/rails/installAuthActionRecorder.js +56 -0
  250. package/lib/docs/rails/readAuthActionRecorder.js +32 -0
  251. package/lib/docs/rails/readBuiltArtifact.js +30 -0
  252. package/lib/docs/readConnectionPlugins.js +26 -0
  253. package/lib/docs/readDevAuthMode.js +32 -0
  254. package/lib/docs/readDocEntry.js +40 -0
  255. package/lib/docs/readDocTitle.js +26 -0
  256. package/lib/docs/readJourneyEmailMatch.js +57 -0
  257. package/lib/docs/readJourneyEmailMatch.test.mjs +110 -0
  258. package/lib/docs/readJourneySession.js +56 -0
  259. package/lib/docs/readMailOutbox.js +37 -0
  260. package/lib/docs/readPagePath.js +27 -0
  261. package/lib/docs/readPluginPackages.js +99 -0
  262. package/lib/docs/readProxyBuildWait.js +38 -0
  263. package/lib/docs/readRoutineSteps.js +45 -0
  264. package/lib/docs/registerShownTextEngine.js +118 -0
  265. package/lib/docs/requestRestart.js +30 -0
  266. package/lib/docs/requestRestart.test.mjs +49 -0
  267. package/lib/docs/resolvePageInstance.js +46 -0
  268. package/lib/docs/resolvePageInstance.test.mjs +81 -0
  269. package/lib/docs/resolveSource.js +11 -7
  270. package/lib/docs/resolveSource.test.mjs +1 -1
  271. package/lib/docs/resolveToolCaller.js +62 -0
  272. package/lib/docs/reviewPage.js +51 -0
  273. package/lib/docs/reviewPageBuilds.js +56 -0
  274. package/lib/docs/reviewPageBuilds.test.mjs +207 -0
  275. package/lib/docs/runCheck.js +75 -0
  276. package/lib/docs/runCheck.test.mjs +91 -0
  277. package/lib/docs/runCheckChild.js +49 -0
  278. package/lib/docs/runEndpoint.js +185 -0
  279. package/lib/docs/runEndpoint.test.mjs +509 -0
  280. package/lib/docs/runErrorBuffers.js +99 -0
  281. package/lib/docs/runErrorBuffers.test.mjs +95 -0
  282. package/lib/docs/runJourney.js +347 -0
  283. package/lib/docs/runJourney.test.mjs +2383 -0
  284. package/lib/docs/runJourneyAsUsers.js +44 -0
  285. package/lib/docs/runJourneyDataSet.test.mjs +274 -0
  286. package/lib/docs/runJourneySteps.js +1320 -0
  287. package/lib/docs/runRequest.js +100 -59
  288. package/lib/docs/runRequest.test.mjs +190 -0
  289. package/lib/docs/saveAnnotatedScreenshot.js +1 -1
  290. package/lib/docs/scaffoldPage.js +3 -1
  291. package/lib/docs/screenshotPage.js +163 -11
  292. package/lib/docs/screenshotPage.test.mjs +263 -1
  293. package/lib/docs/screenshotPageClip.test.mjs +4 -0
  294. package/lib/docs/searchDocs.js +82 -30
  295. package/lib/docs/searchDocs.test.mjs +100 -0
  296. package/lib/docs/searchIcons.js +90 -0
  297. package/lib/docs/searchIcons.test.mjs +127 -0
  298. package/lib/docs/selectExercised.js +39 -0
  299. package/lib/docs/selectFinalState.js +66 -0
  300. package/lib/docs/selectFinalState.test.mjs +51 -0
  301. package/lib/docs/serverErrorStore.js +54 -0
  302. package/lib/docs/serverErrorStore.test.mjs +50 -0
  303. package/lib/docs/setupTestFixtures.mjs +133 -39
  304. package/lib/docs/shownTextSelector.js +28 -0
  305. package/lib/docs/snapshotState.js +13 -5
  306. package/lib/docs/splitSearchTerms.js +62 -0
  307. package/lib/docs/splitSearchTerms.test.mjs +50 -0
  308. package/lib/docs/stateCheckpoints.test.mjs +77 -0
  309. package/lib/docs/tabAvailable.js +6 -5
  310. package/lib/docs/tabChannel.js +127 -21
  311. package/lib/docs/tabChannel.test.mjs +187 -29
  312. package/lib/docs/unsettledPageNote.js +24 -0
  313. package/lib/docs/validateJourneyMail.js +36 -0
  314. package/lib/docs/validateJourneyTimeout.js +38 -0
  315. package/lib/docs/validateJourneyTimeout.test.mjs +29 -0
  316. package/lib/docs/validateRecordingParam.js +42 -0
  317. package/lib/docs/validateStateSelection.js +33 -0
  318. package/lib/docs/validateStateSelection.test.mjs +33 -0
  319. package/lib/docs/validateViewport.js +51 -0
  320. package/lib/docs/validateViewport.test.mjs +54 -0
  321. package/lib/docs/waitForBuild.js +50 -0
  322. package/lib/docs/waitForBuild.test.mjs +75 -0
  323. package/lib/docs/waitForJourneyEmail.js +77 -0
  324. package/lib/docs/withBrowserSlot.js +45 -0
  325. package/lib/docs/withBrowserSlot.test.mjs +75 -0
  326. package/lib/docs/withDataSession.js +53 -0
  327. package/lib/server/applyDataSetRedirect.js +83 -0
  328. package/lib/server/applyDataSetRedirect.test.mjs +340 -0
  329. package/lib/server/applyDataSetRedirectTenantReach.test.mjs +204 -0
  330. package/lib/server/applyDataSetRedirectWalled.test.mjs +131 -0
  331. package/lib/server/auth/createSystemContext.js +73 -0
  332. package/lib/server/auth/getAuth.js +46 -0
  333. package/lib/server/auth/getHeadlessUser.js +9 -8
  334. package/lib/server/auth/getHeadlessUser.test.mjs +41 -0
  335. package/lib/server/auth/getMockUser.js +18 -14
  336. package/lib/server/auth/{getAuthConfig.js → getStrategies.js} +12 -10
  337. package/lib/server/auth/headlessUser.js +3 -2
  338. package/lib/server/auth/journeyActor.js +35 -0
  339. package/lib/server/auth/resolveHeadlessUser.test.mjs +0 -4
  340. package/lib/server/checkPageRecord.js +44 -0
  341. package/lib/server/contextMapBudget.js +24 -0
  342. package/lib/server/contextMapBudget.test.mjs +98 -0
  343. package/lib/server/continueJitKeys.js +28 -0
  344. package/lib/server/continueJitKeys.test.mjs +43 -0
  345. package/lib/server/createDataSessionEndedError.js +33 -0
  346. package/lib/server/createDataSetRefusal.js +26 -0
  347. package/lib/server/createLowdefyContext.js +174 -41
  348. package/lib/server/createLowdefyContext.test.mjs +437 -0
  349. package/lib/server/getClientAddress.js +51 -0
  350. package/lib/server/getClientAddress.test.mjs +56 -0
  351. package/lib/server/getPageJitEnrichment.test.mjs +29 -11
  352. package/lib/server/hashConfigContent.js +26 -0
  353. package/lib/server/importAppCode.test.mjs +57 -0
  354. package/lib/server/isRoutineStep.js +27 -0
  355. package/lib/server/jitPageBuilder.js +309 -126
  356. package/lib/server/jitPageBuilder.skipPluginChecks.test.mjs +78 -0
  357. package/lib/server/jitPageBuilder.test.mjs +218 -0
  358. package/lib/server/journeyCookies/JOURNEY_COOKIES.js +37 -0
  359. package/lib/server/journeyCookies/forwardJourneyCookies.js +46 -0
  360. package/lib/server/journeyCookies/readJourneyCookie.js +52 -0
  361. package/lib/server/journeyCookies/writeJourneyCookie.js +46 -0
  362. package/lib/server/journeyCookies.js +22 -0
  363. package/lib/server/journeyCookies.test.mjs +145 -0
  364. package/lib/server/loadDynamicJsMap.js +47 -0
  365. package/lib/server/log/createHandleError.js +38 -2
  366. package/lib/server/log/createHandleError.test.mjs +125 -2
  367. package/lib/server/log/createLogger.js +3 -0
  368. package/lib/server/log/createLogger.test.mjs +77 -0
  369. package/lib/server/log/logRequest.js +2 -3
  370. package/lib/server/mutantDataSetWrappers.test.mjs +112 -0
  371. package/lib/server/mutants/applyMutant.js +39 -0
  372. package/lib/server/mutants/artifactPatterns.js +25 -0
  373. package/lib/server/mutants/buildAnchor.js +45 -0
  374. package/lib/server/mutants/collectSharedIds.js +41 -0
  375. package/lib/server/mutants/configPathSegments.js +38 -0
  376. package/lib/server/mutants/createMutantReadConfigFile.js +42 -0
  377. package/lib/server/mutants/describeEvent.js +22 -0
  378. package/lib/server/mutants/enumerateArtifact.js +56 -0
  379. package/lib/server/mutants/findNodeByKey.js +45 -0
  380. package/lib/server/mutants/findNodeByPointer.js +49 -0
  381. package/lib/server/mutants/getArtifactKind.js +32 -0
  382. package/lib/server/mutants/groupMutantCopies.js +56 -0
  383. package/lib/server/mutants/hashArtifact.js +25 -0
  384. package/lib/server/mutants/hashMutantTarget.js +34 -0
  385. package/lib/server/mutants/listMutants.js +122 -0
  386. package/lib/server/mutants/listMutants.test.mjs +502 -0
  387. package/lib/server/mutants/mutantId.js +30 -0
  388. package/lib/server/mutants/mutantRuns.js +57 -0
  389. package/lib/server/mutants/mutantRuns.test.mjs +136 -0
  390. package/lib/server/mutants/operators/dropAction.js +47 -0
  391. package/lib/server/mutants/operators/dropBlock.js +44 -0
  392. package/lib/server/mutants/operators/dropPayload.js +52 -0
  393. package/lib/server/mutants/operators/dropStep.js +44 -0
  394. package/lib/server/mutants/operators/flipVisible.js +53 -0
  395. package/lib/server/mutants/operators/index.js +37 -0
  396. package/lib/server/mutants/operators/retargetLink.js +72 -0
  397. package/lib/server/mutants/operators/skipValidate.js +77 -0
  398. package/lib/server/mutants/operators/swapIf.js +112 -0
  399. package/lib/server/mutants/operators.test.mjs +396 -0
  400. package/lib/server/mutants/positionFreePath.js +38 -0
  401. package/lib/server/mutants/removeFromParent.js +28 -0
  402. package/lib/server/mutants/test/fixtures.mjs +173 -0
  403. package/lib/server/mutants/test/keyArtifact.mjs +63 -0
  404. package/lib/server/mutants/validateMutantParam.js +69 -0
  405. package/lib/server/mutants/validateMutantsBody.js +88 -0
  406. package/lib/server/mutants/walkArtifact.js +179 -0
  407. package/lib/server/pageBuildRecords.js +131 -0
  408. package/lib/server/pageBuildRecords.test.mjs +258 -0
  409. package/lib/server/pageCache.mjs +22 -7
  410. package/lib/server/pageCache.test.mjs +22 -7
  411. package/lib/server/pageRebuilds.test.mjs +543 -0
  412. package/lib/server/pruneJitMaps.js +36 -0
  413. package/lib/server/pruneJitMaps.test.mjs +55 -0
  414. package/lib/server/readInstanceRecord.js +33 -0
  415. package/lib/server/readInstanceRecord.test.mjs +99 -0
  416. package/lib/server/readMergedMaps.js +86 -0
  417. package/lib/server/readMergedMaps.test.mjs +79 -0
  418. package/lib/server/recording/appendRecords.js +33 -0
  419. package/lib/server/recording/errorRecordingStamp.js +34 -0
  420. package/lib/server/recording/errorRecordingStamp.test.mjs +31 -0
  421. package/lib/server/recording/isRecordingEnabled.js +31 -0
  422. package/lib/server/recording/readRecordingCookie.js +71 -0
  423. package/lib/server/recording/readRecordingCookie.test.mjs +73 -0
  424. package/lib/server/recording/recordingCookiePayload.js +33 -0
  425. package/lib/server/recording/servedBuilds.js +46 -0
  426. package/lib/server/recording/servedBuilds.test.mjs +42 -0
  427. package/lib/server/scrubSecrets.js +23 -0
  428. package/lib/server/sinkExternalConnections.js +95 -0
  429. package/lib/server/sinkExternalConnections.test.mjs +135 -0
  430. package/lib/server/skipStaleMapWrites.js +62 -0
  431. package/lib/server/skipStaleMapWrites.test.mjs +113 -0
  432. package/lib/server/startIdleGc.js +67 -0
  433. package/lib/server/startIdleGc.test.mjs +138 -0
  434. package/lib/server/testFixtures/importAppCodeTwice.mjs +70 -0
  435. package/lib/server/trackSessionWork.js +30 -0
  436. package/lib/vite/createDevServerExclude.js +40 -0
  437. package/lib/vite/createDevServerExclude.test.mjs +79 -0
  438. package/manager/getContext.mjs +24 -2
  439. package/manager/processes/initialBuild.mjs +14 -0
  440. package/manager/processes/initialBuild.test.mjs +101 -0
  441. package/manager/processes/lowdefyBuild.mjs +30 -7
  442. package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +62 -0
  443. package/manager/processes/lowdefyBuild.test.mjs +136 -1
  444. package/manager/processes/optimizeDependencies.mjs +59 -0
  445. package/manager/processes/restartServer.mjs +34 -6
  446. package/manager/processes/restartServer.test.mjs +162 -0
  447. package/manager/processes/shutdownServer.mjs +11 -0
  448. package/manager/processes/shutdownServer.test.mjs +45 -0
  449. package/manager/processes/startFirstServer.mjs +46 -0
  450. package/manager/processes/startFirstServer.test.mjs +326 -0
  451. package/manager/processes/startMailSink.mjs +135 -0
  452. package/manager/processes/startMailSink.test.mjs +146 -0
  453. package/manager/processes/startProxy.mjs +129 -12
  454. package/manager/processes/startProxy.test.mjs +366 -0
  455. package/manager/processes/startServer.mjs +47 -6
  456. package/manager/processes/startServer.test.mjs +155 -0
  457. package/manager/processes/startWatchers.mjs +6 -4
  458. package/manager/processes/syncServer.mjs +69 -0
  459. package/manager/processes/syncServer.test.mjs +141 -0
  460. package/manager/processes/waitForDevServer.mjs +48 -0
  461. package/manager/processes/waitForDevServer.test.mjs +112 -0
  462. package/manager/run.mjs +128 -43
  463. package/manager/utils/BatchChanges.mjs +21 -5
  464. package/manager/utils/BatchChanges.test.mjs +77 -0
  465. package/manager/utils/acquireDevInstance.mjs +99 -0
  466. package/manager/utils/acquireDevInstance.test.mjs +160 -0
  467. package/manager/utils/createBuildActivity.mjs +67 -0
  468. package/manager/utils/createBuildActivity.test.mjs +58 -0
  469. package/manager/utils/createChangeSignal.mjs +39 -0
  470. package/manager/utils/createChangeSignal.test.mjs +62 -0
  471. package/manager/utils/createCustomPluginMessagesMap.mjs +23 -33
  472. package/manager/utils/createCustomPluginTypesMap.mjs +24 -30
  473. package/manager/utils/createCustomPluginTypesMap.test.mjs +94 -0
  474. package/manager/utils/createDotPathIgnore.mjs +44 -0
  475. package/manager/utils/createDotPathIgnore.test.mjs +65 -0
  476. package/manager/utils/createIdleGc.mjs +51 -0
  477. package/manager/utils/createIdleGc.test.mjs +77 -0
  478. package/manager/utils/createRequestActivity.mjs +100 -0
  479. package/manager/utils/createRequestActivity.test.mjs +121 -0
  480. package/manager/utils/createServerArtifactTracker.mjs +83 -0
  481. package/manager/utils/createServerArtifactTracker.test.mjs +78 -0
  482. package/manager/utils/createServerEnv.mjs +39 -0
  483. package/manager/utils/filesHaveSameBytes.mjs +41 -0
  484. package/manager/utils/findBuildFilesOutsideWatch.mjs +59 -0
  485. package/manager/utils/findBuildFilesOutsideWatch.test.mjs +111 -0
  486. package/manager/utils/importFresh.mjs +46 -0
  487. package/manager/utils/importFresh.test.mjs +72 -0
  488. package/manager/utils/importFreshWorker.mjs +20 -0
  489. package/manager/utils/killTaggedBrowser.mjs +35 -0
  490. package/manager/utils/pagePathChanged.mjs +56 -0
  491. package/manager/utils/pruneRecordings.mjs +85 -0
  492. package/manager/utils/pruneRecordings.test.mjs +117 -0
  493. package/manager/utils/publishBuildDirectory.mjs +121 -0
  494. package/manager/utils/publishBuildDirectory.order.test.mjs +74 -0
  495. package/manager/utils/publishBuildDirectory.test.mjs +262 -0
  496. package/manager/utils/readBasePath.mjs +27 -0
  497. package/manager/utils/readBasePath.test.mjs +43 -0
  498. package/manager/utils/readBuildStatusWait.mjs +67 -0
  499. package/manager/utils/readManagerVersion.mjs +30 -0
  500. package/manager/utils/readManagerVersion.test.mjs +35 -0
  501. package/manager/utils/readPluginDefinitions.mjs +36 -0
  502. package/manager/utils/resolveDevAuthUrl.mjs +58 -0
  503. package/manager/utils/resolveDevAuthUrl.test.mjs +107 -0
  504. package/manager/utils/resolvePorts.mjs +49 -0
  505. package/manager/utils/setupWatcher.mjs +19 -11
  506. package/manager/utils/startRecordingPruner.mjs +45 -0
  507. package/manager/utils/startRecordingPruner.test.mjs +66 -0
  508. package/manager/utils/waitForChildExit.mjs +53 -0
  509. package/manager/utils/waitForChildExit.test.mjs +70 -0
  510. package/manager/utils/waitForServer.mjs +44 -0
  511. package/manager/utils/writeBuildStatus.mjs +1 -1
  512. package/manager/watchers/createPluginSourceIgnore.mjs +77 -0
  513. package/manager/watchers/envWatcher.mjs +9 -2
  514. package/manager/watchers/envWatcher.test.mjs +87 -0
  515. package/manager/watchers/listServerSidePackages.mjs +47 -0
  516. package/manager/watchers/listServerSidePackages.test.mjs +38 -0
  517. package/manager/watchers/lowdefyBuildWatcher.mjs +106 -22
  518. package/manager/watchers/lowdefyBuildWatcher.test.mjs +309 -0
  519. package/manager/watchers/pluginSourceWatcher.mjs +79 -0
  520. package/manager/watchers/pluginSourceWatcher.test.mjs +222 -0
  521. package/manager/watchers/restartRequestWatcher.mjs +71 -0
  522. package/manager/watchers/restartRequestWatcher.test.mjs +111 -0
  523. package/manager/watchers/selectLocalPluginPackages.mjs +48 -0
  524. package/manager/watchers/selectLocalPluginPackages.test.mjs +73 -0
  525. package/manager/watchers/serverPackageWatcher.mjs +38 -0
  526. package/package.json +61 -49
  527. package/package.original.json +61 -49
  528. package/src/app.js +131 -20
  529. package/src/html/renderDevPage.js +63 -11
  530. package/src/html/renderDevPage.test.mjs +138 -0
  531. package/src/lib/getRequestPath.js +29 -0
  532. package/src/middleware/createSameOriginGuard.js +37 -0
  533. package/src/middleware/createSameOriginGuard.test.mjs +102 -0
  534. package/src/middleware/errorHandler.js +51 -1
  535. package/src/middleware/errorHandler.test.mjs +196 -14
  536. package/src/middleware/isRebindingSafeHost.js +44 -0
  537. package/src/middleware/isRebindingSafeHost.test.mjs +32 -0
  538. package/src/middleware/localDevToolsOnly.js +38 -0
  539. package/src/middleware/localDevToolsOnly.test.mjs +61 -0
  540. package/src/middleware/noStore.js +27 -0
  541. package/src/middleware/noStore.test.mjs +48 -0
  542. package/src/middleware/requestTimeout.js +38 -0
  543. package/src/middleware/staleFlag.js +76 -0
  544. package/src/middleware/staleFlag.test.mjs +127 -0
  545. package/src/middleware/stampCaller.js +38 -0
  546. package/src/middleware/stampCaller.test.mjs +64 -0
  547. package/src/routes/agent.js +15 -3
  548. package/src/routes/auth.js +29 -20
  549. package/src/routes/auth.test.mjs +30 -56
  550. package/src/routes/clientError.js +13 -13
  551. package/src/routes/cron.js +3 -1
  552. package/src/routes/cronForward.js +4 -2
  553. package/src/routes/detached.js +22 -9
  554. package/src/routes/devInspect.js +17 -8
  555. package/src/routes/devInspect.test.mjs +44 -7
  556. package/src/routes/devRecording.js +113 -0
  557. package/src/routes/devRecording.test.mjs +253 -0
  558. package/src/routes/docs/buildStatus.js +10 -2
  559. package/src/routes/docs/buildStatus.test.mjs +46 -0
  560. package/src/routes/docs/content.js +5 -3
  561. package/src/routes/docs/evalOperator.js +15 -1
  562. package/src/routes/docs/events.js +50 -0
  563. package/src/routes/docs/events.test.mjs +117 -0
  564. package/src/routes/docs/find.js +9 -1
  565. package/src/routes/docs/icons.js +30 -0
  566. package/src/routes/docs/inspectState.js +16 -1
  567. package/src/routes/docs/journey.js +194 -0
  568. package/src/routes/docs/journey.test.mjs +401 -0
  569. package/src/routes/docs/journeyRefusals.test.mjs +71 -0
  570. package/src/routes/docs/loadState.js +4 -2
  571. package/src/routes/docs/mcp.js +34 -3
  572. package/src/routes/docs/mcp.test.mjs +130 -0
  573. package/src/routes/docs/mutants.js +78 -0
  574. package/src/routes/docs/mutants.test.mjs +180 -0
  575. package/src/routes/docs/parsePathParamsParam.js +41 -0
  576. package/src/routes/docs/parsePathParamsParam.test.mjs +42 -0
  577. package/src/routes/docs/parseUserParam.js +27 -21
  578. package/src/routes/docs/parseUserParam.test.mjs +17 -5
  579. package/src/routes/docs/restart.js +35 -0
  580. package/src/routes/docs/restart.test.mjs +64 -0
  581. package/src/routes/docs/runEndpoint.js +54 -0
  582. package/src/routes/docs/runEndpoint.test.mjs +165 -0
  583. package/src/routes/docs/runRequest.js +25 -8
  584. package/src/routes/docs/runRequest.test.mjs +92 -0
  585. package/src/routes/docs/screenshot.js +50 -2
  586. package/src/routes/docs/screenshot.test.mjs +117 -0
  587. package/src/routes/docs/search.js +15 -2
  588. package/src/routes/docs/snapshotState.js +17 -3
  589. package/src/routes/endpoints.js +27 -26
  590. package/src/routes/endpointsTextAnswer.test.mjs +77 -0
  591. package/src/routes/feedback.js +10 -13
  592. package/src/routes/jitPage.js +99 -35
  593. package/src/routes/jitPage.test.mjs +248 -0
  594. package/src/routes/mcp.js +82 -5
  595. package/src/routes/mcp.test.mjs +215 -0
  596. package/src/routes/mcpMemberToken.test.mjs +125 -0
  597. package/src/routes/mcpProtectedResourceMetadata.js +34 -0
  598. package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
  599. package/src/routes/mountDevPageRoutes.js +25 -0
  600. package/src/routes/mountDevPageRoutes.test.mjs +30 -0
  601. package/src/routes/mountOauthDiscovery.js +44 -0
  602. package/src/routes/mountOauthDiscovery.test.mjs +79 -0
  603. package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
  604. package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
  605. package/src/routes/reload.js +16 -0
  606. package/src/routes/request.js +6 -4
  607. package/src/routes/request.test.mjs +9 -5
  608. package/src/routes/requestBody.test.mjs +159 -0
  609. package/src/routes/usage.js +6 -2
  610. package/src/routes/user.js +27 -0
  611. package/src/routes/websocket.js +29 -8
  612. package/src/routes/websocket.test.mjs +65 -0
  613. package/src/routes/wellKnownFallback.js +30 -0
  614. package/src/routes/wellKnownFallback.test.mjs +32 -0
  615. package/src/websocket/devWebSocket.js +41 -26
  616. package/src/websocket/devWebSocket.test.mjs +157 -0
  617. package/vite.config.js +53 -23
  618. package/lib/server/auth/getDevSession.js +0 -70
  619. package/lib/server/auth/getDevSession.test.mjs +0 -138
  620. package/lib/server/auth/session.js +0 -39
  621. package/manager/processes/warnAuthUrlPortMismatch.mjs +0 -47
  622. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +0 -93
  623. package/manager/watchers/moduleBuildWatcher.mjs +0 -79
  624. package/manager/watchers/serverArtifactWatcher.mjs +0 -120
  625. package/src/routes/apiPage.js +0 -37
@@ -14,60 +14,245 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- /* eslint-disable react/jsx-props-no-spreading */
18
-
19
- import React, { useEffect, useRef } from 'react';
17
+ import React, { useEffect, useRef, useState } from 'react';
18
+ import { createAuthClient } from 'better-auth/react';
20
19
  import {
21
- authConfigManager,
22
- getSession,
23
- SessionProvider,
24
- signIn,
25
- signOut,
26
- useSession,
27
- } from '@hono/auth-js/react';
20
+ adminClient,
21
+ emailOTPClient,
22
+ magicLinkClient,
23
+ organizationClient,
24
+ phoneNumberClient,
25
+ twoFactorClient,
26
+ } from 'better-auth/client/plugins';
27
+ import { oauthProviderClient } from '@better-auth/oauth-provider/client';
28
+ import { passkeyClient } from '@better-auth/passkey/client';
28
29
 
29
- import { serializer } from '@lowdefy/helpers';
30
+ import claimCallerReload from '@lowdefy/client/claimCallerReload.js';
31
+ import { normalizeCaller } from '@lowdefy/helpers';
30
32
 
31
- import rawLowdefyConfig from '../../../build/config.json';
33
+ import bundleBuildId from '../bundleBuildId.js';
34
+ import createFetchCheckingBuild from './createFetchCheckingBuild.js';
32
35
 
33
- const lowdefyConfig = serializer.deserialize(rawLowdefyConfig);
36
+ // The app basePath, from Vite's BASE_URL (`${config.basePath}/`, set in
37
+ // vite.config.js). build/config.json is server-only - it carries every
38
+ // deployment environment's settings - so it is never imported into the client.
39
+ const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
34
40
 
35
- // @hono/auth-js/react configures its fetch paths through a module-level
36
- // manager instead of SessionProvider props.
37
- if (lowdefyConfig.basePath) {
38
- authConfigManager.setConfig({ basePath: `${lowdefyConfig.basePath}/api/auth` });
39
- }
41
+ // Reloads a tab whose auth call fails against a newer build.
42
+ const fetchCheckingBuild = createFetchCheckingBuild({ buildId: bundleBuildId });
43
+
44
+ // GenericOAuth providers have no client plugin here. BetterAuth 1.7.0 dropped
45
+ // genericOAuthClient, and the server plugin's own routes with it: a generic
46
+ // provider is registered as a social provider keyed by its providerId, so it
47
+ // signs in through signIn.social and returns on /callback/:id like any
48
+ // built-in one. The client's path proxy would still turn a signIn.oauth2 call
49
+ // into POST /sign-in/oauth2, a route no 1.7 server serves.
50
+ const authClient = createAuthClient({
51
+ baseURL: `${window.location.origin}${basePath}/api/auth`,
52
+ fetchOptions: { customFetchImpl: fetchCheckingBuild },
53
+ plugins: [
54
+ adminClient(),
55
+ emailOTPClient(),
56
+ magicLinkClient(),
57
+ oauthProviderClient(),
58
+ organizationClient(),
59
+ passkeyClient(),
60
+ phoneNumberClient(),
61
+ twoFactorClient(),
62
+ ],
63
+ });
40
64
 
41
- function Session({ children }) {
42
- const wasAuthenticated = useRef(false);
43
- const { data: session, status } = useSession();
44
- wasAuthenticated.current = wasAuthenticated.current || status === 'authenticated';
65
+ // UpdateSession owns session freshness - suppress BetterAuth's own
66
+ // signal-driven refetch on the calls that would otherwise fire one, so
67
+ // nothing competes with (and aborts) the refetch UpdateSession awaits.
68
+ // Only calls that mutate an existing session are suppressed - calls that
69
+ // can establish one (the sign-ins, and the two-factor, phone-number,
70
+ // email-otp and passkey verifies) keep their signal: its refetch is what makes the store
71
+ // notice a login when no navigation follows.
72
+ // disableSignal silences all atomListeners for the call, including the
73
+ // organization plugin's org atoms - none are exposed to app config, so a
74
+ // future design exposing one must refresh it through its own action.
75
+ const sessionScoped = (params) => ({
76
+ ...params,
77
+ fetchOptions: { ...params?.fetchOptions, disableSignal: true },
78
+ });
79
+
80
+ // The server resolves the caller per request and embeds it in the page
81
+ // config, so the first render never flashes unauthenticated. The BetterAuth
82
+ // client store takes over once its session fetch settles.
83
+ //
84
+ // Roles and merged attributes resolve server-side from the active member row
85
+ // - the base session carries neither. The last server-resolved caller is
86
+ // held in state: while the session user is unchanged it stays authoritative
87
+ // for the fields the session does not carry, and UpdateSession refreshes it
88
+ // from /api/user after a change (e.g. SetActiveOrganization). Holding it in
89
+ // state, not a ref, re-renders Session when it changes so _user recomputes -
90
+ // the same reactive footing the session half already stands on.
91
+ //
92
+ // Only the session half is passed through normalizeCaller. BetterAuth's store
93
+ // returns camelCase keys whatever the columns are named, so untransformed it
94
+ // would sit emailVerified beside the resolved caller's email_verified on one
95
+ // object; anything arriving from the server is already snake_case.
96
+ function Session({ children, reloadSuppressedRef, serverUser }) {
97
+ const { data: session, isPending } = authClient.useSession();
98
+ const [resolvedUser, setResolvedUser] = useState(serverUser);
99
+ const wasAuthenticated = useRef(Boolean(serverUser));
45
100
 
46
101
  useEffect(() => {
47
- if (wasAuthenticated.current && status === 'unauthenticated') {
102
+ if (session) {
103
+ wasAuthenticated.current = true;
104
+ }
105
+ // An auth call that navigates away (a logout or sign-in with a callback)
106
+ // suppresses both reloads below - its own navigation would otherwise race
107
+ // them.
108
+ if (reloadSuppressedRef.current) {
109
+ return;
110
+ }
111
+ // Reload after sign-out (or session revocation) so the server can apply
112
+ // the page auth fork - a protected page redirects to the login page.
113
+ if (wasAuthenticated.current && !isPending && !session) {
48
114
  window.location.reload();
115
+ return;
49
116
  }
50
- }, [status]);
117
+ // The session now names another person than the caller this page renders
118
+ // (a sign-in as someone else, here or in another tab). Reload so the server
119
+ // renders the page for the new person, rather than keep showing the
120
+ // previous person's page and data. At most once per reload interval
121
+ // (claimCallerReload): past it the previous caller stays rendered, and its
122
+ // requests fail when the server answers as the new person.
123
+ if (
124
+ session?.user &&
125
+ resolvedUser &&
126
+ session.user.id !== resolvedUser.id &&
127
+ claimCallerReload({ window })
128
+ ) {
129
+ window.location.reload();
130
+ }
131
+ }, [session, isPending, resolvedUser]);
51
132
 
52
- // If session is passed to SessionProvider from the server-rendered config
53
- // we won't have a loading state here, but unauthenticated first loads do.
54
- if (status === 'loading') {
55
- return '';
133
+ if (isPending) {
134
+ return children(resolvedUser, setResolvedUser);
135
+ }
136
+ if (!session?.user) {
137
+ return children(null, setResolvedUser);
138
+ }
139
+ const resolved = resolvedUser;
140
+ if (!resolved) {
141
+ return children({ roles: [], ...normalizeCaller(session.user) }, setResolvedUser);
142
+ }
143
+ if (resolved.id !== session.user.id) {
144
+ // The session names another person than the resolved caller; the effect
145
+ // above reloads the page. Until it unloads, render the last resolved
146
+ // caller whole rather than mixing the new session user with the previous
147
+ // person's resolved fields, or falling back to a caller with no roles -
148
+ // blocks re-evaluate operators on render, so a role-guarded page would act
149
+ // on an empty role list.
150
+ return children(resolved, setResolvedUser);
56
151
  }
57
- return children(session);
152
+ // _user is one object with one meaning on both sides: the resolved caller is
153
+ // spread whole, so every field resolveAuthentication emits is readable in app
154
+ // config without this file naming it. session.user is the floor - the client
155
+ // store may hold a fresher one than the resolved state, and the resolved
156
+ // object wins wherever both carry a key.
157
+ const user = { ...normalizeCaller(session.user), ...resolved };
158
+ return children(user, setResolvedUser);
58
159
  }
59
160
 
60
- function AuthConfigured({ authConfig, children, serverSession }) {
61
- const auth = { authConfig, getSession, signIn, signOut };
161
+ function AuthConfigured({ authConfig, children, serverUser }) {
162
+ const reloadSuppressedRef = useRef(false);
163
+ const auth = {
164
+ authConfig,
165
+ getSession: ({ disableCookieCache } = {}) =>
166
+ authClient.getSession(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
167
+ // Refetches the session through the session atom - the store's only
168
+ // writer - and resolves only after the store is written. fetchSession
169
+ // returns undefined and never rejects (failures are written into the
170
+ // atom), so the atom's own { data, error } is read back and returned,
171
+ // keeping unwrap working and surfacing a failed refresh to the action.
172
+ refreshSession: async ({ disableCookieCache } = {}) => {
173
+ const sessionAtom = authClient.$store.atoms.session;
174
+ await sessionAtom
175
+ .get()
176
+ .refetch(disableCookieCache ? { query: { disableCookieCache: true } } : undefined);
177
+ const { data, error } = sessionAtom.get();
178
+ return { data, error };
179
+ },
180
+ // The server-resolved caller - roles from the active member row and the
181
+ // merged attributes bag - for re-syncing after session changes.
182
+ getResolvedUser: async () => {
183
+ const response = await fetchCheckingBuild(`${basePath}/api/user`, {
184
+ credentials: 'same-origin',
185
+ });
186
+ if (!response.ok) {
187
+ // /api/user itself always answers 200 { user } - user: null when no
188
+ // caller is admitted, which updateSession branches on. A non-OK
189
+ // response is a transport failure (gateway error page, 5xx) with no
190
+ // parsable body.
191
+ throw new Error(`Failed to fetch the resolved user (HTTP ${response.status}).`);
192
+ }
193
+ return response.json();
194
+ },
195
+ suppressSessionReload: () => {
196
+ reloadSuppressedRef.current = true;
197
+ },
198
+ acceptInvitation: (params) => authClient.organization.acceptInvitation(sessionScoped(params)),
199
+ // addPasskey runs the WebAuthn browser ceremony itself - options fetch,
200
+ // authenticator prompt, verification.
201
+ addPasskey: (params) => authClient.passkey.addPasskey(params),
202
+ changePassword: (params) => authClient.changePassword(sessionScoped(params)),
203
+ deletePasskey: (params) => authClient.passkey.deletePasskey(params),
204
+ updatePasskey: (params) => authClient.passkey.updatePasskey(params),
205
+ leaveOrganization: (params) => authClient.organization.leave(sessionScoped(params)),
206
+ // The caller's memberships from the org plugin, keyed on the session
207
+ // alone - not the active organization. sessionScoped so the list read
208
+ // does not also fire the org-list atom's own refetch of the same route.
209
+ listOrganizations: () => authClient.organization.list(sessionScoped()),
210
+ // POST /oauth2/consent. The signed authorization query is not passed
211
+ // here - the oauthProviderClient fetch plugin stamps it onto the body as
212
+ // oauth_query from window.location.search, so the call must run while
213
+ // the page still holds the query the oauth-provider redirect arrived
214
+ // with.
215
+ oauth2Consent: (params) => authClient.oauth2.consent(params),
216
+ // POST /oauth2/continue - resumes the authorization after the post-login
217
+ // organization choice (the page runs SetActiveOrganization first). Same
218
+ // oauth_query contract as oauth2Consent: the signed query rides from
219
+ // window.location.search, so the call must run before any navigation.
220
+ oauth2Continue: (params) => authClient.oauth2.continue(params),
221
+ // The one-time email code: send, then sign in with it.
222
+ emailOtpSend: (params) => authClient.emailOtp.sendVerificationOtp(params),
223
+ emailOtpVerify: (params) => authClient.signIn.emailOtp(params),
224
+ phoneNumberRequestPasswordReset: (params) =>
225
+ authClient.phoneNumber.requestPasswordReset(params),
226
+ phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
227
+ phoneNumberSendOtp: (params) => authClient.phoneNumber.sendOtp(params),
228
+ phoneNumberVerify: (params) => authClient.phoneNumber.verify(params),
229
+ requestPasswordReset: (params) => authClient.requestPasswordReset(params),
230
+ resetPassword: (params) => authClient.resetPassword(params),
231
+ revokeOtherSessions: () => authClient.revokeOtherSessions(sessionScoped()),
232
+ sendVerificationEmail: (params) => authClient.sendVerificationEmail(params),
233
+ setActiveOrganization: (params) => authClient.organization.setActive(sessionScoped(params)),
234
+ signInEmail: (params) => authClient.signIn.email(params),
235
+ signInMagicLink: (params) => authClient.signIn.magicLink(params),
236
+ signInPasskey: (params) => authClient.signIn.passkey(params),
237
+ signInPhoneNumber: (params) => authClient.signIn.phoneNumber(params),
238
+ signInSocial: (params) => authClient.signIn.social(params),
239
+ signOut: () => authClient.signOut(),
240
+ signUpEmail: (params) => authClient.signUp.email(params),
241
+ twoFactorDisable: (params) => authClient.twoFactor.disable(sessionScoped(params)),
242
+ twoFactorEnable: (params) => authClient.twoFactor.enable(sessionScoped(params)),
243
+ twoFactorGenerateBackupCodes: (params) =>
244
+ authClient.twoFactor.generateBackupCodes(sessionScoped(params)),
245
+ twoFactorVerifyBackupCode: (params) => authClient.twoFactor.verifyBackupCode(params),
246
+ twoFactorVerifyTotp: (params) => authClient.twoFactor.verifyTotp(params),
247
+ };
62
248
  return (
63
- <SessionProvider session={serverSession}>
64
- <Session>
65
- {(session) => {
66
- auth.session = session;
67
- return children(auth);
68
- }}
69
- </Session>
70
- </SessionProvider>
249
+ <Session reloadSuppressedRef={reloadSuppressedRef} serverUser={serverUser}>
250
+ {(user, setResolvedUser) => {
251
+ auth.user = user;
252
+ auth.updateResolvedUser = (resolved) => setResolvedUser(resolved);
253
+ return children(auth);
254
+ }}
255
+ </Session>
71
256
  );
72
257
  }
73
258
 
@@ -13,18 +13,59 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
- /* eslint-disable react/jsx-props-no-spreading */
16
+
17
+ import { ConfigError } from '@lowdefy/errors';
17
18
 
18
19
  function authNotConfigured() {
19
- throw new Error('Auth not configured.');
20
+ throw new ConfigError(
21
+ 'Auth is not configured. Add an "auth" section to lowdefy.yaml to use auth actions.'
22
+ );
20
23
  }
21
24
 
22
- function AuthNotConfigured({ authConfig, children }) {
25
+ // Every key AuthConfigured exposes must be present here, or an auth action in an
26
+ // app without an auth section fails with "auth.x is not a function" instead of
27
+ // the ConfigError that names the missing config.
28
+ function AuthNotConfigured({ authConfig, children, user = null }) {
23
29
  const auth = {
24
30
  authConfig,
31
+ user,
32
+ acceptInvitation: authNotConfigured,
33
+ addPasskey: authNotConfigured,
34
+ changePassword: authNotConfigured,
35
+ deletePasskey: authNotConfigured,
36
+ getResolvedUser: authNotConfigured,
25
37
  getSession: authNotConfigured,
26
- signIn: authNotConfigured,
38
+ leaveOrganization: authNotConfigured,
39
+ listOrganizations: authNotConfigured,
40
+ oauth2Consent: authNotConfigured,
41
+ oauth2Continue: authNotConfigured,
42
+ emailOtpSend: authNotConfigured,
43
+ emailOtpVerify: authNotConfigured,
44
+ phoneNumberRequestPasswordReset: authNotConfigured,
45
+ phoneNumberResetPassword: authNotConfigured,
46
+ phoneNumberSendOtp: authNotConfigured,
47
+ phoneNumberVerify: authNotConfigured,
48
+ refreshSession: authNotConfigured,
49
+ requestPasswordReset: authNotConfigured,
50
+ resetPassword: authNotConfigured,
51
+ revokeOtherSessions: authNotConfigured,
52
+ sendVerificationEmail: authNotConfigured,
53
+ setActiveOrganization: authNotConfigured,
54
+ signInEmail: authNotConfigured,
55
+ signInMagicLink: authNotConfigured,
56
+ signInPasskey: authNotConfigured,
57
+ signInPhoneNumber: authNotConfigured,
58
+ signInSocial: authNotConfigured,
27
59
  signOut: authNotConfigured,
60
+ signUpEmail: authNotConfigured,
61
+ suppressSessionReload: authNotConfigured,
62
+ twoFactorDisable: authNotConfigured,
63
+ twoFactorEnable: authNotConfigured,
64
+ twoFactorGenerateBackupCodes: authNotConfigured,
65
+ twoFactorVerifyBackupCode: authNotConfigured,
66
+ twoFactorVerifyTotp: authNotConfigured,
67
+ updatePasskey: authNotConfigured,
68
+ updateResolvedUser: authNotConfigured,
28
69
  };
29
70
 
30
71
  return children(auth);
@@ -0,0 +1,61 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { readFile } from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import AuthNotConfigured from './AuthNotConfigured.js';
22
+
23
+ const authDir = path.dirname(fileURLToPath(import.meta.url));
24
+
25
+ // The keys of the `const auth = { ... }` literal plus the `auth.x =` assignments
26
+ // made after it - both reach the app through the same context object.
27
+ function readExposedKeys(source) {
28
+ const literal = source.slice(source.indexOf('const auth = {'), source.indexOf('\n };'));
29
+ const keys = [...literal.matchAll(/^ {4}([a-zA-Z0-9_]+)[:,]/gm)].map((match) => match[1]);
30
+ const assigned = [...source.matchAll(/^ +auth\.([a-zA-Z0-9_]+) =/gm)].map((match) => match[1]);
31
+ return [...new Set([...keys, ...assigned])].sort();
32
+ }
33
+
34
+ function callAuthNotConfigured() {
35
+ let auth;
36
+ AuthNotConfigured({ authConfig: { enabled: false }, children: (value) => (auth = value) });
37
+ return auth;
38
+ }
39
+
40
+ // An app with no auth section still renders every block and action. A method
41
+ // AuthConfigured exposes but AuthNotConfigured omits surfaces as
42
+ // "auth.x is not a function" instead of the ConfigError naming the missing config.
43
+ test('AuthNotConfigured exposes every key AuthConfigured exposes', async () => {
44
+ const source = await readFile(path.join(authDir, 'AuthConfigured.jsx'), 'utf8');
45
+ const auth = callAuthNotConfigured();
46
+ expect(Object.keys(auth).sort()).toEqual(readExposedKeys(source));
47
+ });
48
+
49
+ test('AuthNotConfigured keeps authConfig and a null user', () => {
50
+ const auth = callAuthNotConfigured();
51
+ expect(auth.authConfig).toEqual({ enabled: false });
52
+ expect(auth.user).toBeNull();
53
+ });
54
+
55
+ test('AuthNotConfigured methods throw a ConfigError naming the missing auth config', () => {
56
+ const auth = callAuthNotConfigured();
57
+ expect(() => auth.signInEmail({})).toThrow(
58
+ 'Auth is not configured. Add an "auth" section to lowdefy.yaml to use auth actions.'
59
+ );
60
+ expect(() => auth.signInEmail({})).toThrow(expect.objectContaining({ name: 'ConfigError' }));
61
+ });
@@ -0,0 +1,40 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { readdir, readFile } from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const devAuthDir = path.dirname(fileURLToPath(import.meta.url));
22
+ const prodAuthDir = path.resolve(devAuthDir, '../../../../server/lib/client/auth');
23
+
24
+ // The dev and prod server templates each carry their own copy of the auth
25
+ // components, kept in sync by hand. A change applied to one and not the
26
+ // other ships dev-only or prod-only - the app then behaves differently
27
+ // between lowdefy dev and a production build, which no unit test of either
28
+ // copy can catch.
29
+ test('the auth client templates are byte-identical between server and server-dev', async () => {
30
+ const isTemplate = (file) => !file.includes('.test.');
31
+ const devFiles = (await readdir(devAuthDir)).filter(isTemplate).sort();
32
+ const prodFiles = (await readdir(prodAuthDir)).filter(isTemplate).sort();
33
+ expect(prodFiles).toEqual(devFiles);
34
+ for (const file of devFiles) {
35
+ const dev = await readFile(path.join(devAuthDir, file), 'utf8');
36
+ const prod = await readFile(path.join(prodAuthDir, file), 'utf8');
37
+ // Compared per file so a failure names the file that diverged.
38
+ expect({ file, content: prod }).toEqual({ file, content: dev });
39
+ }
40
+ });
@@ -0,0 +1,181 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { createAuthClient } from 'better-auth/client';
18
+
19
+ // AuthConfigured.jsx depends on behaviours of better-auth's client internals
20
+ // that no public API documents:
21
+ // 1. authClient.$store.atoms.session is the real session store, and its
22
+ // value exposes an awaitable refetch - the store's only writer.
23
+ // 2. A getSession path call never writes the store (/get-session is not in
24
+ // the atomListeners matcher) - the defect refreshSession exists to work
25
+ // around.
26
+ // 3. A failed refetch resolves normally (it never rejects) and writes the
27
+ // error into the atom - why refreshSession reads { data, error } back
28
+ // instead of using the refetch's return value.
29
+ // 4. The proxy fires a signal-driven background session fetch shortly
30
+ // after a matched endpoint succeeds, aborting any refetch already in
31
+ // flight, and fetchOptions.disableSignal suppresses it - the
32
+ // sessionScoped mechanism, and why it is required rather than merely
33
+ // awaiting the refetch.
34
+ // A better-auth upgrade that changes any of these breaks UpdateSession the
35
+ // way the original defect did - visibly only in a running app. These tests
36
+ // pin the contract so the break surfaces here instead.
37
+
38
+ const USER_1 = { id: 'user-1', email: 'user-1@example.com' };
39
+ const USER_2 = { id: 'user-2', email: 'user-2@example.com' };
40
+
41
+ function jsonResponse(payload, { status = 200 } = {}) {
42
+ return new Response(JSON.stringify(payload), {
43
+ status,
44
+ headers: { 'Content-Type': 'application/json' },
45
+ });
46
+ }
47
+
48
+ function sessionPayload(user) {
49
+ return { session: { id: 'session-1', userId: user.id }, user };
50
+ }
51
+
52
+ function createClient(handleGetSession) {
53
+ const requests = [];
54
+ const client = createAuthClient({
55
+ baseURL: 'http://localhost:3000/api/auth',
56
+ fetchOptions: {
57
+ customFetchImpl: async (input, init) => {
58
+ const url = String(input);
59
+ requests.push({ url, init });
60
+ if (url.includes('/get-session')) {
61
+ if (handleGetSession) {
62
+ return handleGetSession(url, init);
63
+ }
64
+ return jsonResponse(sessionPayload(USER_1));
65
+ }
66
+ return jsonResponse({ status: true });
67
+ },
68
+ },
69
+ });
70
+ // Mount the session atom with a never-removed subscription, mirroring the
71
+ // app - Session's useSession() keeps it mounted, and mounting is what
72
+ // makes the signal subscription live. It also keeps the store mounted for
73
+ // bare .get() calls: on an unmounted onMount store, .get() temporarily
74
+ // mounts it and schedules nanostores' delayed unmount on a timer that
75
+ // outlives the jest worker. The mount's own initial fetch is skipped in
76
+ // node (isServer), so no request is recorded here.
77
+ client.$store.atoms.session.subscribe(() => {});
78
+ const getSessionRequests = () => requests.filter((r) => r.url.includes('/get-session'));
79
+ return { client, getSessionRequests };
80
+ }
81
+
82
+ function wait(ms) {
83
+ return new Promise((resolve) => setTimeout(resolve, ms));
84
+ }
85
+
86
+ test('the session store is reachable at $store.atoms.session and exposes an awaitable refetch', () => {
87
+ const { client } = createClient();
88
+ const value = client.$store.atoms.session.get();
89
+ expect(value).toMatchObject({ data: null, error: null, isPending: true });
90
+ expect(typeof value.refetch).toBe('function');
91
+ });
92
+
93
+ test('a getSession path call resolves the session without ever writing the store', async () => {
94
+ const { client } = createClient();
95
+ const sessionAtom = client.$store.atoms.session;
96
+ const { data } = await client.getSession({ query: { disableCookieCache: true } });
97
+ expect(data.user).toEqual(USER_1);
98
+ expect(sessionAtom.get().data).toBe(null);
99
+ });
100
+
101
+ test('an awaited refetch writes the store before resolving, carrying the disableCookieCache query', async () => {
102
+ const { client, getSessionRequests } = createClient();
103
+ const sessionAtom = client.$store.atoms.session;
104
+ const result = await sessionAtom.get().refetch({ query: { disableCookieCache: true } });
105
+ // The refetch resolves to undefined - its outcome is only readable from
106
+ // the atom, which is why refreshSession returns the atom's { data, error }.
107
+ expect(result).toBeUndefined();
108
+ const { data, error, isPending } = sessionAtom.get();
109
+ expect(data.user).toEqual(USER_1);
110
+ expect(error).toBe(null);
111
+ expect(isPending).toBe(false);
112
+ expect(getSessionRequests()).toHaveLength(1);
113
+ expect(getSessionRequests()[0].url).toContain('disableCookieCache=true');
114
+ });
115
+
116
+ test('a failed refetch resolves normally and writes the error into the atom', async () => {
117
+ const { client } = createClient(() =>
118
+ jsonResponse({ message: 'Internal error.' }, { status: 500 })
119
+ );
120
+ const sessionAtom = client.$store.atoms.session;
121
+ await expect(sessionAtom.get().refetch()).resolves.toBeUndefined();
122
+ const { error } = sessionAtom.get();
123
+ expect(error).not.toBe(null);
124
+ expect(error.status).toBe(500);
125
+ });
126
+
127
+ test('a matched endpoint fires one background session refetch shortly after success', async () => {
128
+ const { client, getSessionRequests } = createClient();
129
+ await client.changePassword({ currentPassword: 'old', newPassword: 'new' });
130
+ expect(getSessionRequests()).toHaveLength(0);
131
+ // The proxy flips $sessionSignal on a 10ms setTimeout after success.
132
+ await wait(100);
133
+ expect(getSessionRequests()).toHaveLength(1);
134
+ });
135
+
136
+ test('fetchOptions.disableSignal suppresses the background refetch - the sessionScoped mechanism', async () => {
137
+ const { client, getSessionRequests } = createClient();
138
+ await client.changePassword({
139
+ currentPassword: 'old',
140
+ newPassword: 'new',
141
+ fetchOptions: { disableSignal: true },
142
+ });
143
+ await wait(100);
144
+ expect(getSessionRequests()).toHaveLength(0);
145
+ });
146
+
147
+ test('a competing session fetch aborts an in-flight refetch, which resolves having written nothing', async () => {
148
+ let getSessionCalls = 0;
149
+ const { client } = createClient((url, init) => {
150
+ getSessionCalls += 1;
151
+ if (getSessionCalls === 1) {
152
+ // The first refetch's request: rejects on abort like a real fetch,
153
+ // with a slow success fallback so the test stays deterministic even
154
+ // if the abort signal is not forwarded - the aborted-controller check
155
+ // must prevent the write either way.
156
+ return new Promise((resolve, reject) => {
157
+ const timer = setTimeout(() => resolve(jsonResponse(sessionPayload(USER_1))), 80);
158
+ init?.signal?.addEventListener('abort', () => {
159
+ clearTimeout(timer);
160
+ reject(new Error('aborted'));
161
+ });
162
+ });
163
+ }
164
+ return jsonResponse(sessionPayload(USER_2));
165
+ });
166
+ const sessionAtom = client.$store.atoms.session;
167
+ const first = sessionAtom.get().refetch({ query: { disableCookieCache: true } });
168
+ // The store dispatches the fetch on a microtask, and a competitor that
169
+ // arrives first cancels the flight before its request is ever issued -
170
+ // then the mock's first /get-session call would belong to the second
171
+ // refetch. Yield one macrotask so the first request is genuinely in
172
+ // flight, which is the race UpdateSession's awaited refetch loses.
173
+ await wait(0);
174
+ const second = sessionAtom.get().refetch();
175
+ await expect(first).resolves.toBeUndefined();
176
+ await second;
177
+ // Only the second fetch's write landed - the first was aborted mid-flight
178
+ // and wrote nothing, which is why UpdateSession must be the only session
179
+ // refresher for its awaited refetch to mean anything.
180
+ expect(sessionAtom.get().data.user).toEqual(USER_2);
181
+ });