From 9eb2fea5e3c01deb2831f15e26223a34aad0017b Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Mon, 6 Oct 2025 12:03:24 +0800 Subject: [PATCH 01/27] chore: push docker images to acr --- .github/workflows/china-cloud-test.yml | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/china-cloud-test.yml diff --git a/.github/workflows/china-cloud-test.yml b/.github/workflows/china-cloud-test.yml new file mode 100644 index 000000000..55929d5a5 --- /dev/null +++ b/.github/workflows/china-cloud-test.yml @@ -0,0 +1,74 @@ +name: China Cloud Deployment + +on: + push: + branches: [ya/china-cloud-cd] + paths: ["cloud/**"] + +jobs: + china-cloud-deploy: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + # 1. Checkout source code + - uses: actions/checkout@v4 + + # 2. Short commit SHA for tagging + - id: vars + run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + # 3. Compute Docker tags dynamically + # - id: docker-tags + # run: | + # BRANCH=${GITHUB_REF_NAME//\//-} # replace / with - for valid tag + # TAGS="${BRANCH}-${{ steps.vars.outputs.sha_short }}" + # # Add 'latest' only for main or release branches + # if [[ "$GITHUB_REF_NAME" == "main" || "$GITHUB_REF_NAME" == release/* ]]; then + # TAGS="$TAGS latest" + # fi + # echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + - id: docker-tags + run: | + BRANCH=${GITHUB_REF_NAME//\//-} # replace / with - + echo "tags=$BRANCH latest" >> "$GITHUB_OUTPUT" + + # 4. Docker Buildx + - uses: docker/setup-buildx-action@v2 + + # 5. Configure Alibaba Cloud credentials (prebuilt action) + - name: Configure Alibaba Cloud + uses: aliyun/configure-aliyun-credentials-action@v1 + with: + access-key-id: ${{ secrets.ALIBABA_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.ALIBABA_ACCESS_KEY_SECRET }} + region: cn-shenzhen + + # 6. AssumeRole → generate STS token + - id: sts + run: | + CREDS=$(aliyun sts AssumeRole \ + --profile default \ + --RoleArn "${{ secrets.ALIBABA_STS_ROLE_ARN }}" \ + --RoleSessionName "github-actions" \ + --DurationSeconds 3600 \ + --output json) + echo "akid=$(jq -r '.Credentials.AccessKeyId' <<< "$CREDS")" >> "$GITHUB_OUTPUT" + echo "token=$(jq -r '.Credentials.SecurityToken' <<< "$CREDS")" >> "$GITHUB_OUTPUT" + + # 7. Docker login with STS token + - uses: docker/login-action@v1 + with: + registry: mentra-acr-cnsz-a-registry.cn-shenzhen.cr.aliyuncs.com + username: STS.${{ steps.sts.outputs.akid }} + password: ${{ steps.sts.outputs.token }} + + # 8. Build & push Docker image + - uses: docker/build-push-action@v4 + with: + context: ./cloud + file: ./cloud/docker/Dockerfile.porter + push: true + tags: ${{ steps.docker-tags.outputs.tags }} From fdf15b114e05dcc845e6a28934d5311745d620bf Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Mon, 6 Oct 2025 12:06:19 +0800 Subject: [PATCH 02/27] chore: comment paths in action for now --- .github/workflows/china-cloud-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/china-cloud-test.yml b/.github/workflows/china-cloud-test.yml index 55929d5a5..bacc17a3b 100644 --- a/.github/workflows/china-cloud-test.yml +++ b/.github/workflows/china-cloud-test.yml @@ -3,7 +3,7 @@ name: China Cloud Deployment on: push: branches: [ya/china-cloud-cd] - paths: ["cloud/**"] + # paths: ["cloud/**"] jobs: china-cloud-deploy: From 744ea304526a7bb62636a0d9a8adc752283ccd98 Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Mon, 6 Oct 2025 12:09:03 +0800 Subject: [PATCH 03/27] chore: fix the actions file --- .github/workflows/china-cloud-test.yml | 64 ++++++++++++++++++-------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/.github/workflows/china-cloud-test.yml b/.github/workflows/china-cloud-test.yml index bacc17a3b..3770e7ac2 100644 --- a/.github/workflows/china-cloud-test.yml +++ b/.github/workflows/china-cloud-test.yml @@ -38,32 +38,58 @@ jobs: # 4. Docker Buildx - uses: docker/setup-buildx-action@v2 - # 5. Configure Alibaba Cloud credentials (prebuilt action) - - name: Configure Alibaba Cloud - uses: aliyun/configure-aliyun-credentials-action@v1 - with: - access-key-id: ${{ secrets.ALIBABA_ACCESS_KEY_ID }} - access-key-secret: ${{ secrets.ALIBABA_ACCESS_KEY_SECRET }} - region: cn-shenzhen + # 5. Install and configure Alibaba Cloud CLI + - name: Install Alibaba Cloud CLI + run: | + curl -sSL https://aliyuncli.alicdn.com/aliyun-cli-linux-latest-amd64.tgz | tar -xz + sudo mv aliyun /usr/local/bin/ + + - name: Configure Alibaba Cloud CLI + run: | + aliyun configure set \ + --profile default \ + --mode AK \ + --region cn-shenzhen \ + --access-key-id ${{ secrets.ALIBABA_ACCESS_KEY_ID }} \ + --access-key-secret ${{ secrets.ALIBABA_ACCESS_KEY_SECRET }} + shell: bash # 6. AssumeRole → generate STS token - id: sts run: | - CREDS=$(aliyun sts AssumeRole \ - --profile default \ + # Get JSON output and store in a variable + CREDS_JSON=$(aliyun sts AssumeRole \ --RoleArn "${{ secrets.ALIBABA_STS_ROLE_ARN }}" \ --RoleSessionName "github-actions" \ - --DurationSeconds 3600 \ - --output json) - echo "akid=$(jq -r '.Credentials.AccessKeyId' <<< "$CREDS")" >> "$GITHUB_OUTPUT" - echo "token=$(jq -r '.Credentials.SecurityToken' <<< "$CREDS")" >> "$GITHUB_OUTPUT" + --DurationSeconds 3600) + + # Extract values using jq + AKID=$(echo "$CREDS_JSON" | jq -r '.Credentials.AccessKeyId') + TOKEN=$(echo "$CREDS_JSON" | jq -r '.Credentials.SecurityToken') - # 7. Docker login with STS token - - uses: docker/login-action@v1 - with: - registry: mentra-acr-cnsz-a-registry.cn-shenzhen.cr.aliyuncs.com - username: STS.${{ steps.sts.outputs.akid }} - password: ${{ steps.sts.outputs.token }} + # Output for next steps + echo "akid=$AKID" >> "$GITHUB_OUTPUT" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + # For debugging + echo "Temporary credentials generated successfully" + + # 7. Get ACR authorization token and login + - name: Login to ACR + run: | + # Get ACR authorization token using STS credentials + AUTH_TOKEN=$(aliyun cr GetAuthorizationToken \ + --InstanceId mentra-acr-cnsz-a \ + --access-key-id ${{ steps.sts.outputs.akid }} \ + --access-key-secret $(echo "${{ steps.sts.outputs.token }}" | cut -d'.' -f1) \ + --sts-token ${{ steps.sts.outputs.token }} \ + --region cn-shenzhen | jq -r '.data.authorizationToken') + + # Login to ACR + echo $AUTH_TOKEN | docker login \ + --username=cr_temp_user \ + --password-stdin \ + mentra-acr-cnsz-a-registry.cn-shenzhen.cr.aliyuncs.com # 8. Build & push Docker image - uses: docker/build-push-action@v4 From cf6d4e2673746ea45f9fa01515b73bb13890f795 Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Mon, 6 Oct 2025 12:19:05 +0800 Subject: [PATCH 04/27] chore: fix actions agian --- .github/workflows/china-cloud-test.yml | 72 +++++++++----------------- 1 file changed, 24 insertions(+), 48 deletions(-) diff --git a/.github/workflows/china-cloud-test.yml b/.github/workflows/china-cloud-test.yml index 3770e7ac2..5ab3a4bce 100644 --- a/.github/workflows/china-cloud-test.yml +++ b/.github/workflows/china-cloud-test.yml @@ -3,14 +3,16 @@ name: China Cloud Deployment on: push: branches: [ya/china-cloud-cd] - # paths: ["cloud/**"] jobs: china-cloud-deploy: runs-on: ubuntu-latest permissions: - id-token: write contents: read + env: + ACR_INSTANCE_ID: cri-h675v46p9lj694l6 + ACR_REGION_ID: cn-shenzhen + ACR_PUBLIC_DOMAIN: mentra-acr-cnsz-a-registry.cn-shenzhen.cr.aliyuncs.com steps: # 1. Checkout source code @@ -21,80 +23,54 @@ jobs: run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" # 3. Compute Docker tags dynamically - # - id: docker-tags - # run: | - # BRANCH=${GITHUB_REF_NAME//\//-} # replace / with - for valid tag - # TAGS="${BRANCH}-${{ steps.vars.outputs.sha_short }}" - # # Add 'latest' only for main or release branches - # if [[ "$GITHUB_REF_NAME" == "main" || "$GITHUB_REF_NAME" == release/* ]]; then - # TAGS="$TAGS latest" - # fi - # echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - id: docker-tags run: | - BRANCH=${GITHUB_REF_NAME//\//-} # replace / with - + BRANCH=${GITHUB_REF_NAME//\//-} echo "tags=$BRANCH latest" >> "$GITHUB_OUTPUT" # 4. Docker Buildx - uses: docker/setup-buildx-action@v2 - # 5. Install and configure Alibaba Cloud CLI + # 5. Install Alibaba Cloud CLI - name: Install Alibaba Cloud CLI run: | curl -sSL https://aliyuncli.alicdn.com/aliyun-cli-linux-latest-amd64.tgz | tar -xz sudo mv aliyun /usr/local/bin/ + # 6. Configure Alibaba CLI with long-lived AK/SK - name: Configure Alibaba Cloud CLI run: | aliyun configure set \ --profile default \ --mode AK \ - --region cn-shenzhen \ + --region $ACR_REGION_ID \ --access-key-id ${{ secrets.ALIBABA_ACCESS_KEY_ID }} \ --access-key-secret ${{ secrets.ALIBABA_ACCESS_KEY_SECRET }} shell: bash - # 6. AssumeRole → generate STS token - - id: sts + # 7. Get temporary Docker login token + - id: acr-token run: | - # Get JSON output and store in a variable - CREDS_JSON=$(aliyun sts AssumeRole \ - --RoleArn "${{ secrets.ALIBABA_STS_ROLE_ARN }}" \ - --RoleSessionName "github-actions" \ - --DurationSeconds 3600) - - # Extract values using jq - AKID=$(echo "$CREDS_JSON" | jq -r '.Credentials.AccessKeyId') - TOKEN=$(echo "$CREDS_JSON" | jq -r '.Credentials.SecurityToken') - - # Output for next steps - echo "akid=$AKID" >> "$GITHUB_OUTPUT" + TOKEN=$(aliyun cr GetAuthorizationToken \ + --InstanceId $ACR_INSTANCE_ID \ + --RegionId $ACR_REGION_ID \ + | jq -r '.AuthorizationToken') echo "token=$TOKEN" >> "$GITHUB_OUTPUT" - # For debugging - echo "Temporary credentials generated successfully" - - # 7. Get ACR authorization token and login - - name: Login to ACR + # 8. Docker login with temporary token + - name: Docker Login to ACR run: | - # Get ACR authorization token using STS credentials - AUTH_TOKEN=$(aliyun cr GetAuthorizationToken \ - --InstanceId mentra-acr-cnsz-a \ - --access-key-id ${{ steps.sts.outputs.akid }} \ - --access-key-secret $(echo "${{ steps.sts.outputs.token }}" | cut -d'.' -f1) \ - --sts-token ${{ steps.sts.outputs.token }} \ - --region cn-shenzhen | jq -r '.data.authorizationToken') - - # Login to ACR - echo $AUTH_TOKEN | docker login \ - --username=cr_temp_user \ - --password-stdin \ - mentra-acr-cnsz-a-registry.cn-shenzhen.cr.aliyuncs.com + docker login \ + --username cr_temp_user \ + --password ${{ steps.acr-token.outputs.token }} \ + $ACR_PUBLIC_DOMAIN - # 8. Build & push Docker image + # 9. Build & push Docker image - uses: docker/build-push-action@v4 with: context: ./cloud file: ./cloud/docker/Dockerfile.porter push: true - tags: ${{ steps.docker-tags.outputs.tags }} + tags: | + ${{ env.ACR_PUBLIC_DOMAIN }}/mentra-dev/backend:${{ steps.vars.outputs.sha_short }} + ${{ env.ACR_PUBLIC_DOMAIN }}/mentra-dev/backend:latest From 41d05063d9c53b9711d1370e1324fbf3aafb96ab Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Mon, 6 Oct 2025 16:57:39 +0800 Subject: [PATCH 05/27] chore: replace the docker image --- .github/workflows/china-cloud-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/china-cloud-test.yml b/.github/workflows/china-cloud-test.yml index 5ab3a4bce..874b0aa86 100644 --- a/.github/workflows/china-cloud-test.yml +++ b/.github/workflows/china-cloud-test.yml @@ -69,7 +69,7 @@ jobs: - uses: docker/build-push-action@v4 with: context: ./cloud - file: ./cloud/docker/Dockerfile.porter + file: ./cloud/docker/Dockerfile.livekit push: true tags: | ${{ env.ACR_PUBLIC_DOMAIN }}/mentra-dev/backend:${{ steps.vars.outputs.sha_short }} From 29908764c5d1a688c515dc9b124c9f8f728606bd Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Tue, 7 Oct 2025 21:30:21 +0800 Subject: [PATCH 06/27] chore: make changes to make the china cloud work --- .../migrations/002-import-app-configs.ts | 386 +++++++++++------- .../migrations/validate-app-configs.ts | 259 +++++++----- .../src/connections/mongodb.connection.ts | 21 +- 3 files changed, 421 insertions(+), 245 deletions(-) diff --git a/cloud/packages/cloud/scripts/migrations/002-import-app-configs.ts b/cloud/packages/cloud/scripts/migrations/002-import-app-configs.ts index d937d577d..6617b419a 100644 --- a/cloud/packages/cloud/scripts/migrations/002-import-app-configs.ts +++ b/cloud/packages/cloud/scripts/migrations/002-import-app-configs.ts @@ -18,39 +18,45 @@ * --skip-ssl Skip SSL certificate verification (use with caution) */ -import mongoose from 'mongoose'; -import dotenv from 'dotenv'; -import https from 'https'; -import http from 'http'; -import { URL } from 'url'; -import App, { AppI, Permission, PermissionType } from '../../src/models/app.model'; -import { logger as rootLogger } from '../../src/services/logging/pino-logger'; -import { AppSetting, AppSettingType, ToolSchema } from '@mentra/sdk'; +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import https from "https"; +import http from "http"; +import { URL } from "url"; +import App, { AppI } from "../../src/models/app.model"; +import { logger as rootLogger } from "../../src/services/logging/pino-logger"; +import { AppSetting, ToolSchema } from "@mentra/sdk"; // Configure environment dotenv.config(); -const logger = rootLogger.child({ migration: '002-import-app-configs' }); -const DRY_RUN = process.argv.includes('--dry-run'); -const SKIP_SSL = process.argv.includes('--skip-ssl'); +const logger = rootLogger.child({ migration: "002-import-app-configs" }); +const DRY_RUN = process.argv.includes("--dry-run"); +const SKIP_SSL = process.argv.includes("--skip-ssl"); // Parse command line arguments -const PACKAGE_FILTER_ARG = process.argv.find(arg => arg.startsWith('--package-filter=')); -const PACKAGE_FILTER = PACKAGE_FILTER_ARG ? PACKAGE_FILTER_ARG.split('=')[1] : null; +const PACKAGE_FILTER_ARG = process.argv.find((arg) => + arg.startsWith("--package-filter="), +); +const PACKAGE_FILTER = PACKAGE_FILTER_ARG + ? PACKAGE_FILTER_ARG.split("=")[1] + : null; -const TIMEOUT_ARG = process.argv.find(arg => arg.startsWith('--timeout=')); -const TIMEOUT_SECONDS = TIMEOUT_ARG ? parseInt(TIMEOUT_ARG.split('=')[1]) : 10; +const TIMEOUT_ARG = process.argv.find((arg) => arg.startsWith("--timeout=")); +const TIMEOUT_SECONDS = TIMEOUT_ARG ? parseInt(TIMEOUT_ARG.split("=")[1]) : 10; if (DRY_RUN) { - logger.info('DRY RUN MODE: No changes will be made to the database'); + logger.info("DRY RUN MODE: No changes will be made to the database"); } if (PACKAGE_FILTER) { - logger.info(`Package filter mode: Only processing apps with package name matching: ${PACKAGE_FILTER}`); + logger.info( + `Package filter mode: Only processing apps with package name matching: ${PACKAGE_FILTER}`, + ); } if (SKIP_SSL) { - logger.warn('SSL verification disabled - use with caution'); + logger.warn("SSL verification disabled - use with caution"); } logger.info(`HTTP timeout set to ${TIMEOUT_SECONDS} seconds`); @@ -76,16 +82,26 @@ function validateAppConfig(config: any): { cleanedConfig?: AppConfigFile; skippedSettings?: Array<{ index: number; type: string; reason: string }>; } { - if (!config || typeof config !== 'object') { - return { isValid: false, error: 'Configuration file must contain a valid JSON object.' }; + if (!config || typeof config !== "object") { + return { + isValid: false, + error: "Configuration file must contain a valid JSON object.", + }; } - const skippedSettings: Array<{ index: number; type: string; reason: string }> = []; + const skippedSettings: Array<{ + index: number; + type: string; + reason: string; + }> = []; const cleanedConfig: AppConfigFile = {}; // Settings array is optional but must be an array if provided if (config.settings !== undefined && !Array.isArray(config.settings)) { - return { isValid: false, error: 'Optional field "settings" must be an array if provided.' }; + return { + isValid: false, + error: 'Optional field "settings" must be an array if provided.', + }; } if (config.settings === undefined) { @@ -94,7 +110,10 @@ function validateAppConfig(config: any): { // Tools array is optional but must be an array if provided if (config.tools !== undefined && !Array.isArray(config.tools)) { - return { isValid: false, error: 'Optional field "tools" must be an array if provided.' }; + return { + isValid: false, + error: 'Optional field "tools" must be an array if provided.', + }; } // Filter and validate settings @@ -104,12 +123,12 @@ function validateAppConfig(config: any): { const setting = config.settings[index]; // Group settings just need a title - if (setting.type === 'group') { - if (typeof setting.title !== 'string') { + if (setting.type === "group") { + if (typeof setting.title !== "string") { skippedSettings.push({ index: index + 1, type: setting.type, - reason: 'Group type requires a "title" field with a string value.' + reason: 'Group type requires a "title" field with a string value.', }); continue; } @@ -118,20 +137,21 @@ function validateAppConfig(config: any): { } // TITLE_VALUE settings just need label and value - if (setting.type === 'titleValue') { - if (typeof setting.label !== 'string') { + if (setting.type === "titleValue") { + if (typeof setting.label !== "string") { skippedSettings.push({ index: index + 1, type: setting.type, - reason: 'TitleValue type requires a "label" field with a string value.' + reason: + 'TitleValue type requires a "label" field with a string value.', }); continue; } - if (!('value' in setting)) { + if (!("value" in setting)) { skippedSettings.push({ index: index + 1, type: setting.type, - reason: 'TitleValue type requires a "value" field.' + reason: 'TitleValue type requires a "value" field.', }); continue; } @@ -140,44 +160,61 @@ function validateAppConfig(config: any): { } // Regular settings need key and label and type - if (typeof setting.key !== 'string' || typeof setting.label !== 'string' || typeof setting.type !== 'string') { + if ( + typeof setting.key !== "string" || + typeof setting.label !== "string" || + typeof setting.type !== "string" + ) { skippedSettings.push({ index: index + 1, - type: setting.type || 'unknown', - reason: 'Missing required fields "key", "label", or "type" (all must be strings).' + type: setting.type || "unknown", + reason: + 'Missing required fields "key", "label", or "type" (all must be strings).', }); continue; } // Type-specific validation let isValidSetting = true; - let skipReason = ''; + let skipReason = ""; switch (setting.type) { - case 'toggle': - if (setting.defaultValue !== undefined && typeof setting.defaultValue !== 'boolean') { + case "toggle": + if ( + setting.defaultValue !== undefined && + typeof setting.defaultValue !== "boolean" + ) { isValidSetting = false; - skipReason = 'Toggle type requires "defaultValue" to be a boolean if provided.'; + skipReason = + 'Toggle type requires "defaultValue" to be a boolean if provided.'; } break; - case 'text': - case 'text_no_save_button': - if (setting.defaultValue !== undefined && typeof setting.defaultValue !== 'string') { + case "text": + case "text_no_save_button": + if ( + setting.defaultValue !== undefined && + typeof setting.defaultValue !== "string" + ) { isValidSetting = false; - skipReason = 'Text type requires "defaultValue" to be a string if provided.'; + skipReason = + 'Text type requires "defaultValue" to be a string if provided.'; } break; - case 'select': - case 'select_with_search': + case "select": + case "select_with_search": if (!Array.isArray(setting.options)) { isValidSetting = false; skipReason = 'Select type requires an "options" array.'; } else { - for (let optIndex = 0; optIndex < setting.options.length; optIndex++) { + for ( + let optIndex = 0; + optIndex < setting.options.length; + optIndex++ + ) { const opt = setting.options[optIndex]; - if (typeof opt.label !== 'string' || !('value' in opt)) { + if (typeof opt.label !== "string" || !("value" in opt)) { isValidSetting = false; skipReason = `Option ${optIndex + 1}: Each option must have "label" (string) and "value" fields.`; break; @@ -186,67 +223,98 @@ function validateAppConfig(config: any): { } break; - case 'multiselect': + case "multiselect": if (!Array.isArray(setting.options)) { isValidSetting = false; skipReason = 'Multiselect type requires an "options" array.'; } else { - for (let optIndex = 0; optIndex < setting.options.length; optIndex++) { + for ( + let optIndex = 0; + optIndex < setting.options.length; + optIndex++ + ) { const opt = setting.options[optIndex]; - if (typeof opt.label !== 'string' || !('value' in opt)) { + if (typeof opt.label !== "string" || !("value" in opt)) { isValidSetting = false; skipReason = `Option ${optIndex + 1}: Each option must have "label" (string) and "value" fields.`; break; } } - if (isValidSetting && setting.defaultValue !== undefined && !Array.isArray(setting.defaultValue)) { + if ( + isValidSetting && + setting.defaultValue !== undefined && + !Array.isArray(setting.defaultValue) + ) { isValidSetting = false; - skipReason = 'Multiselect type requires "defaultValue" to be an array if provided.'; + skipReason = + 'Multiselect type requires "defaultValue" to be an array if provided.'; } } break; - case 'slider': - if (typeof setting.defaultValue !== 'number' || - typeof setting.min !== 'number' || - typeof setting.max !== 'number' || - setting.min > setting.max) { + case "slider": + if ( + typeof setting.defaultValue !== "number" || + typeof setting.min !== "number" || + typeof setting.max !== "number" || + setting.min > setting.max + ) { isValidSetting = false; - skipReason = 'Slider type requires "defaultValue", "min", and "max" to be numbers, with min ≤ max.'; + skipReason = + 'Slider type requires "defaultValue", "min", and "max" to be numbers, with min ≤ max.'; } break; - case 'numeric_input': - if (setting.defaultValue !== undefined && typeof setting.defaultValue !== 'number') { + case "numeric_input": + if ( + setting.defaultValue !== undefined && + typeof setting.defaultValue !== "number" + ) { isValidSetting = false; - skipReason = 'Numeric input type requires "defaultValue" to be a number if provided.'; + skipReason = + 'Numeric input type requires "defaultValue" to be a number if provided.'; } - if (setting.min !== undefined && typeof setting.min !== 'number') { + if (setting.min !== undefined && typeof setting.min !== "number") { isValidSetting = false; - skipReason = 'Numeric input type requires "min" to be a number if provided.'; + skipReason = + 'Numeric input type requires "min" to be a number if provided.'; } - if (setting.max !== undefined && typeof setting.max !== 'number') { + if (setting.max !== undefined && typeof setting.max !== "number") { isValidSetting = false; - skipReason = 'Numeric input type requires "max" to be a number if provided.'; + skipReason = + 'Numeric input type requires "max" to be a number if provided.'; } - if (setting.step !== undefined && typeof setting.step !== 'number') { + if (setting.step !== undefined && typeof setting.step !== "number") { isValidSetting = false; - skipReason = 'Numeric input type requires "step" to be a number if provided.'; + skipReason = + 'Numeric input type requires "step" to be a number if provided.'; } - if (setting.placeholder !== undefined && typeof setting.placeholder !== 'string') { + if ( + setting.placeholder !== undefined && + typeof setting.placeholder !== "string" + ) { isValidSetting = false; - skipReason = 'Numeric input type requires "placeholder" to be a string if provided.'; + skipReason = + 'Numeric input type requires "placeholder" to be a string if provided.'; } break; - case 'time_picker': - if (setting.defaultValue !== undefined && typeof setting.defaultValue !== 'number') { + case "time_picker": + if ( + setting.defaultValue !== undefined && + typeof setting.defaultValue !== "number" + ) { isValidSetting = false; - skipReason = 'Time picker type requires "defaultValue" to be a number (total seconds) if provided.'; + skipReason = + 'Time picker type requires "defaultValue" to be a number (total seconds) if provided.'; } - if (setting.showSeconds !== undefined && typeof setting.showSeconds !== 'boolean') { + if ( + setting.showSeconds !== undefined && + typeof setting.showSeconds !== "boolean" + ) { isValidSetting = false; - skipReason = 'Time picker type requires "showSeconds" to be a boolean if provided.'; + skipReason = + 'Time picker type requires "showSeconds" to be a boolean if provided.'; } break; @@ -263,7 +331,7 @@ function validateAppConfig(config: any): { skippedSettings.push({ index: index + 1, type: setting.type, - reason: skipReason + reason: skipReason, }); } } @@ -279,7 +347,7 @@ function validateAppConfig(config: any): { return { isValid: true, cleanedConfig, - skippedSettings: skippedSettings.length > 0 ? skippedSettings : undefined + skippedSettings: skippedSettings.length > 0 ? skippedSettings : undefined, }; } @@ -290,8 +358,8 @@ function validateAppConfig(config: any): { * @returns Normalized URL */ function normalizeUrl(url: string): string { - if (!url || url.trim() === '') { - return ''; + if (!url || url.trim() === "") { + return ""; } let normalized = url.trim(); @@ -302,7 +370,7 @@ function normalizeUrl(url: string): string { } // Remove trailing slash - normalized = normalized.replace(/\/+$/, ''); + normalized = normalized.replace(/\/+$/, ""); return normalized; } @@ -316,31 +384,31 @@ function normalizeUrl(url: string): string { function fetchUrl(url: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const parsedUrl = new URL(url); - const isHttps = parsedUrl.protocol === 'https:'; + const isHttps = parsedUrl.protocol === "https:"; const httpModule = isHttps ? https : http; const options = { hostname: parsedUrl.hostname, port: parsedUrl.port || (isHttps ? 443 : 80), path: parsedUrl.pathname + parsedUrl.search, - method: 'GET', + method: "GET", timeout: timeoutMs, headers: { - 'User-Agent': 'AugmentOS-Migration-Script/1.0', - 'Accept': 'application/json, text/plain, */*' + "User-Agent": "AugmentOS-Migration-Script/1.0", + Accept: "application/json, text/plain, */*", }, // Skip SSL certificate verification if requested - ...(SKIP_SSL && isHttps ? { rejectUnauthorized: false } : {}) + ...(SKIP_SSL && isHttps ? { rejectUnauthorized: false } : {}), }; const req = httpModule.request(options, (res) => { - let data = ''; + let data = ""; - res.on('data', (chunk) => { + res.on("data", (chunk) => { data += chunk; }); - res.on('end', () => { + res.on("end", () => { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { resolve(data); } else { @@ -349,13 +417,13 @@ function fetchUrl(url: string, timeoutMs: number): Promise { }); }); - req.on('error', (error) => { + req.on("error", (error) => { reject(error); }); - req.on('timeout', () => { + req.on("timeout", () => { req.destroy(); - reject(new Error('Request timeout')); + reject(new Error("Request timeout")); }); req.end(); @@ -371,7 +439,7 @@ async function fetchAppConfig(app: AppI): Promise<{ config: AppConfigFile; skippedSettingsCount: number; } | null> { - if (!app.publicUrl || app.publicUrl.trim() === '') { + if (!app.publicUrl || app.publicUrl.trim() === "") { logger.debug(`App ${app.packageName} has no publicUrl, skipping`); return null; } @@ -384,7 +452,7 @@ async function fetchAppConfig(app: AppI): Promise<{ const response = await fetchUrl(configUrl, TIMEOUT_SECONDS * 1000); - if (!response || response.trim() === '') { + if (!response || response.trim() === "") { logger.debug(`Empty response from ${configUrl}`); return null; } @@ -400,38 +468,53 @@ async function fetchAppConfig(app: AppI): Promise<{ // Validate configuration structure const validation = validateAppConfig(config); if (!validation.isValid) { - logger.warn(`Invalid config structure for ${app.packageName}: ${validation.error}`); + logger.warn( + `Invalid config structure for ${app.packageName}: ${validation.error}`, + ); return null; } // Log information about skipped settings if any const skippedCount = validation.skippedSettings?.length || 0; if (validation.skippedSettings && validation.skippedSettings.length > 0) { - logger.warn(`Config for ${app.packageName} has ${validation.skippedSettings.length} invalid/unsupported settings that will be skipped:`); - validation.skippedSettings.forEach(skipped => { - logger.warn(` - Setting ${skipped.index} (type: ${skipped.type}): ${skipped.reason}`); + logger.warn( + `Config for ${app.packageName} has ${validation.skippedSettings.length} invalid/unsupported settings that will be skipped:`, + ); + validation.skippedSettings.forEach((skipped) => { + logger.warn( + ` - Setting ${skipped.index} (type: ${skipped.type}): ${skipped.reason}`, + ); }); } - logger.info(`Successfully fetched and validated config for ${app.packageName}`); + logger.info( + `Successfully fetched and validated config for ${app.packageName}`, + ); return { config: validation.cleanedConfig as AppConfigFile, - skippedSettingsCount: skippedCount + skippedSettingsCount: skippedCount, }; - } catch (error) { if (error instanceof Error) { // Log at debug level for common errors to avoid noise - if (error.message.includes('ENOTFOUND') || - error.message.includes('ECONNREFUSED') || - error.message.includes('timeout') || - error.message.includes('HTTP 404')) { - logger.debug(`Config not available for ${app.packageName}: ${error.message}`); + if ( + error.message.includes("ENOTFOUND") || + error.message.includes("ECONNREFUSED") || + error.message.includes("timeout") || + error.message.includes("HTTP 404") + ) { + logger.debug( + `Config not available for ${app.packageName}: ${error.message}`, + ); } else { - logger.warn(`Error fetching config for ${app.packageName}: ${error.message}`); + logger.warn( + `Error fetching config for ${app.packageName}: ${error.message}`, + ); } } else { - logger.warn(`Unknown error fetching config for ${app.packageName}: ${error}`); + logger.warn( + `Unknown error fetching config for ${app.packageName}: ${error}`, + ); } return null; } @@ -446,13 +529,17 @@ async function fetchAppConfig(app: AppI): Promise<{ function removeIdFields(obj: any): any { if (Array.isArray(obj)) { return obj.map(removeIdFields); - } else if (obj !== null && typeof obj === 'object') { + } else if (obj !== null && typeof obj === "object") { const cleaned: any = {}; for (const [key, value] of Object.entries(obj)) { // Skip any field that is exactly "_id" - if (key !== '_id') { + if (key !== "_id") { // Skip empty options or enum arrays - if ((key === 'options' || key === 'enum') && Array.isArray(value) && value.length === 0) { + if ( + (key === "options" || key === "enum") && + Array.isArray(value) && + value.length === 0 + ) { continue; } cleaned[key] = removeIdFields(value); @@ -469,7 +556,10 @@ function removeIdFields(obj: any): any { * @param config - Configuration data to import * @returns Promise resolving to update result summary */ -async function updateAppWithConfig(app: AppI, config: AppConfigFile): Promise<{ +async function updateAppWithConfig( + app: AppI, + config: AppConfigFile, +): Promise<{ fieldsUpdated: string[]; fieldsSkipped: string[]; settingsCount: number; @@ -480,21 +570,23 @@ async function updateAppWithConfig(app: AppI, config: AppConfigFile): Promise<{ const updateData: any = {}; // Check if settings should be updated (only if empty/null/undefined) - const hasExistingSettings = app.settings && Array.isArray(app.settings) && app.settings.length > 0; + const hasExistingSettings = + app.settings && Array.isArray(app.settings) && app.settings.length > 0; if (!hasExistingSettings && config.settings) { updateData.settings = removeIdFields(config.settings); - fieldsUpdated.push('settings'); + fieldsUpdated.push("settings"); } else if (hasExistingSettings) { - fieldsSkipped.push('settings (already has data)'); + fieldsSkipped.push("settings (already has data)"); } // Check if tools should be updated (only if empty/null/undefined) - const hasExistingTools = app.tools && Array.isArray(app.tools) && app.tools.length > 0; + const hasExistingTools = + app.tools && Array.isArray(app.tools) && app.tools.length > 0; if (!hasExistingTools && config.tools) { updateData.tools = removeIdFields(config.tools); - fieldsUpdated.push('tools'); + fieldsUpdated.push("tools"); } else if (hasExistingTools) { - fieldsSkipped.push('tools (already has data)'); + fieldsSkipped.push("tools (already has data)"); } // Only perform update if there's something to update @@ -516,11 +608,12 @@ async function updateAppWithConfig(app: AppI, config: AppConfigFile): Promise<{ async function migrate() { try { // Connect to MongoDB - const dbUri = process.env.MONGO_URL || 'mongodb://localhost:27017/augmentos'; + const dbUri = + process.env.MONGO_URL || "mongodb://localhost:27017/augmentos"; logger.info(`Connecting to MongoDB: ${dbUri}`); - await mongoose.connect(dbUri + "/prod"); - logger.info('Connected to MongoDB'); + await mongoose.connect(dbUri); + logger.info("Connected to MongoDB"); // Initialize counters let totalApps = 0; @@ -546,17 +639,19 @@ async function migrate() { logger.info(`Found ${totalApps} apps to process`); if (totalApps === 0) { - logger.info('No apps found matching criteria'); + logger.info("No apps found matching criteria"); return; } // Process each app - logger.info('Starting app configuration import...'); + logger.info("Starting app configuration import..."); const appCursor = App.find(query).cursor(); for await (const app of appCursor) { appsProcessed++; - logger.info(`Processing app ${appsProcessed}/${totalApps}: ${app.packageName}`); + logger.info( + `Processing app ${appsProcessed}/${totalApps}: ${app.packageName}`, + ); try { // Attempt to fetch app_config.json @@ -573,49 +668,59 @@ async function migrate() { totalSkippedSettings += configResult.skippedSettingsCount; // Update app with imported configuration - const updateResult = await updateAppWithConfig(app, configResult.config); + const updateResult = await updateAppWithConfig( + app, + configResult.config, + ); // Track what was actually updated if (updateResult.fieldsUpdated.length > 0) { configsImported++; - if (updateResult.fieldsUpdated.includes('settings')) { + if (updateResult.fieldsUpdated.includes("settings")) { settingsUpdated++; } - if (updateResult.fieldsUpdated.includes('tools')) { + if (updateResult.fieldsUpdated.includes("tools")) { toolsUpdated++; } } // Track what was skipped - if (updateResult.fieldsSkipped.some(field => field.includes('settings'))) { + if ( + updateResult.fieldsSkipped.some((field) => field.includes("settings")) + ) { settingsSkipped++; } - if (updateResult.fieldsSkipped.some(field => field.includes('tools'))) { + if ( + updateResult.fieldsSkipped.some((field) => field.includes("tools")) + ) { toolsSkipped++; } logger.info(`Successfully processed config for ${app.packageName}:`); if (updateResult.fieldsUpdated.length > 0) { - logger.info(` - Fields updated: ${updateResult.fieldsUpdated.join(', ')}`); + logger.info( + ` - Fields updated: ${updateResult.fieldsUpdated.join(", ")}`, + ); } if (updateResult.fieldsSkipped.length > 0) { - logger.info(` - Fields skipped: ${updateResult.fieldsSkipped.join(', ')}`); + logger.info( + ` - Fields skipped: ${updateResult.fieldsSkipped.join(", ")}`, + ); } logger.info(` - Settings: ${updateResult.settingsCount}`); logger.info(` - Tools: ${updateResult.toolsCount}`); - } catch (error: any) { errorCount++; logger.error(`Error processing app ${app.packageName}:`, error); errors.push({ packageName: app.packageName, - error: error.message || error.toString() + error: error.message || error.toString(), }); } } // Log summary - logger.info('=== Migration Summary ==='); + logger.info("=== Migration Summary ==="); logger.info(`Total apps: ${totalApps}`); logger.info(`Apps processed: ${appsProcessed}`); logger.info(`Configs found: ${configsFound}`); @@ -625,40 +730,41 @@ async function migrate() { logger.info(`Tools updated: ${toolsUpdated}`); logger.info(`Tools skipped (already had data): ${toolsSkipped}`); if (totalSkippedSettings > 0) { - logger.info(`Invalid/unsupported settings skipped during import: ${totalSkippedSettings}`); + logger.info( + `Invalid/unsupported settings skipped during import: ${totalSkippedSettings}`, + ); } logger.info(`Errors: ${errorCount}`); if (DRY_RUN) { - logger.info('DRY RUN: No changes were made to the database'); + logger.info("DRY RUN: No changes were made to the database"); } // Log errors if any if (errors.length > 0) { - logger.warn('=== Errors Encountered ==='); + logger.warn("=== Errors Encountered ==="); errors.forEach((err, index) => { logger.warn(`${index + 1}. ${err.packageName}: ${err.error}`); }); } - logger.info('Migration completed successfully'); - + logger.info("Migration completed successfully"); } catch (error) { - logger.error('Migration failed:', error); + logger.error("Migration failed:", error); throw error; } finally { // Close database connection await mongoose.disconnect(); - logger.info('Database connection closed'); + logger.info("Database connection closed"); } } // Run migration if this file is executed directly if (require.main === module) { migrate().catch((error) => { - logger.error('Migration script failed:', error); + logger.error("Migration script failed:", error); process.exit(1); }); } -export default migrate; \ No newline at end of file +export default migrate; diff --git a/cloud/packages/cloud/scripts/migrations/validate-app-configs.ts b/cloud/packages/cloud/scripts/migrations/validate-app-configs.ts index f1470b836..ee86b4fb9 100644 --- a/cloud/packages/cloud/scripts/migrations/validate-app-configs.ts +++ b/cloud/packages/cloud/scripts/migrations/validate-app-configs.ts @@ -16,32 +16,38 @@ * --only-issues Only show apps with potential issues */ -import mongoose from 'mongoose'; -import dotenv from 'dotenv'; -import App, { AppI } from '../../src/models/app.model'; -import { logger as rootLogger } from '../../src/services/logging/pino-logger'; +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import App, { AppI } from "../../src/models/app.model"; +import { logger as rootLogger } from "../../src/services/logging/pino-logger"; // Configure environment dotenv.config(); -const logger = rootLogger.child({ script: 'validate-app-configs' }); -const DETAILED_OUTPUT = process.argv.includes('--detailed'); -const ONLY_ISSUES = process.argv.includes('--only-issues'); +const logger = rootLogger.child({ script: "validate-app-configs" }); +const DETAILED_OUTPUT = process.argv.includes("--detailed"); +const ONLY_ISSUES = process.argv.includes("--only-issues"); // Parse command line arguments -const PACKAGE_FILTER_ARG = process.argv.find(arg => arg.startsWith('--package-filter=')); -const PACKAGE_FILTER = PACKAGE_FILTER_ARG ? PACKAGE_FILTER_ARG.split('=')[1] : null; +const PACKAGE_FILTER_ARG = process.argv.find((arg) => + arg.startsWith("--package-filter="), +); +const PACKAGE_FILTER = PACKAGE_FILTER_ARG + ? PACKAGE_FILTER_ARG.split("=")[1] + : null; if (PACKAGE_FILTER) { - logger.info(`Package filter mode: Only validating apps with package name matching: ${PACKAGE_FILTER}`); + logger.info( + `Package filter mode: Only validating apps with package name matching: ${PACKAGE_FILTER}`, + ); } if (DETAILED_OUTPUT) { - logger.info('Detailed output mode enabled'); + logger.info("Detailed output mode enabled"); } if (ONLY_ISSUES) { - logger.info('Only issues mode: Only showing apps with potential problems'); + logger.info("Only issues mode: Only showing apps with potential problems"); } /** @@ -93,23 +99,23 @@ function validateApp(app: AppI): AppValidationResult { let score = 0; // Check basic required fields - const hasName = !!(app.name && app.name.trim() !== ''); - const hasDescription = !!(app.description && app.description.trim() !== ''); - const hasPublicUrl = !!(app.publicUrl && app.publicUrl.trim() !== ''); + const hasName = !!(app.name && app.name.trim() !== ""); + const hasDescription = !!(app.description && app.description.trim() !== ""); + const hasPublicUrl = !!(app.publicUrl && app.publicUrl.trim() !== ""); if (hasName) score += 20; - else issues.push('Missing app name'); + else issues.push("Missing app name"); if (hasDescription) score += 20; - else issues.push('Missing app description'); + else issues.push("Missing app description"); if (hasPublicUrl) score += 10; - else issues.push('Missing publicUrl'); + else issues.push("Missing publicUrl"); // Check optional URL fields - const hasLogoUrl = !!(app.logoURL && app.logoURL.trim() !== ''); - const hasWebviewUrl = !!(app.webviewURL && app.webviewURL.trim() !== ''); - const hasVersion = !!(app.version && app.version.trim() !== ''); + const hasLogoUrl = !!(app.logoURL && app.logoURL.trim() !== ""); + const hasWebviewUrl = !!(app.webviewURL && app.webviewURL.trim() !== ""); + const hasVersion = !!(app.version && app.version.trim() !== ""); if (hasLogoUrl) score += 5; if (hasWebviewUrl) score += 5; @@ -134,16 +140,16 @@ function validateApp(app: AppI): AppValidationResult { const setting = app.settings[i]; // Group settings just need a title - if (setting.type === 'group') { - if (!setting.title || setting.title.trim() === '') { + if (setting.type === "group") { + if (!setting.title || setting.title.trim() === "") { issues.push(`Setting ${i + 1}: Group missing title`); } continue; } // TITLE_VALUE settings just need label and value - if (setting.type === 'titleValue') { - if (!setting.label || setting.label.trim() === '') { + if (setting.type === "titleValue") { + if (!setting.label || setting.label.trim() === "") { issues.push(`Setting ${i + 1}: TitleValue missing label`); } if (setting.value === undefined || setting.value === null) { @@ -153,59 +159,97 @@ function validateApp(app: AppI): AppValidationResult { } // Regular settings need key, label, and type - if (!setting.key || setting.key.trim() === '') { + if (!setting.key || setting.key.trim() === "") { issues.push(`Setting ${i + 1}: Missing key`); } - if (!setting.label || setting.label.trim() === '') { + if (!setting.label || setting.label.trim() === "") { issues.push(`Setting ${i + 1}: Missing label`); } - if (!setting.type || setting.type.trim() === '') { + if (!setting.type || setting.type.trim() === "") { issues.push(`Setting ${i + 1}: Missing type`); } // Type-specific validation - if (setting.type === 'select' && (!setting.options || setting.options.length === 0)) { + if ( + setting.type === "select" && + (!setting.options || setting.options.length === 0) + ) { issues.push(`Setting ${i + 1}: Select type missing options`); } - if (setting.type === 'select_with_search' && (!setting.options || setting.options.length === 0)) { + if ( + setting.type === "select_with_search" && + (!setting.options || setting.options.length === 0) + ) { issues.push(`Setting ${i + 1}: SelectWithSearch type missing options`); } - if (setting.type === 'multiselect' && (!setting.options || setting.options.length === 0)) { + if ( + setting.type === "multiselect" && + (!setting.options || setting.options.length === 0) + ) { issues.push(`Setting ${i + 1}: Multiselect type missing options`); } - if (setting.type === 'slider') { - if (typeof setting.min !== 'number' || typeof setting.max !== 'number') { + if (setting.type === "slider") { + if ( + typeof setting.min !== "number" || + typeof setting.max !== "number" + ) { issues.push(`Setting ${i + 1}: Slider missing min/max values`); } } - if (setting.type === 'numeric_input') { - if (setting.min !== undefined && typeof setting.min !== 'number') { - issues.push(`Setting ${i + 1}: Numeric input min value must be a number`); + if (setting.type === "numeric_input") { + if (setting.min !== undefined && typeof setting.min !== "number") { + issues.push( + `Setting ${i + 1}: Numeric input min value must be a number`, + ); } - if (setting.max !== undefined && typeof setting.max !== 'number') { - issues.push(`Setting ${i + 1}: Numeric input max value must be a number`); + if (setting.max !== undefined && typeof setting.max !== "number") { + issues.push( + `Setting ${i + 1}: Numeric input max value must be a number`, + ); } - if (setting.step !== undefined && typeof setting.step !== 'number') { - issues.push(`Setting ${i + 1}: Numeric input step value must be a number`); + if (setting.step !== undefined && typeof setting.step !== "number") { + issues.push( + `Setting ${i + 1}: Numeric input step value must be a number`, + ); } - if (setting.placeholder !== undefined && typeof setting.placeholder !== 'string') { - issues.push(`Setting ${i + 1}: Numeric input placeholder must be a string`); + if ( + setting.placeholder !== undefined && + typeof setting.placeholder !== "string" + ) { + issues.push( + `Setting ${i + 1}: Numeric input placeholder must be a string`, + ); } - if (setting.defaultValue !== undefined && typeof setting.defaultValue !== 'number') { - issues.push(`Setting ${i + 1}: Numeric input defaultValue must be a number`); + if ( + setting.defaultValue !== undefined && + typeof setting.defaultValue !== "number" + ) { + issues.push( + `Setting ${i + 1}: Numeric input defaultValue must be a number`, + ); } } - if (setting.type === 'time_picker') { - if (setting.showSeconds !== undefined && typeof setting.showSeconds !== 'boolean') { - issues.push(`Setting ${i + 1}: Time picker showSeconds must be a boolean`); + if (setting.type === "time_picker") { + if ( + setting.showSeconds !== undefined && + typeof setting.showSeconds !== "boolean" + ) { + issues.push( + `Setting ${i + 1}: Time picker showSeconds must be a boolean`, + ); } - if (setting.defaultValue !== undefined && typeof setting.defaultValue !== 'number') { - issues.push(`Setting ${i + 1}: Time picker defaultValue must be a number (total seconds)`); + if ( + setting.defaultValue !== undefined && + typeof setting.defaultValue !== "number" + ) { + issues.push( + `Setting ${i + 1}: Time picker defaultValue must be a number (total seconds)`, + ); } } } @@ -216,10 +260,10 @@ function validateApp(app: AppI): AppValidationResult { for (let i = 0; i < app.tools.length; i++) { const tool = app.tools[i]; - if (!tool.id || tool.id.trim() === '') { + if (!tool.id || tool.id.trim() === "") { issues.push(`Tool ${i + 1}: Missing id`); } - if (!tool.description || tool.description.trim() === '') { + if (!tool.description || tool.description.trim() === "") { issues.push(`Tool ${i + 1}: Missing description`); } } @@ -230,7 +274,7 @@ function validateApp(app: AppI): AppValidationResult { for (let i = 0; i < app.permissions.length; i++) { const permission = app.permissions[i]; - if (!permission.type || permission.type.trim() === '') { + if (!permission.type || permission.type.trim() === "") { issues.push(`Permission ${i + 1}: Missing type`); } } @@ -238,7 +282,7 @@ function validateApp(app: AppI): AppValidationResult { // Check for completely empty configuration if (!hasSettings && !hasTools && !hasPermissions) { - issues.push('No configuration data (settings, tools, or permissions)'); + issues.push("No configuration data (settings, tools, or permissions)"); } return { @@ -256,7 +300,7 @@ function validateApp(app: AppI): AppValidationResult { hasWebviewUrl, hasVersion, issues, - score + score, }; } @@ -271,19 +315,25 @@ function formatAppResult(result: AppValidationResult): string { lines.push(`📱 ${result.packageName} (Score: ${result.score}/100)`); if (DETAILED_OUTPUT || result.issues.length > 0) { - lines.push(` ✓ Basic info: ${result.hasName ? '✓' : '✗'} name, ${result.hasDescription ? '✓' : '✗'} description`); - lines.push(` ✓ URLs: ${result.hasPublicUrl ? '✓' : '✗'} public, ${result.hasLogoUrl ? '✓' : '✗'} logo, ${result.hasWebviewUrl ? '✓' : '✗'} webview`); - lines.push(` ✓ Config: ${result.settingsCount} settings, ${result.toolsCount} tools, ${result.permissionsCount} permissions`); + lines.push( + ` ✓ Basic info: ${result.hasName ? "✓" : "✗"} name, ${result.hasDescription ? "✓" : "✗"} description`, + ); + lines.push( + ` ✓ URLs: ${result.hasPublicUrl ? "✓" : "✗"} public, ${result.hasLogoUrl ? "✓" : "✗"} logo, ${result.hasWebviewUrl ? "✓" : "✗"} webview`, + ); + lines.push( + ` ✓ Config: ${result.settingsCount} settings, ${result.toolsCount} tools, ${result.permissionsCount} permissions`, + ); if (result.issues.length > 0) { lines.push(` ⚠️ Issues:`); - result.issues.forEach(issue => { + result.issues.forEach((issue) => { lines.push(` - ${issue}`); }); } } - return lines.join('\n'); + return lines.join("\n"); } /** @@ -292,11 +342,12 @@ function formatAppResult(result: AppValidationResult): string { async function validate() { try { // Connect to MongoDB - const dbUri = process.env.MONGO_URL || 'mongodb://localhost:27017/augmentos'; + const dbUri = + process.env.MONGO_URL || "mongodb://localhost:27017/augmentos"; logger.info(`Connecting to MongoDB: ${dbUri}`); - await mongoose.connect(dbUri + "/prod"); - logger.info('Connected to MongoDB'); + await mongoose.connect(dbUri); + logger.info("Connected to MongoDB"); // Construct query filter based on package name pattern if provided const query: any = {}; @@ -317,13 +368,13 @@ async function validate() { averageScore: 0, settingsTotal: 0, toolsTotal: 0, - permissionsTotal: 0 + permissionsTotal: 0, }; const appResults: AppValidationResult[] = []; // Process each app - logger.info('Starting validation...'); + logger.info("Starting validation..."); const appCursor = App.find(query).cursor(); for await (const app of appCursor) { @@ -348,7 +399,8 @@ async function validate() { // Calculate average score if (stats.totalApps > 0) { stats.averageScore = Math.round( - appResults.reduce((sum, result) => sum + result.score, 0) / stats.totalApps + appResults.reduce((sum, result) => sum + result.score, 0) / + stats.totalApps, ); } @@ -356,78 +408,97 @@ async function validate() { appResults.sort((a, b) => a.score - b.score); // Display results - logger.info('\n=== App Configuration Validation Results ===\n'); + logger.info("\n=== App Configuration Validation Results ===\n"); // Show individual app results - appResults.forEach(result => { + appResults.forEach((result) => { // Skip apps without issues if only-issues mode is enabled if (ONLY_ISSUES && result.issues.length === 0) { return; } console.log(formatAppResult(result)); - console.log(''); // Empty line for spacing + console.log(""); // Empty line for spacing }); // Display summary statistics - console.log('=== Summary Statistics ==='); + console.log("=== Summary Statistics ==="); console.log(`📊 Total apps analyzed: ${stats.totalApps}`); console.log(`📊 Average completeness score: ${stats.averageScore}/100`); - console.log(''); - console.log('📋 Apps with configuration:'); - console.log(` • Settings: ${stats.appsWithSettings}/${stats.totalApps} (${Math.round(stats.appsWithSettings/stats.totalApps*100)}%)`); - console.log(` • Tools: ${stats.appsWithTools}/${stats.totalApps} (${Math.round(stats.appsWithTools/stats.totalApps*100)}%)`); - console.log(` • Permissions: ${stats.appsWithPermissions}/${stats.totalApps} (${Math.round(stats.appsWithPermissions/stats.totalApps*100)}%)`); - console.log(''); - console.log('📝 Apps with basic info:'); - console.log(` • Name: ${stats.appsWithName}/${stats.totalApps} (${Math.round(stats.appsWithName/stats.totalApps*100)}%)`); - console.log(` • Description: ${stats.appsWithDescription}/${stats.totalApps} (${Math.round(stats.appsWithDescription/stats.totalApps*100)}%)`); - console.log(` • Public URL: ${stats.appsWithPublicUrl}/${stats.totalApps} (${Math.round(stats.appsWithPublicUrl/stats.totalApps*100)}%)`); - console.log(''); - console.log('🔢 Configuration totals:'); + console.log(""); + console.log("📋 Apps with configuration:"); + console.log( + ` • Settings: ${stats.appsWithSettings}/${stats.totalApps} (${Math.round((stats.appsWithSettings / stats.totalApps) * 100)}%)`, + ); + console.log( + ` • Tools: ${stats.appsWithTools}/${stats.totalApps} (${Math.round((stats.appsWithTools / stats.totalApps) * 100)}%)`, + ); + console.log( + ` • Permissions: ${stats.appsWithPermissions}/${stats.totalApps} (${Math.round((stats.appsWithPermissions / stats.totalApps) * 100)}%)`, + ); + console.log(""); + console.log("📝 Apps with basic info:"); + console.log( + ` • Name: ${stats.appsWithName}/${stats.totalApps} (${Math.round((stats.appsWithName / stats.totalApps) * 100)}%)`, + ); + console.log( + ` • Description: ${stats.appsWithDescription}/${stats.totalApps} (${Math.round((stats.appsWithDescription / stats.totalApps) * 100)}%)`, + ); + console.log( + ` • Public URL: ${stats.appsWithPublicUrl}/${stats.totalApps} (${Math.round((stats.appsWithPublicUrl / stats.totalApps) * 100)}%)`, + ); + console.log(""); + console.log("🔢 Configuration totals:"); console.log(` • Total settings: ${stats.settingsTotal}`); console.log(` • Total tools: ${stats.toolsTotal}`); console.log(` • Total permissions: ${stats.permissionsTotal}`); - console.log(''); - console.log(`⚠️ Apps with issues: ${stats.appsWithIssues}/${stats.totalApps} (${Math.round(stats.appsWithIssues/stats.totalApps*100)}%)`); + console.log(""); + console.log( + `⚠️ Apps with issues: ${stats.appsWithIssues}/${stats.totalApps} (${Math.round((stats.appsWithIssues / stats.totalApps) * 100)}%)`, + ); // Show recommendations - console.log('\n=== Recommendations ==='); + console.log("\n=== Recommendations ==="); if (stats.appsWithIssues > 0) { - console.log('🔧 Review apps with validation issues above'); + console.log("🔧 Review apps with validation issues above"); } if (stats.appsWithSettings < stats.totalApps * 0.5) { - console.log('📄 Consider running migration with --force to import configs for apps without settings'); + console.log( + "📄 Consider running migration with --force to import configs for apps without settings", + ); } if (stats.appsWithName < stats.totalApps) { - console.log('📝 Some apps are missing names - these may need manual updates'); + console.log( + "📝 Some apps are missing names - these may need manual updates", + ); } if (stats.appsWithPublicUrl < stats.totalApps) { - console.log('🌐 Some apps are missing public URLs - migration may not have been able to fetch their configs'); + console.log( + "🌐 Some apps are missing public URLs - migration may not have been able to fetch their configs", + ); } - logger.info('\nValidation completed successfully'); - + logger.info("\nValidation completed successfully"); } catch (error) { - logger.error('Validation failed:', error); + logger.error("Validation failed:", error); throw error; } finally { // Close database connection await mongoose.disconnect(); - logger.info('Database connection closed'); + logger.info("Database connection closed"); } } // Run validation if this file is executed directly if (require.main === module) { validate().catch((error) => { - logger.error('Validation script failed:', error); + logger.error("Validation script failed:", error); process.exit(1); }); } -export default validate; \ No newline at end of file +export default validate; diff --git a/cloud/packages/cloud/src/connections/mongodb.connection.ts b/cloud/packages/cloud/src/connections/mongodb.connection.ts index cdc57102c..bbf2c9baf 100644 --- a/cloud/packages/cloud/src/connections/mongodb.connection.ts +++ b/cloud/packages/cloud/src/connections/mongodb.connection.ts @@ -1,27 +1,26 @@ import { logger } from "../services/logging/pino-logger"; import dotenv from "dotenv"; -import mongoose from 'mongoose'; +import mongoose from "mongoose"; dotenv.config(); const MONGO_URL: string | undefined = process.env.MONGO_URL; -const NODE_ENV: string | undefined = process.env.NODE_ENV; -const IS_PROD = NODE_ENV === 'production'; +// const NODE_ENV: string | undefined = process.env.NODE_ENV; +// const IS_PROD = NODE_ENV === 'production'; // Connect to mongo db. export async function init(): Promise { if (!MONGO_URL) throw "MONGO_URL is undefined"; try { - mongoose.set('strictQuery', false); - const database = IS_PROD ? 'prod' : 'dev'; + mongoose.set("strictQuery", false); + // const database = IS_PROD ? 'prod' : 'dev'; - await mongoose.connect(MONGO_URL + "/prod"); + await mongoose.connect(MONGO_URL); // After connection - await mongoose.connection.db.collection('test').insertOne({ test: true }); + await mongoose.connection.db.collection("test").insertOne({ test: true }); - logger.info('Mongoose Connected'); - } - catch (error) { + logger.info("Mongoose Connected"); + } catch (error) { logger.error(`Unable to connect to database(${MONGO_URL}) ${error}`); throw error; } -} \ No newline at end of file +} From c1e1022160e5da665ad4cb757a84b1cd3cd7a01b Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Wed, 15 Oct 2025 22:06:31 +0800 Subject: [PATCH 07/27] chore: cleaup the auth providers --- cloud/.env.example | 8 +- cloud/bun.lock | 1 - .../packages/cloud/src/routes/auth.routes.ts | 313 +++++++++++------- mobile/.env.example | 9 +- mobile/bun.lock | 17 + mobile/package.json | 1 + mobile/src/app/auth/login.tsx | 118 +------ mobile/src/app/init.tsx | 41 +-- mobile/src/authing/authingClient.ts | 228 +++++++++++++ mobile/src/contexts/AuthContext.tsx | 30 +- mobile/src/managers/RestComms.tsx | 9 +- mobile/src/utils/auth/authProvider.ts | 93 ++++++ mobile/src/utils/auth/authProvider.types.ts | 29 ++ .../src/utils/auth/provider/authingClient.ts | 265 +++++++++++++++ .../src/utils/auth/provider/supabaseClient.ts | 221 +++++++++++++ 15 files changed, 1129 insertions(+), 254 deletions(-) create mode 100644 mobile/src/authing/authingClient.ts create mode 100644 mobile/src/utils/auth/authProvider.ts create mode 100644 mobile/src/utils/auth/authProvider.types.ts create mode 100644 mobile/src/utils/auth/provider/authingClient.ts create mode 100644 mobile/src/utils/auth/provider/supabaseClient.ts diff --git a/cloud/.env.example b/cloud/.env.example index cdf0b4335..bf1e48502 100644 --- a/cloud/.env.example +++ b/cloud/.env.example @@ -2,6 +2,7 @@ # cloud/.env # This file contains the base configuration for the AugmentOS Cloud backend. +DEPLOYMENT_REGION=china NODE_ENV=development CLOUD_VERSION=2.0.0 BASE_PORT=8000 @@ -67,4 +68,9 @@ JOE_MAMA_USER_JWT= # PostHog POSTHOG_PROJECT_API_KEY= -POSTHOG_HOST=https://us.i.posthog.com% \ No newline at end of file +POSTHOG_HOST=https://us.i.posthog.com% + +# AUTHING +AUTHING_APP_HOST= +AUTHING_APP_ID= +AUTHING_APP_SECRET= \ No newline at end of file diff --git a/cloud/bun.lock b/cloud/bun.lock index 22953dc5d..6d29d531b 100644 --- a/cloud/bun.lock +++ b/cloud/bun.lock @@ -153,7 +153,6 @@ "boxen": "^8.0.1", "chalk": "^5.6.2", "cookie-parser": "^1.4.7", - "dotenv": "^16.4.0", "express": "^4.18.2", "jimp": "^1.6.0", diff --git a/cloud/packages/cloud/src/routes/auth.routes.ts b/cloud/packages/cloud/src/routes/auth.routes.ts index 5e18c931a..728b00d01 100644 --- a/cloud/packages/cloud/src/routes/auth.routes.ts +++ b/cloud/packages/cloud/src/routes/auth.routes.ts @@ -1,112 +1,175 @@ //backend/src/routes/apps.ts -import express from 'express'; +import express from "express"; -import jwt from 'jsonwebtoken'; -import { Request, Response } from 'express'; -import { validateCoreToken } from '../middleware/supabaseMiddleware'; -import { tokenService } from '../services/core/temp-token.service'; -import { validateAppApiKey } from '../middleware/validateApiKey'; -import { logger as rootLogger } from '../services/logging/pino-logger'; -const logger = rootLogger.child({ service: 'auth.routes' }); -import appService from '../services/core/app.service'; +import jwt from "jsonwebtoken"; +import { Request, Response } from "express"; +import { validateCoreToken } from "../middleware/supabaseMiddleware"; +import { tokenService } from "../services/core/temp-token.service"; +import { validateAppApiKey } from "../middleware/validateApiKey"; +import { logger as rootLogger } from "../services/logging/pino-logger"; +const logger = rootLogger.child({ service: "auth.routes" }); +import appService from "../services/core/app.service"; const router = express.Router(); export const SUPABASE_JWT_SECRET = process.env.SUPABASE_JWT_SECRET || ""; -export const AUGMENTOS_AUTH_JWT_SECRET = process.env.AUGMENTOS_AUTH_JWT_SECRET || ""; +export const AUGMENTOS_AUTH_JWT_SECRET = + process.env.AUGMENTOS_AUTH_JWT_SECRET || ""; +export const AUTHING_APP_SECRET = process.env.AUTHING_APP_SECRET || ""; export const JOE_MAMA_USER_JWT = process.env.JOE_MAMA_USER_JWT || ""; -router.post('/exchange-token', async (req: Request, res: Response) => { - const { supabaseToken } = req.body; - if (!supabaseToken) { - return res.status(400).json({ error: 'No token provided' }); +router.post("/exchange-token", async (req: Request, res: Response) => { + const { supabaseToken, authingToken } = req.body; + if (!supabaseToken && !authingToken) { + return res.status(400).json({ error: "No token provided" }); } try { // Verify the token using your Supabase JWT secret - const decoded = jwt.verify(supabaseToken, SUPABASE_JWT_SECRET); - const subject = decoded.sub; - const email = (decoded as jwt.JwtPayload).email; + if (supabaseToken) { + console.log("Verifying Supabase token..."); + const decoded = jwt.verify(supabaseToken, SUPABASE_JWT_SECRET); + const subject = decoded.sub; + const email = (decoded as jwt.JwtPayload).email; - // Find user document to get organization information - const User = require('../models/user.model').User; - const user = await User.findOrCreateUser(email); + // Find user document to get organization information + const User = require("../models/user.model").User; + const user = await User.findOrCreateUser(email); - const newData = { + const newData = { sub: subject, email: email, // Include organization info in token organizations: user.organizations || [], - defaultOrg: user.defaultOrg || null - } + defaultOrg: user.defaultOrg || null, + }; - // Generate your own custom token (JWT or otherwise) - const coreToken = jwt.sign(newData, AUGMENTOS_AUTH_JWT_SECRET); + // Generate your own custom token (JWT or otherwise) + const coreToken = jwt.sign(newData, AUGMENTOS_AUTH_JWT_SECRET); - return res.json({ coreToken }); - } catch (error) { - console.error(error, 'Token verification error'); - return res.status(401).json({ error: 'Invalid token' }); - } -}); + return res.json({ coreToken }); + } + if (authingToken) { + console.log("Verifying Authing token..."); + const decoded = jwt.verify(authingToken, AUTHING_APP_SECRET); + const subject = decoded.sub; + const email = (decoded as jwt.JwtPayload).email; -// Generate a temporary token for webview authentication -router.post('/generate-webview-token', validateCoreToken, async (req: Request, res: Response) => { - const userId = (req as any).email; // Use the email property set by validateCoreToken - const { packageName } = req.body; + // Find user document to get organization information + const User = require("../models/user.model").User; + const user = await User.findOrCreateUser(email); - if (!packageName) { - return res.status(400).json({ success: false, error: 'packageName is required' }); - } + const newData = { + sub: subject, + email: email, + // Include organization info in token + organizations: user.organizations || [], + defaultOrg: user.defaultOrg || null, + }; - try { - const tempToken = await tokenService.generateTemporaryToken(userId, packageName); - res.json({ success: true, token: tempToken }); + // Generate your own custom token (JWT or otherwise) + const coreToken = jwt.sign(newData, AUGMENTOS_AUTH_JWT_SECRET); + + return res.json({ coreToken }); + } } catch (error) { - logger.error({ error, userId, packageName }, 'Failed to generate webview token'); - res.status(500).json({ success: false, error: 'Failed to generate token' }); + console.error(error, "Token verification error"); + return res.status(401).json({ error: "Invalid token" }); } }); -// Exchange a temporary token for user details (called by App backend) -router.post('/exchange-user-token', validateAppApiKey, async (req: Request, res: Response) => { - const { aos_temp_token, packageName } = req.body; +// Generate a temporary token for webview authentication +router.post( + "/generate-webview-token", + validateCoreToken, + async (req: Request, res: Response) => { + const userId = (req as any).email; // Use the email property set by validateCoreToken + const { packageName } = req.body; + + if (!packageName) { + return res + .status(400) + .json({ success: false, error: "packageName is required" }); + } - if (!aos_temp_token) { - return res.status(400).json({ success: false, error: 'Missing aos_temp_token' }); - } + try { + const tempToken = await tokenService.generateTemporaryToken( + userId, + packageName, + ); + res.json({ success: true, token: tempToken }); + } catch (error) { + logger.error( + { error, userId, packageName }, + "Failed to generate webview token", + ); + res + .status(500) + .json({ success: false, error: "Failed to generate token" }); + } + }, +); - try { - const result = await tokenService.exchangeTemporaryToken(aos_temp_token, packageName); +// Exchange a temporary token for user details (called by App backend) +router.post( + "/exchange-user-token", + validateAppApiKey, + async (req: Request, res: Response) => { + const { aos_temp_token, packageName } = req.body; + + if (!aos_temp_token) { + return res + .status(400) + .json({ success: false, error: "Missing aos_temp_token" }); + } - if (result) { - res.json({ success: true, userId: result.userId }); - } else { - // Determine specific error based on logs or tokenService implementation - // For simplicity now, returning 401 for any failure - res.status(401).json({ success: false, error: 'Invalid or expired token' }); + try { + const result = await tokenService.exchangeTemporaryToken( + aos_temp_token, + packageName, + ); + + if (result) { + res.json({ success: true, userId: result.userId }); + } else { + // Determine specific error based on logs or tokenService implementation + // For simplicity now, returning 401 for any failure + res + .status(401) + .json({ success: false, error: "Invalid or expired token" }); + } + } catch (error) { + logger.error({ error, packageName }, "Failed to exchange webview token"); + res + .status(500) + .json({ success: false, error: "Failed to exchange token" }); } - } catch (error) { - logger.error({ error, packageName }, 'Failed to exchange webview token'); - res.status(500).json({ success: false, error: 'Failed to exchange token' }); - } -}); + }, +); // Exchange a temporary token for full tokens (for store webview) -router.post('/exchange-store-token', async (req: Request, res: Response) => { +router.post("/exchange-store-token", async (req: Request, res: Response) => { const { aos_temp_token, packageName } = req.body; if (!aos_temp_token) { - return res.status(400).json({ success: false, error: 'Missing aos_temp_token' }); + return res + .status(400) + .json({ success: false, error: "Missing aos_temp_token" }); } // Validate packageName is the store - if (packageName !== 'org.augmentos.store') { - return res.status(403).json({ success: false, error: 'Invalid package name for this endpoint' }); + if (packageName !== "org.augmentos.store") { + return res.status(403).json({ + success: false, + error: "Invalid package name for this endpoint", + }); } try { - const result = await tokenService.exchangeTemporaryToken(aos_temp_token, packageName); + const result = await tokenService.exchangeTemporaryToken( + aos_temp_token, + packageName, + ); if (result) { // For store webview, we need to return the actual tokens @@ -114,7 +177,7 @@ router.post('/exchange-store-token', async (req: Request, res: Response) => { const supabaseToken = JOE_MAMA_USER_JWT; // Using existing user token for now // Get user to include organization information - const User = require('../models/user.model').User; + const User = require("../models/user.model").User; const user = await User.findByEmail(result.userId); // Generate a core token with org information @@ -123,7 +186,7 @@ router.post('/exchange-store-token', async (req: Request, res: Response) => { email: result.userId, // Include organization info in token organizations: user?.organizations || [], - defaultOrg: user?.defaultOrg || null + defaultOrg: user?.defaultOrg || null, }; const coreToken = jwt.sign(userData, AUGMENTOS_AUTH_JWT_SECRET); @@ -132,34 +195,48 @@ router.post('/exchange-store-token', async (req: Request, res: Response) => { userId: result.userId, tokens: { supabaseToken, - coreToken - } + coreToken, + }, }); } else { - res.status(401).json({ success: false, error: 'Invalid or expired token' }); + res + .status(401) + .json({ success: false, error: "Invalid or expired token" }); } } catch (error) { - logger.error({ error, packageName }, 'Failed to exchange store token'); - res.status(500).json({ success: false, error: 'Failed to exchange token' }); + logger.error({ error, packageName }, "Failed to exchange store token"); + res.status(500).json({ success: false, error: "Failed to exchange token" }); } }); // Create a hash with the app's hashed API key -router.post('/hash-with-api-key', validateCoreToken, async (req: Request, res: Response) => { - const { stringToHash, packageName } = req.body; - - if (!stringToHash || !packageName) { - return res.status(400).json({ success: false, error: 'stringToHash and packageName are required' }); - } +router.post( + "/hash-with-api-key", + validateCoreToken, + async (req: Request, res: Response) => { + const { stringToHash, packageName } = req.body; + + if (!stringToHash || !packageName) { + return res.status(400).json({ + success: false, + error: "stringToHash and packageName are required", + }); + } - try { - const hash = await appService.hashWithApiKey(stringToHash, packageName); - res.json({ success: true, hash }); - } catch (error) { - logger.error({ error, packageName }, 'Failed to hash string with API key'); - res.status(500).json({ success: false, error: 'Failed to generate hash' }); - } -}); + try { + const hash = await appService.hashWithApiKey(stringToHash, packageName); + res.json({ success: true, hash }); + } catch (error) { + logger.error( + { error, packageName }, + "Failed to hash string with API key", + ); + res + .status(500) + .json({ success: false, error: "Failed to generate hash" }); + } + }, +); /** * Generate a signed JWT token for webview authentication in Apps. @@ -173,29 +250,43 @@ router.post('/hash-with-api-key', validateCoreToken, async (req: Request, res: R * @throws {400} If packageName is missing * @throws {500} If token generation fails */ -router.post('/generate-webview-signed-user-token', validateCoreToken, async (req: Request, res: Response) => { - const userId: string = (req as any).email; // Use the email property set by validateCoreToken - const { packageName }: { packageName?: string } = req.body; +router.post( + "/generate-webview-signed-user-token", + validateCoreToken, + async (req: Request, res: Response) => { + const userId: string = (req as any).email; // Use the email property set by validateCoreToken + const { packageName }: { packageName?: string } = req.body; + + if (!packageName) { + return res + .status(400) + .json({ success: false, error: "packageName is required" }); + } - if (!packageName) { - return res.status(400).json({ success: false, error: 'packageName is required' }); - } + try { + // Use the issueUserToken from tokenService with the package name + // This generates a token with a frontend token specific to the requesting App + const signedToken: string = await tokenService.issueUserToken( + userId, + packageName, + ); + console.log("[auth.service] Signed user token generated:", signedToken); - try { - // Use the issueUserToken from tokenService with the package name - // This generates a token with a frontend token specific to the requesting App - const signedToken: string = await tokenService.issueUserToken(userId, packageName); - console.log('[auth.service] Signed user token generated:', signedToken); - - res.json({ - success: true, - token: signedToken, - expiresIn: '10m' // Matching the expiration time set in the token - }); - } catch (error) { - logger.error({ error, userId, packageName }, '[auth.service] Failed to generate signed webview user token'); - res.status(500).json({ success: false, error: 'Failed to generate token: ' + error }); - } -}); + res.json({ + success: true, + token: signedToken, + expiresIn: "10m", // Matching the expiration time set in the token + }); + } catch (error) { + logger.error( + { error, userId, packageName }, + "[auth.service] Failed to generate signed webview user token", + ); + res + .status(500) + .json({ success: false, error: "Failed to generate token: " + error }); + } + }, +); -export default router; \ No newline at end of file +export default router; diff --git a/mobile/.env.example b/mobile/.env.example index af7c67ee2..7b7e118e1 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -1,3 +1,6 @@ +# Deployment region +DEPLOYMENT_REGION=china + # Supabase settings SUPABASE_URL=https://ykbiunzfbbtwlzdprmeh.supabase.co SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlrYml1bnpmYmJ0d2x6ZHBybWVoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQyODA2OTMsImV4cCI6MjA0OTg1NjY5M30.rbEsE8IRz-gb3-D0H8VAJtGw-xvipl1Nc-gCnnQ748U @@ -15,4 +18,8 @@ MENTRAOS_APPSTORE_URL=https://apps.mentra.glass POSTHOG_API_KEY=secret # Sentry -SENTRY_DSN=secret \ No newline at end of file +SENTRY_DSN=secret + +# Authing +AUTHING_APP_HOST= +AUTHING_APP_ID= \ No newline at end of file diff --git a/mobile/bun.lock b/mobile/bun.lock index c9c1b69ab..6fddc1f93 100644 --- a/mobile/bun.lock +++ b/mobile/bun.lock @@ -18,6 +18,7 @@ "@shopify/flash-list": "2.0.2", "@supabase/supabase-js": "^2.74.0", "apisauce": "3.0.1", + "authing-js-sdk": "^4.23.55", "babel-plugin-module-resolver": "^5.0.2", "bzip2": "^0.1.1", "core": "file:../core", @@ -454,6 +455,8 @@ "@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="], + "@fingerprintjs/fingerprintjs": ["@fingerprintjs/fingerprintjs@3.4.2", "", { "dependencies": { "tslib": "^2.4.1" } }, "sha512-3Ncze6JsJpB7BpYhqIgvBpfvEX1jsEKrad5hQBpyRQxtoAp6hx3+R46zqfsuQG4D9egQZ+xftQ0u4LPFMB7Wmg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.6.13", "", { "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w=="], @@ -920,6 +923,8 @@ "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "authing-js-sdk": ["authing-js-sdk@4.23.55", "", { "dependencies": { "@fingerprintjs/fingerprintjs": "^3.4.2", "axios": "0.28.0", "crypto-js": "^4.0.0", "jsencrypt": "^3.1.0", "jwt-decode": "^2.2.0", "sm-crypto": "^0.3.7" } }, "sha512-lkNAqic9L/x7NdT/fgnoMH9s9VVxMFWOHypAY4cjEs/Ch3O+LPlZ7zQV9xjHwkp0bkucsuDa0LqBNX2PrV8vhA=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], @@ -1102,6 +1107,8 @@ "crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="], + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], @@ -1742,6 +1749,8 @@ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "jsbn": ["jsbn@1.1.0", "", {}, "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="], + "jsc-android": ["jsc-android@250231.0.0", "", {}, "sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw=="], "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], @@ -1750,6 +1759,8 @@ "jsdom": ["jsdom@20.0.3", "", { "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", "acorn-globals": "^7.0.0", "cssom": "^0.5.0", "cssstyle": "^2.3.0", "data-urls": "^3.0.2", "decimal.js": "^10.4.2", "domexception": "^4.0.0", "escodegen": "^2.0.0", "form-data": "^4.0.0", "html-encoding-sniffer": "^3.0.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.2", "parse5": "^7.1.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.2", "w3c-xmlserializer": "^4.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0", "ws": "^8.11.0", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "canvas": "^2.5.0" }, "optionalPeers": ["canvas"] }, "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ=="], + "jsencrypt": ["jsencrypt@3.5.4", "", {}, "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -1772,6 +1783,8 @@ "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "jwt-decode": ["jwt-decode@2.2.0", "", {}, "sha512-86GgN2vzfUu7m9Wcj63iUkuDzFNYFVmjeDm2GzWpUk+opB0pEpMsw6ePCMrhYkumz2C1ihqtZzOMAg7FiXcNoQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], @@ -2356,6 +2369,8 @@ "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], + "sm-crypto": ["sm-crypto@0.3.13", "", { "dependencies": { "jsbn": "^1.1.0" } }, "sha512-ztNF+pZq6viCPMA1A6KKu3bgpkmYti5avykRHbcFIdSipFdkVmfUw2CnpM2kBJyppIalqvczLNM3wR8OQ0pT5w=="], + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -2800,6 +2815,8 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "authing-js-sdk/axios": ["axios@0.28.0", "", { "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q=="], + "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], diff --git a/mobile/package.json b/mobile/package.json index 23cbcb640..93e7e6c15 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -45,6 +45,7 @@ "@shopify/flash-list": "2.0.2", "@supabase/supabase-js": "^2.74.0", "apisauce": "3.0.1", + "authing-js-sdk": "^4.23.55", "babel-plugin-module-resolver": "^5.0.2", "bzip2": "^0.1.1", "core": "file:../core", diff --git a/mobile/src/app/auth/login.tsx b/mobile/src/app/auth/login.tsx index 97384196e..f32ec6247 100644 --- a/mobile/src/app/auth/login.tsx +++ b/mobile/src/app/auth/login.tsx @@ -1,13 +1,11 @@ -import React, {useState, useRef, useEffect} from "react" +import {useState, useRef, useEffect} from "react" import { View, TouchableOpacity, TextInput, Animated, - SafeAreaView, BackHandler, Platform, - KeyboardAvoidingView, ActivityIndicator, ScrollView, AppState, @@ -16,24 +14,20 @@ import { Keyboard, Modal, } from "react-native" -import LinearGradient from "react-native-linear-gradient" -import {supabase} from "@/supabase/supabaseClient" -import {Linking} from "react-native" -import {Screen, Text, Button, Icon} from "@/components/ignite" -import {translate, TxKeyPath} from "@/i18n" +import {Screen, Text, Button} from "@/components/ignite" +import {translate} from "@/i18n" import {spacing, ThemedStyle} from "@/theme" import {useSafeAreaInsetsStyle} from "@/utils/useSafeAreaInsetsStyle" import {useAppTheme} from "@/utils/useAppTheme" import {FontAwesome} from "@expo/vector-icons" import GoogleIcon from "assets/icons/component/GoogleIcon" import AppleIcon from "assets/icons/component/AppleIcon" -import {router} from "expo-router" import showAlert from "@/utils/AlertUtils" import {Pressable} from "react-native-gesture-handler" import {Spacer} from "@/components/misc/Spacer" import {useNavigationHistory} from "@/contexts/NavigationHistoryContext" +import {mentraAuthProvider} from "@/utils/auth/authProvider" import * as WebBrowser from "expo-web-browser" -import Toast from "react-native-toast-message" export default function LoginScreen() { const [isSigningUp, setIsSigningUp] = useState(false) @@ -43,7 +37,7 @@ export default function LoginScreen() { const [isAuthLoading, setIsAuthLoading] = useState(false) const [formAction, setFormAction] = useState<"signin" | "signup" | null>(null) const [backPressCount, setBackPressCount] = useState(0) - const {goBack, push, replace} = useNavigationHistory() + const {push, replace} = useNavigationHistory() // Get theme and safe area insets const {theme, themed} = useAppTheme() @@ -129,21 +123,10 @@ export default function LoginScreen() { authOverlayOpacity.setValue(0) }, 5000) - const {data, error} = await supabase.auth.signInWithOAuth({ - provider: "google", - options: { - // Must match the deep link scheme/host/path in your AndroidManifest.xml - redirectTo: "com.mentra://auth/callback", - skipBrowserRedirect: true, - queryParams: { - prompt: "select_account", - }, - }, - }) + const {data, error} = await mentraAuthProvider.googleSignIn() // 2) If there's an error, handle it if (error) { - console.error("Supabase Google sign-in error:", error) // showAlert(translate('loginScreen.errors.authError'), error.message); setIsAuthLoading(false) authOverlayOpacity.setValue(0) @@ -175,13 +158,6 @@ export default function LoginScreen() { console.log("signInWithOAuth call finished") } - const showToastMessage = (txPath: TxKeyPath) => { - Toast.show({ - type: "error", - text1: translate(txPath), - position: "bottom", - }) - } const handleAppleSignIn = async () => { try { setIsAuthLoading(true) @@ -193,17 +169,10 @@ export default function LoginScreen() { useNativeDriver: true, }).start() - const {data, error} = await supabase.auth.signInWithOAuth({ - provider: "apple", - options: { - // Match the deep link scheme/host/path in your AndroidManifest.xml - redirectTo: "com.mentra://auth/callback", - }, - }) + const {data, error} = await mentraAuthProvider.appleSignIn() // If there's an error, handle it if (error) { - console.error("Supabase Apple sign-in error:", error) // showAlert(translate('loginScreen.errors.authError'), error.message); setIsAuthLoading(false) authOverlayOpacity.setValue(0) @@ -242,43 +211,22 @@ export default function LoginScreen() { setFormAction("signup") try { - const redirectUrl = "https://augmentos.org/verify-email" // No encoding needed - - const {data, error} = await supabase.auth.signUp({ - email, - password, - options: { - emailRedirectTo: "com.mentra://auth/callback", - }, - }) + const {data, error} = await mentraAuthProvider.signup(email, password) if (error) { - console.log("Sign-up error:", error) - - // Check for common Supabase error messages when email already exists - const errorMessage = error.message.toLowerCase() - - if ( - errorMessage.includes("already registered") || - errorMessage.includes("user already registered") || - errorMessage.includes("email already exists") || - errorMessage.includes("identity already linked") - ) { - // Try to detect if it's a Google or Apple account - // Note: Supabase doesn't always tell us which provider, so we show a generic message + if (error.message.includes("Email already registered")) { showAlert(translate("login:emailAlreadyRegistered"), translate("login:useGoogleSignIn"), [ {text: translate("common:ok")}, ]) } else { showAlert(translate("common:error"), error.message, [{text: translate("common:ok")}]) } - } else if (!data.session) { + } else if (!data?.session) { // Ensure translations are resolved before passing to showAlert const successTitle = translate("login:success") const verificationMessage = translate("login:checkEmailVerification") showAlert(successTitle, verificationMessage, [{text: translate("common:ok")}]) } else { - console.log("Sign-up successful:", data) replace("/") } } catch (err) { @@ -294,16 +242,12 @@ export default function LoginScreen() { Keyboard.dismiss() setIsFormLoading(true) setFormAction("signin") - const {data, error} = await supabase.auth.signInWithPassword({ - email, - password, - }) + const {error} = await mentraAuthProvider.signIn(email, password) if (error) { - showAlert(translate("common:error"), error.message) + showAlert(translate("common:error"), error.message, [{text: translate("common:ok")}]) // Handle sign-in error } else { - console.log("Sign-in successful:", data) replace("/") } setIsFormLoading(false) @@ -531,14 +475,6 @@ const $container: ThemedStyle = () => ({ flex: 1, }) -const $gradientContainer: ThemedStyle = () => ({ - flex: 1, -}) - -const $keyboardAvoidingView: ThemedStyle = () => ({ - flex: 1, -}) - const $scrollContent: ThemedStyle = () => ({ flexGrow: 1, justifyContent: "center", @@ -640,10 +576,6 @@ const $enhancedInputContainer: ThemedStyle = ({colors, spacing, isDar : {}), }) -const $inputIcon: ThemedStyle = ({spacing}) => ({ - marginRight: spacing.xs, -}) - const $enhancedInput: ThemedStyle = ({colors}) => ({ flex: 1, fontSize: 16, @@ -705,9 +637,9 @@ const $appleButtonText: ThemedStyle = ({colors}) => ({ color: colors.text, // Same as Google button text }) -const $primaryButton: ThemedStyle = ({colors, spacing}) => ({}) +const $primaryButton: ThemedStyle = () => ({}) -const $secondaryButton: ThemedStyle = ({colors, spacing}) => ({}) +const $secondaryButton: ThemedStyle = () => ({}) const $pressedButton: ThemedStyle = ({colors}) => ({ backgroundColor: colors.buttonPressed, @@ -725,28 +657,6 @@ const $emailButtonText: ThemedStyle = ({colors}) => ({ fontSize: 16, }) -const $ghostButton: ThemedStyle = ({spacing, colors}) => ({ - backgroundColor: colors.transparent, - height: 48, - borderRadius: 8, - justifyContent: "center", - alignItems: "center", - marginTop: spacing.sm, -}) - -const $backIcon: ThemedStyle = ({spacing}) => ({ - marginRight: spacing.xs, -}) - -const $emailIcon: ThemedStyle = ({spacing}) => ({ - marginRight: spacing.xs, -}) - -const $ghostButtonText: ThemedStyle = ({colors}) => ({ - color: colors.textDim, - fontSize: 15, -}) - const $dividerContainer: ThemedStyle = ({spacing}) => ({ flexDirection: "row", alignItems: "center", diff --git a/mobile/src/app/init.tsx b/mobile/src/app/init.tsx index 6f32e2141..c041d3ea4 100644 --- a/mobile/src/app/init.tsx +++ b/mobile/src/app/init.tsx @@ -107,32 +107,35 @@ export default function InitScreen() { } const handleTokenExchange = async (): Promise => { + console.log("HANDLING TOKEN EXCHANGE.......") setState("loading") setLoadingStatus(translate("versionCheck:connectingToServer")) - // try { - const supabaseToken = session?.access_token - if (!supabaseToken) { - setErrorType("auth") - setState("error") - return - } + try { + const token = session?.access_token + console.log("EXCHANGING TOKEN: ") + console.log(token) + if (!token) { + setErrorType("auth") + setState("error") + return + } - const coreToken = await restComms.exchangeToken(supabaseToken) - const uid = user?.email || user?.id + const coreToken = await restComms.exchangeToken(token) + const uid = user?.email || user?.id - socketComms.setAuthCreds(coreToken, uid) - await mantle.init() + socketComms.setAuthCreds(coreToken, uid) + await mantle.init() - bridge.updateSettings(await useSettingsStore.getState().getCoreSettings()) // send settings to core + bridge.updateSettings(await useSettingsStore.getState().getCoreSettings()) // send settings to core - await navigateToDestination() - // } catch (error) { - // console.error("Token exchange failed:", error) - // await checkCustomUrl() - // setErrorType("connection") - // setState("error") - // } + await navigateToDestination() + } catch (error) { + console.error("Token exchange failed:", error) + await checkCustomUrl() + setErrorType("connection") + setState("error") + } } const checkCloudVersion = async (isRetry = false): Promise => { diff --git a/mobile/src/authing/authingClient.ts b/mobile/src/authing/authingClient.ts new file mode 100644 index 000000000..21fa3b1aa --- /dev/null +++ b/mobile/src/authing/authingClient.ts @@ -0,0 +1,228 @@ +import {AuthenticationClient} from "authing-js-sdk" +import type {AuthenticationClientOptions, User} from "authing-js-sdk" +import AsyncStorage from "@react-native-async-storage/async-storage" +import {EventEmitter} from "events" +import {RequestResultSafeDestructure} from "@supabase/supabase-js" + +interface Session { + access_token?: string + refresh_token?: string + expires_at?: number + user?: User +} + +type SignInResponse = RequestResultSafeDestructure<{ + session: Session + user: User +}> + +const SESSION_KEY = "authing_session" + +type AuthChangeEvent = + | "SIGNED_IN" + | "SIGNED_OUT" + | "TOKEN_REFRESHED" + | "USER_UPDATED" + | "USER_DELETED" + | "PASSWORD_RECOVERY" + +type AuthChangeCallback = (event: AuthChangeEvent, session: any) => void + +interface Subscription { + unsubscribe: () => void +} + +const authingOptions: AuthenticationClientOptions = { + appId: process.env.AUTHING_APP_ID!, + appHost: process.env.AUTHING_APP_HOST!, + lang: "en-US", +} + +export class AuthingClient { + private authing: AuthenticationClient + private eventEmitter: EventEmitter + private refreshTimeout: ReturnType | null = null + private static instance: AuthingClient + + private constructor() { + this.authing = new AuthenticationClient(authingOptions) + this.eventEmitter = new EventEmitter() + this.setupTokenRefresh() + } + + public static async getInstance(): Promise { + if (!AuthingClient.instance) { + AuthingClient.instance = new AuthingClient() + const session = await AuthingClient.instance.getSession() + + if (session?.access_token) { + AuthingClient.instance.authing.setToken(session.access_token) + if (session.user) { + AuthingClient.instance.authing.setCurrentUser(session.user) + } + } + } + return AuthingClient.instance + } + + public onAuthStateChange(callback: AuthChangeCallback): Subscription { + const handler = (event: string, session: Session) => { + callback(event as AuthChangeEvent, session) + } + + this.eventEmitter.on("SIGNED_IN", handler) + this.eventEmitter.on("SIGNED_OUT", handler) + this.eventEmitter.on("TOKEN_REFRESHED", handler) + this.eventEmitter.on("USER_UPDATED", handler) + this.eventEmitter.on("USER_DELETED", handler) + this.eventEmitter.on("PASSWORD_RECOVERY", handler) + + return { + unsubscribe: () => { + this.eventEmitter.off("SIGNED_IN", handler) + this.eventEmitter.off("SIGNED_OUT", handler) + this.eventEmitter.off("TOKEN_REFRESHED", handler) + this.eventEmitter.off("USER_UPDATED", handler) + this.eventEmitter.off("USER_DELETED", handler) + this.eventEmitter.off("PASSWORD_RECOVERY", handler) + }, + } + } + + public async signUp(credentials: {email: string; password: string}) { + try { + const user = await this.authing.registerByEmail(credentials.email, credentials.password) + return user + } catch (error) { + console.error("Error signing up:", error) + throw error + } + } + + public async signInWithPassword(credentials: {email: string; password: string}): Promise { + try { + const user = await this.authing.loginByEmail(credentials.email, credentials.password) + const token = user.token + const tokenExpiresAt = user.tokenExpiredAt + + console.log("Token expires at:", tokenExpiresAt) + + if (token && tokenExpiresAt) { + const session: Session = { + access_token: token, + refresh_token: undefined, // Update this when implementing refresh token flow + expires_at: Number(tokenExpiresAt), + user, + } + await this.saveSession(session) + this.eventEmitter.emit("SIGNED_IN", session) + this.setupTokenRefresh() + return {data: {session, user}, error: null} + } + + throw new Error("Failed to sign in") + } catch (error) { + console.error("Sign in error:", error) + return { + data: {session: null, user: null}, + error, + } + } + } + + public async signOut() { + try { + await this.authing.logout() + await this.clearSession() + this.eventEmitter.emit("SIGNED_OUT", null) + return {error: null} + } catch (error) { + console.error("Sign out error:", error) + return {error} + } + } + + public async getSession(): Promise { + const sessionJson = await AsyncStorage.getItem(SESSION_KEY) + return sessionJson ? JSON.parse(sessionJson) : null + } + + private async setupTokenRefresh() { + try { + const session = await this.getSession() + if (!session?.access_token) return + + const now = Date.now() + const expiresIn = session.expires_at! - now + + // If token is expired, clear session and emit SIGNED_OUT event + if (expiresIn <= 0) { + await this.clearSession() + this.eventEmitter.emit("SIGNED_OUT", null) + return + } + + // Set timeout to refresh token before it expires (5 minutes before) + const refreshTime = Math.max(0, expiresIn - 5 * 60 * 1000) + + this.refreshTimeout = setTimeout(async () => { + try { + await this.refreshToken() + const user = await this.getCurrentUser() + this.eventEmitter.emit("TOKEN_REFRESHED", user) + } catch (error) { + console.error("Token refresh failed:", error) + await this.clearSession() + this.eventEmitter.emit("SIGNED_OUT", null) + } + }, refreshTime) + } catch (error) { + console.error("Error setting up token refresh:", error) + await this.clearSession() + } + } + + private async refreshToken(): Promise { + try { + const user = await this.authing.getCurrentUser() + if (!user) { + throw new Error("No user session") + } + // The authing-js-sdk should handle token refresh automatically + // when making authenticated requests if the token is expired + // This is just a placeholder in case you need custom refresh logic + // For now throwing error + // TODO: To be implemented and tested + throw new Error("Token refresh not implemented") + return + } catch (error) { + console.error("Failed to refresh token:", error) + throw error + } + } + + private async getCurrentUser(): Promise { + try { + return await this.authing.getCurrentUser() + } catch (error) { + console.error("Error getting current user:", error) + return null + } + } + + private async saveSession(session: Session): Promise { + await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)) + } + + private async clearSession(): Promise { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout) + this.refreshTimeout = null + } + await AsyncStorage.removeItem(SESSION_KEY) + // clears the session and user + this.authing.logout() + } +} + +export const authingClient = AuthingClient.getInstance() diff --git a/mobile/src/contexts/AuthContext.tsx b/mobile/src/contexts/AuthContext.tsx index 2869d6d8d..f1d3c7eca 100644 --- a/mobile/src/contexts/AuthContext.tsx +++ b/mobile/src/contexts/AuthContext.tsx @@ -1,6 +1,6 @@ -import React, {createContext, useEffect, useState, useContext} from "react" -import {supabase} from "@/supabase/supabaseClient" +import {FC, createContext, useEffect, useState, useContext} from "react" import {LogoutUtils} from "@/utils/LogoutUtils" +import {mentraAuthProvider} from "@/utils/auth/authProvider" interface AuthContextProps { user: any // or a more specific type from @supabase/supabase-js @@ -16,7 +16,7 @@ const AuthContext = createContext({ logout: () => {}, }) -export const AuthProvider: React.FC<{children: React.ReactNode}> = ({children}) => { +export const AuthProvider: FC<{children: React.ReactNode}> = ({children}) => { const [session, setSession] = useState(null) const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) @@ -26,7 +26,7 @@ export const AuthProvider: React.FC<{children: React.ReactNode}> = ({children}) const getInitialSession = async () => { const { data: {session}, - } = await supabase.auth.getSession() + } = await mentraAuthProvider.getSession() console.log("AuthContext: Initial session:", session) console.log("AuthContext: Initial user:", session?.user) @@ -41,20 +41,20 @@ export const AuthProvider: React.FC<{children: React.ReactNode}> = ({children}) }) // 2. Listen for auth changes - const { - data: {subscription}, - } = supabase.auth.onAuthStateChange((event, session) => { - console.log("AuthContext: Auth state changed:", event) - console.log("AuthContext: Session:", session) - console.log("AuthContext: User:", session?.user) - setSession(session) - setUser(session?.user ?? null) - setLoading(false) - }) + // const { + // data: {subscription}, + // } = supabase.auth.onAuthStateChange((event, session) => { + // console.log("AuthContext: Auth state changed:", event) + // console.log("AuthContext: Session:", session) + // console.log("AuthContext: User:", session?.user) + // setSession(session) + // setUser(session?.user ?? null) + // setLoading(false) + // }) // Cleanup the listener return () => { - subscription?.unsubscribe() + // subscription?.unsubscribe() } }, []) diff --git a/mobile/src/managers/RestComms.tsx b/mobile/src/managers/RestComms.tsx index d7d838532..d9680decb 100644 --- a/mobile/src/managers/RestComms.tsx +++ b/mobile/src/managers/RestComms.tsx @@ -236,7 +236,9 @@ class RestComms { return this.authenticatedRequest("POST", `/appsettings/${appName}`, update) } - public async exchangeToken(supabaseToken: string): Promise { + public async exchangeToken(token: string): Promise { + const DEPLOYMENT_REGION = process.env.DEPLOYMENT_REGION || "global" + const IS_CHINA = DEPLOYMENT_REGION === "china" const baseUrl = await useSettingsStore.getState().getRestUrl() const url = `${baseUrl}/auth/exchange-token` @@ -244,7 +246,10 @@ class RestComms { method: "POST", url, headers: {"Content-Type": "application/json"}, - data: {supabaseToken}, + data: { + supabaseToken: !IS_CHINA ? token : undefined, + authingToken: IS_CHINA ? token : undefined, + }, } const response = await this.makeRequest(config) diff --git a/mobile/src/utils/auth/authProvider.ts b/mobile/src/utils/auth/authProvider.ts new file mode 100644 index 000000000..7a94d3243 --- /dev/null +++ b/mobile/src/utils/auth/authProvider.ts @@ -0,0 +1,93 @@ +import {MentraOauthProviderResponse, MentraSigninResponse} from "./authProvider.types" +import {AuthingWrapperClient} from "./provider/authingClient" +import {SupabaseWrapperClient} from "./provider/supabaseClient" + +const DEPLOYMENT_REGION = process.env.DEPLOYMENT_REGION || "global" +const IS_CHINA = DEPLOYMENT_REGION === "china" + +class MentraAuthProvider { + private isSettingUpClients = false + private authing?: AuthingWrapperClient + private supabase?: SupabaseWrapperClient + + constructor() { + this.checkOrSetupClients() + } + + private async checkOrSetupClients() { + if (this.isSettingUpClients && this.supabase && this.authing) return + this.isSettingUpClients = true + this.supabase = await SupabaseWrapperClient.getInstance() + this.authing = await AuthingWrapperClient.getInstance() + } + + async signup(email: string, password: string): Promise { + this.checkOrSetupClients() + if (IS_CHINA) { + return this.authing!.signUp({ + email, + password, + }) + } else { + return this.supabase!.signUp({ + email, + password, + }) + } + } + + async signIn(email: string, password: string): Promise { + this.checkOrSetupClients() + if (IS_CHINA) { + return this.authing!.signInWithPassword({ + email, + password, + }) + } else { + return this.supabase!.signInWithPassword({ + email, + password, + }) + } + } + + async getSession() { + this.checkOrSetupClients() + if (IS_CHINA) { + console.log("Getting session with Authing...") + return await this.authing!.getSession() + } else { + console.log("Getting session with Supabase...") + return this.supabase!.getSession() + } + } + + async signOut() { + this.checkOrSetupClients() + if (IS_CHINA) { + return this.authing!.signOut() + } else { + return this.supabase!.signOut() + } + } + + async appleSignIn(): Promise { + this.checkOrSetupClients() + if (IS_CHINA) { + throw new Error("Apple sign in not supported in China") + } else { + return this.supabase!.appleSignIn() + } + } + + async googleSignIn(): Promise { + this.checkOrSetupClients() + if (IS_CHINA) { + throw new Error("Google sign in not supported in China") + } else { + return this.supabase!.googleSignIn() + } + } +} + +export const mentraAuthProvider = new MentraAuthProvider() diff --git a/mobile/src/utils/auth/authProvider.types.ts b/mobile/src/utils/auth/authProvider.types.ts new file mode 100644 index 000000000..feec21cf8 --- /dev/null +++ b/mobile/src/utils/auth/authProvider.types.ts @@ -0,0 +1,29 @@ +export type MentraAuthUser = { + id: string + email?: string + name: string +} + +export type MentraAuthSession = { + token?: string + user?: MentraAuthUser +} + +export type MentraOauthProviderResponse = { + data: { + url?: string + } | null + error: { + message: string + } | null +} + +export type MentraSigninResponse = { + data: { + session: MentraAuthSession | null + user: MentraAuthUser | null + } | null + error: { + message: string + } | null +} diff --git a/mobile/src/utils/auth/provider/authingClient.ts b/mobile/src/utils/auth/provider/authingClient.ts new file mode 100644 index 000000000..09ea38d97 --- /dev/null +++ b/mobile/src/utils/auth/provider/authingClient.ts @@ -0,0 +1,265 @@ +import {AuthenticationClient} from "authing-js-sdk" +import type {AuthenticationClientOptions, User} from "authing-js-sdk" +import AsyncStorage from "@react-native-async-storage/async-storage" +import {EventEmitter} from "events" +import {MentraSigninResponse} from "../authProvider.types" + +interface Session { + access_token?: string + refresh_token?: string + expires_at?: number + user?: User +} + +const SESSION_KEY = "authing_session" + +type AuthChangeEvent = + | "SIGNED_IN" + | "SIGNED_OUT" + | "TOKEN_REFRESHED" + | "USER_UPDATED" + | "USER_DELETED" + | "PASSWORD_RECOVERY" + +type AuthChangeCallback = (event: AuthChangeEvent, session: any) => void + +interface Subscription { + unsubscribe: () => void +} + +export class AuthingWrapperClient { + private authing: AuthenticationClient + private eventEmitter: EventEmitter + private refreshTimeout: ReturnType | null = null + private static instance: AuthingWrapperClient + + private constructor() { + const authingOptions: AuthenticationClientOptions = { + appId: process.env.AUTHING_APP_ID!, + appHost: process.env.AUTHING_APP_HOST!, + lang: "en-US", + } + this.authing = new AuthenticationClient(authingOptions) + this.eventEmitter = new EventEmitter() + this.setupTokenRefresh() + } + + public static async getInstance(): Promise { + if (!AuthingWrapperClient.instance) { + AuthingWrapperClient.instance = new AuthingWrapperClient() + const session = await AuthingWrapperClient.instance.getSession() + + if (session?.access_token) { + AuthingWrapperClient.instance.authing.setToken(session.access_token) + if (session.user) { + AuthingWrapperClient.instance.authing.setCurrentUser(session.user) + } + } + } + return AuthingWrapperClient.instance + } + + public onAuthStateChange(callback: AuthChangeCallback): Subscription { + const handler = (event: string, session: Session) => { + callback(event as AuthChangeEvent, session) + } + + this.eventEmitter.on("SIGNED_IN", handler) + this.eventEmitter.on("SIGNED_OUT", handler) + this.eventEmitter.on("TOKEN_REFRESHED", handler) + this.eventEmitter.on("USER_UPDATED", handler) + this.eventEmitter.on("USER_DELETED", handler) + this.eventEmitter.on("PASSWORD_RECOVERY", handler) + + return { + unsubscribe: () => { + this.eventEmitter.off("SIGNED_IN", handler) + this.eventEmitter.off("SIGNED_OUT", handler) + this.eventEmitter.off("TOKEN_REFRESHED", handler) + this.eventEmitter.off("USER_UPDATED", handler) + this.eventEmitter.off("USER_DELETED", handler) + this.eventEmitter.off("PASSWORD_RECOVERY", handler) + }, + } + } + + public async signUp(credentials: {email: string; password: string}): Promise { + try { + const user = await this.authing.registerByEmail(credentials.email, credentials.password) + return { + data: user + ? { + session: { + token: user.token as string, + user: { + id: user.id, + email: user.email!, + name: user.name || "", + }, + }, + user: { + id: user.id, + email: user.email!, + name: user.name || "", + }, + } + : null, + error: null, + } + } catch (error) { + console.error("Error signing up:", error) + return { + data: null, + error: { + message: "Failed to sign up", + }, + } + } + } + + public async signInWithPassword(credentials: {email: string; password: string}): Promise { + try { + const user = await this.authing.loginByEmail(credentials.email, credentials.password) + const token = user.token + const tokenExpiresAt = user.tokenExpiredAt + + console.log("Token expires at:", tokenExpiresAt) + + if (token && tokenExpiresAt) { + const session: Session = { + access_token: token, + refresh_token: undefined, // Update this when implementing refresh token flow + expires_at: Number(tokenExpiresAt), + user, + } + await this.saveSession(session) + this.eventEmitter.emit("SIGNED_IN", session) + this.setupTokenRefresh() + return { + data: { + session: { + token: session.access_token, + user: { + id: session.user!.id, + email: session.user!.email!, + name: session.user!.name || "", + }, + }, + user: { + id: session.user!.id, + email: session.user!.email!, + name: session.user!.name || "", + }, + }, + error: null, + } + } + + throw new Error("Failed to sign in") + } catch (error) { + console.error("Sign in error:", error) + return { + data: null, + error: { + message: "Failed to sign in", + }, + } + } + } + + public async signOut() { + try { + await this.authing.logout() + await this.clearSession() + this.eventEmitter.emit("SIGNED_OUT", null) + return {error: null} + } catch (error) { + console.error("Sign out error:", error) + return {error} + } + } + + public async getSession(): Promise { + const sessionJson = await AsyncStorage.getItem(SESSION_KEY) + return sessionJson ? JSON.parse(sessionJson) : null + } + + private async setupTokenRefresh() { + try { + const session = await this.getSession() + if (!session?.access_token) return + + const now = Date.now() + const expiresIn = session.expires_at! - now + + // If token is expired, clear session and emit SIGNED_OUT event + if (expiresIn <= 0) { + await this.clearSession() + this.eventEmitter.emit("SIGNED_OUT", null) + return + } + + // Set timeout to refresh token before it expires (5 minutes before) + const refreshTime = Math.max(0, expiresIn - 5 * 60 * 1000) + + this.refreshTimeout = setTimeout(async () => { + try { + await this.refreshToken() + const user = await this.getCurrentUser() + this.eventEmitter.emit("TOKEN_REFRESHED", user) + } catch (error) { + console.error("Token refresh failed:", error) + await this.clearSession() + this.eventEmitter.emit("SIGNED_OUT", null) + } + }, refreshTime) + } catch (error) { + console.error("Error setting up token refresh:", error) + await this.clearSession() + } + } + + private async refreshToken(): Promise { + try { + const user = await this.authing.getCurrentUser() + if (!user) { + throw new Error("No user session") + } + // The authing-js-sdk should handle token refresh automatically + // when making authenticated requests if the token is expired + // This is just a placeholder in case you need custom refresh logic + // For now throwing error + // TODO: To be implemented and tested + throw new Error("Token refresh not implemented") + return + } catch (error) { + console.error("Failed to refresh token:", error) + throw error + } + } + + private async getCurrentUser(): Promise { + try { + return await this.authing.getCurrentUser() + } catch (error) { + console.error("Error getting current user:", error) + return null + } + } + + private async saveSession(session: Session): Promise { + await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)) + } + + private async clearSession(): Promise { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout) + this.refreshTimeout = null + } + await AsyncStorage.removeItem(SESSION_KEY) + // clears the session and user + this.authing.logout() + } +} + +export const authingClient = AuthingWrapperClient.getInstance() diff --git a/mobile/src/utils/auth/provider/supabaseClient.ts b/mobile/src/utils/auth/provider/supabaseClient.ts new file mode 100644 index 000000000..a32c55f9b --- /dev/null +++ b/mobile/src/utils/auth/provider/supabaseClient.ts @@ -0,0 +1,221 @@ +import type {SupabaseClient} from "@supabase/supabase-js" +import {supabase as supabaseClient} from "@/supabase/supabaseClient" +import {MentraOauthProviderResponse, MentraSigninResponse} from "../authProvider.types" + +export class SupabaseWrapperClient { + private static instance: SupabaseWrapperClient + private supabase: SupabaseClient + + private constructor() { + this.supabase = supabaseClient + } + + public static async getInstance(): Promise { + if (!SupabaseWrapperClient.instance) { + SupabaseWrapperClient.instance = new SupabaseWrapperClient() + } + return SupabaseWrapperClient.instance + } + + public async getSession() { + return this.supabase.auth.getSession() + } + + public async signOut() { + return this.supabase.auth.signOut() + } + + public async signInWithPassword(credentials: {email: string; password: string}): Promise { + try { + const {data, error} = await this.supabase.auth.signInWithPassword(credentials) + + return { + data: data.user + ? { + session: { + token: data.session.access_token, + user: { + id: data.user.id, + email: data.user.email, + name: data.user.user_metadata.full_name as string, + }, + }, + user: { + id: data.user.id, + email: data.user.email, + name: data.user.user_metadata.full_name as string, + }, + } + : null, + error: error?.message + ? { + message: error.message, + } + : null, + } + } catch (error) { + console.log("Supabase Sign-in error:", error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + + public async signUp(credentials: {email: string; password: string}): Promise { + try { + const {data, error} = await this.supabase.auth.signUp(credentials) + let errorMessage = error?.message.toLowerCase() || "" + if (error) { + console.log("Supabase Sign-up error:", error) + } + // Try to detect if it's a Google or Apple account + // Note: Supabase doesn't always tell us which provider, so we show a generic message + if ( + errorMessage.includes("already registered") || + errorMessage.includes("user already registered") || + errorMessage.includes("email already exists") || + errorMessage.includes("identity already linked") + ) { + errorMessage = "Email already registered" + } else { + errorMessage = "Something went wrong. Please try again." + } + return { + data: { + user: data.user + ? { + id: data.user.id, + email: data.user.email, + name: data.user.user_metadata.full_name as string, + } + : null, + session: + data.session && data.user + ? { + token: data.session.access_token, + user: { + id: data.user.id, + email: data.user.email, + name: data.user.user_metadata.full_name as string, + }, + } + : null, + }, + error: error?.message + ? { + message: errorMessage, + } + : null, + } + } catch (error) { + console.log("Supabase Sign-up error:", error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + + public async googleSignIn(): Promise { + try { + const {data, error} = await this.supabase.auth.signInWithOAuth({ + provider: "google", + options: { + // Must match the deep link scheme/host/path in your AndroidManifest.xml + redirectTo: "com.mentra://auth/callback", + skipBrowserRedirect: true, + queryParams: { + prompt: "select_account", + }, + }, + }) + + let errorMessage = error ? error.message.toLowerCase() : "" + + if ( + errorMessage.includes("already registered") || + errorMessage.includes("user already registered") || + errorMessage.includes("email already exists") || + errorMessage.includes("identity already linked") + ) { + errorMessage = "Email already registered" + } + + return { + data: data.url + ? { + url: data.url, + } + : null, + error: error?.message + ? { + message: errorMessage, + } + : null, + } + } catch (error) { + console.error("Error signing in with Google:", error) + return { + data: null, + error: { + message: "Error signing in with Google", + }, + } + } + } + + public async appleSignIn(): Promise { + try { + const {data, error} = await this.supabase.auth.signInWithOAuth({ + provider: "apple", + options: { + // Must match the deep link scheme/host/path in your AndroidManifest.xml + redirectTo: "com.mentra://auth/callback", + skipBrowserRedirect: true, + queryParams: { + prompt: "select_account", + }, + }, + }) + + let errorMessage = error ? error.message.toLowerCase() : "" + + if ( + errorMessage.includes("already registered") || + errorMessage.includes("user already registered") || + errorMessage.includes("email already exists") || + errorMessage.includes("identity already linked") + ) { + errorMessage = "Email already registered" + } else { + errorMessage = "Something went wrong. Please try again." + } + + return { + data: data.url + ? { + url: data?.url, + } + : null, + error: error?.message + ? { + message: errorMessage, + } + : null, + } + } catch (error) { + console.error("Error signing in with Apple:", error) + return { + data: null, + error: { + message: "Error signing in with Apple", + }, + } + } + } +} From 850361d324fb3deb5a699f183eebe372fcb5d4e9 Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Thu, 16 Oct 2025 17:20:54 +0800 Subject: [PATCH 08/27] chore: remove direct references tosupabase --- mobile/src/app/auth/forgot-password.tsx | 8 +- mobile/src/app/auth/reset-password.tsx | 21 +-- mobile/src/app/settings/change-password.tsx | 10 +- mobile/src/app/settings/profile.tsx | 19 +-- mobile/src/contexts/DeeplinkContext.tsx | 21 +-- mobile/src/utils/LogoutUtils.tsx | 12 +- mobile/src/utils/auth/authProvider.ts | 67 +++++++-- mobile/src/utils/auth/authProvider.types.ts | 41 +++++ .../src/utils/auth/provider/authingClient.ts | 49 +++++- .../src/utils/auth/provider/supabaseClient.ts | 141 +++++++++++++++++- mobile/src/utils/deepLinkRoutes.ts | 22 ++- 11 files changed, 319 insertions(+), 92 deletions(-) diff --git a/mobile/src/app/auth/forgot-password.tsx b/mobile/src/app/auth/forgot-password.tsx index 7f6110bcf..26a12ac15 100644 --- a/mobile/src/app/auth/forgot-password.tsx +++ b/mobile/src/app/auth/forgot-password.tsx @@ -1,6 +1,6 @@ -import React, {useState} from "react" +import {useState} from "react" import {View, TextInput, ActivityIndicator, ScrollView, ViewStyle, TextStyle} from "react-native" -import {supabase} from "@/supabase/supabaseClient" +import {mentraAuthProvider} from "@/utils/auth/authProvider" import {Button, Header, Screen, Text} from "@/components/ignite" import {useAppTheme} from "@/utils/useAppTheme" import {ThemedStyle, spacing} from "@/theme" @@ -29,9 +29,7 @@ export default function ForgotPasswordScreen() { setIsLoading(true) try { - const {error} = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: "com.mentra://auth/reset-password", - }) + const {error} = await mentraAuthProvider.resetPasswordForEmail(email) if (error) { showAlert(translate("common:error"), error.message) diff --git a/mobile/src/app/auth/reset-password.tsx b/mobile/src/app/auth/reset-password.tsx index 3aebe0dd5..cf070c6ef 100644 --- a/mobile/src/app/auth/reset-password.tsx +++ b/mobile/src/app/auth/reset-password.tsx @@ -1,6 +1,5 @@ -import React, {useState, useEffect} from "react" +import {useState, useEffect} from "react" import {View, TextInput, TouchableOpacity, ActivityIndicator, ScrollView, ViewStyle, TextStyle} from "react-native" -import {supabase} from "@/supabase/supabaseClient" import {Button, Header, Screen, Text} from "@/components/ignite" import {useAppTheme} from "@/utils/useAppTheme" import {ThemedStyle, spacing} from "@/theme" @@ -10,6 +9,7 @@ import showAlert from "@/utils/AlertUtils" import {FontAwesome} from "@expo/vector-icons" import {Spacer} from "@/components/misc/Spacer" import Toast from "react-native-toast-message" +import {mentraAuthProvider} from "@/utils/auth/authProvider" export default function ResetPasswordScreen() { const [email, setEmail] = useState("") @@ -33,7 +33,7 @@ export default function ResetPasswordScreen() { const checkSession = async () => { const { data: {session}, - } = await supabase.auth.getSession() + } = await mentraAuthProvider.getSession() if (session) { setIsValidToken(true) // Get the user's email from the session @@ -62,21 +62,16 @@ export default function ResetPasswordScreen() { setIsLoading(true) try { - const {error} = await supabase.auth.updateUser({ - password: newPassword, - }) + const {error} = await mentraAuthProvider.updateUserPassword(newPassword) if (error) { showAlert(translate("common:error"), error.message) } else { // Try to automatically log the user in with the new password if (email) { - const {data: signInData, error: signInError} = await supabase.auth.signInWithPassword({ - email, - password: newPassword, - }) + const {data: signInData, error: signInError} = await mentraAuthProvider.signIn(email, newPassword) - if (!signInError && signInData.session) { + if (!signInError && signInData?.session) { Toast.show({ type: "success", text1: translate("login:passwordResetSuccess"), @@ -96,7 +91,7 @@ export default function ResetPasswordScreen() { position: "bottom", }) - await supabase.auth.signOut() + await mentraAuthProvider.signOut() setTimeout(() => { router.replace("/auth/login") @@ -111,7 +106,7 @@ export default function ResetPasswordScreen() { position: "bottom", }) - await supabase.auth.signOut() + await mentraAuthProvider.signOut() setTimeout(() => { router.replace("/auth/login") diff --git a/mobile/src/app/settings/change-password.tsx b/mobile/src/app/settings/change-password.tsx index 22bcbc78e..7eb101956 100644 --- a/mobile/src/app/settings/change-password.tsx +++ b/mobile/src/app/settings/change-password.tsx @@ -1,6 +1,5 @@ -import React, {useState} from "react" +import {useState} from "react" import {View, TextInput, TouchableOpacity, ActivityIndicator, ScrollView, ViewStyle, TextStyle} from "react-native" -import {supabase} from "@/supabase/supabaseClient" import {Button, Header, Screen, Text} from "@/components/ignite" import {useAppTheme} from "@/utils/useAppTheme" import {ThemedStyle, spacing} from "@/theme" @@ -10,6 +9,7 @@ import showAlert from "@/utils/AlertUtils" import {FontAwesome} from "@expo/vector-icons" import {Spacer} from "@/components/misc/Spacer" import Toast from "react-native-toast-message" +import {mentraAuthProvider} from "@/utils/auth/authProvider" export default function ChangePasswordScreen() { const [newPassword, setNewPassword] = useState("") @@ -39,9 +39,7 @@ export default function ChangePasswordScreen() { setIsLoading(true) try { - const {error} = await supabase.auth.updateUser({ - password: newPassword, - }) + const {error} = await mentraAuthProvider.updateUserPassword(newPassword) if (error) { showAlert(translate("common:error"), error.message) @@ -205,7 +203,7 @@ const $errorText: ThemedStyle = ({colors, spacing}) => ({ marginTop: spacing.xs, }) -const $primaryButton: ThemedStyle = ({colors, spacing}) => ({ +const $primaryButton: ThemedStyle = () => ({ // Using default Button styles from Ignite theme }) diff --git a/mobile/src/app/settings/profile.tsx b/mobile/src/app/settings/profile.tsx index 955960b3d..d2525094a 100644 --- a/mobile/src/app/settings/profile.tsx +++ b/mobile/src/app/settings/profile.tsx @@ -1,6 +1,5 @@ import {useState, useEffect} from "react" import {View, Image, ActivityIndicator, ScrollView, ImageStyle, TextStyle, ViewStyle, Modal} from "react-native" -import {supabase} from "@/supabase/supabaseClient" import {Header, Screen, Text} from "@/components/ignite" import {useAppTheme} from "@/utils/useAppTheme" import {ThemedStyle} from "@/theme" @@ -12,6 +11,7 @@ import showAlert from "@/utils/AlertUtils" import {LogoutUtils} from "@/utils/LogoutUtils" import restComms from "@/managers/RestComms" import {useAuth} from "@/contexts/AuthContext" +import {mentraAuthProvider} from "@/utils/auth/authProvider" export default function ProfileSettingsPage() { const [userData, setUserData] = useState<{ @@ -31,19 +31,16 @@ export default function ProfileSettingsPage() { const fetchUserData = async () => { setLoading(true) try { - const { - data: {user}, - error, - } = await supabase.auth.getUser() + const {data, error} = await mentraAuthProvider.getUser() if (error) { console.error(error) setUserData(null) - } else if (user) { - const fullName = user.user_metadata?.full_name || user.user_metadata?.name || null - const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture || null - const email = user.email || null - const createdAt = user.created_at || null - const provider = user.app_metadata?.provider || null + } else if (data?.user) { + const fullName = data.user.name || null + const avatarUrl = data.user.avatarUrl || null + const email = data.user.email || null + const createdAt = data.user.createdAt || null + const provider = data.user.provider || null setUserData({ fullName, diff --git a/mobile/src/contexts/DeeplinkContext.tsx b/mobile/src/contexts/DeeplinkContext.tsx index 720a24549..81bf40a81 100644 --- a/mobile/src/contexts/DeeplinkContext.tsx +++ b/mobile/src/contexts/DeeplinkContext.tsx @@ -1,12 +1,11 @@ -import React, {createContext, useContext, useEffect, useRef, useState} from "react" +import {FC, ReactNode, createContext, useContext, useEffect} from "react" // import {Linking} from "react-native" -import {useRouter} from "expo-router" -import {useAuth} from "@/contexts/AuthContext" +// import {useAuth} from "@/contexts/AuthContext" import {deepLinkRoutes} from "@/utils/deepLinkRoutes" import {NavObject, useNavigationHistory} from "@/contexts/NavigationHistoryContext" -import {supabase} from "@/supabase/supabaseClient" import * as Linking from "expo-linking" +import {mentraAuthProvider} from "@/utils/auth/authProvider" interface DeeplinkContextType { processUrl: (url: string) => Promise @@ -31,9 +30,8 @@ const DeeplinkContext = createContext({}) export const useDeeplink = () => useContext(DeeplinkContext) -export const DeeplinkProvider: React.FC<{children: React.ReactNode}> = ({children}) => { - const router = useRouter() - const {user} = useAuth() +export const DeeplinkProvider: FC<{children: ReactNode}> = ({children}) => { + // const {user} = useAuth() const {push, replace, goBack, setPendingRoute, getPendingRoute, navigate} = useNavigationHistory() const config = { scheme: "com.mentra", @@ -41,8 +39,8 @@ export const DeeplinkProvider: React.FC<{children: React.ReactNode}> = ({childre routes: deepLinkRoutes, authCheckHandler: async () => { // TODO: this is a hack when we should really be using the auth context: - const session = await supabase.auth.getSession() - if (session.data.session == null) { + const session = await mentraAuthProvider.getSession() + if (!session.token) { return false } return true @@ -56,12 +54,7 @@ export const DeeplinkProvider: React.FC<{children: React.ReactNode}> = ({childre navObject: {push, replace, goBack, setPendingRoute, getPendingRoute}, } - const handleUrlRaw = async ({url}: {url: string}) => { - processUrl(url, false) - } - useEffect(() => { - const subscription = Linking.addEventListener("url", handleUrlRaw) Linking.getInitialURL().then(url => { console.log("@@@@@@@@@@@@@ INITIAL URL @@@@@@@@@@@@@@@", url) if (url) { diff --git a/mobile/src/utils/LogoutUtils.tsx b/mobile/src/utils/LogoutUtils.tsx index 193b2f10b..82f93b27f 100644 --- a/mobile/src/utils/LogoutUtils.tsx +++ b/mobile/src/utils/LogoutUtils.tsx @@ -1,5 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage" -import {supabase} from "@/supabase/supabaseClient" +import {mentraAuthProvider} from "@/utils/auth/authProvider" import bridge from "@/bridge/MantleBridge" import GlobalEventEmitter from "@/utils/GlobalEventEmitter" import restComms from "@/managers/RestComms" @@ -73,13 +73,9 @@ export class LogoutUtils { private static async clearSupabaseAuth(): Promise { console.log(`${this.TAG}: Clearing Supabase authentication...`) - try { - // Try to sign out with Supabase - may fail in offline mode - await supabase.auth.signOut().catch(err => { - console.log(`${this.TAG}: Supabase sign-out failed, continuing with local cleanup:`, err) - }) - } catch (error) { - console.warn(`${this.TAG}: Supabase signOut failed:`, error) + const {error} = await mentraAuthProvider.signOut() + if (error) { + console.error(`${this.TAG}: Error signing out:`, error) } // Completely clear ALL Supabase Auth storage diff --git a/mobile/src/utils/auth/authProvider.ts b/mobile/src/utils/auth/authProvider.ts index 7a94d3243..76a7400a0 100644 --- a/mobile/src/utils/auth/authProvider.ts +++ b/mobile/src/utils/auth/authProvider.ts @@ -1,4 +1,12 @@ -import {MentraOauthProviderResponse, MentraSigninResponse} from "./authProvider.types" +import { + MentraAuthSessionResponse, + MentraAuthUserResponse, + MentraOauthProviderResponse, + MentraPasswordResetResponse, + MentraSigninResponse, + MentraSignOutResponse, + MentraUpdateUserPasswordResponse, +} from "./authProvider.types" import {AuthingWrapperClient} from "./provider/authingClient" import {SupabaseWrapperClient} from "./provider/supabaseClient" @@ -21,8 +29,17 @@ class MentraAuthProvider { this.authing = await AuthingWrapperClient.getInstance() } + async getUser(): Promise { + await this.checkOrSetupClients() + if (IS_CHINA) { + throw new Error("Get user not supported in China") + } else { + return this.supabase!.getUser() + } + } + async signup(email: string, password: string): Promise { - this.checkOrSetupClients() + await this.checkOrSetupClients() if (IS_CHINA) { return this.authing!.signUp({ email, @@ -37,7 +54,7 @@ class MentraAuthProvider { } async signIn(email: string, password: string): Promise { - this.checkOrSetupClients() + await this.checkOrSetupClients() if (IS_CHINA) { return this.authing!.signInWithPassword({ email, @@ -51,19 +68,45 @@ class MentraAuthProvider { } } - async getSession() { - this.checkOrSetupClients() + async resetPasswordForEmail(email: string): Promise { + await this.checkOrSetupClients() + if (IS_CHINA) { + // return this.authing!.resetPasswordForEmail(email) + throw new Error("Reset password for email not supported in China") + } else { + return this.supabase!.resetPasswordForEmail(email) + } + } + + async updateUserPassword(password: string): Promise { + await this.checkOrSetupClients() if (IS_CHINA) { - console.log("Getting session with Authing...") - return await this.authing!.getSession() + throw new Error("Update user password not supported in China") + } else { + return this.supabase!.updateUserPassword(password) + } + } + + async getSession(): Promise { + await this.checkOrSetupClients() + if (IS_CHINA) { + return this.authing!.getSession() } else { - console.log("Getting session with Supabase...") return this.supabase!.getSession() } } - async signOut() { - this.checkOrSetupClients() + async updateSessionWithTokens(tokens: {access_token: string; refresh_token: string}) { + await this.checkOrSetupClients() + if (IS_CHINA) { + throw new Error("Update session with tokens not supported in China") + } else { + return this.supabase!.updateSessionWithTokens(tokens) + } + } + + async signOut(): Promise { + await this.checkOrSetupClients() if (IS_CHINA) { return this.authing!.signOut() } else { @@ -72,7 +115,7 @@ class MentraAuthProvider { } async appleSignIn(): Promise { - this.checkOrSetupClients() + await this.checkOrSetupClients() if (IS_CHINA) { throw new Error("Apple sign in not supported in China") } else { @@ -81,7 +124,7 @@ class MentraAuthProvider { } async googleSignIn(): Promise { - this.checkOrSetupClients() + await this.checkOrSetupClients() if (IS_CHINA) { throw new Error("Google sign in not supported in China") } else { diff --git a/mobile/src/utils/auth/authProvider.types.ts b/mobile/src/utils/auth/authProvider.types.ts index feec21cf8..fa8611113 100644 --- a/mobile/src/utils/auth/authProvider.types.ts +++ b/mobile/src/utils/auth/authProvider.types.ts @@ -2,6 +2,9 @@ export type MentraAuthUser = { id: string email?: string name: string + avatarUrl?: string + createdAt?: string + provider?: string } export type MentraAuthSession = { @@ -9,6 +12,44 @@ export type MentraAuthSession = { user?: MentraAuthUser } +export type MentraAuthSessionResponse = { + data: { + session: MentraAuthSession | null + } | null + error: { + message: string + } | null +} + +export type MentraAuthUserResponse = { + data: { + user: MentraAuthUser | null + } | null + error: { + message: string + } | null +} + +export type MentraSignOutResponse = { + error: { + message: string + } | null +} + +export type MentraUpdateUserPasswordResponse = { + data: {} | null + error: { + message: string + } | null +} + +export type MentraPasswordResetResponse = { + data: {} | null + error: { + message: string + } | null +} + export type MentraOauthProviderResponse = { data: { url?: string diff --git a/mobile/src/utils/auth/provider/authingClient.ts b/mobile/src/utils/auth/provider/authingClient.ts index 09ea38d97..b27d1ce04 100644 --- a/mobile/src/utils/auth/provider/authingClient.ts +++ b/mobile/src/utils/auth/provider/authingClient.ts @@ -2,7 +2,7 @@ import {AuthenticationClient} from "authing-js-sdk" import type {AuthenticationClientOptions, User} from "authing-js-sdk" import AsyncStorage from "@react-native-async-storage/async-storage" import {EventEmitter} from "events" -import {MentraSigninResponse} from "../authProvider.types" +import {MentraAuthSessionResponse, MentraSigninResponse, MentraSignOutResponse} from "../authProvider.types" interface Session { access_token?: string @@ -47,7 +47,7 @@ export class AuthingWrapperClient { public static async getInstance(): Promise { if (!AuthingWrapperClient.instance) { AuthingWrapperClient.instance = new AuthingWrapperClient() - const session = await AuthingWrapperClient.instance.getSession() + const session = await AuthingWrapperClient.instance.readSessionFromStorage() if (session?.access_token) { AuthingWrapperClient.instance.authing.setToken(session.access_token) @@ -167,7 +167,7 @@ export class AuthingWrapperClient { } } - public async signOut() { + public async signOut(): Promise { try { await this.authing.logout() await this.clearSession() @@ -175,18 +175,51 @@ export class AuthingWrapperClient { return {error: null} } catch (error) { console.error("Sign out error:", error) - return {error} + return { + error: { + message: "Failed to sign out", + }, + } } } - public async getSession(): Promise { + public async getSession(): Promise { + try { + const session = await this.readSessionFromStorage() + return { + data: { + session: session + ? { + token: session.access_token, + user: { + id: session.user!.id, + email: session.user!.email!, + name: session.user!.name || "", + }, + } + : null, + }, + error: null, + } + } catch (error) { + console.error("Error getting session:", error) + return { + data: null, + error: { + message: "Failed to get session", + }, + } + } + } + + private async readSessionFromStorage(): Promise { const sessionJson = await AsyncStorage.getItem(SESSION_KEY) return sessionJson ? JSON.parse(sessionJson) : null } private async setupTokenRefresh() { try { - const session = await this.getSession() + const session = await this.readSessionFromStorage() if (!session?.access_token) return const now = Date.now() @@ -263,3 +296,7 @@ export class AuthingWrapperClient { } export const authingClient = AuthingWrapperClient.getInstance() + +// TODO: +// Once we close the app the refresh thing would also turn off +// Fix that diff --git a/mobile/src/utils/auth/provider/supabaseClient.ts b/mobile/src/utils/auth/provider/supabaseClient.ts index a32c55f9b..1ef26e655 100644 --- a/mobile/src/utils/auth/provider/supabaseClient.ts +++ b/mobile/src/utils/auth/provider/supabaseClient.ts @@ -1,6 +1,14 @@ import type {SupabaseClient} from "@supabase/supabase-js" import {supabase as supabaseClient} from "@/supabase/supabaseClient" -import {MentraOauthProviderResponse, MentraSigninResponse} from "../authProvider.types" +import { + MentraAuthUserResponse, + MentraOauthProviderResponse, + MentraPasswordResetResponse, + MentraSigninResponse, + MentraSignOutResponse, + MentraUpdateUserPasswordResponse, + MentraAuthSessionResponse, +} from "../authProvider.types" export class SupabaseWrapperClient { private static instance: SupabaseWrapperClient @@ -17,12 +25,89 @@ export class SupabaseWrapperClient { return SupabaseWrapperClient.instance } - public async getSession() { - return this.supabase.auth.getSession() + public async getUser(): Promise { + try { + const {data, error} = await this.supabase.auth.getUser() + return { + data: data.user + ? { + user: { + id: data.user.id, + email: data.user.email, + name: (data.user.user_metadata.full_name || data.user.user_metadata.name || "") as string, + avatarUrl: data.user.user_metadata?.avatar_url || data.user.user_metadata?.picture, + createdAt: data.user.created_at, + provider: data.user.user_metadata.provider, + }, + } + : null, + error: error?.message + ? { + message: error.message, + } + : null, + } + } catch (error) { + console.error(error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } } - public async signOut() { - return this.supabase.auth.signOut() + public async getSession(): Promise { + try { + const {data, error} = await this.supabase.auth.getSession() + return { + data: { + session: data.session + ? { + token: data.session.access_token, + user: { + id: data.session.user.id, + email: data.session.user.email, + name: data.session.user.user_metadata.full_name as string, + }, + } + : null, + }, + error: error?.message + ? { + message: error.message, + } + : null, + } + } catch (error) { + console.error(error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + + public async updateSessionWithTokens(tokens: {access_token: string; refresh_token: string}) { + try { + const {error} = await this.supabase.auth.setSession(tokens) + return {error} + } catch (error) { + console.error(error) + return { + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + + public async signOut(): Promise { + const {error} = await this.supabase.auth.signOut() + return {error} } public async signInWithPassword(credentials: {email: string; password: string}): Promise { @@ -121,6 +206,52 @@ export class SupabaseWrapperClient { } } + public async updateUserPassword(password: string): Promise { + try { + const {data, error} = await this.supabase.auth.updateUser({ + password, + }) + return { + data, + error: error + ? { + message: error.message, + } + : null, + } + } catch (error) { + console.log("Supabase Update User Password error:", error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + + public async resetPasswordForEmail(email: string): Promise { + try { + const {data, error} = await this.supabase.auth.resetPasswordForEmail(email) + return { + data, + error: error + ? { + message: error.message, + } + : null, + } + } catch (error) { + console.log("Supabase Reset Password error:", error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + public async googleSignIn(): Promise { try { const {data, error} = await this.supabase.auth.signInWithOAuth({ diff --git a/mobile/src/utils/deepLinkRoutes.ts b/mobile/src/utils/deepLinkRoutes.ts index 475522a35..2772e1545 100644 --- a/mobile/src/utils/deepLinkRoutes.ts +++ b/mobile/src/utils/deepLinkRoutes.ts @@ -1,8 +1,7 @@ -import {router} from "expo-router" -import {supabase} from "@/supabase/supabaseClient" -import {NavigationHistoryPush, NavigationHistoryReplace, NavObject} from "@/contexts/NavigationHistoryContext" +import {NavObject} from "@/contexts/NavigationHistoryContext" import {Platform} from "react-native" import * as WebBrowser from "expo-web-browser" +import {mentraAuthProvider} from "./auth/authProvider" export interface DeepLinkRoute { pattern: string @@ -117,7 +116,7 @@ export const deepLinkRoutes: DeepLinkRoute[] = [ { pattern: "/store", handler: (url: string, params: Record, navObject: NavObject) => { - const {packageName, preloaded, authed} = params + const {packageName} = params navObject.replace(`/store?packageName=${packageName}`) }, requiresAuth: true, @@ -170,15 +169,16 @@ export const deepLinkRoutes: DeepLinkRoute[] = [ if (authParams && authParams.access_token && authParams.refresh_token) { try { // Update the Supabase session manually - const {data, error} = await supabase.auth.setSession({ + const {error} = await mentraAuthProvider.updateSessionWithTokens({ access_token: authParams.access_token, refresh_token: authParams.refresh_token, }) if (error) { console.error("Error setting session:", error) } else { - console.log("Session updated:", data.session) - console.log("[LOGIN DEBUG] Session set successfully, data.session exists:", !!data.session) + // console.log("Session updated:", data.session) + // console.log("[LOGIN DEBUG] Session set successfully, data.session exists:", !!data.session) + console.log("[LOGIN DEBUG] Session set successfully") // Dismiss the WebView after successful authentication (non-blocking) console.log("[LOGIN DEBUG] About to dismiss browser, platform:", Platform.OS) try { @@ -217,10 +217,8 @@ export const deepLinkRoutes: DeepLinkRoute[] = [ // Check if this is an auth callback without tokens if (!authParams) { // Try checking if user is already authenticated - const { - data: {session}, - } = await supabase.auth.getSession() - if (session) { + const {data} = await mentraAuthProvider.getSession() + if (data?.session?.token) { navObject.replace("/") } } @@ -252,7 +250,7 @@ export const deepLinkRoutes: DeepLinkRoute[] = [ if (authParams && authParams.access_token && authParams.refresh_token && authParams.type === "recovery") { try { // Set the recovery session - const {data, error} = await supabase.auth.setSession({ + const {error} = await mentraAuthProvider.updateSessionWithTokens({ access_token: authParams.access_token, refresh_token: authParams.refresh_token, }) From 0c8a190198204febf4036b0448d54f723dda3cd2 Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Thu, 16 Oct 2025 21:56:25 +0800 Subject: [PATCH 09/27] fix: fix small issues --- mobile/app.config.ts | 4 ++ mobile/src/app/init.tsx | 2 +- mobile/src/managers/RestComms.tsx | 3 +- mobile/src/types/react-native-config.d.ts | 8 +++ mobile/src/utils/auth/authProvider.ts | 7 ++- .../src/utils/auth/provider/authingClient.ts | 54 +++++++++++++++---- 6 files changed, 65 insertions(+), 13 deletions(-) diff --git a/mobile/app.config.ts b/mobile/app.config.ts index 949d4c3ed..02b08d40b 100644 --- a/mobile/app.config.ts +++ b/mobile/app.config.ts @@ -31,6 +31,10 @@ module.exports = ({config}: ConfigContext): Partial => { SUPABASE_URL: process.env.SUPABASE_URL, SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY, SENTRY_DSN: process.env.SENTRY_DSN, + AUTHING_APP_ID: process.env.AUTHING_APP_ID, + AUTHING_APP_SECRET: process.env.AUTHING_APP_SECRET, + AUTHING_APP_DOMAIN: process.env.AUTHING_APP_DOMAIN, + DEPLOYMENT_REGION: process.env.DEPLOYMENT_REGION, }, version: process.env.MENTRAOS_VERSION, } diff --git a/mobile/src/app/init.tsx b/mobile/src/app/init.tsx index c041d3ea4..1d4349105 100644 --- a/mobile/src/app/init.tsx +++ b/mobile/src/app/init.tsx @@ -112,7 +112,7 @@ export default function InitScreen() { setLoadingStatus(translate("versionCheck:connectingToServer")) try { - const token = session?.access_token + const token = session?.token console.log("EXCHANGING TOKEN: ") console.log(token) if (!token) { diff --git a/mobile/src/managers/RestComms.tsx b/mobile/src/managers/RestComms.tsx index d9680decb..cee61b70c 100644 --- a/mobile/src/managers/RestComms.tsx +++ b/mobile/src/managers/RestComms.tsx @@ -1,6 +1,7 @@ import GlobalEventEmitter from "@/utils/GlobalEventEmitter" import {AppletInterface} from "@/types/AppletTypes" import {useSettingsStore} from "@/stores/settings" +import Constants from "expo-constants" interface ApiResponse { success?: boolean @@ -237,7 +238,7 @@ class RestComms { } public async exchangeToken(token: string): Promise { - const DEPLOYMENT_REGION = process.env.DEPLOYMENT_REGION || "global" + const DEPLOYMENT_REGION = Constants.expoConfig?.extra?.DEPLOYMENT_REGION || "global" const IS_CHINA = DEPLOYMENT_REGION === "china" const baseUrl = await useSettingsStore.getState().getRestUrl() const url = `${baseUrl}/auth/exchange-token` diff --git a/mobile/src/types/react-native-config.d.ts b/mobile/src/types/react-native-config.d.ts index fdb8b4463..4dfd507f4 100644 --- a/mobile/src/types/react-native-config.d.ts +++ b/mobile/src/types/react-native-config.d.ts @@ -16,6 +16,14 @@ declare module "react-native-config" { // Sentry settings SENTRY_DSN?: string + + // Authing settings + AUTHING_APP_ID?: string + AUTHING_APP_SECRET?: string + AUTHING_APP_DOMAIN?: string + + // Deployment region + DEPLOYMENT_REGION?: string } export const Config: NativeConfig diff --git a/mobile/src/utils/auth/authProvider.ts b/mobile/src/utils/auth/authProvider.ts index 76a7400a0..a85f16a6c 100644 --- a/mobile/src/utils/auth/authProvider.ts +++ b/mobile/src/utils/auth/authProvider.ts @@ -9,8 +9,9 @@ import { } from "./authProvider.types" import {AuthingWrapperClient} from "./provider/authingClient" import {SupabaseWrapperClient} from "./provider/supabaseClient" +import Constants from "expo-constants" -const DEPLOYMENT_REGION = process.env.DEPLOYMENT_REGION || "global" +const DEPLOYMENT_REGION = Constants.expoConfig?.extra?.DEPLOYMENT_REGION || "global" const IS_CHINA = DEPLOYMENT_REGION === "china" class MentraAuthProvider { @@ -32,7 +33,7 @@ class MentraAuthProvider { async getUser(): Promise { await this.checkOrSetupClients() if (IS_CHINA) { - throw new Error("Get user not supported in China") + return this.authing!.getUser() } else { return this.supabase!.getUser() } @@ -56,11 +57,13 @@ class MentraAuthProvider { async signIn(email: string, password: string): Promise { await this.checkOrSetupClients() if (IS_CHINA) { + console.log("Signing in with password in China") return this.authing!.signInWithPassword({ email, password, }) } else { + console.log("Signing in with password in Global") return this.supabase!.signInWithPassword({ email, password, diff --git a/mobile/src/utils/auth/provider/authingClient.ts b/mobile/src/utils/auth/provider/authingClient.ts index b27d1ce04..4ccab83af 100644 --- a/mobile/src/utils/auth/provider/authingClient.ts +++ b/mobile/src/utils/auth/provider/authingClient.ts @@ -2,7 +2,13 @@ import {AuthenticationClient} from "authing-js-sdk" import type {AuthenticationClientOptions, User} from "authing-js-sdk" import AsyncStorage from "@react-native-async-storage/async-storage" import {EventEmitter} from "events" -import {MentraAuthSessionResponse, MentraSigninResponse, MentraSignOutResponse} from "../authProvider.types" +import { + MentraAuthSessionResponse, + MentraAuthUserResponse, + MentraSigninResponse, + MentraSignOutResponse, +} from "../authProvider.types" +import Constants from "expo-constants" interface Session { access_token?: string @@ -35,8 +41,8 @@ export class AuthingWrapperClient { private constructor() { const authingOptions: AuthenticationClientOptions = { - appId: process.env.AUTHING_APP_ID!, - appHost: process.env.AUTHING_APP_HOST!, + appId: Constants.expoConfig?.extra?.AUTHING_APP_ID || "", + appHost: Constants.expoConfig?.extra?.AUTHING_APP_HOST || "", lang: "en-US", } this.authing = new AuthenticationClient(authingOptions) @@ -83,6 +89,35 @@ export class AuthingWrapperClient { } } + public async getUser(): Promise { + try { + const user = await this.authing.getCurrentUser() + return { + data: user + ? { + user: { + id: user.id, + email: user.email!, + name: user.name || "", + avatarUrl: user.photo || "", + createdAt: user.createdAt as string, + provider: user.identities?.[0]?.provider as string, + }, + } + : null, + error: null, + } + } catch (error) { + console.error("Error getting user:", error) + return { + data: null, + error: { + message: "Failed to get user", + }, + } + } + } + public async signUp(credentials: {email: string; password: string}): Promise { try { const user = await this.authing.registerByEmail(credentials.email, credentials.password) @@ -106,12 +141,12 @@ export class AuthingWrapperClient { : null, error: null, } - } catch (error) { + } catch (error: any) { console.error("Error signing up:", error) return { data: null, error: { - message: "Failed to sign up", + message: error.message, }, } } @@ -119,16 +154,17 @@ export class AuthingWrapperClient { public async signInWithPassword(credentials: {email: string; password: string}): Promise { try { + console.log("AuthingWrapperClient: signInWithPassword") const user = await this.authing.loginByEmail(credentials.email, credentials.password) const token = user.token - const tokenExpiresAt = user.tokenExpiredAt + const tokenExpiresAt = user.tokenExpiredAt && new Date(user.tokenExpiredAt).getTime() console.log("Token expires at:", tokenExpiresAt) if (token && tokenExpiresAt) { const session: Session = { access_token: token, - refresh_token: undefined, // Update this when implementing refresh token flow + refresh_token: undefined, // TODO: Update this when implementing refresh token flow expires_at: Number(tokenExpiresAt), user, } @@ -156,12 +192,12 @@ export class AuthingWrapperClient { } throw new Error("Failed to sign in") - } catch (error) { + } catch (error: any) { console.error("Sign in error:", error) return { data: null, error: { - message: "Failed to sign in", + message: error.message, }, } } From 5625b0469c7b466b75eb2d5ff9a920bfa48a2bd0 Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Thu, 16 Oct 2025 22:31:27 +0800 Subject: [PATCH 10/27] chore: fix the auth listener --- mobile/src/contexts/AuthContext.tsx | 43 +++++++++----- mobile/src/utils/auth/authProvider.ts | 12 ++++ mobile/src/utils/auth/authProvider.types.ts | 9 +++ .../src/utils/auth/provider/authingClient.ts | 58 ++++++++++--------- .../src/utils/auth/provider/supabaseClient.ts | 19 ++++++ 5 files changed, 98 insertions(+), 43 deletions(-) diff --git a/mobile/src/contexts/AuthContext.tsx b/mobile/src/contexts/AuthContext.tsx index f1d3c7eca..2fe76f88c 100644 --- a/mobile/src/contexts/AuthContext.tsx +++ b/mobile/src/contexts/AuthContext.tsx @@ -22,12 +22,12 @@ export const AuthProvider: FC<{children: React.ReactNode}> = ({children}) => { const [loading, setLoading] = useState(true) useEffect(() => { + let subscription: {unsubscribe: () => void} | undefined + // 1. Check for an active session on mount const getInitialSession = async () => { - const { - data: {session}, - } = await mentraAuthProvider.getSession() - + const {data: initialSessionData} = await mentraAuthProvider.getSession() + const session = initialSessionData?.session console.log("AuthContext: Initial session:", session) console.log("AuthContext: Initial user:", session?.user) setSession(session) @@ -35,26 +35,37 @@ export const AuthProvider: FC<{children: React.ReactNode}> = ({children}) => { setLoading(false) } + // 2. Setup auth state change listener + const setupAuthListener = async () => { + try { + const {data: authStateChangeData} = await mentraAuthProvider.onAuthStateChange((event, session: any) => { + console.log("AuthContext: Auth state changed:", event) + console.log("AuthContext: Session:", session) + console.log("AuthContext: User:", session?.user) + setSession(session) + setUser(session?.user ?? null) + setLoading(false) + }) + + if (authStateChangeData?.subscription) { + subscription = authStateChangeData.subscription + } + } catch (error) { + console.error("AuthContext: Error setting up auth listener:", error) + } + } + + // Run both initial checks getInitialSession().catch(error => { console.error("AuthContext: Error getting initial session:", error) setLoading(false) }) - // 2. Listen for auth changes - // const { - // data: {subscription}, - // } = supabase.auth.onAuthStateChange((event, session) => { - // console.log("AuthContext: Auth state changed:", event) - // console.log("AuthContext: Session:", session) - // console.log("AuthContext: User:", session?.user) - // setSession(session) - // setUser(session?.user ?? null) - // setLoading(false) - // }) + setupAuthListener() // Cleanup the listener return () => { - // subscription?.unsubscribe() + subscription?.unsubscribe() } }, []) diff --git a/mobile/src/utils/auth/authProvider.ts b/mobile/src/utils/auth/authProvider.ts index a85f16a6c..c6fc7c070 100644 --- a/mobile/src/utils/auth/authProvider.ts +++ b/mobile/src/utils/auth/authProvider.ts @@ -1,5 +1,6 @@ import { MentraAuthSessionResponse, + MentraAuthStateChangeSubscriptionResponse, MentraAuthUserResponse, MentraOauthProviderResponse, MentraPasswordResetResponse, @@ -30,6 +31,17 @@ class MentraAuthProvider { this.authing = await AuthingWrapperClient.getInstance() } + async onAuthStateChange( + callback: (event: string, session: any) => void, + ): Promise { + await this.checkOrSetupClients() + if (IS_CHINA) { + return this.authing!.onAuthStateChange(callback) + } else { + return this.supabase!.onAuthStateChange(callback) + } + } + async getUser(): Promise { await this.checkOrSetupClients() if (IS_CHINA) { diff --git a/mobile/src/utils/auth/authProvider.types.ts b/mobile/src/utils/auth/authProvider.types.ts index fa8611113..56e5f133e 100644 --- a/mobile/src/utils/auth/authProvider.types.ts +++ b/mobile/src/utils/auth/authProvider.types.ts @@ -7,6 +7,15 @@ export type MentraAuthUser = { provider?: string } +export type MentraAuthStateChangeSubscriptionResponse = { + data: { + subscription: any + } | null + error: { + message: string + } | null +} + export type MentraAuthSession = { token?: string user?: MentraAuthUser diff --git a/mobile/src/utils/auth/provider/authingClient.ts b/mobile/src/utils/auth/provider/authingClient.ts index 4ccab83af..05bb13a7c 100644 --- a/mobile/src/utils/auth/provider/authingClient.ts +++ b/mobile/src/utils/auth/provider/authingClient.ts @@ -3,7 +3,9 @@ import type {AuthenticationClientOptions, User} from "authing-js-sdk" import AsyncStorage from "@react-native-async-storage/async-storage" import {EventEmitter} from "events" import { + MentraAuthSession, MentraAuthSessionResponse, + MentraAuthStateChangeSubscriptionResponse, MentraAuthUserResponse, MentraSigninResponse, MentraSignOutResponse, @@ -29,10 +31,6 @@ type AuthChangeEvent = type AuthChangeCallback = (event: AuthChangeEvent, session: any) => void -interface Subscription { - unsubscribe: () => void -} - export class AuthingWrapperClient { private authing: AuthenticationClient private eventEmitter: EventEmitter @@ -65,27 +63,32 @@ export class AuthingWrapperClient { return AuthingWrapperClient.instance } - public onAuthStateChange(callback: AuthChangeCallback): Subscription { + public onAuthStateChange(callback: AuthChangeCallback): MentraAuthStateChangeSubscriptionResponse { const handler = (event: string, session: Session) => { callback(event as AuthChangeEvent, session) } - this.eventEmitter.on("SIGNED_IN", handler) - this.eventEmitter.on("SIGNED_OUT", handler) - this.eventEmitter.on("TOKEN_REFRESHED", handler) - this.eventEmitter.on("USER_UPDATED", handler) - this.eventEmitter.on("USER_DELETED", handler) - this.eventEmitter.on("PASSWORD_RECOVERY", handler) + this.eventEmitter.on("SIGNED_IN", (session: Session) => handler("SIGNED_IN", session)) + this.eventEmitter.on("SIGNED_OUT", (session: Session) => handler("SIGNED_OUT", session)) + this.eventEmitter.on("TOKEN_REFRESHED", (session: Session) => handler("TOKEN_REFRESHED", session)) + this.eventEmitter.on("USER_UPDATED", (session: Session) => handler("USER_UPDATED", session)) + this.eventEmitter.on("USER_DELETED", (session: Session) => handler("USER_DELETED", session)) + this.eventEmitter.on("PASSWORD_RECOVERY", (session: Session) => handler("PASSWORD_RECOVERY", session)) return { - unsubscribe: () => { - this.eventEmitter.off("SIGNED_IN", handler) - this.eventEmitter.off("SIGNED_OUT", handler) - this.eventEmitter.off("TOKEN_REFRESHED", handler) - this.eventEmitter.off("USER_UPDATED", handler) - this.eventEmitter.off("USER_DELETED", handler) - this.eventEmitter.off("PASSWORD_RECOVERY", handler) + data: { + subscription: { + unsubscribe: () => { + this.eventEmitter.off("SIGNED_IN", handler) + this.eventEmitter.off("SIGNED_OUT", handler) + this.eventEmitter.off("TOKEN_REFRESHED", handler) + this.eventEmitter.off("USER_UPDATED", handler) + this.eventEmitter.off("USER_DELETED", handler) + this.eventEmitter.off("PASSWORD_RECOVERY", handler) + }, + }, }, + error: null, } } @@ -169,18 +172,19 @@ export class AuthingWrapperClient { user, } await this.saveSession(session) - this.eventEmitter.emit("SIGNED_IN", session) + const authSession: MentraAuthSession = { + token: session.access_token, + user: { + id: session.user!.id, + email: session.user!.email!, + name: session.user!.name || "", + }, + } + this.eventEmitter.emit("SIGNED_IN", authSession) this.setupTokenRefresh() return { data: { - session: { - token: session.access_token, - user: { - id: session.user!.id, - email: session.user!.email!, - name: session.user!.name || "", - }, - }, + session: authSession, user: { id: session.user!.id, email: session.user!.email!, diff --git a/mobile/src/utils/auth/provider/supabaseClient.ts b/mobile/src/utils/auth/provider/supabaseClient.ts index 1ef26e655..62ca2196f 100644 --- a/mobile/src/utils/auth/provider/supabaseClient.ts +++ b/mobile/src/utils/auth/provider/supabaseClient.ts @@ -8,6 +8,7 @@ import { MentraSignOutResponse, MentraUpdateUserPasswordResponse, MentraAuthSessionResponse, + MentraAuthStateChangeSubscriptionResponse, } from "../authProvider.types" export class SupabaseWrapperClient { @@ -25,6 +26,24 @@ export class SupabaseWrapperClient { return SupabaseWrapperClient.instance } + public onAuthStateChange(callback: (event: string, session: any) => void): MentraAuthStateChangeSubscriptionResponse { + try { + const {data} = this.supabase.auth.onAuthStateChange(callback) + return { + data, + error: null, + } + } catch (error) { + console.error(error) + return { + data: null, + error: { + message: "Something went wrong. Please try again.", + }, + } + } + } + public async getUser(): Promise { try { const {data, error} = await this.supabase.auth.getUser() From cbd255961ca53a30291cfeb3d37b485c466a6861 Mon Sep 17 00:00:00 2001 From: Yash Agarwal Date: Fri, 17 Oct 2025 13:55:19 +0800 Subject: [PATCH 11/27] chore: minor fixes --- mobile/src/app/auth/login.tsx | 30 +++++++++------- mobile/src/supabase/supabaseClient.ts | 18 ---------- mobile/src/utils/auth/authProvider.ts | 34 +++++++++++++++++++ .../src/utils/auth/provider/authingClient.ts | 15 +++++--- .../src/utils/auth/provider/supabaseClient.ts | 8 +++++ 5 files changed, 71 insertions(+), 34 deletions(-) diff --git a/mobile/src/app/auth/login.tsx b/mobile/src/app/auth/login.tsx index f32ec6247..96e549c1d 100644 --- a/mobile/src/app/auth/login.tsx +++ b/mobile/src/app/auth/login.tsx @@ -28,6 +28,7 @@ import {Spacer} from "@/components/misc/Spacer" import {useNavigationHistory} from "@/contexts/NavigationHistoryContext" import {mentraAuthProvider} from "@/utils/auth/authProvider" import * as WebBrowser from "expo-web-browser" +import Constants from "expo-constants" export default function LoginScreen() { const [isSigningUp, setIsSigningUp] = useState(false) @@ -38,6 +39,7 @@ export default function LoginScreen() { const [formAction, setFormAction] = useState<"signin" | "signup" | null>(null) const [backPressCount, setBackPressCount] = useState(0) const {push, replace} = useNavigationHistory() + const IS_CHINA_DEPLOYMENT = Constants.expoConfig?.extra?.DEPLOYMENT_REGION === "china" // Get theme and safe area insets const {theme, themed} = useAppTheme() @@ -395,14 +397,16 @@ export default function LoginScreen() { ) : ( - - - - - - + {!IS_CHINA_DEPLOYMENT && ( + + + + + + + )} - {Platform.OS === "ios" && ( + {Platform.OS === "ios" && !IS_CHINA_DEPLOYMENT && ( @@ -411,11 +415,13 @@ export default function LoginScreen() { )} - - - - - + {!IS_CHINA_DEPLOYMENT && ( + + + + + + )}