@lowdefy/shell 3.23.3 → 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.
- package/LICENSE +183 -183
- package/README.md +364 -2
- package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
- package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
- package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
- package/app/+native-intent.js +39 -0
- package/app/_layout.js +36 -0
- package/app.config.ts +183 -0
- package/appLinks.js +65 -0
- package/applyShellPlugins.js +43 -0
- package/authCallbackIntentFilters.js +46 -0
- package/babel.config.js +25 -0
- package/checkShellConfig.js +172 -0
- package/checkShellPlugins.js +74 -0
- package/getPrivacyManifest.js +39 -0
- package/metro.config.js +73 -0
- package/native/LowdefyAppShortcuts.swift.template +84 -0
- package/native/withAppShortcuts.js +60 -0
- package/native-fingerprint.json +16 -0
- package/package.json +120 -40
- package/profiles.js +607 -0
- package/readShellConfig.js +36 -0
- package/resolveShellPlugins.js +109 -0
- package/runtimeVersion.js +112 -0
- package/scripts/checkReleaseBundle.mjs +358 -0
- package/scripts/createDayjsLocales.mjs +67 -0
- package/scripts/createShellAssets.mjs +54 -0
- package/scripts/createShellVersion.mjs +83 -0
- package/scripts/createTypeManifest.mjs +205 -0
- package/shell.dev.json +11 -0
- package/shellVariant.js +126 -0
- package/src/ShellContext.js +32 -0
- package/src/actions/CopyToClipboard.js +29 -0
- package/src/actions/CopyToClipboard.test.js +33 -0
- package/src/actions/GeolocationCurrentPosition.js +61 -0
- package/src/actions/GeolocationCurrentPosition.test.js +79 -0
- package/src/actions/GetLocalStorage.js +36 -0
- package/src/actions/RemoveLocalStorage.js +31 -0
- package/src/actions/ScrollTo.js +66 -0
- package/src/actions/ScrollTo.test.js +83 -0
- package/src/actions/SetFocus.js +33 -0
- package/src/actions/SetFocus.test.js +49 -0
- package/src/actions/SetLocalStorage.js +34 -0
- package/src/actions/localStorage.test.js +68 -0
- package/src/actions/measureInScrollView.js +36 -0
- package/src/actions/nativeActions.js +41 -0
- package/src/auth/AuthButton.js +44 -0
- package/src/auth/AuthGate.js +57 -0
- package/src/auth/AuthLink.js +43 -0
- package/src/auth/LockScreen.js +67 -0
- package/src/auth/LockScreen.test.js +37 -0
- package/src/auth/SignInScreen.js +268 -0
- package/src/auth/SignInScreen.test.js +187 -0
- package/src/auth/createAppAuth.js +325 -0
- package/src/auth/createAppAuth.test.js +424 -0
- package/src/auth/createAuthApi.js +115 -0
- package/src/auth/createAuthApi.test.js +138 -0
- package/src/auth/createCoreAuth.js +96 -0
- package/src/auth/createCoreAuth.test.js +188 -0
- package/src/auth/createDeviceKey.js +62 -0
- package/src/auth/createDeviceKey.test.js +54 -0
- package/src/auth/createSessionStores.js +45 -0
- package/src/auth/createTokenStore.js +103 -0
- package/src/auth/createTokenStore.test.js +116 -0
- package/src/auth/getAuthSettings.js +24 -0
- package/src/auth/getAuthView.js +39 -0
- package/src/auth/getAuthView.test.js +52 -0
- package/src/auth/getSheetReturnUrl.js +35 -0
- package/src/auth/getTokenStoreOptions.js +46 -0
- package/src/auth/isBackgroundShell.js +26 -0
- package/src/auth/isSheetReturnLink.js +35 -0
- package/src/auth/parseQuery.js +39 -0
- package/src/auth/purgeAppData.js +80 -0
- package/src/auth/purgeAppData.test.js +104 -0
- package/src/auth/shouldLock.js +26 -0
- package/src/auth/signInWithSheet.js +97 -0
- package/src/auth/signInWithSheet.test.js +205 -0
- package/src/auth/styles.js +78 -0
- package/src/auth/unregisterPushToken.js +62 -0
- package/src/auth/unregisterPushToken.test.js +67 -0
- package/src/auth/usesBiometricUnlock.js +23 -0
- package/src/auth/watchSession.js +47 -0
- package/src/auth/withoutCookies.js +26 -0
- package/src/cache/createSqliteCacheStore.js +113 -0
- package/src/cache/createSqliteCacheStore.test.js +93 -0
- package/src/cache/getCacheKey.js +25 -0
- package/src/cache/getLastCacheKeyName.js +22 -0
- package/src/components/AppProvider.js +118 -0
- package/src/components/Frame.js +116 -0
- package/src/components/Frame.test.js +101 -0
- package/src/components/LoadingScreen.js +35 -0
- package/src/components/MenuButton.js +121 -0
- package/src/components/MenuButton.test.js +74 -0
- package/src/components/MessageScreen.js +51 -0
- package/src/components/NavigationTheme.js +33 -0
- package/src/components/NoConnectionBanner.js +51 -0
- package/src/components/NoConnectionBanner.test.js +73 -0
- package/src/components/OtherPageScreen.js +95 -0
- package/src/components/OtherPageScreen.test.js +74 -0
- package/src/components/PageScreen.js +254 -0
- package/src/components/PageScreen.test.js +492 -0
- package/src/components/RouteErrorScreen.js +47 -0
- package/src/components/ShellRoot.js +34 -0
- package/src/components/ShellRoot.test.js +62 -0
- package/src/components/ShellThemeProvider.js +45 -0
- package/src/components/SwitchToTab.js +36 -0
- package/src/components/SwitchToTab.test.js +63 -0
- package/src/components/TabIconImages.js +72 -0
- package/src/components/UpdateScreen.js +40 -0
- package/src/components/getNavigationTheme.js +44 -0
- package/src/components/getNavigationTheme.test.js +41 -0
- package/src/components/styles.js +50 -0
- package/src/components/useSurfaceTokens.js +47 -0
- package/src/config/getAppApiBase.js +34 -0
- package/src/config/getShellConfig.js +37 -0
- package/src/config/getShellConfig.test.js +80 -0
- package/src/createShellApp.js +126 -0
- package/src/dev/DevShell.js +239 -0
- package/src/dev/DevShell.test.js +233 -0
- package/src/dev/connect/ConfirmSheet.js +71 -0
- package/src/dev/connect/ConnectScreen.js +117 -0
- package/src/dev/connect/QrScanner.js +89 -0
- package/src/dev/connection/createDevShellConfig.js +47 -0
- package/src/dev/connection/createDevTransport.js +51 -0
- package/src/dev/connection/createDevTransport.test.js +87 -0
- package/src/dev/connection/createServerList.js +48 -0
- package/src/dev/connection/isLoopbackHost.js +25 -0
- package/src/dev/connection/pairedShellApp.test.js +104 -0
- package/src/dev/connection/parseDevLink.js +66 -0
- package/src/dev/connection/parseDevLink.test.js +97 -0
- package/src/dev/connection/parseQuery.js +33 -0
- package/src/dev/connection/parseServerInput.js +40 -0
- package/src/dev/connection/parseServerUrl.js +45 -0
- package/src/dev/devStyles.js +97 -0
- package/src/dev/index.js +21 -0
- package/src/dev/inject/createDeviceInjections.js +188 -0
- package/src/dev/inject/createDeviceInjections.test.js +224 -0
- package/src/dev/inject/createOfflineFetch.js +35 -0
- package/src/dev/inject/createOfflineFetch.test.js +68 -0
- package/src/dev/inject/injectPush.js +60 -0
- package/src/dev/inject/injectShare.js +55 -0
- package/src/dev/inject/withInjectedDevice.js +98 -0
- package/src/dev/menu/BlockGallery.js +33 -0
- package/src/dev/menu/DevMenu.js +121 -0
- package/src/dev/menu/createLogBuffer.js +83 -0
- package/src/dev/menu/watchShake.js +52 -0
- package/src/dev/menu/watchShake.test.js +61 -0
- package/src/dev/recorder/createNativeRecorder.js +204 -0
- package/src/dev/recorder/createNativeRecorder.test.js +307 -0
- package/src/dev/redirectDevLink.js +30 -0
- package/src/dev/redirectDevLink.test.js +49 -0
- package/src/dev/steps/createStepRunner.js +184 -0
- package/src/dev/steps/createStepRunner.test.js +262 -0
- package/src/dev/steps/findBlock.js +78 -0
- package/src/dev/steps/isContextSettled.js +37 -0
- package/src/dev/steps/waitForSettle.js +39 -0
- package/src/dev/steps/waitUntil.js +40 -0
- package/src/dev/tab/DevTabClient.js +97 -0
- package/src/dev/tab/answerEval.js +39 -0
- package/src/dev/tab/answerInspect.js +45 -0
- package/src/dev/tab/createDevTabClient.js +272 -0
- package/src/dev/tab/createDevTabClient.test.js +317 -0
- package/src/dev/tab/describeTabError.js +35 -0
- package/src/dev/tab/devClientMarker.js +22 -0
- package/src/dev/tab/devClientMarker.test.js +23 -0
- package/src/dev/tab/findPageContext.js +43 -0
- package/src/dev/tab/getShownPage.js +42 -0
- package/src/dev/tab/readEventStream.js +68 -0
- package/src/dev/tab/reloadApp.js +42 -0
- package/src/dev/tab/reloadApp.test.js +115 -0
- package/src/device/DeviceHosts.js +32 -0
- package/src/device/basic/createFilesFeature.js +41 -0
- package/src/device/basic/createHapticsFeature.js +46 -0
- package/src/device/basic/createKeepAwakeFeature.js +37 -0
- package/src/device/basic/createPhotosFeature.js +40 -0
- package/src/device/basic/createRequestQueueFeature.js +37 -0
- package/src/device/basic/createShareFeature.js +63 -0
- package/src/device/basic/createShareFeature.test.js +47 -0
- package/src/device/basic/createSpeechFeature.js +43 -0
- package/src/device/basic/createUploadFeature.js +41 -0
- package/src/device/basic/toImagePickerOptions.js +41 -0
- package/src/device/basic/toPickedFile.js +54 -0
- package/src/device/calendar/index.js +68 -0
- package/src/device/contacts/index.js +67 -0
- package/src/device/createDevice.js +65 -0
- package/src/device/device.js +27 -0
- package/src/device/field/MapView.js +93 -0
- package/src/device/field/ScanCodeSheet.js +86 -0
- package/src/device/field/ScannerView.js +52 -0
- package/src/device/field/createAudioFeature.js +70 -0
- package/src/device/field/createBrightnessFeature.js +40 -0
- package/src/device/field/createCameraFeature.js +56 -0
- package/src/device/field/createDocumentScannerFeature.js +51 -0
- package/src/device/field/createLocationFeature.js +39 -0
- package/src/device/field/createScanRequests.js +55 -0
- package/src/device/field/index.js +39 -0
- package/src/device/field/scanRequests.js +22 -0
- package/src/device/injected.js +60 -0
- package/src/device/injected.test.js +39 -0
- package/src/device/nfc/index.js +89 -0
- package/src/device/nfc/toNfcRecord.js +44 -0
- package/src/device/nfc/toNfcRecord.test.js +48 -0
- package/src/device/tracking/createBacklog.js +63 -0
- package/src/device/tracking/createBacklog.test.js +47 -0
- package/src/device/tracking/index.js +211 -0
- package/src/device/tracking/index.test.js +110 -0
- package/src/host/createDeviceStore.js +32 -0
- package/src/host/createMemoryStore.js +32 -0
- package/src/host/createNetworkStatus.js +94 -0
- package/src/host/createNetworkStatus.test.js +94 -0
- package/src/host/createRouterAdapter.js +33 -0
- package/src/host/createShellFetch.js +62 -0
- package/src/host/createShellFetch.test.js +165 -0
- package/src/host/createShellGlobals.js +62 -0
- package/src/host/createShellHost.js +83 -0
- package/src/host/createShellHost.test.js +36 -0
- package/src/host/getOrigin.js +31 -0
- package/src/host/isNetworkError.js +24 -0
- package/src/host/isOnline.js +22 -0
- package/src/host/networkStatus.js +24 -0
- package/src/host/readServerError.js +45 -0
- package/src/host/readServerError.test.js +85 -0
- package/src/host/reportShellError.js +47 -0
- package/src/host/reportShellError.test.js +79 -0
- package/src/host/withNetworkErrors.js +38 -0
- package/src/host/withNetworkErrors.test.js +61 -0
- package/src/host/withShellHeader.js +25 -0
- package/src/host/withShellHeader.test.js +28 -0
- package/src/js/evaluateJsEntries.js +43 -0
- package/src/js/evaluateJsEntries.test.js +48 -0
- package/src/locale/createShellLocale.js +112 -0
- package/src/locale/createShellLocale.test.js +74 -0
- package/src/locale/dayjsLocales.js +165 -0
- package/src/locale/getDeviceLocales.js +23 -0
- package/src/locale/loadDayjsLocale.js +32 -0
- package/src/locale/resolveActiveLocale.js +46 -0
- package/src/locale/resolveActiveLocale.test.js +40 -0
- package/src/native/backgroundFetch.js +97 -0
- package/src/native/callEndpoint.js +63 -0
- package/src/native/createAppEventTrigger.js +70 -0
- package/src/native/currentApp.js +49 -0
- package/src/native/getPagePath.js +32 -0
- package/src/native/openPath.js +55 -0
- package/src/native/openPath.test.js +39 -0
- package/src/native/parseShellLink.js +38 -0
- package/src/native/parseShellLink.test.js +29 -0
- package/src/native/startAppServices.js +94 -0
- package/src/native/startAppServices.test.js +97 -0
- package/src/native/startShareIn.js +59 -0
- package/src/native/startShortcuts.js +88 -0
- package/src/native/startShortcuts.test.js +59 -0
- package/src/native/toSharePayload.js +47 -0
- package/src/native/toSharePayload.test.js +47 -0
- package/src/native/whenAppReady.js +48 -0
- package/src/navigation/SlotContext.js +24 -0
- package/src/navigation/frameSlots.js +27 -0
- package/src/navigation/getFrame.js +30 -0
- package/src/navigation/getInitialScreenOptions.js +63 -0
- package/src/navigation/getInitialScreenOptions.test.js +66 -0
- package/src/navigation/getPresentation.js +28 -0
- package/src/navigation/getSlotRootPath.js +27 -0
- package/src/navigation/getStackOptions.js +185 -0
- package/src/navigation/getTabHref.js +45 -0
- package/src/navigation/getTabIcons.js +43 -0
- package/src/navigation/isPageUnderHeader.js +46 -0
- package/src/navigation/navigation.test.js +342 -0
- package/src/navigation/toAppLinkPath.js +43 -0
- package/src/navigation/toAppLinkPath.test.js +53 -0
- package/src/navigation/toRouterHref.js +29 -0
- package/src/notifications/createNotificationsFeature.js +61 -0
- package/src/notifications/getCategoryAction.js +31 -0
- package/src/notifications/handleNotificationResponse.js +82 -0
- package/src/notifications/registerPushToken.js +71 -0
- package/src/notifications/startNotifications.js +114 -0
- package/src/notifications/toCategoryActions.js +42 -0
- package/src/notifications/toNotificationEvent.js +36 -0
- package/src/notifications/toScheduleRequest.js +62 -0
- package/src/plugins/appTypes.js +28 -0
- package/src/plugins/prefixTypes.js +28 -0
- package/src/plugins/shellPlugins.json +15 -0
- package/src/plugins/typeManifest.js +789 -0
- package/src/plugins/types.js +64 -0
- package/src/plugins/types.test.js +173 -0
- package/src/polyfills/installGetRandomValues.js +28 -0
- package/src/queue/createRequestQueue.js +198 -0
- package/src/queue/createRequestQueue.test.js +215 -0
- package/src/queue/createSqliteQueueStore.js +121 -0
- package/src/queue/summarizeQueue.js +39 -0
- package/src/session/createAppSession.js +323 -0
- package/src/session/createAppSession.test.js +391 -0
- package/src/session/getPageView.js +59 -0
- package/src/session/getPageView.test.js +137 -0
- package/src/sync/encodePagePath.js +27 -0
- package/src/sync/fetchPage.js +72 -0
- package/src/sync/fetchPage.test.js +84 -0
- package/src/sync/fetchRoot.js +43 -0
- package/src/sync/fetchUser.js +33 -0
- package/src/sync/runLimited.js +36 -0
- package/src/sync/toRootConfig.js +35 -0
- package/src/test/createFakeNetwork.js +40 -0
- package/src/test/createFakeSecureStore.js +40 -0
- package/src/test/createFakeServer.js +98 -0
- package/src/test/createMemoryCacheStore.js +59 -0
- package/src/test/createNetworkError.js +24 -0
- package/src/test/expoImage.js +42 -0
- package/src/test/expoNotifications.js +40 -0
- package/src/test/expoQuickActions.js +22 -0
- package/src/test/expoSpeechRecognition.js +31 -0
- package/src/test/expoTaskManager.js +25 -0
- package/src/test/expoUi.js +55 -0
- package/src/test/expoUiBottomSheet.js +64 -0
- package/src/test/expoUiDateTimePicker.js +26 -0
- package/src/test/expoUiMenu.js +46 -0
- package/src/test/expoUiSegmentedControl.js +47 -0
- package/src/test/expoUiSwiftUi.js +62 -0
- package/src/test/expoUiSwiftUiModifiers.js +23 -0
- package/src/test/expoWebBrowser.js +23 -0
- package/src/test/fixtures.js +98 -0
- package/src/test/nativeModule.js +33 -0
- package/src/test/renderPageScreen.js +109 -0
- package/src/types/checkTypeSet.js +39 -0
- package/src/types/collectPageTypes.js +49 -0
- package/src/types/collectPageTypes.test.js +90 -0
- package/src/types/findMissingPageTypes.js +40 -0
- package/src/version/compareVersions.js +38 -0
- package/src/version/isShellTooOld.js +30 -0
- package/src/version/shellVersion.js +21 -0
- package/src/version/shellVersion.test.js +32 -0
- package/src/widgets/fetchWidgetData.js +59 -0
- package/src/widgets/index.js +156 -0
- package/src/widgets/refreshWidgets.js +31 -0
- package/withExcludedNativeModules.js +93 -0
- package/dist/location.js +0 -33
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/icon-32.png +0 -0
- package/dist/public/icon-512.png +0 -0
- package/dist/public/icon.svg +0 -17
- package/dist/public/logo-dark-theme.png +0 -0
- package/dist/public/logo-light-theme.png +0 -0
- package/dist/public/logo-square-dark-theme.png +0 -0
- package/dist/public/logo-square-light-theme.png +0 -0
- package/dist/public/manifest.webmanifest +0 -16
- package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
- package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
- package/dist/shell/454.b597ca21366afda000af.js +0 -2
- package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
- package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
- package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
- package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
- package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
- package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
- package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
- package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
- package/dist/shell/index.html +0 -57
- package/dist/shell/main.f01501a75909f38ccc32.js +0 -1
- package/dist/shell/runtime.e50f6c3a5f7c74a0a717.js +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
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 { getAuthorizationHeaders } from '@lowdefy/client-core';
|
|
18
|
+
import { type } from '@lowdefy/helpers';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The auth client the core's auth actions call (client-core createAuthMethods), as the web's is
|
|
22
|
+
* its Better Auth client: each method calls a Better Auth route with the session token as a bearer
|
|
23
|
+
* token and resolves with Better Auth's `{ data, error }`. Calls go through the watched fetch, so
|
|
24
|
+
* a 401 ends the session on the phone as any other call's does.
|
|
25
|
+
*
|
|
26
|
+
* The session the shell holds (the caller, its roles and organisation, and the pages it may open)
|
|
27
|
+
* is the app session's last sync. A switch of organisation syncs it before the action resolves, so
|
|
28
|
+
* the switch takes effect at once; UpdateSession syncs it too.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} params
|
|
31
|
+
* @param {string} params.apiBase The app server's URL.
|
|
32
|
+
* @param {Function} params.fetch The watched fetch (watchSession).
|
|
33
|
+
* @param {Function} params.getSession The app session (createAppSession), once started.
|
|
34
|
+
* @param {{ get }} params.tokenStore The session token store.
|
|
35
|
+
*/
|
|
36
|
+
function createCoreAuth({ apiBase, fetch, getSession, tokenStore }) {
|
|
37
|
+
async function call({ body = {}, method = 'POST', path }) {
|
|
38
|
+
const headers = getAuthorizationHeaders({ host: { authTransport: 'bearer', tokenStore } });
|
|
39
|
+
const init = { method, headers };
|
|
40
|
+
if (method !== 'GET') {
|
|
41
|
+
init.headers = { ...headers, 'Content-Type': 'application/json' };
|
|
42
|
+
init.body = JSON.stringify(body);
|
|
43
|
+
}
|
|
44
|
+
const res = await fetch(`${apiBase}/api/auth${path}`, init);
|
|
45
|
+
// A route an app's org policy disables answers 404 with no JSON body.
|
|
46
|
+
const data = await res.json().catch(() => null);
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const details = type.isObject(data) ? data : {};
|
|
49
|
+
return { data: null, error: { ...details, status: res.status, statusText: res.statusText } };
|
|
50
|
+
}
|
|
51
|
+
return { data, error: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function sync() {
|
|
55
|
+
await getSession().sync();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function listOrganizations() {
|
|
59
|
+
return call({ method: 'GET', path: '/organization/list' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function setActiveOrganization({ organizationId, organizationSlug }) {
|
|
63
|
+
const result = await call({
|
|
64
|
+
path: '/organization/set-active',
|
|
65
|
+
body: { organizationId, organizationSlug },
|
|
66
|
+
});
|
|
67
|
+
if (result.error === null) {
|
|
68
|
+
await sync();
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function refreshSession() {
|
|
74
|
+
return call({ method: 'GET', path: '/get-session' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function getResolvedUser() {
|
|
78
|
+
await sync();
|
|
79
|
+
return { user: getSession().getState().user };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function signOut() {
|
|
83
|
+
return call({ path: '/sign-out' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
authConfig: {},
|
|
88
|
+
getResolvedUser,
|
|
89
|
+
listOrganizations,
|
|
90
|
+
refreshSession,
|
|
91
|
+
setActiveOrganization,
|
|
92
|
+
signOut,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default createCoreAuth;
|
|
@@ -0,0 +1,188 @@
|
|
|
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 createAppSession from '../session/createAppSession.js';
|
|
18
|
+
import createCoreAuth from './createCoreAuth.js';
|
|
19
|
+
import createFakeServer, { header } from '../test/createFakeServer.js';
|
|
20
|
+
import createMemoryCacheStore from '../test/createMemoryCacheStore.js';
|
|
21
|
+
import createMemoryStore from '../host/createMemoryStore.js';
|
|
22
|
+
import { createPages, createRoot } from '../test/fixtures.js';
|
|
23
|
+
|
|
24
|
+
const apiBase = 'https://field.example.com';
|
|
25
|
+
const token = 'session-token.signature';
|
|
26
|
+
|
|
27
|
+
const organizations = [
|
|
28
|
+
{ id: 'org-1', name: 'Acme', slug: 'acme' },
|
|
29
|
+
{ id: 'org-2', name: 'Globex', slug: 'globex' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// The app's server: Better Auth's organization routes over the bearer token, with the caller
|
|
33
|
+
// and user /api/user resolves moving to the organisation set-active makes active.
|
|
34
|
+
function authRoutes(server) {
|
|
35
|
+
return function answer({ path, init }) {
|
|
36
|
+
if (header(init, 'authorization') !== `Bearer ${token}`) {
|
|
37
|
+
return { status: 401, body: { code: 'UNAUTHORIZED', message: 'Unauthorized' } };
|
|
38
|
+
}
|
|
39
|
+
if (path === '/organization/list') {
|
|
40
|
+
return { body: organizations };
|
|
41
|
+
}
|
|
42
|
+
if (path === '/organization/set-active') {
|
|
43
|
+
const { organizationId } = JSON.parse(init.body);
|
|
44
|
+
if (!organizations.some((org) => org.id === organizationId)) {
|
|
45
|
+
return {
|
|
46
|
+
status: 403,
|
|
47
|
+
body: { code: 'USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION', message: 'Not a member.' },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
server.state.caller = `c-ana-${organizationId}; roles=r-${organizationId}`;
|
|
51
|
+
server.state.user = { id: 'ana', organization_id: organizationId, roles: ['editor'] };
|
|
52
|
+
return { body: organizations.find((org) => org.id === organizationId) };
|
|
53
|
+
}
|
|
54
|
+
if (path === '/get-session') {
|
|
55
|
+
return { body: { session: { id: 's1' }, user: { id: 'ana' } } };
|
|
56
|
+
}
|
|
57
|
+
if (path === '/sign-out') {
|
|
58
|
+
return { body: { success: true } };
|
|
59
|
+
}
|
|
60
|
+
return { status: 404, body: null };
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function setup({ heldToken = token } = {}) {
|
|
65
|
+
const server = createFakeServer({
|
|
66
|
+
caller: 'c-ana-org-1; roles=r-org-1',
|
|
67
|
+
user: { id: 'ana', organization_id: 'org-1', roles: ['viewer'] },
|
|
68
|
+
root: createRoot(),
|
|
69
|
+
rootEtag: '"root-1"',
|
|
70
|
+
pages: createPages([['today', 'today', '"p-today-1"']]),
|
|
71
|
+
});
|
|
72
|
+
server.state.auth = authRoutes(server);
|
|
73
|
+
const tokenStore = { get: () => heldToken };
|
|
74
|
+
const session = createAppSession({
|
|
75
|
+
apiBase,
|
|
76
|
+
app: { id: 'field' },
|
|
77
|
+
cacheStore: createMemoryCacheStore(),
|
|
78
|
+
fetch: server.fetch,
|
|
79
|
+
kv: createMemoryStore(),
|
|
80
|
+
tokenStore,
|
|
81
|
+
});
|
|
82
|
+
await session.start();
|
|
83
|
+
const coreAuth = createCoreAuth({
|
|
84
|
+
apiBase,
|
|
85
|
+
fetch: server.fetch,
|
|
86
|
+
getSession: () => session,
|
|
87
|
+
tokenStore,
|
|
88
|
+
});
|
|
89
|
+
return { coreAuth, server, session };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function authCalls(server) {
|
|
93
|
+
return server.fetch.mock.calls
|
|
94
|
+
.filter(([url]) => url.startsWith(`${apiBase}/api/auth/`))
|
|
95
|
+
.map(([url, init]) => ({
|
|
96
|
+
path: url.slice(`${apiBase}/api/auth`.length),
|
|
97
|
+
method: init.method,
|
|
98
|
+
authorization: header(init, 'authorization'),
|
|
99
|
+
body: init.body === undefined ? undefined : JSON.parse(init.body),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('listOrganizations lists the memberships with the session token as a bearer', async () => {
|
|
104
|
+
const { coreAuth, server } = await setup();
|
|
105
|
+
expect(await coreAuth.listOrganizations()).toEqual({ data: organizations, error: null });
|
|
106
|
+
expect(authCalls(server)).toEqual([
|
|
107
|
+
{
|
|
108
|
+
path: '/organization/list',
|
|
109
|
+
method: 'GET',
|
|
110
|
+
authorization: `Bearer ${token}`,
|
|
111
|
+
body: undefined,
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('setActiveOrganization switches the organisation and syncs the session the shell holds', async () => {
|
|
117
|
+
const { coreAuth, server, session } = await setup();
|
|
118
|
+
const { generation, homeRequests } = session.getState();
|
|
119
|
+
expect(await coreAuth.setActiveOrganization({ organizationId: 'org-2' })).toEqual({
|
|
120
|
+
data: organizations[1],
|
|
121
|
+
error: null,
|
|
122
|
+
});
|
|
123
|
+
expect(authCalls(server)).toEqual([
|
|
124
|
+
{
|
|
125
|
+
path: '/organization/set-active',
|
|
126
|
+
method: 'POST',
|
|
127
|
+
authorization: `Bearer ${token}`,
|
|
128
|
+
body: { organizationId: 'org-2' },
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
const state = session.getState();
|
|
132
|
+
expect(state.user).toEqual({ id: 'ana', organization_id: 'org-2', roles: ['editor'] });
|
|
133
|
+
// Another caller: every screen is rebuilt for the new organisation, from its home page.
|
|
134
|
+
expect(state.generation).toBe(generation + 1);
|
|
135
|
+
expect(state.homeRequests).toBe(homeRequests + 1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('setActiveOrganization gives a refused switch as an error and leaves the session', async () => {
|
|
139
|
+
const { coreAuth, server, session } = await setup();
|
|
140
|
+
const before = session.getState();
|
|
141
|
+
const syncs = server.fetch.mock.calls.length;
|
|
142
|
+
expect(await coreAuth.setActiveOrganization({ organizationId: 'org-9' })).toEqual({
|
|
143
|
+
data: null,
|
|
144
|
+
error: {
|
|
145
|
+
code: 'USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION',
|
|
146
|
+
message: 'Not a member.',
|
|
147
|
+
status: 403,
|
|
148
|
+
statusText: undefined,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
expect(server.fetch.mock.calls).toHaveLength(syncs + 1);
|
|
152
|
+
expect(session.getState()).toBe(before);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('an organisation route an app disables answers as an error with its status', async () => {
|
|
156
|
+
const { coreAuth, server } = await setup();
|
|
157
|
+
server.state.auth = () => ({ status: 404, body: null });
|
|
158
|
+
expect(await coreAuth.listOrganizations()).toEqual({
|
|
159
|
+
data: null,
|
|
160
|
+
error: { status: 404, statusText: undefined },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('refreshSession reads the session and getResolvedUser syncs the caller', async () => {
|
|
165
|
+
const { coreAuth, server } = await setup();
|
|
166
|
+
expect(await coreAuth.refreshSession()).toEqual({
|
|
167
|
+
data: { session: { id: 's1' }, user: { id: 'ana' } },
|
|
168
|
+
error: null,
|
|
169
|
+
});
|
|
170
|
+
server.state.user = { id: 'ana', organization_id: 'org-1', roles: ['admin'] };
|
|
171
|
+
expect(await coreAuth.getResolvedUser()).toEqual({
|
|
172
|
+
user: { id: 'ana', organization_id: 'org-1', roles: ['admin'] },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('signOut ends the session on the server with the bearer token', async () => {
|
|
177
|
+
const { coreAuth, server } = await setup();
|
|
178
|
+
expect(await coreAuth.signOut()).toEqual({ data: { success: true }, error: null });
|
|
179
|
+
expect(authCalls(server)).toEqual([
|
|
180
|
+
{ path: '/sign-out', method: 'POST', authorization: `Bearer ${token}`, body: {} },
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a call with no session token held sends no authorization', async () => {
|
|
185
|
+
const { coreAuth, server } = await setup({ heldToken: null });
|
|
186
|
+
expect((await coreAuth.listOrganizations()).error.status).toBe(401);
|
|
187
|
+
expect(authCalls(server)[0].authorization).toBe(null);
|
|
188
|
+
});
|
|
@@ -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 * as Crypto from 'expo-crypto';
|
|
18
|
+
import * as SecureStore from 'expo-secure-store';
|
|
19
|
+
|
|
20
|
+
const KEY_BYTES = 32;
|
|
21
|
+
// Kept on this phone only: never synced to another device or restored from a backup to one.
|
|
22
|
+
const OPTIONS = { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY };
|
|
23
|
+
|
|
24
|
+
function toHex(bytes) {
|
|
25
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* This phone's device key for an app: a random secret made the first time it is asked for and
|
|
30
|
+
* kept in the Keychain or Keystore, never cleared on sign-out. The shell sends it on its email-code
|
|
31
|
+
* calls (x-lowdefy-device-key), so the app's server can let a phone that signed in with an address
|
|
32
|
+
* before past that address's send cap, which a stranger can fill but cannot do as this phone.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {string} params.appId The app, from shell.json.
|
|
36
|
+
* @returns {() => Promise<string>} Reads, or first makes, the key; read once and then held.
|
|
37
|
+
*/
|
|
38
|
+
function createDeviceKey({ appId }) {
|
|
39
|
+
const storeKey = `lowdefy-device-key.${appId.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
40
|
+
let pending = null;
|
|
41
|
+
async function load() {
|
|
42
|
+
const stored = await SecureStore.getItemAsync(storeKey, OPTIONS);
|
|
43
|
+
if (stored !== null) {
|
|
44
|
+
return stored;
|
|
45
|
+
}
|
|
46
|
+
const key = toHex(Crypto.getRandomBytes(KEY_BYTES));
|
|
47
|
+
await SecureStore.setItemAsync(storeKey, key, OPTIONS);
|
|
48
|
+
return key;
|
|
49
|
+
}
|
|
50
|
+
return function getDeviceKey() {
|
|
51
|
+
if (pending === null) {
|
|
52
|
+
// A failed read is tried again on the next call.
|
|
53
|
+
pending = load().catch((error) => {
|
|
54
|
+
pending = null;
|
|
55
|
+
throw error;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return pending;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default createDeviceKey;
|
|
@@ -0,0 +1,54 @@
|
|
|
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 createDeviceKey from './createDeviceKey.js';
|
|
20
|
+
|
|
21
|
+
jest.mock('expo-secure-store', () =>
|
|
22
|
+
jest.requireActual('../test/createFakeSecureStore.js').default()
|
|
23
|
+
);
|
|
24
|
+
jest.mock('expo-crypto', () => ({
|
|
25
|
+
getRandomBytes: jest.fn((count) => Uint8Array.from({ length: count }, (_, index) => index)),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const SecureStore = jest.requireMock('expo-secure-store');
|
|
29
|
+
const Crypto = jest.requireMock('expo-crypto');
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
SecureStore.items.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a device key is made once, kept on this device only and read back after', async () => {
|
|
36
|
+
const getDeviceKey = createDeviceKey({ appId: 'field' });
|
|
37
|
+
const key = await getDeviceKey();
|
|
38
|
+
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
39
|
+
expect(await getDeviceKey()).toBe(key);
|
|
40
|
+
expect(Crypto.getRandomBytes).toHaveBeenCalledTimes(1);
|
|
41
|
+
expect(SecureStore.setItemAsync).toHaveBeenCalledWith('lowdefy-device-key.field', key, {
|
|
42
|
+
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
43
|
+
});
|
|
44
|
+
// A new shell launch reads the stored key rather than making another.
|
|
45
|
+
expect(await createDeviceKey({ appId: 'field' })()).toBe(key);
|
|
46
|
+
expect(Crypto.getRandomBytes).toHaveBeenCalledTimes(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('each app on the phone has its own device key', async () => {
|
|
50
|
+
const field = await createDeviceKey({ appId: 'field' })();
|
|
51
|
+
Crypto.getRandomBytes.mockImplementationOnce((count) => new Uint8Array(count).fill(9));
|
|
52
|
+
const office = await createDeviceKey({ appId: 'office' })();
|
|
53
|
+
expect(office).not.toBe(field);
|
|
54
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* The page cache and device store the app's session writes through. Once the session has ended
|
|
19
|
+
* (isEnded), a sync or page load still in flight writes nothing: the person's pages arriving after
|
|
20
|
+
* their sign-out are dropped, not cached.
|
|
21
|
+
*/
|
|
22
|
+
function createSessionStores({ cacheStore, isEnded, kv }) {
|
|
23
|
+
function unlessEnded(write) {
|
|
24
|
+
return async (params) => {
|
|
25
|
+
if (isEnded()) return;
|
|
26
|
+
await write(params);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
cacheStore: {
|
|
31
|
+
read: async (cacheKey) => (isEnded() ? null : cacheStore.read(cacheKey)),
|
|
32
|
+
writeRoot: unlessEnded(cacheStore.writeRoot),
|
|
33
|
+
writePage: unlessEnded(cacheStore.writePage),
|
|
34
|
+
removePages: unlessEnded(cacheStore.removePages),
|
|
35
|
+
},
|
|
36
|
+
kv: {
|
|
37
|
+
get: (key) => kv.get(key),
|
|
38
|
+
set: (key, value) => {
|
|
39
|
+
if (!isEnded()) kv.set(key, value);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default createSessionStores;
|
|
@@ -0,0 +1,103 @@
|
|
|
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 * as SecureStore from 'expo-secure-store';
|
|
18
|
+
|
|
19
|
+
import getTokenStoreOptions from './getTokenStoreOptions.js';
|
|
20
|
+
|
|
21
|
+
const PROTECTED = 'protected';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The session token store a bearer host gives the core (validateAuthTransport): one token per app
|
|
25
|
+
* in the Keychain or Keystore (getTokenStoreOptions), read once per unlock and held in memory, so
|
|
26
|
+
* `get` is synchronous.
|
|
27
|
+
*
|
|
28
|
+
* Whether the stored token is behind biometrics is kept beside it in the device store (not a
|
|
29
|
+
* secret), since it must be read with the options it was written with.
|
|
30
|
+
*
|
|
31
|
+
* `set` and `clear` tell subscribers: a token cleared anywhere (the core on a 401, a Logout
|
|
32
|
+
* action, the shell's own sign-out) is how the shell hears the session ended. `load` and `unload`
|
|
33
|
+
* (a lock drops the protected token from memory) tell no one.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} params
|
|
36
|
+
* @param {string} params.appId The app, from shell.json.
|
|
37
|
+
* @param {boolean} params.background The shell does background work (isBackgroundShell).
|
|
38
|
+
* @param {Function} params.getBiometric Whether a new token goes behind biometrics.
|
|
39
|
+
* @param {{ get, set, remove }} params.kv The device store.
|
|
40
|
+
*/
|
|
41
|
+
function createTokenStore({ appId, background, getBiometric, kv }) {
|
|
42
|
+
const key = `lowdefy-session-token.${appId.replace(/[^A-Za-z0-9._-]/g, '_')}`;
|
|
43
|
+
const modeKey = `lowdefy-shell:token-mode:${appId}`;
|
|
44
|
+
const listeners = new Set();
|
|
45
|
+
let token = null;
|
|
46
|
+
|
|
47
|
+
function notify() {
|
|
48
|
+
listeners.forEach((listener) => listener(token));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isProtected() {
|
|
52
|
+
return !background && kv.get(modeKey) === PROTECTED;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
isProtected,
|
|
57
|
+
async load() {
|
|
58
|
+
token = await SecureStore.getItemAsync(
|
|
59
|
+
key,
|
|
60
|
+
getTokenStoreOptions({ background, biometric: isProtected() })
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
get() {
|
|
64
|
+
return token;
|
|
65
|
+
},
|
|
66
|
+
async set(value) {
|
|
67
|
+
const biometric = !background && getBiometric();
|
|
68
|
+
if (biometric !== isProtected()) {
|
|
69
|
+
await SecureStore.deleteItemAsync(
|
|
70
|
+
key,
|
|
71
|
+
getTokenStoreOptions({ background, biometric: !biometric })
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
await SecureStore.setItemAsync(key, value, getTokenStoreOptions({ background, biometric }));
|
|
75
|
+
kv.set(modeKey, biometric ? PROTECTED : 'plain');
|
|
76
|
+
token = value;
|
|
77
|
+
notify();
|
|
78
|
+
},
|
|
79
|
+
unload() {
|
|
80
|
+
token = null;
|
|
81
|
+
},
|
|
82
|
+
async clear() {
|
|
83
|
+
const held = token !== null;
|
|
84
|
+
token = null;
|
|
85
|
+
// Both forms, so a token written before a change of unlock mode never outlives sign-out.
|
|
86
|
+
await SecureStore.deleteItemAsync(key, getTokenStoreOptions({ background, biometric: true }));
|
|
87
|
+
await SecureStore.deleteItemAsync(
|
|
88
|
+
key,
|
|
89
|
+
getTokenStoreOptions({ background, biometric: false })
|
|
90
|
+
);
|
|
91
|
+
kv.remove(modeKey);
|
|
92
|
+
if (held) {
|
|
93
|
+
notify();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
subscribe(listener) {
|
|
97
|
+
listeners.add(listener);
|
|
98
|
+
return () => listeners.delete(listener);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default createTokenStore;
|
|
@@ -0,0 +1,116 @@
|
|
|
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 createMemoryStore from '../host/createMemoryStore.js';
|
|
20
|
+
import createTokenStore from './createTokenStore.js';
|
|
21
|
+
|
|
22
|
+
jest.mock('expo-secure-store', () =>
|
|
23
|
+
jest.requireActual('../test/createFakeSecureStore.js').default()
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const SecureStore = jest.requireMock('expo-secure-store');
|
|
27
|
+
|
|
28
|
+
const KEY = 'lowdefy-session-token.field';
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
SecureStore.items.clear();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function create({ background = false, biometric = false, kv = createMemoryStore() } = {}) {
|
|
35
|
+
return {
|
|
36
|
+
kv,
|
|
37
|
+
store: createTokenStore({ appId: 'field', background, getBiometric: () => biometric, kv }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('a shell without background work keeps the token readable only while the phone is unlocked', async () => {
|
|
42
|
+
const { store } = create();
|
|
43
|
+
await store.set('token-1');
|
|
44
|
+
expect(SecureStore.items.get(`${KEY}|no-auth`)).toEqual({
|
|
45
|
+
value: 'token-1',
|
|
46
|
+
options: { keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' },
|
|
47
|
+
});
|
|
48
|
+
expect(store.isProtected()).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('with biometric unlock the token is kept behind Face ID or a fingerprint', async () => {
|
|
52
|
+
const { store } = create({ biometric: true });
|
|
53
|
+
await store.set('token-1');
|
|
54
|
+
expect(SecureStore.items.get(`${KEY}|auth`).options).toEqual({
|
|
55
|
+
keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
56
|
+
requireAuthentication: true,
|
|
57
|
+
authenticationPrompt: 'Unlock to continue',
|
|
58
|
+
});
|
|
59
|
+
expect(store.isProtected()).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a shell with background work keeps the token readable after first unlock, biometrics or not', async () => {
|
|
63
|
+
const { store } = create({ background: true, biometric: true });
|
|
64
|
+
await store.set('token-1');
|
|
65
|
+
expect(SecureStore.items.get(`${KEY}|no-auth`).options).toEqual({
|
|
66
|
+
keychainAccessible: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY',
|
|
67
|
+
});
|
|
68
|
+
expect(store.isProtected()).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the token is read once, with the options it was written with, and held in memory', async () => {
|
|
72
|
+
const kv = createMemoryStore();
|
|
73
|
+
await create({ biometric: true, kv }).store.set('token-1');
|
|
74
|
+
const { store } = create({ biometric: true, kv });
|
|
75
|
+
expect(store.get()).toBeNull();
|
|
76
|
+
await store.load();
|
|
77
|
+
expect(SecureStore.getItemAsync).toHaveBeenLastCalledWith(
|
|
78
|
+
KEY,
|
|
79
|
+
expect.objectContaining({ requireAuthentication: true })
|
|
80
|
+
);
|
|
81
|
+
SecureStore.getItemAsync.mockClear();
|
|
82
|
+
expect(store.get()).toBe('token-1');
|
|
83
|
+
expect(store.get()).toBe('token-1');
|
|
84
|
+
expect(SecureStore.getItemAsync).not.toHaveBeenCalled();
|
|
85
|
+
store.unload();
|
|
86
|
+
expect(store.get()).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a change of unlock mode moves the token, leaving no copy behind', async () => {
|
|
90
|
+
let biometric = false;
|
|
91
|
+
const store = createTokenStore({
|
|
92
|
+
appId: 'field',
|
|
93
|
+
background: false,
|
|
94
|
+
getBiometric: () => biometric,
|
|
95
|
+
kv: createMemoryStore(),
|
|
96
|
+
});
|
|
97
|
+
await store.set('token-1');
|
|
98
|
+
biometric = true;
|
|
99
|
+
await store.set('token-2');
|
|
100
|
+
expect([...SecureStore.items.keys()]).toEqual([`${KEY}|auth`]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('set and clear tell subscribers; clear removes every copy of the token', async () => {
|
|
104
|
+
const { kv, store } = create();
|
|
105
|
+
const listener = jest.fn();
|
|
106
|
+
store.subscribe(listener);
|
|
107
|
+
await store.set('token-1');
|
|
108
|
+
expect(listener).toHaveBeenLastCalledWith('token-1');
|
|
109
|
+
await store.clear();
|
|
110
|
+
expect(listener).toHaveBeenLastCalledWith(null);
|
|
111
|
+
expect(SecureStore.items.size).toBe(0);
|
|
112
|
+
expect(kv.keys()).toEqual([]);
|
|
113
|
+
listener.mockClear();
|
|
114
|
+
await store.clear();
|
|
115
|
+
expect(listener).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
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 mobile.auth settings the app's server sends in the mobile root (emailCode, unlock,
|
|
18
|
+
// unlockAfter, sharedDevice), or null when the app has no auth: the shell then opens it without a
|
|
19
|
+
// sign-in screen.
|
|
20
|
+
function getAuthSettings(root) {
|
|
21
|
+
return root?.auth ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default getAuthSettings;
|