@lowdefy/shell 3.23.2 → 7.2.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 (356) hide show
  1. package/LICENSE +183 -183
  2. package/README.md +364 -2
  3. package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
  4. package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
  5. package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
  6. package/app/+native-intent.js +39 -0
  7. package/app/_layout.js +36 -0
  8. package/app.config.ts +183 -0
  9. package/appLinks.js +65 -0
  10. package/applyShellPlugins.js +43 -0
  11. package/authCallbackIntentFilters.js +46 -0
  12. package/babel.config.js +25 -0
  13. package/checkShellConfig.js +172 -0
  14. package/checkShellPlugins.js +74 -0
  15. package/getPrivacyManifest.js +39 -0
  16. package/metro.config.js +73 -0
  17. package/native/LowdefyAppShortcuts.swift.template +84 -0
  18. package/native/withAppShortcuts.js +60 -0
  19. package/native-fingerprint.json +16 -0
  20. package/package.json +120 -40
  21. package/profiles.js +607 -0
  22. package/readShellConfig.js +36 -0
  23. package/resolveShellPlugins.js +109 -0
  24. package/runtimeVersion.js +112 -0
  25. package/scripts/checkReleaseBundle.mjs +358 -0
  26. package/scripts/createDayjsLocales.mjs +67 -0
  27. package/scripts/createShellAssets.mjs +54 -0
  28. package/scripts/createShellVersion.mjs +83 -0
  29. package/scripts/createTypeManifest.mjs +205 -0
  30. package/shell.dev.json +11 -0
  31. package/shellVariant.js +126 -0
  32. package/src/ShellContext.js +32 -0
  33. package/src/actions/CopyToClipboard.js +29 -0
  34. package/src/actions/CopyToClipboard.test.js +33 -0
  35. package/src/actions/GeolocationCurrentPosition.js +61 -0
  36. package/src/actions/GeolocationCurrentPosition.test.js +79 -0
  37. package/src/actions/GetLocalStorage.js +36 -0
  38. package/src/actions/RemoveLocalStorage.js +31 -0
  39. package/src/actions/ScrollTo.js +66 -0
  40. package/src/actions/ScrollTo.test.js +83 -0
  41. package/src/actions/SetFocus.js +33 -0
  42. package/src/actions/SetFocus.test.js +49 -0
  43. package/src/actions/SetLocalStorage.js +34 -0
  44. package/src/actions/localStorage.test.js +68 -0
  45. package/src/actions/measureInScrollView.js +36 -0
  46. package/src/actions/nativeActions.js +41 -0
  47. package/src/auth/AuthButton.js +44 -0
  48. package/src/auth/AuthGate.js +57 -0
  49. package/src/auth/AuthLink.js +43 -0
  50. package/src/auth/LockScreen.js +67 -0
  51. package/src/auth/LockScreen.test.js +37 -0
  52. package/src/auth/SignInScreen.js +268 -0
  53. package/src/auth/SignInScreen.test.js +187 -0
  54. package/src/auth/createAppAuth.js +325 -0
  55. package/src/auth/createAppAuth.test.js +424 -0
  56. package/src/auth/createAuthApi.js +115 -0
  57. package/src/auth/createAuthApi.test.js +138 -0
  58. package/src/auth/createCoreAuth.js +96 -0
  59. package/src/auth/createCoreAuth.test.js +188 -0
  60. package/src/auth/createDeviceKey.js +62 -0
  61. package/src/auth/createDeviceKey.test.js +54 -0
  62. package/src/auth/createSessionStores.js +45 -0
  63. package/src/auth/createTokenStore.js +103 -0
  64. package/src/auth/createTokenStore.test.js +116 -0
  65. package/src/auth/getAuthSettings.js +24 -0
  66. package/src/auth/getAuthView.js +39 -0
  67. package/src/auth/getAuthView.test.js +52 -0
  68. package/src/auth/getSheetReturnUrl.js +35 -0
  69. package/src/auth/getTokenStoreOptions.js +46 -0
  70. package/src/auth/isBackgroundShell.js +26 -0
  71. package/src/auth/isSheetReturnLink.js +35 -0
  72. package/src/auth/parseQuery.js +39 -0
  73. package/src/auth/purgeAppData.js +80 -0
  74. package/src/auth/purgeAppData.test.js +104 -0
  75. package/src/auth/shouldLock.js +26 -0
  76. package/src/auth/signInWithSheet.js +97 -0
  77. package/src/auth/signInWithSheet.test.js +205 -0
  78. package/src/auth/styles.js +78 -0
  79. package/src/auth/unregisterPushToken.js +62 -0
  80. package/src/auth/unregisterPushToken.test.js +67 -0
  81. package/src/auth/usesBiometricUnlock.js +23 -0
  82. package/src/auth/watchSession.js +47 -0
  83. package/src/auth/withoutCookies.js +26 -0
  84. package/src/cache/createSqliteCacheStore.js +113 -0
  85. package/src/cache/createSqliteCacheStore.test.js +93 -0
  86. package/src/cache/getCacheKey.js +25 -0
  87. package/src/cache/getLastCacheKeyName.js +22 -0
  88. package/src/components/AppProvider.js +118 -0
  89. package/src/components/Frame.js +116 -0
  90. package/src/components/Frame.test.js +101 -0
  91. package/src/components/LoadingScreen.js +35 -0
  92. package/src/components/MenuButton.js +121 -0
  93. package/src/components/MenuButton.test.js +74 -0
  94. package/src/components/MessageScreen.js +51 -0
  95. package/src/components/NavigationTheme.js +33 -0
  96. package/src/components/NoConnectionBanner.js +51 -0
  97. package/src/components/NoConnectionBanner.test.js +73 -0
  98. package/src/components/OtherPageScreen.js +95 -0
  99. package/src/components/OtherPageScreen.test.js +74 -0
  100. package/src/components/PageScreen.js +254 -0
  101. package/src/components/PageScreen.test.js +492 -0
  102. package/src/components/RouteErrorScreen.js +47 -0
  103. package/src/components/ShellRoot.js +34 -0
  104. package/src/components/ShellRoot.test.js +62 -0
  105. package/src/components/ShellThemeProvider.js +45 -0
  106. package/src/components/SwitchToTab.js +36 -0
  107. package/src/components/SwitchToTab.test.js +63 -0
  108. package/src/components/TabIconImages.js +72 -0
  109. package/src/components/UpdateScreen.js +40 -0
  110. package/src/components/getNavigationTheme.js +44 -0
  111. package/src/components/getNavigationTheme.test.js +41 -0
  112. package/src/components/styles.js +50 -0
  113. package/src/components/useSurfaceTokens.js +47 -0
  114. package/src/config/getAppApiBase.js +34 -0
  115. package/src/config/getShellConfig.js +37 -0
  116. package/src/config/getShellConfig.test.js +80 -0
  117. package/src/createShellApp.js +126 -0
  118. package/src/dev/DevShell.js +239 -0
  119. package/src/dev/DevShell.test.js +233 -0
  120. package/src/dev/connect/ConfirmSheet.js +71 -0
  121. package/src/dev/connect/ConnectScreen.js +117 -0
  122. package/src/dev/connect/QrScanner.js +89 -0
  123. package/src/dev/connection/createDevShellConfig.js +47 -0
  124. package/src/dev/connection/createDevTransport.js +51 -0
  125. package/src/dev/connection/createDevTransport.test.js +87 -0
  126. package/src/dev/connection/createServerList.js +48 -0
  127. package/src/dev/connection/isLoopbackHost.js +25 -0
  128. package/src/dev/connection/pairedShellApp.test.js +104 -0
  129. package/src/dev/connection/parseDevLink.js +66 -0
  130. package/src/dev/connection/parseDevLink.test.js +97 -0
  131. package/src/dev/connection/parseQuery.js +33 -0
  132. package/src/dev/connection/parseServerInput.js +40 -0
  133. package/src/dev/connection/parseServerUrl.js +45 -0
  134. package/src/dev/devStyles.js +97 -0
  135. package/src/dev/index.js +21 -0
  136. package/src/dev/inject/createDeviceInjections.js +188 -0
  137. package/src/dev/inject/createDeviceInjections.test.js +224 -0
  138. package/src/dev/inject/createOfflineFetch.js +35 -0
  139. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  140. package/src/dev/inject/injectPush.js +60 -0
  141. package/src/dev/inject/injectShare.js +55 -0
  142. package/src/dev/inject/withInjectedDevice.js +98 -0
  143. package/src/dev/menu/BlockGallery.js +33 -0
  144. package/src/dev/menu/DevMenu.js +121 -0
  145. package/src/dev/menu/createLogBuffer.js +83 -0
  146. package/src/dev/menu/watchShake.js +52 -0
  147. package/src/dev/menu/watchShake.test.js +61 -0
  148. package/src/dev/recorder/createNativeRecorder.js +204 -0
  149. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  150. package/src/dev/redirectDevLink.js +30 -0
  151. package/src/dev/redirectDevLink.test.js +49 -0
  152. package/src/dev/steps/createStepRunner.js +184 -0
  153. package/src/dev/steps/createStepRunner.test.js +262 -0
  154. package/src/dev/steps/findBlock.js +78 -0
  155. package/src/dev/steps/isContextSettled.js +37 -0
  156. package/src/dev/steps/waitForSettle.js +39 -0
  157. package/src/dev/steps/waitUntil.js +40 -0
  158. package/src/dev/tab/DevTabClient.js +97 -0
  159. package/src/dev/tab/answerEval.js +39 -0
  160. package/src/dev/tab/answerInspect.js +45 -0
  161. package/src/dev/tab/createDevTabClient.js +272 -0
  162. package/src/dev/tab/createDevTabClient.test.js +317 -0
  163. package/src/dev/tab/describeTabError.js +35 -0
  164. package/src/dev/tab/devClientMarker.js +22 -0
  165. package/src/dev/tab/devClientMarker.test.js +23 -0
  166. package/src/dev/tab/findPageContext.js +43 -0
  167. package/src/dev/tab/getShownPage.js +42 -0
  168. package/src/dev/tab/readEventStream.js +68 -0
  169. package/src/dev/tab/reloadApp.js +42 -0
  170. package/src/dev/tab/reloadApp.test.js +115 -0
  171. package/src/device/DeviceHosts.js +32 -0
  172. package/src/device/basic/createFilesFeature.js +41 -0
  173. package/src/device/basic/createHapticsFeature.js +46 -0
  174. package/src/device/basic/createKeepAwakeFeature.js +37 -0
  175. package/src/device/basic/createPhotosFeature.js +40 -0
  176. package/src/device/basic/createRequestQueueFeature.js +37 -0
  177. package/src/device/basic/createShareFeature.js +63 -0
  178. package/src/device/basic/createShareFeature.test.js +47 -0
  179. package/src/device/basic/createSpeechFeature.js +43 -0
  180. package/src/device/basic/createUploadFeature.js +41 -0
  181. package/src/device/basic/toImagePickerOptions.js +41 -0
  182. package/src/device/basic/toPickedFile.js +54 -0
  183. package/src/device/calendar/index.js +68 -0
  184. package/src/device/contacts/index.js +67 -0
  185. package/src/device/createDevice.js +65 -0
  186. package/src/device/device.js +27 -0
  187. package/src/device/field/MapView.js +93 -0
  188. package/src/device/field/ScanCodeSheet.js +86 -0
  189. package/src/device/field/ScannerView.js +52 -0
  190. package/src/device/field/createAudioFeature.js +70 -0
  191. package/src/device/field/createBrightnessFeature.js +40 -0
  192. package/src/device/field/createCameraFeature.js +56 -0
  193. package/src/device/field/createDocumentScannerFeature.js +51 -0
  194. package/src/device/field/createLocationFeature.js +39 -0
  195. package/src/device/field/createScanRequests.js +55 -0
  196. package/src/device/field/index.js +39 -0
  197. package/src/device/field/scanRequests.js +22 -0
  198. package/src/device/injected.js +60 -0
  199. package/src/device/injected.test.js +39 -0
  200. package/src/device/nfc/index.js +89 -0
  201. package/src/device/nfc/toNfcRecord.js +44 -0
  202. package/src/device/nfc/toNfcRecord.test.js +48 -0
  203. package/src/device/tracking/createBacklog.js +63 -0
  204. package/src/device/tracking/createBacklog.test.js +47 -0
  205. package/src/device/tracking/index.js +211 -0
  206. package/src/device/tracking/index.test.js +110 -0
  207. package/src/host/createDeviceStore.js +32 -0
  208. package/src/host/createMemoryStore.js +32 -0
  209. package/src/host/createNetworkStatus.js +94 -0
  210. package/src/host/createNetworkStatus.test.js +94 -0
  211. package/src/host/createRouterAdapter.js +33 -0
  212. package/src/host/createShellFetch.js +62 -0
  213. package/src/host/createShellFetch.test.js +165 -0
  214. package/src/host/createShellGlobals.js +62 -0
  215. package/src/host/createShellHost.js +83 -0
  216. package/src/host/createShellHost.test.js +36 -0
  217. package/src/host/getOrigin.js +31 -0
  218. package/src/host/isNetworkError.js +24 -0
  219. package/src/host/isOnline.js +22 -0
  220. package/src/host/networkStatus.js +24 -0
  221. package/src/host/readServerError.js +45 -0
  222. package/src/host/readServerError.test.js +85 -0
  223. package/src/host/reportShellError.js +47 -0
  224. package/src/host/reportShellError.test.js +79 -0
  225. package/src/host/withNetworkErrors.js +38 -0
  226. package/src/host/withNetworkErrors.test.js +61 -0
  227. package/src/host/withShellHeader.js +25 -0
  228. package/src/host/withShellHeader.test.js +28 -0
  229. package/src/js/evaluateJsEntries.js +43 -0
  230. package/src/js/evaluateJsEntries.test.js +48 -0
  231. package/src/locale/createShellLocale.js +112 -0
  232. package/src/locale/createShellLocale.test.js +74 -0
  233. package/src/locale/dayjsLocales.js +165 -0
  234. package/src/locale/getDeviceLocales.js +23 -0
  235. package/src/locale/loadDayjsLocale.js +32 -0
  236. package/src/locale/resolveActiveLocale.js +46 -0
  237. package/src/locale/resolveActiveLocale.test.js +40 -0
  238. package/src/native/backgroundFetch.js +97 -0
  239. package/src/native/callEndpoint.js +63 -0
  240. package/src/native/createAppEventTrigger.js +70 -0
  241. package/src/native/currentApp.js +49 -0
  242. package/src/native/getPagePath.js +32 -0
  243. package/src/native/openPath.js +55 -0
  244. package/src/native/openPath.test.js +39 -0
  245. package/src/native/parseShellLink.js +38 -0
  246. package/src/native/parseShellLink.test.js +29 -0
  247. package/src/native/startAppServices.js +94 -0
  248. package/src/native/startAppServices.test.js +97 -0
  249. package/src/native/startShareIn.js +59 -0
  250. package/src/native/startShortcuts.js +88 -0
  251. package/src/native/startShortcuts.test.js +59 -0
  252. package/src/native/toSharePayload.js +47 -0
  253. package/src/native/toSharePayload.test.js +47 -0
  254. package/src/native/whenAppReady.js +48 -0
  255. package/src/navigation/SlotContext.js +24 -0
  256. package/src/navigation/frameSlots.js +27 -0
  257. package/src/navigation/getFrame.js +30 -0
  258. package/src/navigation/getInitialScreenOptions.js +63 -0
  259. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  260. package/src/navigation/getPresentation.js +28 -0
  261. package/src/navigation/getSlotRootPath.js +27 -0
  262. package/src/navigation/getStackOptions.js +185 -0
  263. package/src/navigation/getTabHref.js +45 -0
  264. package/src/navigation/getTabIcons.js +43 -0
  265. package/src/navigation/isPageUnderHeader.js +46 -0
  266. package/src/navigation/navigation.test.js +342 -0
  267. package/src/navigation/toAppLinkPath.js +43 -0
  268. package/src/navigation/toAppLinkPath.test.js +53 -0
  269. package/src/navigation/toRouterHref.js +29 -0
  270. package/src/notifications/createNotificationsFeature.js +61 -0
  271. package/src/notifications/getCategoryAction.js +31 -0
  272. package/src/notifications/handleNotificationResponse.js +82 -0
  273. package/src/notifications/registerPushToken.js +71 -0
  274. package/src/notifications/startNotifications.js +114 -0
  275. package/src/notifications/toCategoryActions.js +42 -0
  276. package/src/notifications/toNotificationEvent.js +36 -0
  277. package/src/notifications/toScheduleRequest.js +62 -0
  278. package/src/plugins/appTypes.js +28 -0
  279. package/src/plugins/prefixTypes.js +28 -0
  280. package/src/plugins/shellPlugins.json +15 -0
  281. package/src/plugins/typeManifest.js +789 -0
  282. package/src/plugins/types.js +64 -0
  283. package/src/plugins/types.test.js +173 -0
  284. package/src/polyfills/installGetRandomValues.js +28 -0
  285. package/src/queue/createRequestQueue.js +198 -0
  286. package/src/queue/createRequestQueue.test.js +215 -0
  287. package/src/queue/createSqliteQueueStore.js +121 -0
  288. package/src/queue/summarizeQueue.js +39 -0
  289. package/src/session/createAppSession.js +323 -0
  290. package/src/session/createAppSession.test.js +391 -0
  291. package/src/session/getPageView.js +59 -0
  292. package/src/session/getPageView.test.js +137 -0
  293. package/src/sync/encodePagePath.js +27 -0
  294. package/src/sync/fetchPage.js +72 -0
  295. package/src/sync/fetchPage.test.js +84 -0
  296. package/src/sync/fetchRoot.js +43 -0
  297. package/src/sync/fetchUser.js +33 -0
  298. package/src/sync/runLimited.js +36 -0
  299. package/src/sync/toRootConfig.js +35 -0
  300. package/src/test/createFakeNetwork.js +40 -0
  301. package/src/test/createFakeSecureStore.js +40 -0
  302. package/src/test/createFakeServer.js +98 -0
  303. package/src/test/createMemoryCacheStore.js +59 -0
  304. package/src/test/createNetworkError.js +24 -0
  305. package/src/test/expoImage.js +42 -0
  306. package/src/test/expoNotifications.js +40 -0
  307. package/src/test/expoQuickActions.js +22 -0
  308. package/src/test/expoSpeechRecognition.js +31 -0
  309. package/src/test/expoTaskManager.js +25 -0
  310. package/src/test/expoUi.js +55 -0
  311. package/src/test/expoUiBottomSheet.js +64 -0
  312. package/src/test/expoUiDateTimePicker.js +26 -0
  313. package/src/test/expoUiMenu.js +46 -0
  314. package/src/test/expoUiSegmentedControl.js +47 -0
  315. package/src/test/expoUiSwiftUi.js +62 -0
  316. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  317. package/src/test/expoWebBrowser.js +23 -0
  318. package/src/test/fixtures.js +98 -0
  319. package/src/test/nativeModule.js +33 -0
  320. package/src/test/renderPageScreen.js +109 -0
  321. package/src/types/checkTypeSet.js +39 -0
  322. package/src/types/collectPageTypes.js +49 -0
  323. package/src/types/collectPageTypes.test.js +90 -0
  324. package/src/types/findMissingPageTypes.js +40 -0
  325. package/src/version/compareVersions.js +38 -0
  326. package/src/version/isShellTooOld.js +30 -0
  327. package/src/version/shellVersion.js +21 -0
  328. package/src/version/shellVersion.test.js +32 -0
  329. package/src/widgets/fetchWidgetData.js +59 -0
  330. package/src/widgets/index.js +156 -0
  331. package/src/widgets/refreshWidgets.js +31 -0
  332. package/withExcludedNativeModules.js +93 -0
  333. package/dist/location.js +0 -33
  334. package/dist/public/apple-touch-icon.png +0 -0
  335. package/dist/public/icon-32.png +0 -0
  336. package/dist/public/icon-512.png +0 -0
  337. package/dist/public/icon.svg +0 -17
  338. package/dist/public/logo-dark-theme.png +0 -0
  339. package/dist/public/logo-light-theme.png +0 -0
  340. package/dist/public/logo-square-dark-theme.png +0 -0
  341. package/dist/public/logo-square-light-theme.png +0 -0
  342. package/dist/public/manifest.webmanifest +0 -16
  343. package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
  344. package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
  345. package/dist/shell/454.b597ca21366afda000af.js +0 -2
  346. package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
  347. package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
  348. package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
  349. package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
  350. package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
  351. package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
  352. package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
  353. package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
  354. package/dist/shell/index.html +0 -57
  355. package/dist/shell/main.bde87d1433f0625faed6.js +0 -1
  356. package/dist/shell/runtime.1ead93159ba56c05b969.js +0 -1
