diff --git a/.github/actions/cache-bundler/action.yml b/.github/actions/cache-bundler/action.yml index 70d765ba14..240e8fdfed 100644 --- a/.github/actions/cache-bundler/action.yml +++ b/.github/actions/cache-bundler/action.yml @@ -24,12 +24,22 @@ outputs: runs: using: "composite" steps: + - id: get-hash + name: Hash lock file + shell: bash + run: | + if [ -f "${{ inputs.lock-file }}" ]; then + echo "hash=$(shasum -a 256 "${{ inputs.lock-file }}" | awk '{ print $1 }')" >> $GITHUB_OUTPUT + else + echo "::warning::Lock file '${{ inputs.lock-file }}' not found." + echo "hash=no-lock-file" >> $GITHUB_OUTPUT + fi - id: cache name: Cache Ruby gems uses: actions/cache@v4 with: path: ${{ inputs.path }} - key: ${{ runner.os }}-gems-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }} + key: ${{ runner.os }}-gems-${{ inputs.cache-version }}-${{ steps.get-hash.outputs.hash }} restore-keys: | ${{ runner.os }}-gems-${{ inputs.cache-version }}- ${{ runner.os }}-gems- diff --git a/.github/actions/cache-gradle/action.yml b/.github/actions/cache-gradle/action.yml index 973fe5b31c..f811e388b2 100644 --- a/.github/actions/cache-gradle/action.yml +++ b/.github/actions/cache-gradle/action.yml @@ -29,7 +29,7 @@ runs: uses: actions/cache@v4 with: path: ${{ inputs.path }} - key: ${{ runner.os }}-gradle-${{ inputs.cache-version }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-gradle-${{ inputs.cache-version }}-${{ hashFiles('**/build.gradle', '**/settings.gradle', '**/gradle-wrapper.properties', '**/gradle.properties') }} restore-keys: | ${{ runner.os }}-gradle-${{ inputs.cache-version }}- ${{ runner.os }}-gradle- diff --git a/.github/actions/cache-pods/action.yml b/.github/actions/cache-pods/action.yml index 7e0f0cb2fb..1e59f59994 100644 --- a/.github/actions/cache-pods/action.yml +++ b/.github/actions/cache-pods/action.yml @@ -9,7 +9,7 @@ inputs: default: | ios/Pods ~/Library/Caches/CocoaPods - lock-file: + lockfile: description: Path to Podfile.lock required: false default: ios/Podfile.lock @@ -31,7 +31,7 @@ runs: uses: actions/cache@v4 with: path: ${{ inputs.path }} - key: ${{ runner.os }}-pods-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }} + key: ${{ runner.os }}-pods-${{ inputs.cache-version }}-${{ hashFiles(inputs.lockfile) }} restore-keys: | ${{ runner.os }}-pods-${{ inputs.cache-version }}- ${{ runner.os }}-pods- diff --git a/.github/actions/cache-yarn/action.yml b/.github/actions/cache-yarn/action.yml index 94e037b82e..4ca33b20c0 100644 --- a/.github/actions/cache-yarn/action.yml +++ b/.github/actions/cache-yarn/action.yml @@ -25,12 +25,22 @@ outputs: runs: using: "composite" steps: + - id: get-hash + name: Hash lock file + shell: bash + run: | + if [ -f "${{ inputs.lock-file }}" ]; then + echo "hash=$(shasum -a 256 "${{ inputs.lock-file }}" | awk '{ print $1 }')" >> $GITHUB_OUTPUT + else + echo "::warning::Lock file '${{ inputs.lock-file }}' not found." + echo "hash=no-lock-file" >> $GITHUB_OUTPUT + fi - id: cache name: Cache Yarn dependencies uses: actions/cache@v4 with: path: ${{ inputs.path }} - key: ${{ runner.os }}-yarn-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }} + key: ${{ runner.os }}-yarn-${{ inputs.cache-version }}-${{ steps.get-hash.outputs.hash }} restore-keys: | ${{ runner.os }}-yarn-${{ inputs.cache-version }}- ${{ runner.os }}-yarn- diff --git a/.github/actions/yarnrc-hash/action.yml b/.github/actions/yarnrc-hash/action.yml new file mode 100644 index 0000000000..63d1e730ac --- /dev/null +++ b/.github/actions/yarnrc-hash/action.yml @@ -0,0 +1,25 @@ +name: Compute .yarnrc.yml hash + +description: Compute a stable hash for .yarnrc.yml to use in cache keys. + +outputs: + hash: + description: Hash of .yarnrc.yml (or "no-yarnrc" if the file is missing) + value: ${{ steps.compute-yarnrc-hash.outputs.hash }} + +runs: + using: composite + steps: + - name: Compute .yarnrc.yml hash + id: compute-yarnrc-hash + shell: bash + run: | + if [ -f .yarnrc.yml ]; then + if command -v shasum >/dev/null 2>&1; then + echo "hash=$(shasum -a 256 .yarnrc.yml | awk '{ print $1 }')" >> "$GITHUB_OUTPUT" + else + echo "hash=$(sha256sum .yarnrc.yml | awk '{ print $1 }')" >> "$GITHUB_OUTPUT" + fi + else + echo "hash=no-yarnrc" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/mobile-bundle-analysis.yml b/.github/workflows/mobile-bundle-analysis.yml index cff87db958..0b44b25ca0 100644 --- a/.github/workflows/mobile-bundle-analysis.yml +++ b/.github/workflows/mobile-bundle-analysis.yml @@ -37,32 +37,26 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - - name: Cache Node Modules - uses: actions/cache@v4 + - name: Cache Yarn + uses: ./.github/actions/cache-yarn with: path: | .yarn/cache node_modules app/node_modules - key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn- - - name: Cache Ruby Bundler - uses: actions/cache@v4 + cache-version: node-${{ env.NODE_VERSION_SANITIZED }} + - name: Cache Bundler + uses: ./.github/actions/cache-bundler with: path: app/vendor/bundle - key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ hashFiles('app/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems- + lock-file: app/Gemfile.lock + cache-version: ruby${{ env.RUBY_VERSION }} - name: Cache Gradle - uses: actions/cache@v4 + uses: ./.github/actions/cache-gradle with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('app/android/**/gradle-wrapper.properties', 'app/android/**/gradle-wrapper.jar') }} - restore-keys: | - ${{ runner.os }}-gradle- - name: Install Mobile Dependencies uses: ./.github/actions/mobile-setup with: @@ -100,30 +94,25 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - - name: Cache Node Modules - uses: actions/cache@v4 + - name: Cache Yarn + uses: ./.github/actions/cache-yarn with: path: | .yarn/cache node_modules app/node_modules - key: ${{ runner.os }}-node${{ env.NODE_VERSION }}-yarn-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-node${{ env.NODE_VERSION }}-yarn- - - name: Cache Ruby Bundler - uses: actions/cache@v4 + cache-version: node-${{ env.NODE_VERSION_SANITIZED }} + - name: Cache Bundler + uses: ./.github/actions/cache-bundler with: path: app/vendor/bundle - key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ hashFiles('app/Gemfile.lock') }} - restore-keys: | - ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems- + lock-file: app/Gemfile.lock + cache-version: ruby${{ env.RUBY_VERSION }} - name: Cache CocoaPods - uses: actions/cache@v4 + uses: ./.github/actions/cache-pods with: path: app/ios/Pods - key: ${{ runner.os }}-pods-${{ hashFiles('app/ios/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- + lock-file: app/ios/Podfile.lock - name: Install Mobile Dependencies uses: ./.github/actions/mobile-setup with: diff --git a/.github/workflows/mobile-deploy.yml b/.github/workflows/mobile-deploy.yml index 5e3ae1c143..44654a7b02 100644 --- a/.github/workflows/mobile-deploy.yml +++ b/.github/workflows/mobile-deploy.yml @@ -367,6 +367,10 @@ jobs: echo "Xcode path:" xcode-select -p + - name: Compute .yarnrc.yml hash + id: yarnrc-hash + uses: ./.github/actions/yarnrc-hash + - name: Cache Yarn artifacts id: yarn-cache uses: ./.github/actions/cache-yarn @@ -375,7 +379,7 @@ jobs: .yarn/cache .yarn/install-state.gz .yarn/unplugged - cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }} + cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }} - name: Cache Ruby gems id: gems-cache @@ -976,6 +980,10 @@ jobs: # Use version-manager script to apply versions node ${{ env.APP_PATH }}/scripts/version-manager.cjs apply "$VERSION" "$IOS_BUILD" "$ANDROID_BUILD" + - name: Compute .yarnrc.yml hash + id: yarnrc-hash + uses: ./.github/actions/yarnrc-hash + - name: Cache Yarn artifacts id: yarn-cache uses: ./.github/actions/cache-yarn @@ -984,7 +992,7 @@ jobs: .yarn/cache .yarn/install-state.gz .yarn/unplugged - cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }} + cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }} - name: Cache Ruby gems id: gems-cache diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml index b4105a466b..359ac222d8 100644 --- a/.github/workflows/mobile-e2e.yml +++ b/.github/workflows/mobile-e2e.yml @@ -56,6 +56,9 @@ jobs: node-version: ${{ env.NODE_VERSION }} - run: corepack enable - run: corepack prepare yarn@4.6.0 --activate + - name: Compute .yarnrc.yml hash + id: yarnrc-hash + uses: ./.github/actions/yarnrc-hash - name: Cache Yarn dependencies uses: ./.github/actions/cache-yarn with: @@ -63,7 +66,7 @@ jobs: .yarn/cache .yarn/install-state.gz .yarn/unplugged - cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }} + cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }} - name: Toggle Yarn hardened mode for trusted PRs if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV @@ -246,6 +249,9 @@ jobs: node-version: ${{ env.NODE_VERSION }} - run: corepack enable - run: corepack prepare yarn@4.6.0 --activate + - name: Compute .yarnrc.yml hash + id: yarnrc-hash + uses: ./.github/actions/yarnrc-hash - name: Cache Yarn dependencies uses: ./.github/actions/cache-yarn with: @@ -253,7 +259,7 @@ jobs: .yarn/cache .yarn/install-state.gz .yarn/unplugged - cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }} + cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }} - name: Toggle Yarn hardened mode for trusted PRs if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV diff --git a/.github/workflows/mobile-sdk-demo-e2e.yml b/.github/workflows/mobile-sdk-demo-e2e.yml index 2d7b6e8ed0..58eb6440d8 100644 --- a/.github/workflows/mobile-sdk-demo-e2e.yml +++ b/.github/workflows/mobile-sdk-demo-e2e.yml @@ -31,6 +31,7 @@ on: jobs: android-e2e: + name: Android E2E Tests Demo App # Currently build-only for Android. E2E steps are preserved but skipped (if: false). # To re-enable full E2E: change `if: false` to `if: true` on emulator steps. concurrency: @@ -192,6 +193,7 @@ jobs: ios-e2e: timeout-minutes: 60 runs-on: macos-latest-large + name: iOS E2E Tests Demo App concurrency: group: ${{ github.workflow }}-ios-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 64b700677b..4c2a8d32cc 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -14,7 +14,7 @@ on: workflow_dispatch: permissions: - id-token: write # Required for OIDC + id-token: write # Required for OIDC contents: read jobs: diff --git a/.github/workflows/qrcode-sdk-ci.yml b/.github/workflows/qrcode-sdk-ci.yml index 5409fefb3d..5849e7d494 100644 --- a/.github/workflows/qrcode-sdk-ci.yml +++ b/.github/workflows/qrcode-sdk-ci.yml @@ -68,6 +68,7 @@ jobs: shell: bash run: | yarn workspace @selfxyz/common build + yarn workspace @selfxyz/sdk-common build yarn workspace @selfxyz/qrcode build - name: Cache build artifacts @@ -258,6 +259,9 @@ jobs: key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }} fail-on-cache-miss: true + - name: Build SDK common dependency + run: yarn workspace @selfxyz/sdk-common build + - name: Run tests run: yarn workspace @selfxyz/qrcode test diff --git a/.gitleaks.toml b/.gitleaks.toml index db2ddd6505..9de50febcb 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -658,6 +658,8 @@ regexes = [ stopwords = [ "000000", "6fe4476ee5a1832882e326b506d14126", + "8853c3c635164864da68a6dbbcec7148506c3bcf", + "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature", "_ec2_", "aaaaaa", "about", @@ -3188,4 +3190,3 @@ id = "zendesk-secret-key" description = "Detected a Zendesk Secret Key, risking unauthorized access to customer support services and sensitive ticketing data." regex = '''(?i)[\w.-]{0,50}?(?:zendesk)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)''' keywords = ["zendesk"] - diff --git a/.gitleaksignore b/.gitleaksignore index e61ef92b54..04085c0664 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -3,10 +3,6 @@ 1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:73 1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:74 8bc1e85075f73906767652ab35d5563efce2a931:packages/mobile-sdk-alpha/src/animations/passport_verify.json:aws-access-token:6 -f506113a22e5b147132834e4659f5af308448389:app/tests/utils/deeplinks.test.ts:generic-api-key:183 -5a67b5cc50f291401d1da4e51706d0cfcf1c2316:app/tests/utils/deeplinks.test.ts:generic-api-key:182 0e4555eee6589aa9cca68f451227b149277d8c90:app/tests/src/utils/points/api.test.ts:generic-api-key:34 -app/ios/Podfile.lock:generic-api-key:2594 -app/tests/src/navigation/deeplinks.test.ts:generic-api-key:208 circuits/circuits/gcp_jwt_verifier/example_jwt.txt:jwt:1 cadd7ae5b768c261230f84426eac879c1853ce70:app/ios/Podfile.lock:generic-api-key:2586 diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 0ec54a0a57..60745d8c40 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -2201,7 +2201,7 @@ DEPENDENCIES: - lottie-ios - lottie-react-native (from `../node_modules/lottie-react-native`) - Mixpanel-swift (~> 5.0.0) - - "NFCPassportReader (from `git@github.com:selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`)" + - NFCPassportReader (from `https://github.com/selfxyz/NFCPassportReader.git`, commit `9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b`) - QKMRZScanner - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) @@ -2340,7 +2340,7 @@ EXTERNAL SOURCES: :path: "../node_modules/lottie-react-native" NFCPassportReader: :commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b - :git: "git@github.com:selfxyz/NFCPassportReader.git" + :git: https://github.com/selfxyz/NFCPassportReader.git RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -2517,15 +2517,15 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: NFCPassportReader: :commit: 9eff7c4e3a9037fdc1e03301584e0d5dcf14d76b - :git: "git@github.com:selfxyz/NFCPassportReader.git" + :git: https://github.com/selfxyz/NFCPassportReader.git SwiftQRScanner: :commit: c71ff91297640a944de4bca61434155c3f9b0979 :git: https://github.com/vinodiOS/SwiftQRScanner SPEC CHECKSUMS: AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 - boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + boost: 4cb898d0bf20404aab1850c656dcea009429d6c1 + DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 @@ -2540,7 +2540,7 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + glog: 69ef571f3de08433d766d614c73a9838a06bf7eb GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 diff --git a/app/package.json b/app/package.json index 32acd1c1ae..04456a425d 100644 --- a/app/package.json +++ b/app/package.json @@ -74,10 +74,14 @@ "web:preview": "vite preview" }, "resolutions": { - "punycode": "npm:punycode.js@2.3.1" + "punycode": "npm:punycode.js@2.3.1", + "react-native-blur-effect": "1.1.3", + "react-native-webview": "13.16.0" }, "overrides": { - "punycode": "npm:punycode.js@2.3.1" + "punycode": "npm:punycode.js@2.3.1", + "react-native-blur-effect": "1.1.3", + "react-native-webview": "13.16.0" }, "dependencies": { "@babel/runtime": "^7.28.3", @@ -137,6 +141,7 @@ "react-native": "0.76.9", "react-native-app-auth": "^8.0.3", "react-native-biometrics": "^3.0.1", + "react-native-blur-effect": "^1.1.3", "react-native-check-version": "^1.3.0", "react-native-cloud-storage": "^2.2.2", "react-native-device-info": "^14.0.4", diff --git a/app/src/components/BackupDocumentationLink.tsx b/app/src/components/BackupDocumentationLink.tsx index 7d79ef73f9..246177f746 100644 --- a/app/src/components/BackupDocumentationLink.tsx +++ b/app/src/components/BackupDocumentationLink.tsx @@ -6,6 +6,8 @@ import React from 'react'; import { Platform } from 'react-native'; import { Anchor, styled } from 'tamagui'; +import { androidBackupDocsUrl, appleICloudDocsUrl } from '@/consts/links'; + const StyledAnchor = styled(Anchor, { fontSize: 15, fontFamily: 'DINOT-Medium', @@ -15,16 +17,13 @@ const StyledAnchor = styled(Anchor, { const BackupDocumentationLink: React.FC = () => { if (Platform.OS === 'ios') { return ( - + iCloud data ); } return ( - + Android Backup ); diff --git a/app/src/components/navbar/HomeNavBar.tsx b/app/src/components/navbar/HomeNavBar.tsx index f69ec5dd9d..5c4c02f238 100644 --- a/app/src/components/navbar/HomeNavBar.tsx +++ b/app/src/components/navbar/HomeNavBar.tsx @@ -21,6 +21,7 @@ import CogHollowIcon from '@/assets/icons/cog_hollow.svg'; import PlusCircleIcon from '@/assets/icons/plus_circle.svg'; import ScanIcon from '@/assets/icons/qr_scan.svg'; import { NavBar } from '@/components/navbar/BaseNavBar'; +import { apiBaseUrl } from '@/consts/links'; import { buttonTap } from '@/integrations/haptics'; import { extraYPadding } from '@/utils/styleUtils'; @@ -40,7 +41,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => { if (uuidRegex.test(content)) { try { const response = await fetch( - `https://api.self.xyz/consume-deferred-linking-token?token=${content}`, + `${apiBaseUrl}/consume-deferred-linking-token?token=${content}`, ); const result = await response.json(); if (result.status !== 'success') { diff --git a/app/src/components/navbar/Points.tsx b/app/src/components/navbar/Points.tsx index 7c3e3c7209..c4f78348a3 100644 --- a/app/src/components/navbar/Points.tsx +++ b/app/src/components/navbar/Points.tsx @@ -29,6 +29,7 @@ import StarBlackIcon from '@/assets/icons/star_black.svg'; import LogoInversed from '@/assets/images/logo_inversed.svg'; import MajongImage from '@/assets/images/majong.png'; import { PointHistoryList } from '@/components/PointHistoryList'; +import { appsUrl } from '@/consts/links'; import { useIncomingPoints, usePoints } from '@/hooks/usePoints'; import { usePointsGuardrail } from '@/hooks/usePointsGuardrail'; import type { RootStackParamList } from '@/navigation'; @@ -428,7 +429,7 @@ const Points: React.FC = () => { onPress={() => { selfClient.trackEvent(PointEvents.EXPLORE_APPS); navigation.navigate('WebView', { - url: 'https://apps.self.xyz', + url: appsUrl, title: 'Explore Apps', }); }} diff --git a/app/src/consts/links.ts b/app/src/consts/links.ts index 096688a935..462339e2e2 100644 --- a/app/src/consts/links.ts +++ b/app/src/consts/links.ts @@ -2,22 +2,40 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +// ============================================================================= +// External Links +// ============================================================================= +// All external URLs used in the mobile app are centralized here for easy +// maintenance and testing. Links are sorted alphabetically. + +export const androidBackupDocsUrl = + 'https://developer.android.com/identity/data/autobackup'; +export const apiBaseUrl = 'https://api.self.xyz'; +export const apiPingUrl = 'https://api.self.xyz/ping'; export const appStoreUrl = 'https://apps.apple.com/app/self-zk/id6478563710'; - +export const appleICloudDocsUrl = 'https://support.apple.com/en-us/102651'; +export const appsUrl = 'https://apps.self.xyz'; export const gitHubUrl = 'https://github.com/selfxyz/self'; - +export const googleDriveAppDataScope = + 'https://www.googleapis.com/auth/drive.appdata'; +export const googleOAuthAuthorizationEndpoint = + 'https://accounts.google.com/o/oauth2/v2/auth'; +export const googleOAuthTokenEndpoint = 'https://oauth2.googleapis.com/token'; +export const notificationApiStagingUrl = + 'https://notification.staging.self.xyz'; +export const notificationApiUrl = 'https://notification.self.xyz'; export const playStoreUrl = 'https://play.google.com/store/apps/details?id=com.proofofpassportapp'; - +export const pointsApiBaseUrl = 'https://points.self.xyz'; export const privacyUrl = 'https://self.xyz/privacy'; - +export const referralBaseUrl = 'https://referral.self.xyz'; +export const selfLogoReverseUrl = + 'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png'; export const selfUrl = 'https://self.xyz'; - export const supportedBiometricIdsUrl = 'https://docs.self.xyz/use-self/self-map-countries-list'; - export const telegramUrl = 'https://t.me/selfprotocolbuilder'; - export const termsUrl = 'https://self.xyz/terms'; - +export const turnkeyOAuthRedirectAndroidUri = 'https://redirect.self.xyz'; +export const turnkeyOAuthRedirectIosUri = 'https://oauth-redirect.turnkey.com'; export const xUrl = 'https://x.com/selfprotocol'; diff --git a/app/src/devtools/mocks/index.ts b/app/src/devtools/mocks/index.ts index 050b22ecb2..81b71ed2a2 100644 --- a/app/src/devtools/mocks/index.ts +++ b/app/src/devtools/mocks/index.ts @@ -2,11 +2,16 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +import { + turnkeyOAuthRedirectAndroidUri, + turnkeyOAuthRedirectIosUri, +} from '@/consts/links'; + // Turnkey OAuth redirect URIs -export const TURNKEY_OAUTH_REDIRECT_URI_ANDROID = 'https://redirect.self.xyz'; +export const TURNKEY_OAUTH_REDIRECT_URI_ANDROID = + turnkeyOAuthRedirectAndroidUri; -export const TURNKEY_OAUTH_REDIRECT_URI_IOS = - 'https://oauth-redirect.turnkey.com'; +export const TURNKEY_OAUTH_REDIRECT_URI_IOS = turnkeyOAuthRedirectIosUri; // Re-export all mocks for easier imports export { parseScanResponse, scan } from '@/devtools/mocks/nfcScanner'; diff --git a/app/src/hooks/useConnectionModal.ts b/app/src/hooks/useConnectionModal.ts index 876a1c918a..31189c547c 100644 --- a/app/src/hooks/useConnectionModal.ts +++ b/app/src/hooks/useConnectionModal.ts @@ -7,6 +7,7 @@ import { Linking, Platform } from 'react-native'; import { SettingsEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; +import { apiPingUrl } from '@/consts/links'; import { useModal } from '@/hooks/useModal'; import { useNetInfo } from '@/hooks/useNetInfo'; import { navigationRef } from '@/navigation'; @@ -34,7 +35,7 @@ const connectionModalParams = { export default function useConnectionModal() { const { isConnected, isInternetReachable } = useNetInfo({ - reachabilityUrl: 'https://api.self.xyz/ping', + reachabilityUrl: apiPingUrl, }); const { showModal, dismissModal, visible } = useModal(connectionModalParams); //isConnected and isInternetReachable can be null for unknown state diff --git a/app/src/hooks/useReferralMessage.ts b/app/src/hooks/useReferralMessage.ts index 228a88eb69..741a31b567 100644 --- a/app/src/hooks/useReferralMessage.ts +++ b/app/src/hooks/useReferralMessage.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; +import { referralBaseUrl } from '@/consts/links'; import { getOrGeneratePointsAddress } from '@/providers/authProvider'; import { useSettingStore } from '@/stores/settingStore'; @@ -15,8 +16,7 @@ interface ReferralMessageResult { const buildReferralMessageFromAddress = ( userPointsAddress: string, ): ReferralMessageResult => { - const baseDomain = 'https://referral.self.xyz'; - const referralLink = `${baseDomain}/referral/${userPointsAddress}`; + const referralLink = `${referralBaseUrl}/referral/${userPointsAddress}`; return { message: `Join Self and use my referral link:\n\n${referralLink}`, referralLink, diff --git a/app/src/navigation/shared.ts b/app/src/navigation/shared.ts index f234e5cedc..d953682486 100644 --- a/app/src/navigation/shared.ts +++ b/app/src/navigation/shared.ts @@ -8,6 +8,7 @@ import type { NativeStackScreenProps, } from '@react-navigation/native-stack'; +import { selfUrl } from '@/consts/links'; import type { SharedRoutesParamList } from '@/navigation/types'; import ComingSoonScreen from '@/screens/shared/ComingSoonScreen'; import { WebViewScreen } from '@/screens/shared/WebViewScreen'; @@ -33,7 +34,7 @@ const sharedScreens: { [K in ScreenName]: ScreenConfig } = { headerShown: false, } as NativeStackNavigationOptions, initialParams: { - url: 'https://self.xyz', + url: selfUrl, title: undefined, shareTitle: undefined, shareMessage: undefined, diff --git a/app/src/providers/authProvider.tsx b/app/src/providers/authProvider.tsx index 9b165d1a5b..2072974154 100644 --- a/app/src/providers/authProvider.tsx +++ b/app/src/providers/authProvider.tsx @@ -369,6 +369,11 @@ export async function getOrGeneratePointsAddress( return pointsAddr; } +export function getPrivateKeyFromMnemonic(mnemonic: string) { + const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic); + return wallet.privateKey; +} + export async function hasSecretStored() { const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME }); return !!seed; @@ -470,8 +475,7 @@ export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) { return null; } const mnemonic = JSON.parse(foundMnemonic) as Mnemonic; - const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic.phrase); - return wallet.privateKey; + return getPrivateKeyFromMnemonic(mnemonic.phrase); } export const useAuth = () => { diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 1006174a5a..34cf31c678 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -89,6 +89,23 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => { }, }, documents: selfClientDocumentsAdapter, + navigation: { + goBack: () => { + if (navigationRef.isReady()) { + navigationRef.goBack(); + } + }, + goTo: (routeName, params) => { + if (navigationRef.isReady()) { + if (params !== undefined) { + // @ts-expect-error + navigationRef.navigate(routeName, params); + } else { + navigationRef.navigate(routeName as never); + } + } + }, + }, crypto: { async hash( data: Uint8Array, diff --git a/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx b/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx index 29b5a9a7f2..7498f29514 100644 --- a/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx +++ b/app/src/screens/account/recovery/AccountRecoveryChoiceScreen.tsx @@ -32,9 +32,9 @@ import RestoreAccountSvg from '@/assets/icons/restore_account.svg'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { RootStackParamList } from '@/navigation'; -import { useAuth } from '@/providers/authProvider'; +import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider'; import { - loadPassportDataAndSecret, + loadPassportData, reStorePassportDataWithRightCSCA, } from '@/providers/passportDataProvider'; import { STORAGE_NAME, useBackupMnemonic } from '@/services/cloud-backup'; @@ -85,32 +85,55 @@ const AccountRecoveryChoiceScreen: React.FC = () => { return false; } - const passportDataAndSecret = - (await loadPassportDataAndSecret()) as string; - const { passportData, secret } = JSON.parse(passportDataAndSecret); + const passportData = await loadPassportData(); + const secret = getPrivateKeyFromMnemonic(mnemonic.phrase); + + if (!passportData || !secret) { + console.warn('Failed to load passport data or secret'); + trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, { + reason: 'no_passport_data_or_secret', + }); + navigation.navigate({ name: 'Home', params: {} }); + setRestoring(false); + return false; + } + + const passportDataParsed = JSON.parse(passportData); + const { isRegistered, csca } = - await isUserRegisteredWithAlternativeCSCA(passportData, secret, { - getCommitmentTree(docCategory) { - return useProtocolStore.getState()[docCategory].commitment_tree; - }, - getAltCSCA(docCategory) { - if (docCategory === 'aadhaar') { - const publicKeys = - useProtocolStore.getState().aadhaar.public_keys; - // Convert string[] to Record format expected by AlternativeCSCA - return publicKeys - ? Object.fromEntries(publicKeys.map(key => [key, key])) - : {}; - } + await isUserRegisteredWithAlternativeCSCA( + passportDataParsed, + secret as string, + { + getCommitmentTree(docCategory) { + return useProtocolStore.getState()[docCategory].commitment_tree; + }, + getAltCSCA(docCategory) { + if (docCategory === 'aadhaar') { + const publicKeys = + useProtocolStore.getState().aadhaar.public_keys; + // Convert string[] to Record format expected by AlternativeCSCA + return publicKeys + ? Object.fromEntries(publicKeys.map(key => [key, key])) + : {}; + } - return useProtocolStore.getState()[docCategory].alternative_csca; + return useProtocolStore.getState()[docCategory] + .alternative_csca; + }, }, - }); + ); if (!isRegistered) { console.warn( 'Secret provided did not match a registered ID. Please try again.', ); - trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED); + trackEvent( + BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED, + { + reason: 'document_not_registered', + hasCSCA: !!csca, + }, + ); navigation.navigate({ name: 'Home', params: {} }); setRestoring(false); return false; @@ -118,7 +141,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => { if (isCloudRestore && !cloudBackupEnabled) { toggleCloudBackupEnabled(); } - reStorePassportDataWithRightCSCA(passportData, csca as string); + await reStorePassportDataWithRightCSCA( + passportDataParsed, + csca as string, + ); await markCurrentDocumentAsRegistered(selfClient); trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS); trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED); diff --git a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx index e82a056d8a..3f3e5e6381 100644 --- a/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx +++ b/app/src/screens/account/recovery/RecoverWithPhraseScreen.tsx @@ -31,9 +31,9 @@ import { import Paste from '@/assets/icons/paste.svg'; import type { RootStackParamList } from '@/navigation'; -import { useAuth } from '@/providers/authProvider'; +import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider'; import { - loadPassportDataAndSecret, + loadPassportData, reStorePassportDataWithRightCSCA, } from '@/providers/passportDataProvider'; @@ -74,8 +74,10 @@ const RecoverWithPhraseScreen: React.FC = () => { return; } - const passportDataAndSecret = await loadPassportDataAndSecret(); - if (!passportDataAndSecret) { + const passportData = await loadPassportData(); + const secret = getPrivateKeyFromMnemonic(slimMnemonic); + + if (!passportData || !secret) { console.warn( 'No passport data found on device. Please scan or import your document.', ); @@ -86,9 +88,10 @@ const RecoverWithPhraseScreen: React.FC = () => { setRestoring(false); return; } - const { passportData, secret } = JSON.parse(passportDataAndSecret); + const passportDataParsed = JSON.parse(passportData); + const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA( - passportData, + passportDataParsed, secret as string, { getCommitmentTree(docCategory) { @@ -122,7 +125,7 @@ const RecoverWithPhraseScreen: React.FC = () => { } if (csca) { - await reStorePassportDataWithRightCSCA(passportData, csca); + await reStorePassportDataWithRightCSCA(passportDataParsed, csca); } await markCurrentDocumentAsRegistered(selfClient); diff --git a/app/src/screens/app/LoadingScreen.tsx b/app/src/screens/app/LoadingScreen.tsx index 2173e088df..9d9e9deae4 100644 --- a/app/src/screens/app/LoadingScreen.tsx +++ b/app/src/screens/app/LoadingScreen.tsx @@ -9,6 +9,7 @@ import type { StaticScreenProps } from '@react-navigation/native'; import { useFocusEffect, useIsFocused } from '@react-navigation/native'; import type { DocumentCategory } from '@selfxyz/common/utils/types'; +import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha'; import { advercase, dinot, @@ -17,7 +18,6 @@ import { } from '@selfxyz/mobile-sdk-alpha'; import failAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/fail.json'; import proveLoadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/prove.json'; -import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha/browser'; import { black, slate400, diff --git a/app/src/screens/documents/aadhaar/AadhaarUploadScreen.tsx b/app/src/screens/documents/aadhaar/AadhaarUploadScreen.tsx index 0edfe106c0..d624ac2b8d 100644 --- a/app/src/screens/documents/aadhaar/AadhaarUploadScreen.tsx +++ b/app/src/screens/documents/aadhaar/AadhaarUploadScreen.tsx @@ -174,7 +174,7 @@ const AadhaarUploadScreen: React.FC = () => { - Generate a QR code from the mAadaar app + Generate a QR code from the Aadhaar app - - - - ); + return ; } diff --git a/app/src/screens/shared/WebViewScreen.tsx b/app/src/screens/shared/WebViewScreen.tsx index 7874066960..4ff1c7d788 100644 --- a/app/src/screens/shared/WebViewScreen.tsx +++ b/app/src/screens/shared/WebViewScreen.tsx @@ -23,6 +23,7 @@ import { import { WebViewNavBar } from '@/components/navbar/WebViewNavBar'; import { WebViewFooter } from '@/components/WebViewFooter'; +import { selfUrl } from '@/consts/links'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import type { SharedRoutesParamList } from '@/navigation/types'; @@ -39,7 +40,7 @@ type WebViewScreenProps = NativeStackScreenProps< 'WebView' >; -const defaultUrl = 'https://self.xyz'; +const defaultUrl = selfUrl; export const WebViewScreen: React.FC = ({ route }) => { const navigation = useNavigation(); diff --git a/app/src/services/cloud-backup/google.ts b/app/src/services/cloud-backup/google.ts index 61edfa4562..5b2d18a810 100644 --- a/app/src/services/cloud-backup/google.ts +++ b/app/src/services/cloud-backup/google.ts @@ -7,6 +7,12 @@ import { authorize } from 'react-native-app-auth'; import { GOOGLE_SIGNIN_ANDROID_CLIENT_ID } from '@env'; import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper'; +import { + googleDriveAppDataScope, + googleOAuthAuthorizationEndpoint, + googleOAuthTokenEndpoint, +} from '@/consts/links'; + // Ensure the client ID is available at runtime (skip in test environment) const isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID; @@ -22,10 +28,10 @@ const config: AuthConfiguration = { // ensure this prints the correct values before calling authorize clientId: GOOGLE_SIGNIN_ANDROID_CLIENT_ID || 'mock-client-id', redirectUrl: 'com.proofofpassportapp:/oauth2redirect', - scopes: ['https://www.googleapis.com/auth/drive.appdata'], + scopes: [googleDriveAppDataScope], serviceConfiguration: { - authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenEndpoint: 'https://oauth2.googleapis.com/token', + authorizationEndpoint: googleOAuthAuthorizationEndpoint, + tokenEndpoint: googleOAuthTokenEndpoint, }, additionalParameters: { access_type: 'offline', prompt: 'consent' as const }, }; diff --git a/app/src/services/notifications/notificationService.shared.ts b/app/src/services/notifications/notificationService.shared.ts index 5058c0617f..4bc8c8d656 100644 --- a/app/src/services/notifications/notificationService.shared.ts +++ b/app/src/services/notifications/notificationService.shared.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +import { notificationApiStagingUrl, notificationApiUrl } from '@/consts/links'; + export interface DeviceTokenRegistration { session_id: string; device_token: string; @@ -18,9 +20,9 @@ export interface RemoteMessage { [key: string]: unknown; } -export const API_URL = 'https://notification.self.xyz'; +export const API_URL = notificationApiUrl; -export const API_URL_STAGING = 'https://notification.staging.self.xyz'; +export const API_URL_STAGING = notificationApiStagingUrl; export const getStateMessage = (state: string): string => { switch (state) { case 'idle': diff --git a/app/src/services/points/constants.ts b/app/src/services/points/constants.ts index cae1520870..3b47460f64 100644 --- a/app/src/services/points/constants.ts +++ b/app/src/services/points/constants.ts @@ -2,4 +2,6 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -export const POINTS_API_BASE_URL = 'https://points.self.xyz'; +import { pointsApiBaseUrl } from '@/consts/links'; + +export const POINTS_API_BASE_URL = pointsApiBaseUrl; diff --git a/app/src/services/points/utils.ts b/app/src/services/points/utils.ts index 8b899da6a1..938670c91b 100644 --- a/app/src/services/points/utils.ts +++ b/app/src/services/points/utils.ts @@ -6,6 +6,7 @@ import { v4 } from 'uuid'; import { SelfAppBuilder } from '@selfxyz/common/utils/appType'; +import { selfLogoReverseUrl } from '@/consts/links'; import { getOrGeneratePointsAddress } from '@/providers/authProvider'; import { POINTS_API_BASE_URL } from '@/services/points/constants'; import type { IncomingPoints } from '@/services/points/types'; @@ -175,8 +176,7 @@ export const pointsSelfApp = async () => { userId: v4(), userIdType: 'uuid', disclosures: {}, - logoBase64: - 'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png', + logoBase64: selfLogoReverseUrl, header: '', }); diff --git a/app/tests/src/consts/links.test.ts b/app/tests/src/consts/links.test.ts new file mode 100644 index 0000000000..14c5a57912 --- /dev/null +++ b/app/tests/src/consts/links.test.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { describe, expect, it } from '@jest/globals'; + +import * as links from '@/consts/links'; + +describe('links', () => { + describe('URL format validation', () => { + it('should export only valid HTTPS URLs', () => { + const allLinks = Object.entries(links); + + allLinks.forEach(([_name, url]) => { + expect(url).toMatch(/^https:\/\/.+/); + expect(url).not.toContain(' '); + // Ensure no trailing slashes (consistency) + expect(url).not.toMatch(/\/$/); + }); + }); + + it('should have unique URLs (no duplicates)', () => { + const allUrls = Object.values(links); + const uniqueUrls = new Set(allUrls); + + expect(uniqueUrls.size).toBe(allUrls.length); + }); + }); + + describe('Critical URL validation', () => { + it('should have correct Telegram URL', () => { + // This test would have caught the wrong Telegram URL bug! + expect(links.telegramUrl).toBe('https://t.me/selfprotocolbuilder'); + }); + + it('should have correct GitHub URL', () => { + expect(links.gitHubUrl).toBe('https://github.com/selfxyz/self'); + }); + + it('should have correct X (Twitter) URL', () => { + expect(links.xUrl).toBe('https://x.com/selfprotocol'); + }); + + it('should have correct Self main URL', () => { + expect(links.selfUrl).toBe('https://self.xyz'); + }); + + it('should have correct privacy policy URL', () => { + expect(links.privacyUrl).toBe('https://self.xyz/privacy'); + }); + + it('should have correct terms URL', () => { + expect(links.termsUrl).toBe('https://self.xyz/terms'); + }); + }); + + describe('Self platform URLs', () => { + it('should use self.xyz domain for platform URLs', () => { + expect(links.selfUrl).toContain('self.xyz'); + expect(links.privacyUrl).toContain('self.xyz'); + expect(links.termsUrl).toContain('self.xyz'); + expect(links.appsUrl).toContain('self.xyz'); + expect(links.referralBaseUrl).toContain('self.xyz'); + expect(links.apiBaseUrl).toContain('self.xyz'); + expect(links.pointsApiBaseUrl).toContain('self.xyz'); + expect(links.notificationApiUrl).toContain('self.xyz'); + }); + }); + + describe('App store URLs', () => { + it('should have valid App Store URL', () => { + expect(links.appStoreUrl).toMatch(/^https:\/\/apps\.apple\.com\//); + expect(links.appStoreUrl).toContain('id6478563710'); + }); + + it('should have valid Play Store URL', () => { + expect(links.playStoreUrl).toMatch( + /^https:\/\/play\.google\.com\/store\//, + ); + expect(links.playStoreUrl).toContain('com.proofofpassportapp'); + }); + }); + + describe('OAuth URLs', () => { + it('should have valid Google OAuth endpoints', () => { + expect(links.googleOAuthAuthorizationEndpoint).toBe( + 'https://accounts.google.com/o/oauth2/v2/auth', + ); + expect(links.googleOAuthTokenEndpoint).toBe( + 'https://oauth2.googleapis.com/token', + ); + }); + + it('should have valid Turnkey redirect URIs', () => { + expect(links.turnkeyOAuthRedirectAndroidUri).toContain('self.xyz'); + expect(links.turnkeyOAuthRedirectIosUri).toContain('turnkey.com'); + }); + }); + + describe('Export completeness', () => { + it('should export at least 20 links', () => { + // Ensures we don't accidentally remove links + const linkCount = Object.keys(links).length; + expect(linkCount).toBeGreaterThanOrEqual(20); + }); + + it('should have descriptive variable names', () => { + const allLinks = Object.keys(links); + + allLinks.forEach(name => { + // Should be camelCase + expect(name).toMatch(/^[a-z][a-zA-Z0-9]*$/); + // Should end with Url, Uri, Endpoint, or Scope + const isValid = + name.endsWith('Url') || + name.endsWith('Uri') || + name.endsWith('Endpoint') || + name.endsWith('Scope'); + if (!isValid) { + console.log(`Invalid variable name: ${name}`); + } + expect(isValid).toBe(true); + }); + }); + }); +}); diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index 7eda29754a..b22526d083 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -228,7 +228,7 @@ describe('deeplinks', () => { .mockImplementation(() => {}); const url = - 'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature'; + 'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature'; // gitleaks:allow handleUrl({} as SelfClient, url); const { navigationRef } = require('@/navigation'); @@ -265,7 +265,7 @@ describe('deeplinks', () => { .mockImplementation(() => {}); const url = - 'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; + 'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; // gitleaks:allow handleUrl({} as SelfClient, url); const { navigationRef } = require('@/navigation'); @@ -530,7 +530,7 @@ describe('deeplinks', () => { it('returns id_token and scope parameters', () => { const url = - 'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; + 'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; // gitleaks:allow const result = parseAndValidateUrlParams(url); expect(result.id_token).toBeTruthy(); expect(result.scope).toBe('email profile'); diff --git a/app/tests/src/proving/provingUtils.test.ts b/app/tests/src/proving/provingUtils.test.ts index 2372b07d9f..246c25626a 100644 --- a/app/tests/src/proving/provingUtils.test.ts +++ b/app/tests/src/proving/provingUtils.test.ts @@ -12,23 +12,27 @@ describe('provingUtils', () => { const plaintext = 'hello world'; const encrypted = encryptAES256GCM(plaintext, forge.util.createBuffer(key)); + // Convert arrays to Uint8Array first to ensure proper byte conversion + const nonceBytes = new Uint8Array(encrypted.nonce); + const authTagBytes = new Uint8Array(encrypted.auth_tag); + const cipherTextBytes = new Uint8Array(encrypted.cipher_text); + + // Validate tag length (128 bits = 16 bytes) + expect(authTagBytes.length).toBe(16); + const decipher = forge.cipher.createDecipher( 'AES-GCM', forge.util.createBuffer(key), ); decipher.start({ - iv: forge.util.createBuffer( - Buffer.from(encrypted.nonce).toString('binary'), - ), + iv: forge.util.createBuffer(Buffer.from(nonceBytes).toString('binary')), tagLength: 128, tag: forge.util.createBuffer( - Buffer.from(encrypted.auth_tag).toString('binary'), + Buffer.from(authTagBytes).toString('binary'), ), }); decipher.update( - forge.util.createBuffer( - Buffer.from(encrypted.cipher_text).toString('binary'), - ), + forge.util.createBuffer(Buffer.from(cipherTextBytes).toString('binary')), ); const success = decipher.finish(); const decrypted = decipher.output.toString(); diff --git a/app/version.json b/app/version.json index 13c2626bf0..6c045f741f 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 190, - "lastDeployed": "2025-11-19T05:35:50.564Z" + "build": 191, + "lastDeployed": "2025-11-21T00:06:05.459Z" }, "android": { - "build": 122, - "lastDeployed": "2025-11-19T05:35:50.564Z" + "build": 123, + "lastDeployed": "2025-11-21T00:06:05.459Z" } } diff --git a/circuits/circuits/gcp_jwt_verifier/gcp_jwt_verifier.circom b/circuits/circuits/gcp_jwt_verifier/gcp_jwt_verifier.circom index e8017d415c..2536dd38cf 100644 --- a/circuits/circuits/gcp_jwt_verifier/gcp_jwt_verifier.circom +++ b/circuits/circuits/gcp_jwt_verifier/gcp_jwt_verifier.circom @@ -8,6 +8,7 @@ include "../utils/gcp_jwt/verifyCertificateSignature.circom"; include "../utils/gcp_jwt/verifyJSONFieldExtraction.circom"; include "circomlib/circuits/comparators.circom"; include "@openpassport/zk-email-circuits/utils/array.circom"; +include "@openpassport/zk-email-circuits/utils/bytes.circom"; /// @title GCPJWTVerifier /// @notice Verifies GCP JWT signature and full x5c certificate chain @@ -70,11 +71,11 @@ template GCPJWTVerifier( // GCP spec: nonce must be 10-74 bytes decoded - // Base64url encoding: 10 bytes = 14 chars, 74 bytes = 99 chars // https://cloud.google.com/confidential-computing/confidential-space/docs/connect-external-resources // EAT nonce (payload.eat_nonce[0]) - var MAX_EAT_NONCE_B64_LENGTH = 99; // Max length for base64url string (74 bytes decoded = 99 b64url chars) + var MAX_EAT_NONCE_B64_LENGTH = 74; // Max length for base64url string (74 bytes decoded = 99 b64url chars) var MAX_EAT_NONCE_KEY_LENGTH = 10; // Length of "eat_nonce" key (without quotes) + var EAT_NONCE_PACKED_CHUNKS = computeIntChunkLength(MAX_EAT_NONCE_B64_LENGTH); signal input eat_nonce_0_b64_length; // Length of base64url string signal input eat_nonce_0_key_offset; // Offset in payload where "eat_nonce" key starts (after opening quote) signal input eat_nonce_0_value_offset; // Offset in payload where eat_nonce[0] value appears @@ -83,6 +84,7 @@ template GCPJWTVerifier( var MAX_IMAGE_DIGEST_LENGTH = 71; // "sha256:" + 64 hex chars var IMAGE_HASH_LENGTH = 64; // Just the hex hash portion var MAX_IMAGE_DIGEST_KEY_LENGTH = 12; // Length of "image_digest" key (without quotes) + var IMAGE_HASH_PACKED_CHUNKS = computeIntChunkLength(IMAGE_HASH_LENGTH); signal input image_digest_length; // Length of full string (should be 71) signal input image_digest_key_offset; // Offset in payload where "image_digest" key starts (after opening quote) signal input image_digest_value_offset; // Offset in payload where image_digest value appears @@ -91,8 +93,8 @@ template GCPJWTVerifier( var maxPayloadLength = (maxB64PayloadLength * 3) \ 4; signal output rootCAPubkeyHash; // Root CA (x5c[2]) pubkey, trust anchor - signal output eat_nonce_0_b64_output[MAX_EAT_NONCE_B64_LENGTH]; // eat_nonce[0] base64url string - signal output image_hash[IMAGE_HASH_LENGTH]; // Container image SHA256 hash (without "sha256:" prefix) + signal output eat_nonce_0_b64_packed[EAT_NONCE_PACKED_CHUNKS]; // eat_nonce[0] base64url string packed with PackBytes + signal output image_hash_packed[IMAGE_HASH_PACKED_CHUNKS]; // Container image SHA256 hash (64 hex chars) packed with PackBytes // Verify JWT Signature (using x5c[0] public key) component jwtVerifier = JWTVerifier(n, k, maxMessageLength, maxB64HeaderLength, maxB64PayloadLength); @@ -162,7 +164,7 @@ template GCPJWTVerifier( // Validate nonce maximum length (74 bytes decoded = 99 base64url chars) component length_max_check = LessEqThan(log2Ceil(MAX_EAT_NONCE_B64_LENGTH)); length_max_check.in[0] <== eat_nonce_0_b64_length; - length_max_check.in[1] <== 99; + length_max_check.in[1] <== MAX_EAT_NONCE_B64_LENGTH; length_max_check.out === 1; // Validate nonce offset bounds (prevent reading beyond payload) @@ -195,7 +197,7 @@ template GCPJWTVerifier( eatNonceExtractor.expected_key_name <== expected_eat_nonce_key; // Output the extracted base64url string - eat_nonce_0_b64_output <== eatNonceExtractor.extracted_value; + eat_nonce_0_b64_packed <== PackBytes(MAX_EAT_NONCE_B64_LENGTH)(eatNonceExtractor.extracted_value); // Validate length is exactly 71 ("sha256:" + 64 hex chars) image_digest_length === 71; @@ -244,9 +246,12 @@ template GCPJWTVerifier( extracted_image_digest[6] === 58; // ':' // Extract and output only the 64-char hash (skip "sha256:" prefix) + signal image_hash_bytes[IMAGE_HASH_LENGTH]; for (var i = 0; i < IMAGE_HASH_LENGTH; i++) { - image_hash[i] <== extracted_image_digest[7 + i]; + image_hash_bytes[i] <== extracted_image_digest[7 + i]; } + + image_hash_packed <== PackBytes(IMAGE_HASH_LENGTH)(image_hash_bytes); } component main = GCPJWTVerifier(1, 120, 35); diff --git a/circuits/circuits/gcp_jwt_verifier/prepare.ts b/circuits/circuits/gcp_jwt_verifier/prepare.ts index a5f3435bd4..5cf4c134fa 100644 --- a/circuits/circuits/gcp_jwt_verifier/prepare.ts +++ b/circuits/circuits/gcp_jwt_verifier/prepare.ts @@ -251,10 +251,6 @@ async function main() { ); } - // Decode for verification/logging (not used in circuit) - const eatNonce0Buffer = Buffer.from(eatNonce0Base64url, 'base64url'); - console.log(`[INFO] eat_nonce[0] decoded: ${eatNonce0Buffer.length} bytes`); - // Find offset of eat_nonce[0] in the decoded payload JSON // Decode the payload from base64url to get the exact JSON string const payloadJSON = Buffer.from(payloadB64, 'base64url').toString('utf8'); @@ -285,6 +281,30 @@ async function main() { eatNonce0CharCodes[i] = eatNonce0Base64url.charCodeAt(i); } + const eatNonce1Base64url = payload.eat_nonce[1]; + console.log(`[INFO] eat_nonce[1] (base64url): ${eatNonce1Base64url}`); + console.log(`[INFO] eat_nonce[1] string length: ${eatNonce1Base64url.length} characters`); + + if (eatNonce1Base64url.length > MAX_EAT_NONCE_B64_LENGTH) { + throw new Error( + `[ERROR] eat_nonce[1] length ${eatNonce1Base64url.length} exceeds max ${MAX_EAT_NONCE_B64_LENGTH}` + ); + } + + const eatNonce1ValueOffset = payloadJSON.indexOf(eatNonce1Base64url); + if (eatNonce1ValueOffset === -1) { + console.error('[ERROR] Could not find eat_nonce[1] value in decoded payload JSON'); + console.error('[DEBUG] Payload JSON:', payloadJSON); + console.error('[DEBUG] Looking for:', eatNonce1Base64url); + throw new Error('[ERROR] Could not find eat_nonce[1] value in decoded payload JSON'); + } + console.log(`[INFO] eat_nonce[1] value offset in payload: ${eatNonce1ValueOffset}`); + + const eatNonce1CharCodes = new Array(MAX_EAT_NONCE_B64_LENGTH).fill(0); + for (let i = 0; i < eatNonce1Base64url.length; i++) { + eatNonce1CharCodes[i] = eatNonce1Base64url.charCodeAt(i); + } + // Extract image_digest from payload.submods.container.image_digest if (!payload.submods?.container?.image_digest) { throw new Error('[ERROR] No image_digest found in payload.submods.container'); @@ -378,6 +398,9 @@ async function main() { eat_nonce_0_key_offset: eatNonce0KeyOffset.toString(), eat_nonce_0_value_offset: eatNonce0ValueOffset.toString(), + // EAT nonce[1] (circuit will extract value directly from payload) + eat_nonce_1_b64_length: eatNonce1Base64url.length.toString(), + // Container image digest (circuit will extract value directly from payload) image_digest_length: imageDigest.length.toString(), image_digest_key_offset: imageDigestKeyOffset.toString(), diff --git a/circuits/circuits/utils/gcp_jwt/verifyJSONFieldExtraction.circom b/circuits/circuits/utils/gcp_jwt/verifyJSONFieldExtraction.circom index e806055b8a..1a3e779f11 100644 --- a/circuits/circuits/utils/gcp_jwt/verifyJSONFieldExtraction.circom +++ b/circuits/circuits/utils/gcp_jwt/verifyJSONFieldExtraction.circom @@ -74,39 +74,30 @@ template ExtractAndVerifyJSONField( // Check character at colon+1: must be '[' (91) or space (32) signal char_after_colon <== ItemAtIndex(maxJSONLength)(json, colon_position + 1); + signal value_start <== ItemAtIndex(maxJSONLength)(json, value_offset); + // is_bracket: 1 if char is '[', 0 otherwise component is_bracket = IsEqual(); is_bracket.in[0] <== char_after_colon; is_bracket.in[1] <== 91; // '[' - // is_space: 1 if char is space, 0 otherwise - component is_space = IsEqual(); - is_space.in[0] <== char_after_colon; - is_space.in[1] <== 32; // ' ' - - // Exactly one must be true: char is either '[' or space - is_bracket.out + is_space.out === 1; + // is_quote: 1 if char is quote, 0 otherwise + component is_quote = IsEqual(); + is_quote.in[0] <== char_after_colon; + is_quote.in[1] <== 34; // " - // If bracket at colon+1: check quote at colon+2, value at colon+3 - // If space at colon+1: check bracket at colon+2, quote at colon+3, value at colon+4 + // Exactly one must be true: char is either [ or quote + is_bracket.out + is_quote.out === 1; - // When is_bracket=1 (no space): expect quote at colon+2 + // When is_bracket=1 : expect quote at colon+2 signal char_at_plus2 <== ItemAtIndex(maxJSONLength)(json, colon_position + 2); - // When is_space=1: expect bracket at colon+2 // Constraint: if is_bracket=1, char_at_plus2 must be quote(34) - // if is_space=1, char_at_plus2 must be bracket(91) + // if is_quote=1, char_at_plus2 must be value[0] is_bracket.out * (char_at_plus2 - 34) === 0; // If bracket at +1, quote at +2 - is_space.out * (char_at_plus2 - 91) === 0; // If space at +1, bracket at +2 - - // When is_space=1: check quote at colon+3 - signal char_at_plus3 <== ItemAtIndex(maxJSONLength)(json, colon_position + 3); - is_space.out * (char_at_plus3 - 34) === 0; // If space at +1, quote at +3 - - // Enforce value_offset based on pattern - // Pattern 1 (no space): :[" -> value at colon+3 - // Pattern 2 (space): : [" -> value at colon+4 - signal expected_value_offset <== colon_position + 3 + is_space.out; - value_offset === expected_value_offset; + component is_value_after_quote = IsEqual(); + is_value_after_quote.in[0] <== char_at_plus2; + is_value_after_quote.in[1] <== value_start; + is_quote.out * (1 - is_value_after_quote.out) === 0; // Extract value from JSON and output directly extracted_value <== SelectSubArray( @@ -114,10 +105,20 @@ template ExtractAndVerifyJSONField( maxValueLength )(json, value_offset, value_length); - // Validate value ends with closing quote and bracket: "value"] + // Validate value ends with closing quote and then either ']' or ',' after signal closing_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length); closing_quote === 34; // ASCII code for " - signal closing_bracket <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1); - closing_bracket === 93; // ASCII code for ] + // The character following the closing quote must be either ']' (93) or ',' (44) + signal char_after_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1); + component is_closing_bracket = IsEqual(); + is_closing_bracket.in[0] <== char_after_quote; + is_closing_bracket.in[1] <== 93; // ']' + + component is_comma = IsEqual(); + is_comma.in[0] <== char_after_quote; + is_comma.in[1] <== 44; // ',' + + // Exactly one of the two must be true + is_closing_bracket.out + is_comma.out === 1; } diff --git a/circuits/package.json b/circuits/package.json index d9a7a70b16..43cf1c4682 100644 --- a/circuits/package.json +++ b/circuits/package.json @@ -19,6 +19,7 @@ "nice": "prettier --write .", "test": "yarn test-base 'tests/**/*.test.ts' --exit", "test-base": "yarn ts-mocha -n import=tsx --max-old-space-size=8192 --paths -p tsconfig.json", + "test-gcp-jwt-verifier": "yarn test-base 'tests/gcp_jwt_verifier/gcp_jwt_verifier.test.ts' --exit", "test-custom-hasher": "yarn test-base 'tests/other_circuits/custom_hasher.test.ts' --exit", "test-disclose": "yarn test-base 'tests/disclose/vc_and_disclose.test.ts' --exit", "test-disclose-aadhaar": "yarn test-base 'tests/disclose/vc_and_disclose_aadhaar.test.ts' --exit", @@ -49,9 +50,9 @@ "@zk-email/jwt-tx-builder-circuits": "0.1.0", "@zk-email/jwt-tx-builder-helpers": "0.1.0", "@zk-email/zk-regex-circom": "^1.2.1", - "@zk-kit/binary-merkle-root.circom": "https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree", + "@zk-kit/binary-merkle-root.circom": "npm:@selfxyz/binary-merkle-root.circom@^0.0.1", "@zk-kit/circuits": "^1.0.0-beta", - "anon-aadhaar-circuits": "https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main", + "anon-aadhaar-circuits": "https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits", "asn1": "^0.2.6", "asn1.js": "^5.4.1", "asn1js": "^3.0.5", diff --git a/circuits/tests/disclose/vc_and_disclose_aadhaar.test.ts b/circuits/tests/disclose/vc_and_disclose_aadhaar.test.ts index cf1aace155..a3e9010bc9 100644 --- a/circuits/tests/disclose/vc_and_disclose_aadhaar.test.ts +++ b/circuits/tests/disclose/vc_and_disclose_aadhaar.test.ts @@ -17,9 +17,28 @@ import nameAndYobAadhaarjson from '../consts/ofac/nameAndYobAadhaarSMT.json' wit import fs from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// const privateKeyPath = path.join(__dirname, '../../../node_modules/anon-aadhaar-circuits/assets/testPrivateKey.pem'); + +// Dynamically resolve the anon-aadhaar-circuits package location +function resolvePackagePath(packageName: string, subpath: string): string { + try { + // Try to resolve the package's package.json + const packageJsonPath = require.resolve(`${packageName}/package.json`, { + paths: [__dirname], + }); + const packageDir = path.dirname(packageJsonPath); + return path.join(packageDir, subpath); + } catch (error) { + // Fallback to traditional node_modules search + const modulePath = path.join(__dirname, '../../node_modules', packageName, subpath); + if (fs.existsSync(modulePath)) { + return modulePath; + } + throw new Error(`Could not resolve ${packageName}/${subpath}`); + } +} + const privateKeyPem = fs.readFileSync( - path.join(__dirname, '../../node_modules/anon-aadhaar-circuits/assets/testPrivateKey.pem'), + resolvePackagePath('anon-aadhaar-circuits', 'assets/testPrivateKey.pem'), 'utf8' ); diff --git a/package.json b/package.json index e6a7715bb4..cd827c0b9c 100644 --- a/package.json +++ b/package.json @@ -47,13 +47,16 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-native": "0.76.9", - "react-native-passkey": "3.3.1" + "react-native-blur-effect": "1.1.3", + "react-native-passkey": "3.3.1", + "react-native-webview": "13.16.0" }, "dependencies": { "@babel/runtime": "^7.28.3", "js-sha1": "^0.7.0", "react": "^18.3.1", - "react-native": "0.76.9" + "react-native": "0.76.9", + "uuid": "^13.0.0" }, "devDependencies": { "@react-native-community/cli-server-api": "^16.0.3", diff --git a/packages/mobile-sdk-alpha/package.json b/packages/mobile-sdk-alpha/package.json index d2768df275..b1709b0826 100644 --- a/packages/mobile-sdk-alpha/package.json +++ b/packages/mobile-sdk-alpha/package.json @@ -151,6 +151,7 @@ "dependencies": { "@babel/runtime": "^7.28.3", "@selfxyz/common": "workspace:^", + "@selfxyz/euclid": "^0.4.1", "@xstate/react": "^5.0.5", "node-forge": "^1.3.1", "react-native-nfc-manager": "^3.17.1", @@ -191,9 +192,11 @@ "lottie-react-native": "7.2.2", "react": "^18.3.1", "react-native": "0.76.9", + "react-native-blur-effect": "^1.1.3", "react-native-haptic-feedback": "*", "react-native-localize": "*", - "react-native-svg": "*" + "react-native-svg": "*", + "react-native-webview": "^13.16.0" }, "packageManager": "yarn@4.6.0", "publishConfig": { diff --git a/packages/mobile-sdk-alpha/src/client.ts b/packages/mobile-sdk-alpha/src/client.ts index 5845869ad6..a03337ad37 100644 --- a/packages/mobile-sdk-alpha/src/client.ts +++ b/packages/mobile-sdk-alpha/src/client.ts @@ -42,7 +42,7 @@ const optionalDefaults: Required> = { }, }; -const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] as const; +const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents', 'navigation'] as const; export const createListenersMap = (): { map: Map void>>; @@ -212,7 +212,12 @@ export function createSelfClient({ getMRZState: () => { return useMRZStore.getState(); }, - + goBack: () => { + adapters.navigation.goBack(); + }, + goTo: (routeName, params) => { + adapters.navigation.goTo(routeName, params); + }, // for reactivity (if needed) useProvingStore, useSelfAppStore, diff --git a/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx b/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx index 83f1bef806..32ff30b05f 100644 --- a/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx +++ b/packages/mobile-sdk-alpha/src/flows/onboarding/country-picker-screen.tsx @@ -2,67 +2,26 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { memo, useCallback } from 'react'; -import { ActivityIndicator, FlatList, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useCallback, useState } from 'react'; import { commonNames } from '@selfxyz/common/constants/countries'; +import { CountryPickerScreen as CountryPickerUI } from '@selfxyz/euclid'; -import { BodyText, RoundFlag, XStack, YStack } from '../../components'; -import { black, slate100, slate500 } from '../../constants/colors'; -import { advercase, dinot } from '../../constants/fonts'; +import { RoundFlag } from '../../components'; import { useSelfClient } from '../../context'; import { useCountries } from '../../documents/useCountries'; import { buttonTap } from '../../haptic'; import { SdkEvents } from '../../types/events'; -interface CountryListItem { - key: string; - countryCode: string; -} - -const ITEM_HEIGHT = 65; -const FLAG_SIZE = 32; - -const CountryItem = memo<{ - countryCode: string; - onSelect: (code: string) => void; -}>(({ countryCode, onSelect }) => { - const countryName = commonNames[countryCode as keyof typeof commonNames]; - - if (!countryName) return null; - - return ( - onSelect(countryCode)} style={styles.countryItemContainer}> - - - {countryName} - - - ); -}); - -CountryItem.displayName = 'CountryItem'; - -const Loading = () => ( - - - -); -Loading.displayName = 'Loading'; - const CountryPickerScreen: React.FC = () => { const selfClient = useSelfClient(); + const [searchValue, setSearchValue] = useState(''); const { countryData, countryList, loading, userCountryCode, showSuggestion } = useCountries(); - const onPressCountry = useCallback( + const onCountrySelect = useCallback( (countryCode: string) => { buttonTap(); - // if (__DEV__) { - // console.log('Selected country code:', countryCode); - // console.log('Current countryData:', countryData); - // console.log('Available country codes:', Object.keys(countryData)); - // } const documentTypes = countryData[countryCode]; if (__DEV__) { console.log('documentTypes for', countryCode, ':', documentTypes); @@ -87,105 +46,34 @@ const CountryPickerScreen: React.FC = () => { [countryData, selfClient], ); - const renderItem = useCallback( - ({ item }: { item: CountryListItem }) => , - [onPressCountry], - ); + const renderFlag = useCallback((countryCode: string, size: number) => { + return ; + }, []); - const keyExtractor = useCallback((item: CountryListItem) => item.countryCode, []); + const getCountryName = useCallback((countryCode: string) => { + return commonNames[countryCode as keyof typeof commonNames] || countryCode; + }, []); - const getItemLayout = useCallback( - (_data: ArrayLike | null | undefined, index: number) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - }), - [], - ); + const onSearchChange = useCallback((value: string) => { + setSearchValue(value); + }, []); return ( - - - Select the country that issued your ID - - Self has support for over 300 ID types. You can select the type of ID in the next step - - - {loading ? ( - - ) : ( - - {showSuggestion && ( - - SUGGESTION - - SELECT AN ISSUING COUNTRY - - )} - - - )} - + console.log('Info pressed TODO: Implement')} + onSearchChange={onSearchChange} + /> ); }; CountryPickerScreen.displayName = 'CountryPickerScreen'; -const styles = StyleSheet.create({ - countryItemContainer: { - paddingVertical: 13, - }, - countryItemContent: { - alignItems: 'center', - gap: 16, - }, - countryItemText: { - fontSize: 16, - color: black, - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - titleText: { - fontSize: 29, - fontFamily: advercase, - color: black, - }, - subtitleText: { - fontSize: 16, - color: slate500, - marginTop: 20, - }, - sectionLabel: { - fontSize: 16, - color: black, - fontFamily: dinot, - letterSpacing: 0.8, - marginBottom: 8, - }, - sectionLabelBottom: { - fontSize: 16, - color: black, - fontFamily: dinot, - letterSpacing: 0.8, - marginTop: 20, - }, -}); - export default CountryPickerScreen; diff --git a/packages/mobile-sdk-alpha/src/index.ts b/packages/mobile-sdk-alpha/src/index.ts index 51bf05283c..b1517f99cf 100644 --- a/packages/mobile-sdk-alpha/src/index.ts +++ b/packages/mobile-sdk-alpha/src/index.ts @@ -17,8 +17,10 @@ export type { MRZValidation, NFCScanResult, NFCScannerAdapter, + NavigationAdapter, NetworkAdapter, Progress, + RouteName, SelfClient, StorageAdapter, TrackEventParams, diff --git a/packages/mobile-sdk-alpha/src/types/public.ts b/packages/mobile-sdk-alpha/src/types/public.ts index 149f631545..c24c40a613 100644 --- a/packages/mobile-sdk-alpha/src/types/public.ts +++ b/packages/mobile-sdk-alpha/src/types/public.ts @@ -199,6 +199,36 @@ export interface Adapters { auth: AuthAdapter; /** Required document persistence layer. Implementations must be idempotent. */ documents: DocumentsAdapter; + /** Required navigation adapter for handling screen transitions. */ + navigation: NavigationAdapter; +} + +/** + * Map these route names to your navigation configuration. + * Includes all screens that the SDK may navigate to across host applications. + */ +export type RouteName = + // Document acquisition flow + | 'DocumentCamera' + | 'DocumentOnboarding' + | 'CountryPicker' + | 'IDPicker' + | 'DocumentNFCScan' + | 'ManageDocuments' + // Account/onboarding flow + | 'Home' + | 'AccountVerifiedSuccess' + | 'AccountRecoveryChoice' + | 'SaveRecoveryPhrase' + // Error/fallback screens + | 'ComingSoon' + | 'DocumentDataNotFound' + // Settings + | 'Settings'; + +export interface NavigationAdapter { + goBack(): void; + goTo(routeName: RouteName, params?: Record): void; } /** @@ -284,6 +314,8 @@ export interface SelfClient { scanNFC(opts: NFCScanOpts & { signal?: AbortSignal }): Promise; /** Parses MRZ text and returns structured fields plus checksum metadata. */ extractMRZInfo(mrz: string): MRZInfo; + goBack(): void; + goTo(routeName: RouteName, params?: Record): void; /** * Convenience wrapper around {@link AnalyticsAdapter.trackEvent}. Calls are diff --git a/packages/mobile-sdk-alpha/tests/client.test.ts b/packages/mobile-sdk-alpha/tests/client.test.ts index bf8fab8873..a6e842a0a7 100644 --- a/packages/mobile-sdk-alpha/tests/client.test.ts +++ b/packages/mobile-sdk-alpha/tests/client.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../src'; import { createListenersMap, createSelfClient, SdkEvents } from '../src/index'; -import type { AuthAdapter } from '../src/types/public'; +import type { AuthAdapter, NavigationAdapter } from '../src/types/public'; describe('createSelfClient', () => { // Test eager validation during client creation @@ -27,21 +27,21 @@ describe('createSelfClient', () => { it('throws when network adapter missing during creation', () => { // @ts-expect-error -- missing adapters - expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth } })).toThrow( + expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth, navigation } })).toThrow( 'network adapter not provided', ); }); it('throws when crypto adapter missing during creation', () => { // @ts-expect-error -- missing adapters - expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth } })).toThrow( + expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth, navigation } })).toThrow( 'crypto adapter not provided', ); }); it('throws when documents adapter missing during creation', () => { // @ts-expect-error -- missing adapters - expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth } })).toThrow( + expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth, navigation } })).toThrow( 'documents adapter not provided', ); }); @@ -49,7 +49,7 @@ describe('createSelfClient', () => { it('creates client successfully with all required adapters', () => { const client = createSelfClient({ config: {}, - adapters: { scanner, network, crypto, documents, auth }, + adapters: { scanner, network, crypto, documents, auth, navigation }, listeners: new Map(), }); expect(client).toBeTruthy(); @@ -59,7 +59,7 @@ describe('createSelfClient', () => { const scanMock = vi.fn().mockResolvedValue({ passportData: { mock: true } }); const client = createSelfClient({ config: {}, - adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth }, + adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation }, listeners: new Map(), }); const result = await client.scanNFC({ @@ -85,7 +85,7 @@ describe('createSelfClient', () => { const scanMock = vi.fn().mockRejectedValue(err); const client = createSelfClient({ config: {}, - adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth }, + adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation }, listeners: new Map(), }); await expect( @@ -106,7 +106,7 @@ describe('createSelfClient', () => { const client = createSelfClient({ config: {}, - adapters: { scanner, network, crypto, documents, auth }, + adapters: { scanner, network, crypto, documents, auth, navigation }, listeners: listeners.map, }); @@ -134,7 +134,7 @@ describe('createSelfClient', () => { it('parses MRZ via client', () => { const client = createSelfClient({ config: {}, - adapters: { scanner, network, crypto, documents, auth }, + adapters: { scanner, network, crypto, documents, auth, navigation }, listeners: new Map(), }); const sample = `P { const client = createSelfClient({ config: {}, adapters: { + navigation, scanner, network, crypto, @@ -171,7 +172,7 @@ describe('createSelfClient', () => { const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key')); const client = createSelfClient({ config: {}, - adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } }, + adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } }, listeners: new Map(), }); @@ -181,7 +182,7 @@ describe('createSelfClient', () => { const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key')); const client = createSelfClient({ config: {}, - adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } }, + adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } }, listeners: new Map(), }); await expect(client.hasPrivateKey()).resolves.toBe(true); @@ -222,3 +223,8 @@ const documents: DocumentsAdapter = { saveDocument: async () => {}, deleteDocument: async () => {}, }; + +const navigation: NavigationAdapter = { + goBack: vi.fn(), + goTo: vi.fn(), +}; diff --git a/packages/mobile-sdk-alpha/tests/documents/utils.test.ts b/packages/mobile-sdk-alpha/tests/documents/utils.test.ts index 2aec22457d..5bb68e197e 100644 --- a/packages/mobile-sdk-alpha/tests/documents/utils.test.ts +++ b/packages/mobile-sdk-alpha/tests/documents/utils.test.ts @@ -35,6 +35,10 @@ const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAda }), }, }, + navigation: { + goBack: () => {}, + goTo: (_routeName: string, _params?: Record) => {}, + }, scanner: { scan: async () => ({ passportData: { diff --git a/packages/mobile-sdk-alpha/tests/utils/testHelpers.ts b/packages/mobile-sdk-alpha/tests/utils/testHelpers.ts index bf9ec616c6..cfcf2853bc 100644 --- a/packages/mobile-sdk-alpha/tests/utils/testHelpers.ts +++ b/packages/mobile-sdk-alpha/tests/utils/testHelpers.ts @@ -3,6 +3,8 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. /* eslint-disable sort-exports/sort-exports */ +import type { NavigationAdapter } from 'src/types/public'; + import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../../src'; // Shared test data @@ -60,12 +62,18 @@ const mockAuth = { getPrivateKey: async () => 'stubbed-private-key', }; +const mockNavigation: NavigationAdapter = { + goBack: vi.fn(), + goTo: vi.fn(), +}; + export const mockAdapters = { scanner: mockScanner, network: mockNetwork, crypto: mockCrypto, documents: mockDocuments, auth: mockAuth, + navigation: mockNavigation, }; // Shared test expectations diff --git a/packages/mobile-sdk-demo/package.json b/packages/mobile-sdk-demo/package.json index 5450494ea7..adbd866e54 100644 --- a/packages/mobile-sdk-demo/package.json +++ b/packages/mobile-sdk-demo/package.json @@ -42,6 +42,7 @@ "lottie-react-native": "7.2.2", "react": "^18.3.1", "react-native": "0.76.9", + "react-native-blur-effect": "1.1.3", "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^2.3.3", "react-native-keychain": "^10.0.0", @@ -49,6 +50,7 @@ "react-native-safe-area-context": "^5.6.1", "react-native-svg": "15.12.1", "react-native-vector-icons": "^10.3.0", + "react-native-webview": "13.16.0", "stream-browserify": "^3.0.0", "util": "^0.12.5" }, diff --git a/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx b/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx index 33f7f94cec..eab40d2ae2 100644 --- a/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx +++ b/packages/mobile-sdk-demo/src/providers/SelfClientProvider.tsx @@ -11,6 +11,7 @@ import { createListenersMap, SdkEvents, type Adapters, + type RouteName, type TrackEventParams, type WsConn, reactNativeScannerAdapter, @@ -18,8 +19,40 @@ import { import { persistentDocumentsAdapter } from '../utils/documentStore'; import { getOrCreateSecret } from '../utils/secureStorage'; +import type { ScreenName } from '../navigation/NavigationProvider'; import { useNavigation } from '../navigation/NavigationProvider'; +/** + * Maps SDK RouteName values to demo app ScreenName values. + * Routes not in this map are not supported in the demo app. + */ +const ROUTE_TO_SCREEN_MAP: Partial> = { + Home: 'Home', + CountryPicker: 'CountrySelection', + IDPicker: 'IDSelection', + DocumentCamera: 'MRZ', + DocumentNFCScan: 'NFC', + ManageDocuments: 'Documents', + AccountVerifiedSuccess: 'Success', + // Routes not implemented in demo app: + // 'DocumentOnboarding': null, + // 'SaveRecoveryPhrase': null, + // 'AccountRecoveryChoice': null, + // 'ComingSoon': null, + // 'DocumentDataNotFound': null, + // 'Settings': null, +} as const; + +/** + * Translates SDK RouteName to demo app ScreenName. + * + * @param routeName - The route name from the SDK + * @returns The corresponding demo app screen name, or null if not supported + */ +function translateRouteToScreen(routeName: RouteName): ScreenName | null { + return ROUTE_TO_SCREEN_MAP[routeName] ?? null; +} + const createFetch = () => { const fetchImpl = globalThis.fetch; if (!fetchImpl) { @@ -129,6 +162,23 @@ export function SelfClientProvider({ children, onNavigate }: SelfClientProviderP }, ws: createWsAdapter(), }, + navigation: { + goBack: () => { + navigation.goBack(); + }, + goTo: (routeName, params) => { + const screenName = translateRouteToScreen(routeName); + if (screenName) { + // SDK passes generic Record, but demo navigation expects specific types + // This is safe because we control the route mapping + navigation.navigate(screenName, params as any); + } else { + console.warn( + `[SelfClientProvider] SDK route "${routeName}" is not mapped to a demo screen. Ignoring navigation request.`, + ); + } + }, + }, documents: persistentDocumentsAdapter, crypto: { async hash(data: Uint8Array): Promise { diff --git a/sdk/qrcode/components/SelfQRcode.tsx b/sdk/qrcode/components/SelfQRcode.tsx index 19f312a7e5..fcd6edf73d 100644 --- a/sdk/qrcode/components/SelfQRcode.tsx +++ b/sdk/qrcode/components/SelfQRcode.tsx @@ -97,7 +97,7 @@ const SelfQRcode = ({ return ; case QRcodeSteps.PROOF_GENERATION_FAILED: return ( - //@ts-ignore + // @ts-expect-error Lottie typings don't match the default export shape =18.2.0" + react-native: ">=0.72.0" + react-native-blur-effect: ^1.1.3 + react-native-svg: ">=15.14.0" + react-native-webview: ^13.16.0 + checksum: 10c0/f25a30b936d5ab1c154008296c64e0b4f97d91cf16e420b9bc3d2f4d9196ae426d1c2b28af653e36a9a580f78a42953f6bca3e9e9fbb36a15860636b9a0cb5fd + languageName: node + linkType: hard + "@selfxyz/mobile-app@workspace:app": version: 0.0.0-use.local resolution: "@selfxyz/mobile-app@workspace:app" @@ -7862,6 +7875,7 @@ __metadata: react-native: "npm:0.76.9" react-native-app-auth: "npm:^8.0.3" react-native-biometrics: "npm:^3.0.1" + react-native-blur-effect: "npm:^1.1.3" react-native-check-version: "npm:^1.3.0" react-native-cloud-storage: "npm:^2.2.2" react-native-device-info: "npm:^14.0.4" @@ -7909,6 +7923,7 @@ __metadata: dependencies: "@babel/runtime": "npm:^7.28.3" "@selfxyz/common": "workspace:^" + "@selfxyz/euclid": "npm:^0.4.1" "@testing-library/react": "npm:^14.1.2" "@types/react": "npm:^18.3.4" "@types/react-dom": "npm:^18.3.0" @@ -7946,9 +7961,11 @@ __metadata: lottie-react-native: 7.2.2 react: ^18.3.1 react-native: 0.76.9 + react-native-blur-effect: ^1.1.3 react-native-haptic-feedback: "*" react-native-localize: "*" react-native-svg: "*" + react-native-webview: ^13.16.0 languageName: unknown linkType: soft @@ -14121,12 +14138,12 @@ __metadata: languageName: node linkType: hard -"@zk-kit/binary-merkle-root.circom@https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree": - version: 1.0.0 - resolution: "@zk-kit/binary-merkle-root.circom@https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree" +"@zk-kit/binary-merkle-root.circom@npm:@selfxyz/binary-merkle-root.circom@^0.0.1": + version: 0.0.1 + resolution: "@selfxyz/binary-merkle-root.circom@npm:0.0.1" dependencies: circomlib: "npm:^2.0.5" - checksum: 10c0/f7bef284bae30b261610c5e2791c8aa7cc6ab42f5b4caf1e4b251543f506989dfb27d27963979a1db2c67b9c3d3561ce287fa8e0391bddee567c62963cd8f743 + checksum: 10c0/616426859e67702ef61c28651e38ebf218d560af13630e183f034f8cc66f40e01fa3b7ecb4ab5ed41ceb9c1f0ec8b3e62bdf7f184de38f1d7a8d7d1058a04fd5 languageName: node linkType: hard @@ -14505,13 +14522,13 @@ __metadata: languageName: node linkType: hard -"anon-aadhaar-circuits@https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main": +"anon-aadhaar-circuits@https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits": version: 2.4.3 - resolution: "anon-aadhaar-circuits@https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main" + resolution: "anon-aadhaar-circuits@https://github.com/selfxyz/anon-aadhaar.git#workspace=%40anon-aadhaar%2Fcircuits&commit=1b9efa501cff3cf25dc260b060bf611229e316a4" dependencies: "@anon-aadhaar/core": "npm:^2.4.3" "@zk-email/circuits": "npm:^6.1.1" - checksum: 10c0/93138d1c251988402482f1719ed37764b962250a51deb67bf5b855b91a6f89df2776ffe6135e8accc7a0d57dd13e7c210fc02fc6562af249ea4305f24d7d55f4 + checksum: 10c0/1e092f002e6a413fd034016320eedfb789158996f707d0c8c2055450baa35660fd90657e34e05c4a23094ed397e9088b6e9feb3463287bf9a0b272cc1fde592f languageName: node linkType: hard @@ -25613,6 +25630,7 @@ __metadata: react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-native: "npm:0.76.9" + react-native-blur-effect: "npm:1.1.3" react-native-get-random-values: "npm:^1.11.0" react-native-haptic-feedback: "npm:^2.3.3" react-native-keychain: "npm:^10.0.0" @@ -25621,6 +25639,7 @@ __metadata: react-native-svg: "npm:15.12.1" react-native-svg-transformer: "npm:^1.5.1" react-native-vector-icons: "npm:^10.3.0" + react-native-webview: "npm:13.16.0" stream-browserify: "npm:^3.0.0" typescript: "npm:^5.9.2" util: "npm:^0.12.5" @@ -28332,6 +28351,17 @@ __metadata: languageName: node linkType: hard +"react-native-blur-effect@npm:1.1.3": + version: 1.1.3 + resolution: "react-native-blur-effect@npm:1.1.3" + peerDependencies: + react: ^17.0.2 + react-native: ^0.66.4 + react-native-webview: ^13.6.2 + checksum: 10c0/5036214ac36fd430c7cea41bf0f14b2aa18338ae7f3e5df4142775dd4462f26ea3bc53710397bfe01c3a2c4450c219978f86dbc5d1989deefa39ca3c4ac80bb6 + languageName: node + linkType: hard + "react-native-check-version@npm:^1.3.0": version: 1.4.0 resolution: "react-native-check-version@npm:1.4.0" @@ -28716,7 +28746,7 @@ __metadata: languageName: node linkType: hard -"react-native-webview@npm:^13.16.0": +"react-native-webview@npm:13.16.0": version: 13.16.0 resolution: "react-native-webview@npm:13.16.0" dependencies: @@ -29936,6 +29966,7 @@ __metadata: react: "npm:^18.3.1" react-native: "npm:0.76.9" typescript: "npm:^5.9.2" + uuid: "npm:^13.0.0" languageName: unknown linkType: soft