@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,205 @@
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 { createHash } from 'node:crypto';
18
+ import { jest } from '@jest/globals';
19
+ import { Platform } from 'react-native';
20
+
21
+ import getSheetReturnUrl from './getSheetReturnUrl.js';
22
+ import isSheetReturnLink from './isSheetReturnLink.js';
23
+ import parseQuery from './parseQuery.js';
24
+ import signInWithSheet from './signInWithSheet.js';
25
+
26
+ jest.mock('expo-crypto', () => {
27
+ const nodeCrypto = jest.requireActual('node:crypto');
28
+ return {
29
+ CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
30
+ digest: jest.fn(async (algorithm, data) =>
31
+ nodeCrypto.createHash('sha256').update(data).digest()
32
+ ),
33
+ getRandomBytes: jest.fn((count) => new Uint8Array(nodeCrypto.randomBytes(count))),
34
+ randomUUID: jest.fn(() => 'state-1'),
35
+ };
36
+ });
37
+
38
+ jest.mock('expo-web-browser', () => ({ openAuthSessionAsync: jest.fn() }));
39
+
40
+ const WebBrowser = jest.requireMock('expo-web-browser');
41
+
42
+ const apiBase = 'https://field.example.com';
43
+ const shell = { id: 'field', scheme: 'com.example.field' };
44
+
45
+ function base64Url(buffer) {
46
+ return Buffer.from(buffer).toString('base64url');
47
+ }
48
+
49
+ function createAuthApi() {
50
+ return { exchangeToken: jest.fn(async () => ({ status: 'signedIn' })) };
51
+ }
52
+
53
+ afterEach(() => {
54
+ jest.restoreAllMocks();
55
+ });
56
+
57
+ function onAndroid() {
58
+ jest.replaceProperty(Platform, 'OS', 'android');
59
+ }
60
+
61
+ test('the sheet opens /auth/mobile ephemeral, with the shell, a state, an S256 challenge and the platform', async () => {
62
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
63
+ type: 'success',
64
+ url: 'com.example.field://auth?token=ott-1&state=state-1',
65
+ });
66
+ const authApi = createAuthApi();
67
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'signedIn' });
68
+ const [url, redirectUrl, options] = WebBrowser.openAuthSessionAsync.mock.calls[0];
69
+ expect(url.startsWith(`${apiBase}/auth/mobile?`)).toBe(true);
70
+ const query = parseQuery(url);
71
+ expect(query.shell).toBe('field');
72
+ expect(query.state).toBe('state-1');
73
+ expect(query.code_challenge_method).toBe('S256');
74
+ expect(query.code_challenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
75
+ expect(query.platform).toBe('ios');
76
+ expect(redirectUrl).toBe('com.example.field://auth');
77
+ expect(options).toEqual({ preferEphemeralSession: true });
78
+
79
+ // The verifier goes only to the exchange, and it is the one the challenge was made from.
80
+ const { token, codeVerifier } = authApi.exchangeToken.mock.calls[0][0];
81
+ expect(token).toBe('ott-1');
82
+ expect(codeVerifier).toHaveLength(86);
83
+ expect(base64Url(createHash('sha256').update(codeVerifier).digest())).toBe(query.code_challenge);
84
+ expect(url).not.toContain(codeVerifier);
85
+ });
86
+
87
+ test('each sign-in makes a new verifier, which stays in the call that made it', async () => {
88
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
89
+ type: 'success',
90
+ url: 'com.example.field://auth?token=ott-1&state=state-1',
91
+ });
92
+ const authApi = createAuthApi();
93
+ await signInWithSheet({ apiBase, authApi, shell });
94
+ await signInWithSheet({ apiBase, authApi, shell });
95
+ const [first, second] = authApi.exchangeToken.mock.calls.map(([params]) => params.codeVerifier);
96
+ expect(first).not.toBe(second);
97
+ // No call out of the sign-in but the exchange carries the verifier: not the sheet, not storage.
98
+ const Crypto = jest.requireMock('expo-crypto');
99
+ const otherCalls = JSON.stringify([
100
+ WebBrowser.openAuthSessionAsync.mock.calls,
101
+ Crypto.randomUUID.mock.calls,
102
+ Crypto.getRandomBytes.mock.calls,
103
+ ]);
104
+ expect(otherCalls).not.toContain(first);
105
+ expect(otherCalls).not.toContain(second);
106
+ });
107
+
108
+ test('a redirect with another state is refused and the token is not exchanged', async () => {
109
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
110
+ type: 'success',
111
+ url: 'com.example.field://auth?token=ott-1&state=someone-else',
112
+ });
113
+ const authApi = createAuthApi();
114
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
115
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
116
+ });
117
+
118
+ test('a redirect without a token fails', async () => {
119
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
120
+ type: 'success',
121
+ url: 'com.example.field://auth?state=state-1',
122
+ });
123
+ const authApi = createAuthApi();
124
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
125
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
126
+ });
127
+
128
+ test('on Android the sheet returns by the https app link and exchanges its token', async () => {
129
+ onAndroid();
130
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
131
+ type: 'success',
132
+ url: `${apiBase}/auth/mobile/callback?token=ott-1&state=state-1`,
133
+ });
134
+ const authApi = createAuthApi();
135
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'signedIn' });
136
+ const [url, redirectUrl] = WebBrowser.openAuthSessionAsync.mock.calls[0];
137
+ expect(parseQuery(url).platform).toBe('android');
138
+ expect(redirectUrl).toBe(`${apiBase}/auth/mobile/callback`);
139
+ expect(authApi.exchangeToken.mock.calls[0][0].token).toBe('ott-1');
140
+ });
141
+
142
+ test('on Android a callback with another state is refused and the token is not exchanged', async () => {
143
+ onAndroid();
144
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
145
+ type: 'success',
146
+ url: `${apiBase}/auth/mobile/callback?token=ott-1&state=someone-else`,
147
+ });
148
+ const authApi = createAuthApi();
149
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
150
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
151
+ });
152
+
153
+ test('on Android a return to the custom scheme is not the app link and is refused', async () => {
154
+ onAndroid();
155
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
156
+ type: 'success',
157
+ url: 'com.example.field://auth?token=ott-1&state=state-1',
158
+ });
159
+ const authApi = createAuthApi();
160
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'failed' });
161
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
162
+ });
163
+
164
+ test('Cancel on the confirmation page cancels the sign-in', async () => {
165
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({
166
+ type: 'success',
167
+ url: 'com.example.field://auth?error=cancelled&state=state-1',
168
+ });
169
+ const authApi = createAuthApi();
170
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'cancelled' });
171
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
172
+ });
173
+
174
+ test('getSheetReturnUrl is the scheme on iOS and the app link under the app url on Android', () => {
175
+ expect(getSheetReturnUrl({ apiBase, platform: 'ios', shell })).toBe('com.example.field://auth');
176
+ expect(getSheetReturnUrl({ apiBase: `${apiBase}/app/`, platform: 'android', shell })).toBe(
177
+ `${apiBase}/app/auth/mobile/callback`
178
+ );
179
+ });
180
+
181
+ test('isSheetReturnLink tells the app link return from page links', () => {
182
+ expect(isSheetReturnLink(`${apiBase}/auth/mobile/callback?token=ott-1&state=s`)).toBe(true);
183
+ expect(isSheetReturnLink(`${apiBase}/app/auth/mobile/callback`)).toBe(true);
184
+ expect(isSheetReturnLink('/auth/mobile/callback?token=ott-1')).toBe(true);
185
+ expect(isSheetReturnLink(`${apiBase}/auth/mobile`)).toBe(false);
186
+ expect(isSheetReturnLink(`${apiBase}/jobs?next=/auth/mobile/callback`)).toBe(false);
187
+ expect(isSheetReturnLink('/jobs/42')).toBe(false);
188
+ expect(isSheetReturnLink('com.example.field://jobs')).toBe(false);
189
+ });
190
+
191
+ test('closing the sheet cancels the sign-in', async () => {
192
+ WebBrowser.openAuthSessionAsync.mockResolvedValue({ type: 'cancel' });
193
+ const authApi = createAuthApi();
194
+ expect(await signInWithSheet({ apiBase, authApi, shell })).toEqual({ status: 'cancelled' });
195
+ expect(authApi.exchangeToken).not.toHaveBeenCalled();
196
+ });
197
+
198
+ test('parseQuery reads a custom-scheme URL query, decoding each value', () => {
199
+ expect(parseQuery('com.example.field://auth?token=a%2Bb&state=x+y&empty#frag')).toEqual({
200
+ token: 'a+b',
201
+ state: 'x y',
202
+ empty: '',
203
+ });
204
+ expect(parseQuery('com.example.field://auth')).toEqual({});
205
+ });
@@ -0,0 +1,78 @@
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 { StyleSheet } from 'react-native';
18
+
19
+ const styles = StyleSheet.create({
20
+ screen: {
21
+ flexGrow: 1,
22
+ justifyContent: 'center',
23
+ padding: 24,
24
+ gap: 16,
25
+ },
26
+ appName: {
27
+ fontSize: 28,
28
+ fontWeight: '700',
29
+ textAlign: 'center',
30
+ marginBottom: 8,
31
+ },
32
+ label: {
33
+ fontSize: 16,
34
+ },
35
+ input: {
36
+ borderRadius: 10,
37
+ paddingHorizontal: 16,
38
+ paddingVertical: 12,
39
+ fontSize: 17,
40
+ },
41
+ codeInput: {
42
+ letterSpacing: 8,
43
+ textAlign: 'center',
44
+ fontSize: 24,
45
+ },
46
+ message: {
47
+ fontSize: 15,
48
+ },
49
+ error: {
50
+ fontSize: 15,
51
+ },
52
+ button: {
53
+ alignItems: 'center',
54
+ paddingVertical: 14,
55
+ borderRadius: 12,
56
+ },
57
+ buttonDisabled: {
58
+ opacity: 0.5,
59
+ },
60
+ buttonText: {
61
+ color: '#FFFFFF',
62
+ fontSize: 17,
63
+ fontWeight: '600',
64
+ },
65
+ links: {
66
+ flexDirection: 'row',
67
+ justifyContent: 'space-between',
68
+ },
69
+ link: {
70
+ fontSize: 17,
71
+ paddingVertical: 8,
72
+ },
73
+ centered: {
74
+ alignSelf: 'center',
75
+ },
76
+ });
77
+
78
+ export default styles;
@@ -0,0 +1,62 @@
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 { Platform } from 'react-native';
18
+ import * as Notifications from 'expo-notifications';
19
+
20
+ import callEndpoint from '../native/callEndpoint.js';
21
+
22
+ // This phone's native push token (APNs or FCM), as registerPushToken sent it; null when the phone
23
+ // has none (never allowed to notify, no signal).
24
+ async function getDeviceToken() {
25
+ try {
26
+ const { data } = await Notifications.getDevicePushTokenAsync();
27
+ return data ?? null;
28
+ } catch (error) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Tells the app's mobile.notifications.unregister endpoint that this phone signs out, so no
35
+ * notification for the person reaches it afterwards: { token, platform, shellId }, with the session
36
+ * that is ending, so the app drops this phone's token and keeps the person's other phones. Called
37
+ * before the token is removed, and never waited on: offline, the sign-out still ends on the phone,
38
+ * and the app drops the token when a push to it fails.
39
+ *
40
+ * A session the server already ended (a 401 clears the token, createAppAuth handleTokenChange)
41
+ * has no session left to call with, so nothing is unregistered: the push token stays with the app
42
+ * until a push to it fails or the phone registers for someone else.
43
+ *
44
+ * @param {Object} params
45
+ * @param {Object} params.app { apiBase, fetch, session, shell, tokenStore }.
46
+ */
47
+ async function unregisterPushToken({ app }) {
48
+ const endpointId = app.session?.getState().root?.notifications?.unregister;
49
+ const sessionToken = app.tokenStore.get();
50
+ if (typeof endpointId !== 'string' || sessionToken === null) {
51
+ return;
52
+ }
53
+ // The sign-out clears the token store while the device token is still being read, so the call
54
+ // carries the session token as it was when the sign-out began.
55
+ await callEndpoint({
56
+ app: { ...app, tokenStore: { get: () => sessionToken } },
57
+ endpointId,
58
+ payload: { token: await getDeviceToken(), platform: Platform.OS, shellId: app.shell.id },
59
+ });
60
+ }
61
+
62
+ export default unregisterPushToken;
@@ -0,0 +1,67 @@
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 { Platform } from 'react-native';
18
+ import * as Notifications from 'expo-notifications';
19
+
20
+ import callEndpoint from '../native/callEndpoint.js';
21
+ import unregisterPushToken from './unregisterPushToken.js';
22
+
23
+ jest.mock('../native/callEndpoint.js', () => jest.fn(async () => null));
24
+ jest.mock('expo-notifications', () => ({
25
+ getDevicePushTokenAsync: jest.fn(async () => ({ data: 'device-token' })),
26
+ }));
27
+
28
+ function createApp({ token = 'session-token' } = {}) {
29
+ return {
30
+ session: { getState: () => ({ root: { notifications: { unregister: 'drop_push' } } }) },
31
+ shell: { id: 'field' },
32
+ tokenStore: { get: () => token },
33
+ };
34
+ }
35
+
36
+ test('a sign-out sends this phone push token, so the app drops that one', async () => {
37
+ await unregisterPushToken({ app: createApp() });
38
+ expect(callEndpoint).toHaveBeenCalledWith(
39
+ expect.objectContaining({
40
+ endpointId: 'drop_push',
41
+ payload: { token: 'device-token', platform: Platform.OS, shellId: 'field' },
42
+ })
43
+ );
44
+ });
45
+
46
+ test('a sign-out that clears the session token while the push token is read still calls with it', async () => {
47
+ const app = createApp();
48
+ let current = 'session-token';
49
+ app.tokenStore = { get: () => current };
50
+ Notifications.getDevicePushTokenAsync.mockImplementationOnce(async () => {
51
+ current = null;
52
+ return { data: 'device-token' };
53
+ });
54
+ await unregisterPushToken({ app });
55
+ expect(callEndpoint.mock.calls[0][0].app.tokenStore.get()).toBe('session-token');
56
+ });
57
+
58
+ test('a phone without a push token sends token null', async () => {
59
+ Notifications.getDevicePushTokenAsync.mockRejectedValueOnce(new Error('not registered'));
60
+ await unregisterPushToken({ app: createApp() });
61
+ expect(callEndpoint.mock.calls[0][0].payload.token).toBeNull();
62
+ });
63
+
64
+ test('a session that already ended unregisters nothing', async () => {
65
+ await unregisterPushToken({ app: createApp({ token: null }) });
66
+ expect(callEndpoint).not.toHaveBeenCalled();
67
+ });
@@ -0,0 +1,23 @@
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
+ // Biometric unlock is on when the app asks for it, except on a shared device, which signs out on
18
+ // every close instead.
19
+ function usesBiometricUnlock(settings) {
20
+ return settings?.unlock === 'biometric' && settings.sharedDevice !== true;
21
+ }
22
+
23
+ export default usesBiometricUnlock;
@@ -0,0 +1,47 @@
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 { parseCallerStamp } from '@lowdefy/helpers';
18
+
19
+ function sentToken(headers) {
20
+ return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === 'authorization');
21
+ }
22
+
23
+ /**
24
+ * The fetch the app's session and core call the server with, watching what the server says of
25
+ * the session token a call carried:
26
+ * - a 401, or an answer as the signed-out caller (an empty x-lowdefy-caller stamp): the session is
27
+ * gone (signed out elsewhere, ended by an admin, expired), so onSessionEnded;
28
+ * - a 403: this organisation refused the person (removed, or no longer allowed), so onRefused.
29
+ * A call that carried no token says nothing about a session.
30
+ */
31
+ function watchSession({ fetch, onRefused, onSessionEnded }) {
32
+ return async function watchedFetch(url, init = {}) {
33
+ const res = await fetch(url, init);
34
+ if (!sentToken(init.headers)) {
35
+ return res;
36
+ }
37
+ const stamp = parseCallerStamp(res.headers.get('x-lowdefy-caller'));
38
+ if (res.status === 401 || stamp?.caller === '') {
39
+ onSessionEnded();
40
+ } else if (res.status === 403) {
41
+ onRefused();
42
+ }
43
+ return res;
44
+ };
45
+ }
46
+
47
+ export default watchSession;
@@ -0,0 +1,26 @@
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
+ // Every call a shell makes leaves cookies alone. React Native's fetch keeps a cookie jar unless
18
+ // told not to, and a request carrying a Cookie is not a native one to the server: it would get
19
+ // no set-auth-token on sign-in. The session travels only as the bearer token.
20
+ function withoutCookies(fetch) {
21
+ return function cookielessFetch(url, init = {}) {
22
+ return fetch(url, { ...init, credentials: 'omit' });
23
+ };
24
+ }
25
+
26
+ export default withoutCookies;
@@ -0,0 +1,113 @@
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
+ // The page cache in SQLite: per cache key (getCacheKey), the mobile root with its ETag and the
18
+ // caller, and each page with its index hash and ETag. All of it is the server's answers kept as
19
+ // JSON; nothing here is shared between callers.
20
+ const SCHEMA = `
21
+ CREATE TABLE IF NOT EXISTS roots (
22
+ cache_key TEXT PRIMARY KEY NOT NULL,
23
+ root TEXT NOT NULL,
24
+ root_etag TEXT,
25
+ user TEXT
26
+ );
27
+ CREATE TABLE IF NOT EXISTS pages (
28
+ cache_key TEXT NOT NULL,
29
+ page_id TEXT NOT NULL,
30
+ page TEXT NOT NULL,
31
+ PRIMARY KEY (cache_key, page_id)
32
+ );
33
+ `;
34
+
35
+ /**
36
+ * @param {Object} params
37
+ * @param {Object} params.db An expo-sqlite database (openDatabaseSync).
38
+ */
39
+ function createSqliteCacheStore({ db }) {
40
+ let ready = null;
41
+ function init() {
42
+ if (ready === null) {
43
+ ready = db.execAsync(SCHEMA);
44
+ }
45
+ return ready;
46
+ }
47
+
48
+ return {
49
+ async read(cacheKey) {
50
+ await init();
51
+ const row = await db.getFirstAsync(
52
+ 'SELECT root, root_etag, user FROM roots WHERE cache_key = ?',
53
+ [cacheKey]
54
+ );
55
+ if (row === null) {
56
+ return null;
57
+ }
58
+ const pageRows = await db.getAllAsync('SELECT page_id, page FROM pages WHERE cache_key = ?', [
59
+ cacheKey,
60
+ ]);
61
+ return {
62
+ root: JSON.parse(row.root),
63
+ rootEtag: row.root_etag,
64
+ user: JSON.parse(row.user),
65
+ pages: Object.fromEntries(pageRows.map((page) => [page.page_id, JSON.parse(page.page)])),
66
+ };
67
+ },
68
+ async writeRoot({ cacheKey, entry }) {
69
+ await init();
70
+ await db.runAsync(
71
+ 'INSERT OR REPLACE INTO roots (cache_key, root, root_etag, user) VALUES (?, ?, ?, ?)',
72
+ [cacheKey, JSON.stringify(entry.root), entry.rootEtag ?? null, JSON.stringify(entry.user)]
73
+ );
74
+ },
75
+ async writePage({ cacheKey, pageId, page }) {
76
+ await init();
77
+ await db.runAsync(
78
+ 'INSERT OR REPLACE INTO pages (cache_key, page_id, page) VALUES (?, ?, ?)',
79
+ [cacheKey, pageId, JSON.stringify(page)]
80
+ );
81
+ },
82
+ // One caller's cache: an organisation that refused the person.
83
+ async removeEntry({ cacheKey }) {
84
+ await init();
85
+ await db.runAsync('DELETE FROM roots WHERE cache_key = ?', [cacheKey]);
86
+ await db.runAsync('DELETE FROM pages WHERE cache_key = ?', [cacheKey]);
87
+ },
88
+ // Every caller's cache of one app (getCacheKey starts each key with the app id): sign-out.
89
+ async removeApp({ appId }) {
90
+ await init();
91
+ const prefix = `${appId}:`;
92
+ await db.runAsync('DELETE FROM roots WHERE substr(cache_key, 1, length(?)) = ?', [
93
+ prefix,
94
+ prefix,
95
+ ]);
96
+ await db.runAsync('DELETE FROM pages WHERE substr(cache_key, 1, length(?)) = ?', [
97
+ prefix,
98
+ prefix,
99
+ ]);
100
+ },
101
+ async removePages({ cacheKey, pageIds }) {
102
+ await init();
103
+ for (const pageId of pageIds) {
104
+ await db.runAsync('DELETE FROM pages WHERE cache_key = ? AND page_id = ?', [
105
+ cacheKey,
106
+ pageId,
107
+ ]);
108
+ }
109
+ },
110
+ };
111
+ }
112
+
113
+ export default createSqliteCacheStore;