@@ -0,0 +1,424 @@
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 { jest } from '@jest/globals';
18
+
19
+ import createAppAuth from './createAppAuth.js';
20
+ import createAppSession from '../session/createAppSession.js';
21
+ import createFakeServer, { header } from '../test/createFakeServer.js';
22
+ import createMemoryCacheStore from '../test/createMemoryCacheStore.js';
23
+ import createMemoryStore from '../host/createMemoryStore.js';
24
+ import getAuthView from './getAuthView.js';
25
+ import { createPages, createRoot } from '../test/fixtures.js';
26
+
27
+ jest.mock('expo-secure-store', () =>
28
+ jest.requireActual('../test/createFakeSecureStore.js').default()
29
+ );
30
+ jest.mock('expo-crypto', () => ({ getRandomBytes: (count) => new Uint8Array(count).fill(7) }));
31
+ jest.mock('expo-web-browser', () => ({}));
32
+ jest.mock('expo-local-authentication', () => ({}));
33
+
34
+ const SecureStore = jest.requireMock('expo-secure-store');
35
+
36
+ const apiBase = 'https://field.example.com';
37
+ const app = { id: 'field', name: 'Field', urls: { production: apiBase } };
38
+ const standardShell = { id: 'field', scheme: 'com.example.field', profile: 'standard' };
39
+ const trackingShell = { ...standardShell, profile: 'field-tracking' };
40
+ const SIGNED_IN = 'c-ana; session=s1; roles=r1';
41
+
42
+ const settings = { emailCode: true, unlock: 'none', unlockAfter: 300, sharedDevice: false };
43
+
44
+ function response({ body = {}, headers = {}, status = 200 }) {
45
+ return {
46
+ ok: status >= 200 && status < 300,
47
+ status,
48
+ headers: { get: (name) => headers[name.toLowerCase()] ?? null },
49
+ json: async () => structuredClone(body),
50
+ };
51
+ }
52
+
53
+ // The fake Lowdefy server, with Better Auth's sign-in and sign-out routes and sessions: a call
54
+ // answers as the person its bearer token belongs to, or as the signed-out caller.
55
+ function createServer({ auth = settings } = {}) {
56
+ const sessions = new Set();
57
+ const server = createFakeServer({
58
+ root: createRoot({ auth }),
59
+ rootEtag: '"root-1"',
60
+ pages: createPages([
61
+ ['today', 'today', '"p-today-1"'],
62
+ ['jobs', 'jobs', '"p-jobs-1"'],
63
+ ['jobs/{job_id}', 'job', '"p-job-1"'],
64
+ ]),
65
+ });
66
+ const serve = server.fetch.getMockImplementation();
67
+ server.fetch.mockImplementation(async (url, init = {}) => {
68
+ const { pathname } = new URL(url);
69
+ const body = init.body ? JSON.parse(init.body) : {};
70
+ const token = header(init, 'authorization')?.slice('Bearer '.length) ?? null;
71
+ const signedIn = token !== null && sessions.has(token);
72
+ if (pathname === '/api/auth/sign-in/email-otp') {
73
+ if (body.otp !== '123456') return response({ status: 400, body: { code: 'INVALID_OTP' } });
74
+ sessions.add('token-1');
75
+ return response({ body: { token: 'unsigned' }, headers: { 'set-auth-token': 'token-1' } });
76
+ }
77
+ if (pathname === '/api/auth/sign-out') {
78
+ sessions.delete(token);
79
+ return response({ body: { success: true } });
80
+ }
81
+ if (pathname === '/api/endpoints/admin') {
82
+ return response({ status: 403, headers: { 'x-lowdefy-caller': SIGNED_IN } });
83
+ }
84
+ if (pathname.startsWith('/api/request/') && !signedIn) {
85
+ return response({ status: 401, headers: { 'x-lowdefy-caller': '' } });
86
+ }
87
+ server.state.caller = signedIn ? SIGNED_IN : '';
88
+ server.state.user = signedIn ? { id: 'ana' } : null;
89
+ return serve(url, init);
90
+ });
91
+ return { server, sessions };
92
+ }
93
+
94
+ function createFakeAppState() {
95
+ let listener = null;
96
+ return {
97
+ addEventListener: (type, fn) => {
98
+ listener = fn;
99
+ return { remove: () => (listener = null) };
100
+ },
101
+ emit: (next) => listener?.(next),
102
+ };
103
+ }
104
+
105
+ async function until(check) {
106
+ for (let attempt = 0; attempt < 100; attempt += 1) {
107
+ if (check()) return;
108
+ await new Promise((resolve) => setTimeout(resolve, 0));
109
+ }
110
+ throw new Error('Timed out.');
111
+ }
112
+
113
+ async function setup({
114
+ cacheStore = createMemoryCacheStore(),
115
+ kv = createMemoryStore(),
116
+ queueStore = { removeApp: jest.fn(async () => undefined) },
117
+ server = createServer().server,
118
+ shell = standardShell,
119
+ streamFetch = jest.fn(),
120
+ } = {}) {
121
+ const clock = { now: 0 };
122
+ const appState = createFakeAppState();
123
+ const localAuthentication = { authenticateAsync: jest.fn(async () => ({ success: true })) };
124
+ const onSignedOut = jest.fn();
125
+ const auth = createAppAuth({
126
+ apiBase,
127
+ app,
128
+ appState,
129
+ cacheStore,
130
+ fetch: server.fetch,
131
+ kv,
132
+ localAuthentication,
133
+ now: () => clock.now,
134
+ onSignedOut,
135
+ queueStore,
136
+ shell,
137
+ streamFetch,
138
+ });
139
+ const session = createAppSession({
140
+ apiBase,
141
+ app,
142
+ cacheStore: auth.sessionStores.cacheStore,
143
+ fetch: auth.fetch,
144
+ kv: auth.sessionStores.kv,
145
+ tokenStore: auth.tokenStore,
146
+ });
147
+ await auth.start({ session });
148
+ const view = () => getAuthView({ auth: auth.getState(), session: session.getState() });
149
+ return { appState, auth, cacheStore, clock, kv, localAuthentication, onSignedOut, session, view };
150
+ }
151
+
152
+ async function signIn(context) {
153
+ await until(() => context.view() === 'signIn');
154
+ expect(await context.auth.signInWithCode({ email: 'ana@example.com', otp: '123456' })).toEqual({
155
+ status: 'signedIn',
156
+ });
157
+ await until(() => context.session.getState().cacheKey === 'field:c-ana:r1');
158
+ await until(() => context.session.getState().status === 'ready');
159
+ }
160
+
161
+ beforeEach(() => {
162
+ SecureStore.items.clear();
163
+ });
164
+
165
+ test('an app with auth asks for sign-in when nobody is signed in, and keeps its settings', async () => {
166
+ const context = await setup();
167
+ await until(() => context.view() === 'signIn');
168
+ expect(JSON.parse(context.kv.get('lowdefy-shell:auth:field'))).toEqual({
169
+ settings,
170
+ slug: 'field',
171
+ });
172
+ });
173
+
174
+ test('an app without auth opens without a sign-in screen', async () => {
175
+ const context = await setup({ server: createServer({ auth: null }).server });
176
+ await until(() => context.session.getState().status === 'ready');
177
+ expect(context.view()).toBe('app');
178
+ });
179
+
180
+ test('signing in by email code keeps the token and opens the app as the person', async () => {
181
+ const context = await setup();
182
+ await signIn(context);
183
+ expect(context.auth.tokenStore.get()).toBe('token-1');
184
+ expect(SecureStore.items.get('lowdefy-session-token.field|no-auth').value).toBe('token-1');
185
+ expect(context.view()).toBe('app');
186
+ expect(context.session.getState().user).toEqual({ id: 'ana' });
187
+ });
188
+
189
+ test('a wrong code signs nobody in', async () => {
190
+ const context = await setup();
191
+ await until(() => context.view() === 'signIn');
192
+ expect(await context.auth.signInWithCode({ email: 'ana@example.com', otp: '000000' })).toEqual({
193
+ status: 'invalid',
194
+ });
195
+ expect(context.auth.tokenStore.get()).toBeNull();
196
+ expect(context.view()).toBe('signIn');
197
+ });
198
+
199
+ function keepPersonData(kv) {
200
+ kv.set('lowdefy:field::lowdefy_user:ana:draft', '{"note":"x"}');
201
+ kv.set('lowdefy:field::theme', 'dark');
202
+ }
203
+
204
+ // Only the phone's device key outlives a sign-out: it marks this phone, not the person.
205
+ function expectOnlyDeviceKey() {
206
+ const left = [...SecureStore.items.keys()].filter(
207
+ (key) => !key.startsWith('lowdefy-device-key.')
208
+ );
209
+ expect(left).toEqual([]);
210
+ }
211
+
212
+ function expectPurged(context) {
213
+ expect(context.auth.tokenStore.get()).toBeNull();
214
+ expectOnlyDeviceKey();
215
+ expect([...context.cacheStore.roots.keys()]).toEqual([]);
216
+ expect(context.kv.get('lowdefy-shell:last-cache-key:field')).toBeNull();
217
+ expect(context.kv.get('lowdefy:field::lowdefy_user:ana:draft')).toBeNull();
218
+ expect(context.kv.get('lowdefy:field::theme')).toBe('dark');
219
+ }
220
+
221
+ test('sign-out ends the session on the server and removes the person from the phone', async () => {
222
+ const { server, sessions } = createServer();
223
+ const context = await setup({ server });
224
+ await signIn(context);
225
+ keepPersonData(context.kv);
226
+ await context.auth.signOut();
227
+ const signOutCall = server.fetch.mock.calls.find(([url]) => url.endsWith('/api/auth/sign-out'));
228
+ expect(header(signOutCall[1], 'authorization')).toBe('Bearer token-1');
229
+ expect(sessions.size).toBe(0);
230
+ expectPurged(context);
231
+ expect(context.onSignedOut).toHaveBeenCalledTimes(1);
232
+ });
233
+
234
+ test('a 401 to a call that carried the token removes the person and asks for sign-in again', async () => {
235
+ const { server, sessions } = createServer();
236
+ const context = await setup({ server });
237
+ await signIn(context);
238
+ keepPersonData(context.kv);
239
+ sessions.clear();
240
+ const res = await context.auth.fetch(`${apiBase}/api/request/today/get_jobs`, {
241
+ method: 'POST',
242
+ headers: { Authorization: 'Bearer token-1' },
243
+ });
244
+ expect(res.status).toBe(401);
245
+ await until(() => context.onSignedOut.mock.calls.length === 1);
246
+ expectPurged(context);
247
+ // The session was already gone: the shell does not call sign-out.
248
+ expect(server.fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/sign-out'))).toBe(false);
249
+ });
250
+
251
+ test('a 401 to a streamed chat call that carried the token ends the session too', async () => {
252
+ const { server } = createServer();
253
+ const streamFetch = jest.fn(async () => response({ status: 401 }));
254
+ const context = await setup({ server, streamFetch });
255
+ await signIn(context);
256
+ keepPersonData(context.kv);
257
+ const res = await context.auth.streamFetch(`${apiBase}/api/agent/chat/assistant`, {
258
+ method: 'POST',
259
+ headers: { Authorization: 'Bearer token-1' },
260
+ });
261
+ expect(res.status).toBe(401);
262
+ expect(streamFetch).toHaveBeenCalledWith(`${apiBase}/api/agent/chat/assistant`, {
263
+ method: 'POST',
264
+ headers: { Authorization: 'Bearer token-1' },
265
+ });
266
+ await until(() => context.onSignedOut.mock.calls.length === 1);
267
+ expectPurged(context);
268
+ });
269
+
270
+ test('an answer as the signed-out caller to a call with the token ends the session', async () => {
271
+ const { server, sessions } = createServer();
272
+ const context = await setup({ server });
273
+ await signIn(context);
274
+ keepPersonData(context.kv);
275
+ sessions.clear();
276
+ await context.session.sync().catch(() => {});
277
+ await until(() => context.onSignedOut.mock.calls.length === 1);
278
+ expectPurged(context);
279
+ });
280
+
281
+ test('the token cleared by the core (a Logout action) is a sign-out too', async () => {
282
+ const context = await setup();
283
+ await signIn(context);
284
+ keepPersonData(context.kv);
285
+ await context.auth.tokenStore.clear();
286
+ await until(() => context.onSignedOut.mock.calls.length === 1);
287
+ expectPurged(context);
288
+ });
289
+
290
+ test('a 403 drops the cache of the organisation that refused the person, and only that', async () => {
291
+ const context = await setup();
292
+ await signIn(context);
293
+ expect(context.cacheStore.roots.has('field:c-ana:r1')).toBe(true);
294
+ expect(context.cacheStore.roots.has('field::')).toBe(true);
295
+ await context.auth.fetch(`${apiBase}/api/endpoints/admin`, {
296
+ method: 'POST',
297
+ headers: { Authorization: 'Bearer token-1' },
298
+ });
299
+ await until(() => !context.cacheStore.roots.has('field:c-ana:r1'));
300
+ expect(context.cacheStore.roots.has('field::')).toBe(true);
301
+ expect(context.onSignedOut).not.toHaveBeenCalled();
302
+ });
303
+
304
+ test('with biometric unlock the app locks after unlockAfter seconds in the background', async () => {
305
+ const { server } = createServer({ auth: { ...settings, unlock: 'biometric', unlockAfter: 60 } });
306
+ const context = await setup({ server });
307
+ await signIn(context);
308
+ expect(context.auth.tokenStore.isProtected()).toBe(true);
309
+
310
+ context.appState.emit('background');
311
+ context.clock.now = 59_999;
312
+ context.appState.emit('active');
313
+ expect(context.view()).toBe('app');
314
+
315
+ context.appState.emit('background');
316
+ context.clock.now += 60_000;
317
+ context.appState.emit('active');
318
+ expect(context.view()).toBe('lock');
319
+ // The protected token leaves memory; reading it again is the unlock prompt.
320
+ expect(context.auth.tokenStore.get()).toBeNull();
321
+ expect(context.onSignedOut).not.toHaveBeenCalled();
322
+
323
+ SecureStore.getItemAsync.mockClear();
324
+ expect(await context.auth.unlock()).toBe(true);
325
+ expect(SecureStore.getItemAsync).toHaveBeenCalledWith(
326
+ 'lowdefy-session-token.field',
327
+ expect.objectContaining({ requireAuthentication: true })
328
+ );
329
+ expect(context.auth.tokenStore.get()).toBe('token-1');
330
+ expect(context.view()).toBe('app');
331
+ });
332
+
333
+ test('a cancelled unlock prompt keeps the app locked', async () => {
334
+ const { server } = createServer({ auth: { ...settings, unlock: 'biometric', unlockAfter: 0 } });
335
+ const context = await setup({ server });
336
+ await signIn(context);
337
+ context.appState.emit('background');
338
+ context.appState.emit('active');
339
+ SecureStore.getItemAsync.mockRejectedValueOnce(new Error('User canceled the authentication'));
340
+ expect(await context.auth.unlock()).toBe(false);
341
+ expect(context.view()).toBe('lock');
342
+ });
343
+
344
+ test('an app opened with a protected token shows the lock screen before reading it', async () => {
345
+ const kv = createMemoryStore();
346
+ const cacheStore = createMemoryCacheStore();
347
+ const { server } = createServer({ auth: { ...settings, unlock: 'biometric' } });
348
+ const first = await setup({ cacheStore, kv, server });
349
+ await signIn(first);
350
+ first.auth.dispose();
351
+
352
+ SecureStore.getItemAsync.mockClear();
353
+ const context = await setup({ cacheStore, kv, server });
354
+ expect(context.view()).toBe('lock');
355
+ expect(SecureStore.getItemAsync).not.toHaveBeenCalled();
356
+ expect(context.session.getState().status).toBe('loading');
357
+ expect(await context.auth.unlock()).toBe(true);
358
+ await until(() => context.session.getState().cacheKey === 'field:c-ana:r1');
359
+ expect(context.view()).toBe('app');
360
+ });
361
+
362
+ test('a shell with background work locks with the device biometrics and keeps its token', async () => {
363
+ const { server } = createServer({ auth: { ...settings, unlock: 'biometric', unlockAfter: 0 } });
364
+ const context = await setup({ server, shell: trackingShell });
365
+ await signIn(context);
366
+ expect(context.auth.tokenStore.isProtected()).toBe(false);
367
+ context.appState.emit('background');
368
+ context.appState.emit('active');
369
+ expect(context.view()).toBe('lock');
370
+ expect(context.auth.tokenStore.get()).toBe('token-1');
371
+ context.localAuthentication.authenticateAsync.mockResolvedValueOnce({ success: false });
372
+ expect(await context.auth.unlock()).toBe(false);
373
+ expect(context.view()).toBe('lock');
374
+ expect(await context.auth.unlock()).toBe(true);
375
+ expect(context.localAuthentication.authenticateAsync).toHaveBeenLastCalledWith({
376
+ promptMessage: 'Unlock Field',
377
+ });
378
+ expect(context.view()).toBe('app');
379
+ });
380
+
381
+ test('a shared device signs out when the app closes, and skips biometrics', async () => {
382
+ const { server } = createServer({
383
+ auth: { ...settings, unlock: 'biometric', unlockAfter: 0, sharedDevice: true },
384
+ });
385
+ const context = await setup({ server });
386
+ await signIn(context);
387
+ expect(context.auth.tokenStore.isProtected()).toBe(false);
388
+ keepPersonData(context.kv);
389
+ context.appState.emit('background');
390
+ await until(() => context.onSignedOut.mock.calls.length === 1);
391
+ expectPurged(context);
392
+ });
393
+
394
+ test('a shared device signs out on start when its token was stored behind biometrics', async () => {
395
+ const kv = createMemoryStore();
396
+ const first = await setup({
397
+ kv,
398
+ server: createServer({ auth: { ...settings, unlock: 'biometric' } }).server,
399
+ });
400
+ await signIn(first);
401
+ expect(first.auth.tokenStore.isProtected()).toBe(true);
402
+ first.auth.dispose();
403
+ // The app became a shared device, and the shell kept the new settings, before it was killed.
404
+ const shared = { ...settings, unlock: 'biometric', sharedDevice: true };
405
+ kv.set('lowdefy-shell:auth:field', JSON.stringify({ settings: shared, slug: 'field' }));
406
+ const context = await setup({
407
+ kv,
408
+ server: createServer({ auth: shared }).server,
409
+ });
410
+ expect(context.onSignedOut).toHaveBeenCalledTimes(1);
411
+ expect(context.localAuthentication.authenticateAsync).not.toHaveBeenCalled();
412
+ expectOnlyDeviceKey();
413
+ });
414
+
415
+ test('a shared device signs out on start when the app was closed without going to the background', async () => {
416
+ const kv = createMemoryStore();
417
+ const { server } = createServer({ auth: { ...settings, sharedDevice: true } });
418
+ const first = await setup({ kv, server });
419
+ await signIn(first);
420
+ first.auth.dispose();
421
+ const context = await setup({ kv, server });
422
+ expect(context.onSignedOut).toHaveBeenCalledTimes(1);
423
+ expectOnlyDeviceKey();
424
+ });
@@ -0,0 +1,115 @@
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 { type } from '@lowdefy/helpers';
18
+
19
+ import readServerError from '../host/readServerError.js';
20
+
21
+ /**
22
+ * The app server's Better Auth routes the shell signs in and out with. Calls carry no cookie
23
+ * (withoutCookies) and no Origin, so the server answers a sign-in with the session token in the
24
+ * bearer plugin's set-auth-token header, which goes to the token store.
25
+ *
26
+ * Results are what the screens act on:
27
+ * - sendCode: `sent` (also for an address the app does not admit: the server never says),
28
+ * `unavailable` (the app has no email code sign-in: open the sheet), `rateLimited`.
29
+ * - signInWithCode: `signedIn`, `twoFactor` (finish in the sheet), `invalid` (a wrong or expired
30
+ * code), `rateLimited`.
31
+ * - exchangeToken: `signedIn`, or `failed` when the server refuses the one-time token.
32
+ * Any other answer throws.
33
+ *
34
+ * @param {Object} params
35
+ * @param {string} params.apiBase The app server's URL.
36
+ * @param {Function} params.fetch The shell's fetch, without the session watch.
37
+ * @param {() => Promise<string>} params.getDeviceKey This phone's device key (createDeviceKey),
38
+ * sent on the email-code calls so the server can tell this phone from a stranger.
39
+ * @param {{ set }} params.tokenStore The session token store.
40
+ */
41
+ function createAuthApi({ apiBase, fetch, getDeviceKey, tokenStore }) {
42
+ function post({ body, headers = {}, path }) {
43
+ return fetch(`${apiBase}/api/auth${path}`, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json', ...headers },
46
+ body: JSON.stringify(body),
47
+ });
48
+ }
49
+
50
+ async function keepSessionToken(res) {
51
+ const token = res.headers.get('set-auth-token');
52
+ if (type.isNone(token)) {
53
+ throw new Error("The app's server signed you in but sent no session token.");
54
+ }
55
+ await tokenStore.set(token);
56
+ }
57
+
58
+ function unexpected(res, what) {
59
+ return readServerError({
60
+ res,
61
+ message: `The app's server answered ${res.status} to the ${what}.`,
62
+ });
63
+ }
64
+
65
+ async function deviceKeyHeader() {
66
+ return { 'x-lowdefy-device-key': await getDeviceKey() };
67
+ }
68
+
69
+ async function sendCode({ email }) {
70
+ const res = await post({
71
+ path: '/email-otp/send-verification-otp',
72
+ body: { email, type: 'sign-in' },
73
+ headers: await deviceKeyHeader(),
74
+ });
75
+ if (res.ok) return { status: 'sent' };
76
+ if (res.status === 404) return { status: 'unavailable' };
77
+ if (res.status === 429) return { status: 'rateLimited' };
78
+ throw await unexpected(res, 'sign-in code request');
79
+ }
80
+
81
+ async function signInWithCode({ email, otp }) {
82
+ const res = await post({
83
+ path: '/sign-in/email-otp',
84
+ body: { email, otp },
85
+ headers: await deviceKeyHeader(),
86
+ });
87
+ if (res.status === 429) return { status: 'rateLimited' };
88
+ if (res.status >= 400 && res.status < 500) return { status: 'invalid' };
89
+ if (!res.ok) throw await unexpected(res, 'sign-in');
90
+ const body = await res.json();
91
+ if (body?.twoFactorRedirect === true) return { status: 'twoFactor' };
92
+ await keepSessionToken(res);
93
+ return { status: 'signedIn' };
94
+ }
95
+
96
+ async function exchangeToken({ token, codeVerifier }) {
97
+ const res = await post({
98
+ path: '/one-time-token/verify',
99
+ body: { token, code_verifier: codeVerifier },
100
+ });
101
+ if (res.status >= 400 && res.status < 500) return { status: 'failed' };
102
+ if (!res.ok) throw await unexpected(res, 'sign-in');
103
+ await keepSessionToken(res);
104
+ return { status: 'signedIn' };
105
+ }
106
+
107
+ // Ends the session on the server: its row is deleted, so the token is dead everywhere.
108
+ function signOut({ token }) {
109
+ return post({ path: '/sign-out', body: {}, headers: { Authorization: `Bearer ${token}` } });
110
+ }
111
+
112
+ return { exchangeToken, sendCode, signInWithCode, signOut };
113
+ }
114
+
115
+ export default createAuthApi;
@@ -0,0 +1,138 @@
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 { jest } from '@jest/globals';
18
+
19
+ import createAuthApi from './createAuthApi.js';
20
+ import withoutCookies from './withoutCookies.js';
21
+
22
+ const apiBase = 'https://field.example.com';
23
+ const DEVICE_KEY = 'a1'.repeat(32);
24
+
25
+ function response({ body = {}, headers = {}, status = 200 }) {
26
+ return {
27
+ ok: status >= 200 && status < 300,
28
+ status,
29
+ headers: { get: (name) => headers[name] ?? null },
30
+ json: async () => body,
31
+ };
32
+ }
33
+
34
+ function setup(answer) {
35
+ const fetch = jest.fn(async () => response(answer));
36
+ const tokenStore = { set: jest.fn(async () => undefined) };
37
+ const getDeviceKey = async () => DEVICE_KEY;
38
+ return { api: createAuthApi({ apiBase, fetch, getDeviceKey, tokenStore }), fetch, tokenStore };
39
+ }
40
+
41
+ function sent(fetch) {
42
+ const [url, init] = fetch.mock.calls[0];
43
+ return { path: url.slice(apiBase.length), body: JSON.parse(init.body), headers: init.headers };
44
+ }
45
+
46
+ test('sendCode asks Better Auth for a sign-in code', async () => {
47
+ const { api, fetch } = setup({ body: { success: true } });
48
+ expect(await api.sendCode({ email: 'ana@example.com' })).toEqual({ status: 'sent' });
49
+ expect(sent(fetch)).toEqual({
50
+ path: '/api/auth/email-otp/send-verification-otp',
51
+ body: { email: 'ana@example.com', type: 'sign-in' },
52
+ headers: { 'Content-Type': 'application/json', 'x-lowdefy-device-key': DEVICE_KEY },
53
+ });
54
+ });
55
+
56
+ test('sendCode tells an app without email codes, and a rate limit, from a sent code', async () => {
57
+ expect(await setup({ status: 404 }).api.sendCode({ email: 'a@b.c' })).toEqual({
58
+ status: 'unavailable',
59
+ });
60
+ expect(await setup({ status: 429 }).api.sendCode({ email: 'a@b.c' })).toEqual({
61
+ status: 'rateLimited',
62
+ });
63
+ await expect(setup({ status: 500 }).api.sendCode({ email: 'a@b.c' })).rejects.toThrow(
64
+ "The app's server answered 500 to the sign-in code request."
65
+ );
66
+ });
67
+
68
+ test('signInWithCode keeps the session token from set-auth-token', async () => {
69
+ const { api, fetch, tokenStore } = setup({ headers: { 'set-auth-token': 'token-1' } });
70
+ expect(await api.signInWithCode({ email: 'ana@example.com', otp: '123456' })).toEqual({
71
+ status: 'signedIn',
72
+ });
73
+ expect(sent(fetch).path).toBe('/api/auth/sign-in/email-otp');
74
+ expect(sent(fetch).headers['x-lowdefy-device-key']).toBe(DEVICE_KEY);
75
+ expect(tokenStore.set).toHaveBeenCalledWith('token-1');
76
+ });
77
+
78
+ test('signInWithCode hands a second factor to the sheet and keeps no token', async () => {
79
+ const { api, tokenStore } = setup({ body: { twoFactorRedirect: true } });
80
+ expect(await api.signInWithCode({ email: 'a@b.c', otp: '123456' })).toEqual({
81
+ status: 'twoFactor',
82
+ });
83
+ expect(tokenStore.set).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test('signInWithCode reads a refused code as invalid', async () => {
87
+ const { api, tokenStore } = setup({ status: 400, body: { code: 'INVALID_OTP' } });
88
+ expect(await api.signInWithCode({ email: 'a@b.c', otp: '000000' })).toEqual({
89
+ status: 'invalid',
90
+ });
91
+ expect(tokenStore.set).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test('a sign-in answered without a session token fails', async () => {
95
+ const { api } = setup({ body: {} });
96
+ await expect(api.signInWithCode({ email: 'a@b.c', otp: '123456' })).rejects.toThrow(
97
+ "The app's server signed you in but sent no session token."
98
+ );
99
+ });
100
+
101
+ test('exchangeToken sends the one-time token with the code verifier', async () => {
102
+ const { api, fetch, tokenStore } = setup({ headers: { 'set-auth-token': 'token-1' } });
103
+ expect(await api.exchangeToken({ token: 'ott-1', codeVerifier: 'verifier' })).toEqual({
104
+ status: 'signedIn',
105
+ });
106
+ expect(sent(fetch)).toMatchObject({
107
+ path: '/api/auth/one-time-token/verify',
108
+ body: { token: 'ott-1', code_verifier: 'verifier' },
109
+ });
110
+ expect(tokenStore.set).toHaveBeenCalledWith('token-1');
111
+ });
112
+
113
+ test('exchangeToken fails when the server refuses the token', async () => {
114
+ const { api, tokenStore } = setup({ status: 400, body: { message: 'Invalid token' } });
115
+ expect(await api.exchangeToken({ token: 'ott-1', codeVerifier: 'wrong' })).toEqual({
116
+ status: 'failed',
117
+ });
118
+ expect(tokenStore.set).not.toHaveBeenCalled();
119
+ });
120
+
121
+ test('signOut ends the session the token names', async () => {
122
+ const { api, fetch } = setup({ body: { success: true } });
123
+ await api.signOut({ token: 'token-1' });
124
+ expect(sent(fetch)).toEqual({
125
+ path: '/api/auth/sign-out',
126
+ body: {},
127
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token-1' },
128
+ });
129
+ });
130
+
131
+ test('every shell call omits cookies', async () => {
132
+ const fetch = jest.fn(async () => response({}));
133
+ await withoutCookies(fetch)('https://field.example.com/api/user', { method: 'GET' });
134
+ expect(fetch).toHaveBeenCalledWith('https://field.example.com/api/user', {
135
+ method: 'GET',
136
+ credentials: 'omit',
137
+ });
138
+ });