From b01daa5f9eb87409746baf8950c96872cf3df04a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:31:05 +0000 Subject: [PATCH 1/5] auth: add isolated hosted sign-in and independent Pages delivery --- .githooks/repo-checks | 3 +- .github/workflows/auth-pages-build.yml | 54 +++ .github/workflows/auth-pages-ci.yml | 90 +++++ .github/workflows/auth-pages-publish.yml | 69 ++++ .github/workflows/pages-tests.yml | 4 + .gitignore | 1 + apps/maple-research/docs/auth-site.md | 71 ++++ .../frontend/auth-build-boundary.ts | 30 ++ apps/maple-research/frontend/auth.html | 14 + apps/maple-research/frontend/bun.lock | 228 ++++++++++- apps/maple-research/frontend/package.json | 9 +- .../fonts/Manrope-VariableFont_wght.ttf | Bin 0 -> 167160 bytes .../frontend/public-auth/maple-logo-dark.svg | 11 + .../frontend/src/auth-site/AuthSite.tsx | 40 ++ .../src/auth-site/CallbackRecovery.tsx | 39 ++ .../src/auth-site/HostedAppleSignIn.tsx | 158 ++++++++ .../frontend/src/auth-site/HostedCallback.tsx | 74 ++++ .../frontend/src/auth-site/HostedStart.tsx | 66 ++++ .../frontend/src/auth-site/bootstrap.test.ts | 35 ++ .../src/auth-site/buildBoundary.test.ts | 39 ++ .../src/auth-site/fixtures/AuthSite.case.tsx | 373 ++++++++++++++++++ .../fixtures/HostedAppleSignIn.case.tsx | 308 +++++++++++++++ .../src/auth-site/fixtures/bootstrap.tsx | 173 ++++++++ .../frontend/src/auth-site/main.tsx | 14 + .../frontend/src/auth-site/route.test.ts | 206 ++++++++++ .../frontend/src/auth-site/route.ts | 66 ++++ .../frontend/src/auth-site/style.css | 56 +++ .../frontend/src/auth-site/ui.test.ts | 33 ++ .../src/components/AppleAuthProvider.test.tsx | 44 ++- .../src/components/AppleAuthProvider.tsx | 78 +--- .../frontend/src/lib/test/preload.ts | 18 +- .../src/routes/auth.$provider.callback.tsx | 38 +- .../frontend/src/routes/desktop-auth.tsx | 11 +- .../frontend/src/routes/login.tsx | 13 +- .../frontend/src/routes/signup.tsx | 13 +- .../frontend/src/services/appleOAuth.test.ts | 32 ++ .../frontend/src/services/appleOAuth.ts | 54 +++ .../services/desktopOAuthTransport.test.ts | 23 ++ .../src/services/desktopOAuthTransport.ts | 14 +- .../src/services/nativeOAuthAttempt.test.ts | 23 ++ .../src/services/nativeOAuthAttempt.ts | 10 +- .../frontend/src/services/oauthConfig.test.ts | 66 ++++ .../frontend/src/services/oauthConfig.ts | 50 +++ .../maple-research/frontend/src/vite-env.d.ts | 1 + .../frontend/tailwind.auth.config.cjs | 15 + .../frontend/tsconfig.node.json | 2 +- .../frontend/vite.auth.config.ts | 47 +++ apps/maple-research/frontend/vite.config.ts | 2 + docs/pages-deployments.md | 80 ++++ flake.nix | 6 +- scripts/ci/_common.sh | 6 + scripts/ci/auth-web.sh | 46 +++ scripts/ci/pages_artifact.py | 18 +- scripts/ci/pages_auth_build.py | 68 ++++ scripts/ci/pages_auth_deploy.py | 122 ++++++ scripts/ci/pages_deploy.py | 95 +++-- scripts/ci/test_pages_auth_build.py | 95 +++++ scripts/ci/test_pages_auth_deploy.py | 274 +++++++++++++ scripts/ci/test_pages_auth_workflows.py | 237 +++++++++++ 59 files changed, 3708 insertions(+), 157 deletions(-) create mode 100644 .github/workflows/auth-pages-build.yml create mode 100644 .github/workflows/auth-pages-ci.yml create mode 100644 .github/workflows/auth-pages-publish.yml create mode 100644 apps/maple-research/docs/auth-site.md create mode 100644 apps/maple-research/frontend/auth-build-boundary.ts create mode 100644 apps/maple-research/frontend/auth.html create mode 100644 apps/maple-research/frontend/public-auth/fonts/Manrope-VariableFont_wght.ttf create mode 100644 apps/maple-research/frontend/public-auth/maple-logo-dark.svg create mode 100644 apps/maple-research/frontend/src/auth-site/AuthSite.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/HostedAppleSignIn.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/HostedCallback.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/HostedStart.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/bootstrap.test.ts create mode 100644 apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts create mode 100644 apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/fixtures/HostedAppleSignIn.case.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/fixtures/bootstrap.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/main.tsx create mode 100644 apps/maple-research/frontend/src/auth-site/route.test.ts create mode 100644 apps/maple-research/frontend/src/auth-site/route.ts create mode 100644 apps/maple-research/frontend/src/auth-site/style.css create mode 100644 apps/maple-research/frontend/src/auth-site/ui.test.ts create mode 100644 apps/maple-research/frontend/src/services/appleOAuth.test.ts create mode 100644 apps/maple-research/frontend/src/services/appleOAuth.ts create mode 100644 apps/maple-research/frontend/src/services/oauthConfig.test.ts create mode 100644 apps/maple-research/frontend/src/services/oauthConfig.ts create mode 100644 apps/maple-research/frontend/tailwind.auth.config.cjs create mode 100644 apps/maple-research/frontend/vite.auth.config.ts create mode 100644 scripts/ci/auth-web.sh create mode 100644 scripts/ci/pages_auth_build.py create mode 100644 scripts/ci/pages_auth_deploy.py create mode 100644 scripts/ci/test_pages_auth_build.py create mode 100644 scripts/ci/test_pages_auth_deploy.py create mode 100644 scripts/ci/test_pages_auth_workflows.py diff --git a/.githooks/repo-checks b/.githooks/repo-checks index 688a2686b..6f86dab92 100755 --- a/.githooks/repo-checks +++ b/.githooks/repo-checks @@ -11,7 +11,8 @@ workflows=$(hook_staged_matching '^\.github/workflows/.*\.ya?ml$') if [ -n "$workflows" ]; then hook_require actionlint for workflow in $workflows; do - if [ "$workflow" = ".github/workflows/pages-publish.yml" ]; then + if [ "$workflow" = ".github/workflows/pages-publish.yml" ] || + [ "$workflow" = ".github/workflows/auth-pages-publish.yml" ]; then hook_run actionlint -config-file .github/actionlint.yaml \ -ignore 'unexpected key "deployment" for "environment" section' "$workflow" else diff --git a/.github/workflows/auth-pages-build.yml b/.github/workflows/auth-pages-build.yml new file mode 100644 index 000000000..ce154bb7f --- /dev/null +++ b/.github/workflows/auth-pages-build.yml @@ -0,0 +1,54 @@ +name: Auth Pages build + +permissions: + contents: read + +on: + workflow_dispatch: + +jobs: + build: + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest-8-cores + timeout-minutes: 30 + steps: + - name: Checkout auth source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Test Pages artifact and deployment boundaries + run: nix build --no-update-lock-file --no-link --print-build-logs .#checks.x86_64-linux.pages + + - name: Build auth with production services + env: + MAPLE_AUTH_ENVIRONMENT: release + run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh + + - name: Describe the auth artifact + run: | + set -euo pipefail + artifact_dir="apps/maple-research/frontend/src-tauri/target/reproducibility" + nix develop --no-update-lock-file .#pages -c python3 -I scripts/ci/pages_artifact.py manifest \ + --archive "$artifact_dir/maple-auth-dist.tar.gz" \ + --profile auth-release \ + --sha "$GITHUB_SHA" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --output "$artifact_dir/pages-artifact.json" + + - name: Upload the auth artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: maple-auth-production-${{ github.run_id }}-${{ github.run_attempt }} + path: | + apps/maple-research/frontend/src-tauri/target/reproducibility/maple-auth-dist.tar.gz + apps/maple-research/frontend/src-tauri/target/reproducibility/pages-artifact.json + if-no-files-found: error + retention-days: 5 diff --git a/.github/workflows/auth-pages-ci.yml b/.github/workflows/auth-pages-ci.yml new file mode 100644 index 000000000..bf3325e79 --- /dev/null +++ b/.github/workflows/auth-pages-ci.yml @@ -0,0 +1,90 @@ +name: Auth Pages CI + +permissions: + contents: read + +on: + pull_request: + # Include stacked PRs and forks; this job has no publishing authority. + paths: + - ".github/workflows/auth-pages-*.yml" + - ".github/workflows/pages-tests.yml" + - "apps/maple-research/frontend/**" + - "!apps/maple-research/frontend/src-tauri/**" + - "sdk/src/**" + - "!sdk/src/lib/test/**" + - "sdk/bun.lock" + - "sdk/bunfig.toml" + - "sdk/package.json" + - "sdk/tsconfig.build.json" + - "sdk/tsconfig.json" + - "sdk/vite.config.ts" + - "scripts/prepare-frontend-deps.sh" + - "scripts/prepare-typescript-sdk.sh" + - "scripts/ci/_common.sh" + - "scripts/ci/frontend.sh" + - "scripts/ci/web.sh" + - "scripts/ci/auth-web.sh" + - "scripts/ci/pages_*.py" + - "scripts/ci/test_pages_*.py" + - "flake.nix" + - "flake.lock" + push: + branches: [master] + paths: + - ".github/workflows/auth-pages-*.yml" + - ".github/workflows/pages-tests.yml" + - "apps/maple-research/frontend/**" + - "!apps/maple-research/frontend/src-tauri/**" + - "sdk/src/**" + - "!sdk/src/lib/test/**" + - "sdk/bun.lock" + - "sdk/bunfig.toml" + - "sdk/package.json" + - "sdk/tsconfig.build.json" + - "sdk/tsconfig.json" + - "sdk/vite.config.ts" + - "scripts/prepare-frontend-deps.sh" + - "scripts/prepare-typescript-sdk.sh" + - "scripts/ci/_common.sh" + - "scripts/ci/frontend.sh" + - "scripts/ci/web.sh" + - "scripts/ci/auth-web.sh" + - "scripts/ci/pages_*.py" + - "scripts/ci/test_pages_*.py" + - "flake.nix" + - "flake.lock" + +jobs: + auth: + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'push' && github.ref == 'refs/heads/master') + runs-on: ubuntu-latest-8-cores + timeout-minutes: 30 + steps: + - name: Checkout auth source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Test Pages artifact and deployment boundaries + run: nix build --no-update-lock-file --no-link --print-build-logs .#checks.x86_64-linux.pages + + - name: Test shared frontend + run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/frontend.sh + + - name: Build app with development services + env: + MAPLE_WEB_ENVIRONMENT: pr + run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/web.sh + + - name: Build auth with development services + env: + MAPLE_AUTH_ENVIRONMENT: pr + run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh diff --git a/.github/workflows/auth-pages-publish.yml b/.github/workflows/auth-pages-publish.yml new file mode 100644 index 000000000..293311338 --- /dev/null +++ b/.github/workflows/auth-pages-publish.yml @@ -0,0 +1,69 @@ +name: Publish Auth Pages + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + build_run_id: + description: Successful Auth Pages build run ID + required: true + type: string + build_run_attempt: + description: Successful Auth Pages build run attempt + required: true + type: string + +jobs: + production: + name: Publish verified auth build + if: >- + vars.MAPLE_AUTH_PAGES_PRODUCTION_ENABLED == 'true' && + github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: pages-auth-production + cancel-in-progress: false + environment: + name: auth-pages-production + # Explicit artifact-SHA deployment status owns the production URL. + deployment: false + permissions: + contents: write + actions: read + deployments: write + steps: + - name: Checkout trusted publisher + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Install trusted deployment dependencies + working-directory: services/updates + run: nix develop --no-update-lock-file ../..#pages -c bun install --frozen-lockfile --ignore-scripts + + - name: Verify and prepare the auth artifact + env: + GH_TOKEN: ${{ github.token }} + MAPLE_AUTH_PAGES_PRODUCTION_ENABLED: ${{ vars.MAPLE_AUTH_PAGES_PRODUCTION_ENABLED }} + run: >- + nix develop --no-update-lock-file .#pages -c python3 -I scripts/ci/pages_auth_deploy.py + prepare --state "$RUNNER_TEMP/maple-auth-pages" + + - name: Deploy the verified auth artifact + env: + GH_TOKEN: ${{ github.token }} + MAPLE_AUTH_PAGES_PRODUCTION_ENABLED: ${{ vars.MAPLE_AUTH_PAGES_PRODUCTION_ENABLED }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: >- + nix develop --no-update-lock-file .#pages -c python3 -I scripts/ci/pages_auth_deploy.py + deploy --state "$RUNNER_TEMP/maple-auth-pages" diff --git a/.github/workflows/pages-tests.yml b/.github/workflows/pages-tests.yml index f8ff5a723..c0abf729a 100644 --- a/.github/workflows/pages-tests.yml +++ b/.github/workflows/pages-tests.yml @@ -7,10 +7,12 @@ on: pull_request: paths: - ".github/workflows/pages-*.yml" + - ".github/workflows/auth-pages-*.yml" - "scripts/ci/pages_*.py" - "scripts/ci/test_pages_*.py" - "scripts/ci/_common.sh" - "scripts/ci/web.sh" + - "scripts/ci/auth-web.sh" - "services/updates/package.json" - "services/updates/bun.lock" - "flake.nix" @@ -19,10 +21,12 @@ on: branches: [master] paths: - ".github/workflows/pages-*.yml" + - ".github/workflows/auth-pages-*.yml" - "scripts/ci/pages_*.py" - "scripts/ci/test_pages_*.py" - "scripts/ci/_common.sh" - "scripts/ci/web.sh" + - "scripts/ci/auth-web.sh" - "services/updates/package.json" - "services/updates/bun.lock" - "flake.nix" diff --git a/.gitignore b/.gitignore index 44cddd2a0..8f7b09260 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ apps/maple-research/frontend/node_modules/ # Build outputs /apps/maple-research/frontend/dist/ +/apps/maple-research/frontend/dist-auth/ /apps/maple-research/frontend/build/ # Environment variables diff --git a/apps/maple-research/docs/auth-site.md b/apps/maple-research/docs/auth-site.md new file mode 100644 index 000000000..96520900d --- /dev/null +++ b/apps/maple-research/docs/auth-site.md @@ -0,0 +1,71 @@ +# Hosted native sign-in + +The frontend builds two independent static sites. The existing `build` command +produces the Maple web app in `dist`. `build:auth` produces the hosted native +sign-in site in `dist-auth`, using `auth.html` and `src/auth-site/main.tsx`. +The auth entry does not load the web-app router, chat, billing, Agent Mode, or +the legacy V1 bridge. + +## Routes and compatibility + +- `/start` and the permanent `/desktop-auth` alias accept only `transport=v2`, + a supported `provider`, and the native session and request IDs created by + Maple. They do not accept an arbitrary return URL. +- `/auth/github/callback` and `/auth/google/callback` complete the pending + browser flow and show the existing account confirmation before minting the + native handoff grant. Callback errors keep the address intact for clients + that explicitly ask the user to paste it. +- Apple uses its popup API with the existing Services ID + `cloud.opensecret.maple.services`. It requires the auth domain and callback + to be registered with Apple before live use. A static site cannot process + Apple's form-post callback. +- `/complete` displays completion guidance. Other paths cannot start a login + or fall through to the web app. + +The SDK retains browser credentials on their current origin. Confirmation, +account ownership, pending-flow expiry, and native grant checks retain their +existing behavior. Completing or cancelling this flow does not sign the user +out of the web app or erase their browser credentials. + +Browser OAuth initiation explicitly selects a callback on the initiating +origin. The backend must allow that exact URL. The legacy V1 bridge remains +part of the web app and continues to use its default callback. + +`VITE_AUTH_ORIGIN` selects the origin for native browser entry, using +`/desktop-auth` on that origin. It accepts an HTTPS origin, or exact loopback +HTTP in development. Its default and the current PR/release build profiles +remain `https://trymaple.ai`; building this change does not switch installed +clients to the auth subdomain. + +## Build and local validation + +From the repository root, use the pinned toolchain: + +```sh +nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh +``` + +The default `pr` profile uses development services and ignores local dotenv +files. The script validates and archives the auth-only output. It does not +publish it or change OAuth settings. + +For a configured local development session, the frontend also exposes +`dev:auth` (loopback port 5174) and `preview:auth`. Preserve any externally +managed configuration and service ownership. Serve the built `dist-auth` +artifact when claiming artifact smoke evidence. + +The frontend tests cover route validation, callback selection, popup and +handoff behavior. Real provider sign-in, native application opening, and live +response headers need separate runtime rehearsal before traffic is redirected. + +## Independent publication + +See [Pages deployments](../../../docs/pages-deployments.md) for the separate +auth artifact and publisher. Auth publication has its own manual trigger, +activation flag, environment, project, and production ref. An app release +does not publish the auth site. Provider registration, backend callback +allowlists, and traffic redirection are separate rollout steps. + +An unpublished local SDK link is supported during stacked development, but +must be replaced with the reviewed published SDK version before merging this +consumer change. The production auth build rejects a local SDK link. diff --git a/apps/maple-research/frontend/auth-build-boundary.ts b/apps/maple-research/frontend/auth-build-boundary.ts new file mode 100644 index 000000000..c33537e8e --- /dev/null +++ b/apps/maple-research/frontend/auth-build-boundary.ts @@ -0,0 +1,30 @@ +import path from "path"; + +const SHARED_AUTH_MODULES = new Set([ + "components/HostedNativeSignInConfirmation.tsx", + "components/ui/button.tsx", + "config/openSecretClientConfig.ts", + "config/openSecretPcrEnvironment.ts", + "services/appleOAuth.ts", + "services/desktopOAuthTransport.ts", + "services/oauthConfig.ts", + "utils/utils.ts" +]); + +/** Fail the dedicated build if a shared import pulls in the application or V1 SDK. */ +export function assertAuthBundleIsolation(moduleIds: Iterable, sourceRoot: string): void { + const prefix = `${path.resolve(sourceRoot).replace(/\\/gu, "/")}/`; + for (const moduleId of moduleIds) { + const id = moduleId.replace(/\\/gu, "/").split("?")[0]; + if (id.includes("/@opensecret/") || id.includes("/@opensecret+")) { + throw new Error("The dedicated auth build must not include the legacy SDK"); + } + if (!id.startsWith(prefix)) continue; + const relative = id.slice(prefix.length); + if (!relative.startsWith("auth-site/") && !SHARED_AUTH_MODULES.has(relative)) { + throw new Error( + `The dedicated auth build imported an unapproved application module: ${relative}` + ); + } + } +} diff --git a/apps/maple-research/frontend/auth.html b/apps/maple-research/frontend/auth.html new file mode 100644 index 000000000..18576ee75 --- /dev/null +++ b/apps/maple-research/frontend/auth.html @@ -0,0 +1,14 @@ + + + + + + + + Sign in to Maple + + +
+ + + diff --git a/apps/maple-research/frontend/bun.lock b/apps/maple-research/frontend/bun.lock index 1badf54a5..8862c3a70 100644 --- a/apps/maple-research/frontend/bun.lock +++ b/apps/maple-research/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maple", "dependencies": { - "@mapleai/sdk": "4.0.1", + "@mapleai/sdk": "file:../../../sdk", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -235,7 +235,17 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@mapleai/sdk": ["@mapleai/sdk@4.0.1", "", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3JpFw72xGhGTHqcdU2re3NVUwvYvjPyNLd7pzUe/9v92rnxLm30HNyEPXbNKPyTlBm5BT4qr9scI0LjRQUirzg=="], + "@mapleai/sdk": ["@mapleai/sdk@file:../../../sdk", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "devDependencies": { "@eslint/js": "9.39.5", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", "vite-plugin-dts": "4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }], + + "@microsoft/api-extractor": ["@microsoft/api-extractor@7.59.1", "", { "dependencies": { "@microsoft/api-extractor-model": "7.33.12", "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.24.1", "@rushstack/rig-package": "0.7.3", "@rushstack/terminal": "0.24.4", "@rushstack/ts-command-line": "5.3.14", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-GjRUqx1MTY7xuH36urwASkfBPzrdxYG+irVeV7C9JEdRtn509AgMmwit/BhvztQzIoFk9vhFDTVYOp2cjw+9Uw=="], + + "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.33.12", "", { "dependencies": { "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.24.1" } }, "sha512-TdKOYgwf98xLjNW+y3iXIiCf4ZLQbilGAlqkMTTAqqAWfgRXAn9YCSGMsaiB1U7OPRvslUWg6n08EqQPbP93tA=="], + + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], + + "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.1", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -353,6 +363,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], @@ -403,6 +415,16 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@rushstack/node-core-library": ["@rushstack/node-core-library@5.24.1", "", { "dependencies": { "ajv": "~8.20.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.7.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ZlOrzv92MwnsCXA45qWfDj4L/kTasKghXezu+M2WmdtkbnXyPnZSCovfeBZx1Yc5qm+LkElbLw6IeSSWZDhZUg=="], + + "@rushstack/problem-matcher": ["@rushstack/problem-matcher@0.2.1", "", { "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog=="], + + "@rushstack/rig-package": ["@rushstack/rig-package@0.7.3", "", { "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" } }, "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA=="], + + "@rushstack/terminal": ["@rushstack/terminal@0.24.4", "", { "dependencies": { "@rushstack/node-core-library": "5.24.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-3fRBWK0IMY293lBx5ycgit1DTMUi+nhOjALHlrIad9hQsqzM9Ak+XdBI1gJ/tZPxW+LraeAc4SsmMdcOflBmAQ=="], + + "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.14", "", { "dependencies": { "@rushstack/terminal": "0.24.4", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-lT2JKZk2dukBMp4GFOh4RaDfVzpZehGgQOGpzpSliUn317NgEmOOCXyd7/d0eU46HHsbRxizP83GAm39s0lAlg=="], + "@stablelib/aead": ["@stablelib/aead@2.0.0", "", {}, "sha512-U/RMANRxbT/ahIpYsPSiFwDFNjADHdnCFfmo09MO1ai2XmerPAOPtMl0qmX7XVvygnACC6ijKDyHBoT2rGyElg=="], "@stablelib/base64": ["@stablelib/base64@2.0.1", "", {}, "sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ=="], @@ -479,6 +501,8 @@ "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], @@ -519,6 +543,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], @@ -543,12 +569,34 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="], + + "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="], + + "@vue/language-core": ["@vue/language-core@2.2.0", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^0.4.9", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw=="], + + "@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "alien-signals": ["alien-signals@0.4.14", "", {}, "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q=="], + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -621,6 +669,10 @@ "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], + + "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], @@ -631,6 +683,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], @@ -683,8 +737,12 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -695,6 +753,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -713,6 +773,8 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -727,6 +789,8 @@ "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -753,6 +817,8 @@ "hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -761,6 +827,8 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], @@ -795,6 +863,8 @@ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -809,16 +879,22 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -937,8 +1013,12 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], @@ -969,6 +1049,8 @@ "parse5": ["parse5@7.2.1", "", { "dependencies": { "entities": "^4.5.0" } }, "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -987,6 +1069,8 @@ "pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="], + "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -1013,6 +1097,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -1061,6 +1147,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1085,10 +1173,16 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1143,6 +1237,8 @@ "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1161,6 +1257,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], @@ -1185,6 +1283,10 @@ "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + "vite-plugin-dts": ["vite-plugin-dts@4.5.4", "", { "dependencies": { "@microsoft/api-extractor": "^7.50.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg=="], + + "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -1225,10 +1327,30 @@ "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@mapleai/sdk/@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], + + "@mapleai/sdk/@types/bun": ["@types/bun@1.1.13", "", { "dependencies": { "bun-types": "1.1.34" } }, "sha512-KmQxSBgVWCl6RSuerlLGZlIWfdxkKqat0nxN61+qu4y1KDn0Ll3j7v1Pl8GnaL3a/U6GGWVTJh75ap62kR1E8Q=="], + + "@mapleai/sdk/@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + + "@mapleai/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "@mapleai/sdk/eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], + "@mapleai/sdk/openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], + "@mapleai/sdk/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + + "@mapleai/sdk/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "@mapleai/sdk/typescript-eslint": ["typescript-eslint@8.66.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.66.0", "@typescript-eslint/parser": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw=="], + "@mapleai/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@microsoft/api-extractor/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@opensecret/react-v1/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1249,6 +1371,14 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rushstack/node-core-library/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "@rushstack/node-core-library/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], @@ -1281,6 +1411,10 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "babel-dead-code-elimination/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -1297,6 +1431,8 @@ "micromark-extension-math/katex": ["katex@0.16.21", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "openai/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -1317,6 +1453,8 @@ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "vite-plugin-dts/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1327,6 +1465,30 @@ "@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@mapleai/sdk/@types/bun/bun-types": ["bun-types@1.1.34", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-br5QygTEL/TwB4uQOb96Ky22j4Gq2WxWH/8Oqv20fk5HagwKXo/akB+LiYgSfzexCt6kkcUaVm+bKiPl71xPvw=="], + + "@mapleai/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@mapleai/sdk/eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@mapleai/sdk/eslint/@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="], + + "@mapleai/sdk/eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "@mapleai/sdk/eslint/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.66.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/type-utils": "8.66.0", "@typescript-eslint/utils": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.66.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.66.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.66.0", "@typescript-eslint/tsconfig-utils": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.66.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA=="], + + "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], @@ -1343,6 +1505,8 @@ "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "babel-dead-code-elimination/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "babel-dead-code-elimination/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], @@ -1355,6 +1519,8 @@ "babel-dead-code-elimination/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1365,10 +1531,68 @@ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@mapleai/sdk/@types/bun/bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], + + "@mapleai/sdk/eslint/@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.66.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.66.0", "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.66.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + "babel-dead-code-elimination/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "babel-dead-code-elimination/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@mapleai/sdk/@types/bun/bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], + "babel-dead-code-elimination/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/apps/maple-research/frontend/package.json b/apps/maple-research/frontend/package.json index 43fae6e35..d5eb92043 100644 --- a/apps/maple-research/frontend/package.json +++ b/apps/maple-research/frontend/package.json @@ -24,7 +24,12 @@ "preformat": "bash ../../../scripts/prepare-frontend-deps.sh", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "preformat:check": "bash ../../../scripts/prepare-frontend-deps.sh", - "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "predev:auth": "bash ../../../scripts/prepare-frontend-deps.sh", + "dev:auth": "vite --config vite.auth.config.ts", + "prebuild:auth": "bash ../../../scripts/prepare-frontend-deps.sh", + "build:auth": "tsc -b && vite build --config vite.auth.config.ts", + "preview:auth": "vite preview --config vite.auth.config.ts" }, "resolutions": { "@babel/core": "^7.29.7", @@ -47,7 +52,7 @@ "yaml": "^2.8.3" }, "dependencies": { - "@mapleai/sdk": "4.0.1", + "@mapleai/sdk": "file:../../../sdk", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/apps/maple-research/frontend/public-auth/fonts/Manrope-VariableFont_wght.ttf b/apps/maple-research/frontend/public-auth/fonts/Manrope-VariableFont_wght.ttf new file mode 100644 index 0000000000000000000000000000000000000000..765c1b1f36b995d24df4364e73b2e5531a9f4e34 GIT binary patch literal 167160 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 HztsFM+_R~l literal 0 HcmV?d00001 diff --git a/apps/maple-research/frontend/public-auth/maple-logo-dark.svg b/apps/maple-research/frontend/public-auth/maple-logo-dark.svg new file mode 100644 index 000000000..bb539706d --- /dev/null +++ b/apps/maple-research/frontend/public-auth/maple-logo-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/maple-research/frontend/src/auth-site/AuthSite.tsx b/apps/maple-research/frontend/src/auth-site/AuthSite.tsx new file mode 100644 index 000000000..8e65156a8 --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/AuthSite.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { useOpenSecret } from "@mapleai/sdk"; +import { HostedStart } from "./HostedStart"; +import { HostedCallback } from "./HostedCallback"; +import { ApplePopupRecovery, CallbackRecovery } from "./CallbackRecovery"; +import { isAuthCallbackPath, parseAuthSiteRoute } from "./route"; + +export function AuthSite() { + const { auth } = useOpenSecret(); + const [route] = useState(() => parseAuthSiteRoute(window.location)); + const pendingBootstrap = auth.loading && (route.kind === "start" || route.kind === "callback"); + return ( +
+
+ Maple +

+ {route.kind === "invalid" ? "Sign-in unavailable" : "Sign in to Maple"} +

+ {pendingBootstrap &&

Preparing sign-in…

} + {!pendingBootstrap && route.kind === "start" && } + {!pendingBootstrap && route.kind === "callback" && } + {route.kind === "complete" && ( +

You can close this page and return to Maple.

+ )} + {route.kind === "invalid" && ( +
+

+ This page cannot start a sign-in. Open Maple and start sign-in there. +

+ {window.location.pathname === "/auth/apple/callback" ? ( + + ) : ( + isAuthCallbackPath(window.location.pathname) && + )} +
+ )} +
+
+ ); +} diff --git a/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx b/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx new file mode 100644 index 000000000..c73af916a --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function ApplePopupRecovery() { + return ( +

+ Apple sign-in must finish in its popup. Return to the Maple sign-in tab and try again, + allowing popups when your browser asks. +

+ ); +} + +export function CallbackRecovery() { + const [copyStatus, setCopyStatus] = useState(null); + + const copyAddress = async () => { + try { + // Keep this user initiated: the address contains a one-time authorization code. + await navigator.clipboard.writeText(window.location.href); + setCopyStatus("Address copied. Paste it only into the Maple sign-in you started."); + } catch { + setCopyStatus("Copy the full address from your browser's address bar instead."); + } + }; + + return ( +
+

+ If Maple Agent asked you to paste a callback URL, copy this page's full address and paste it + into that sign-in window. Otherwise, start a new sign-in in Maple. +

+

Only paste this address into the Maple sign-in you started. Do not share it.

+ + {copyStatus &&

{copyStatus}

} +
+ ); +} diff --git a/apps/maple-research/frontend/src/auth-site/HostedAppleSignIn.tsx b/apps/maple-research/frontend/src/auth-site/HostedAppleSignIn.tsx new file mode 100644 index 000000000..fbd42e68f --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/HostedAppleSignIn.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "react"; +import { useOpenSecret } from "@mapleai/sdk"; +import { Button } from "@/components/ui/button"; +import { HostedNativeSignInConfirmation } from "@/components/HostedNativeSignInConfirmation"; +import { + getAppleAuthorizationNonce, + getAppleAuthError, + isAppleAuthCancellation +} from "@/services/appleOAuth"; +import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; +import { + isCurrentDesktopOAuthTarget, + type TransportV2DesktopOAuthState +} from "@/services/desktopOAuthTransport"; + +function loadApplePopupSdk(): Promise { + if (window.AppleID) return Promise.resolve(); + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = + "https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"; + script.async = true; + script.onload = () => { + if (window.AppleID) resolve(); + else reject(new Error("Apple sign-in did not load")); + }; + script.onerror = () => { + script.remove(); + reject(new Error("Apple sign-in did not load")); + }; + document.head.appendChild(script); + }); +} + +export function HostedAppleSignIn({ target }: { target: TransportV2DesktopOAuthState }) { + const os = useOpenSecret(); + const currentOs = useRef(os); + currentOs.current = os; + const active = useRef(true); + const preparing = useRef(false); + const expectedState = useRef(null); + const submitted = useRef(false); + const [status, setStatus] = useState<"loading" | "ready" | "working" | "retry" | "confirm">( + "loading" + ); + const [message, setMessage] = useState(null); + + const prepare = async () => { + if (preparing.current || !active.current) return; + preparing.current = true; + submitted.current = false; + expectedState.current = null; + setStatus("loading"); + try { + if (!isCurrentDesktopOAuthTarget(target)) throw new Error("Sign-in expired"); + const redirectURI = getBrowserOAuthCallbackUrl("apple", window.location.origin); + const [response] = await Promise.all([ + currentOs.current.initiateAppleAuth("", redirectURI), + loadApplePopupSdk() + ]); + if (!active.current) return; + if (!isCurrentDesktopOAuthTarget(target)) throw new Error("Sign-in changed"); + const apple = window.AppleID; + if (!apple) throw new Error("Apple sign-in did not load"); + apple.auth.init({ + clientId: "cloud.opensecret.maple.services", + scope: "name email", + redirectURI, + state: response.state, + nonce: getAppleAuthorizationNonce(response.auth_url), + usePopup: true + }); + expectedState.current = response.state; + setStatus("ready"); + } catch { + if (active.current) { + setMessage("Apple sign-in could not start. Try again, or start a new sign-in in Maple."); + setStatus("retry"); + } + } finally { + preparing.current = false; + } + }; + + useEffect(() => { + active.current = true; + void prepare(); + return () => { + active.current = false; + }; + // One preparation per mounted native attempt; SDK context updates must not restart it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target]); + + const signIn = () => { + if (submitted.current || status !== "ready" || !expectedState.current) return; + submitted.current = true; + setMessage(null); + setStatus("working"); + const state = expectedState.current; + const failed = (value: unknown) => { + if (!active.current) return; + const error = getAppleAuthError(value); + setMessage( + isAppleAuthCancellation(error) + ? "Apple sign-in was cancelled. You can try again." + : "Apple sign-in could not finish. Allow popups for this site, then try again." + ); + expectedState.current = null; + setStatus("retry"); + }; + try { + if (!isCurrentDesktopOAuthTarget(target)) throw new Error("Sign-in changed"); + // No await before this call: Apple's popup must open in the user's click gesture. + const apple = window.AppleID; + if (!apple) throw new Error("Apple sign-in did not load"); + const authorization = apple.auth.signIn(); + void authorization + .then(async ({ authorization: result }) => { + if (!active.current) return; + if (!isCurrentDesktopOAuthTarget(target) || result.state !== state || !result.code) { + throw new Error("Sign-in changed"); + } + await currentOs.current.handleAppleCallback(result.code, result.state, ""); + if (!active.current) return; + if (!isCurrentDesktopOAuthTarget(target)) throw new Error("Sign-in changed"); + setStatus("confirm"); + }) + .catch(failed); + } catch (error) { + failed(error); + } + }; + + if (status === "confirm") return ; + return ( +
+

Sign in with Apple to continue to Maple.

+ {message &&

{message}

} + {status === "retry" ? ( + + ) : ( + + )} +

+ Apple opens in a popup. If you close it or your browser blocks it, you can try again here. +

+
+ ); +} diff --git a/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx b/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx new file mode 100644 index 000000000..dcd45bd05 --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from "react"; +import { useOpenSecret } from "@mapleai/sdk"; +import { HostedNativeSignInConfirmation } from "@/components/HostedNativeSignInConfirmation"; +import { + isCurrentDesktopOAuthTarget, + isNativeOAuthRedirect, + readTransportV2DesktopOAuth +} from "@/services/desktopOAuthTransport"; +import type { AuthSiteRoute } from "./route"; +import { ApplePopupRecovery, CallbackRecovery } from "./CallbackRecovery"; + +export function HostedCallback({ route }: { route: Extract }) { + const os = useOpenSecret(); + const active = useRef(true); + const processed = useRef(false); + const [target] = useState(() => + isNativeOAuthRedirect() ? readTransportV2DesktopOAuth(route.provider) : null + ); + const [status, setStatus] = useState<"processing" | "confirm" | "failed">("processing"); + + useEffect(() => { + active.current = true; + if (!processed.current) { + processed.current = true; + void (async () => { + // Browser Apple sign-in completes only in HostedAppleSignIn's popup owner. + // This registered callback path is a passive recovery page, not a redirect fallback. + if (route.provider === "apple") { + setStatus("failed"); + return; + } + // GPUI's paste flow does not own a browser continuation. Leave its URL untouched. + if (!target || !isCurrentDesktopOAuthTarget(target)) { + setStatus("failed"); + return; + } + try { + const callback = { + github: os.handleGitHubCallback, + google: os.handleGoogleCallback + }[route.provider]; + await callback(route.code, route.state, ""); + if (active.current) { + setStatus(isCurrentDesktopOAuthTarget(target) ? "confirm" : "failed"); + } + } catch { + // Do not clear the address or another attempt's pending state on a stale callback. + // Browser credentials retain the SDK's existing persistence behavior. + if (active.current) setStatus("failed"); + } + })(); + } + return () => { + active.current = false; + }; + }, [os.handleGitHubCallback, os.handleGoogleCallback, route, target]); + + if (status === "confirm" && target) return ; + if (status === "failed") { + return ( +
+ {route.provider === "apple" ? ( + + ) : ( + <> +

This browser sign-in could not be completed.

+ + + )} +
+ ); + } + return

Completing sign-in…

; +} diff --git a/apps/maple-research/frontend/src/auth-site/HostedStart.tsx b/apps/maple-research/frontend/src/auth-site/HostedStart.tsx new file mode 100644 index 000000000..b26915dbc --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/HostedStart.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; +import { useOpenSecret } from "@mapleai/sdk"; +import { + claimTransportV2DesktopOAuthInitiation, + isCurrentDesktopOAuthTarget, + markTransportV2DesktopOAuth, + readTransportV2DesktopOAuth, + type TransportV2DesktopOAuthState +} from "@/services/desktopOAuthTransport"; +import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; +import type { AuthSiteRoute } from "./route"; +import { HostedAppleSignIn } from "./HostedAppleSignIn"; + +export function HostedStart({ route }: { route: Extract }) { + const os = useOpenSecret(); + const active = useRef(true); + const started = useRef(false); + const [target, setTarget] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + active.current = true; + if (!started.current) { + started.current = true; + void (async () => { + try { + markTransportV2DesktopOAuth(route); + const pending = readTransportV2DesktopOAuth(route.provider); + if (!pending) throw new Error("Native sign-in is unavailable"); + setTarget(pending); + if (route.provider === "apple") return; + if (!claimTransportV2DesktopOAuthInitiation(route)) { + setFailed(true); + return; + } + const initiate = + route.provider === "google" ? os.initiateGoogleAuth : os.initiateGitHubAuth; + const response = await initiate( + "", + getBrowserOAuthCallbackUrl(route.provider, window.location.origin) + ); + if (!active.current) return; + if (!isCurrentDesktopOAuthTarget(pending)) { + setFailed(true); + return; + } + window.location.href = response.auth_url; + } catch { + if (active.current) setFailed(true); + } + })(); + } + return () => { + active.current = false; + }; + }, [os.initiateGitHubAuth, os.initiateGoogleAuth, route]); + + if (failed) { + return ( +

This sign-in changed or could not start. Start a new sign-in in Maple.

+ ); + } + if (route.provider === "apple" && target) return ; + const providerName = { github: "GitHub", google: "Google", apple: "Apple" }[route.provider]; + return

Opening {providerName} sign-in…

; +} diff --git a/apps/maple-research/frontend/src/auth-site/bootstrap.test.ts b/apps/maple-research/frontend/src/auth-site/bootstrap.test.ts new file mode 100644 index 000000000..76c97de4c --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/bootstrap.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const frontendDirectory = fileURLToPath(new URL("../..", import.meta.url)); + +describe("hosted auth with the real SDK provider", () => { + for (const scenario of ["cold-start", "cold-callback", "retained-start", "retained-callback"]) { + test(`${scenario} waits for provider bootstrap`, () => { + // Separate processes keep the real SDK's initial module-level API URL empty, + // regardless of context mocks or SDK initialization in other test files. + const result = Bun.spawnSync( + [ + process.execPath, + "--no-env-file", + "--preload", + "./src/lib/test/preload.ts", + "--preload", + "./src/lib/test/der-loader.ts", + "./src/auth-site/fixtures/bootstrap.tsx", + scenario + ], + { + cwd: frontendDirectory, + env: { PATH: process.env.PATH, LANG: "C.UTF-8" }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000 + } + ); + expect(new TextDecoder().decode(result.stderr)).toBe(""); + expect(result.exitCode).toBe(0); + expect(new TextDecoder().decode(result.stdout).trim()).toBe("bootstrap verified"); + }, 15_000); + } +}); diff --git a/apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts b/apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts new file mode 100644 index 000000000..698746a2d --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { assertAuthBundleIsolation } from "../../auth-build-boundary"; + +const root = "/fixture/frontend/src"; + +describe("dedicated auth bundle boundary", () => { + test("accepts the dedicated entry and explicitly shared auth dependencies", () => { + expect(() => + assertAuthBundleIsolation( + [ + `${root}/auth-site/main.tsx`, + `${root}/components/HostedNativeSignInConfirmation.tsx`, + `${root}/services/desktopOAuthTransport.ts`, + "/fixture/node_modules/@mapleai/sdk/dist/index.js" + ], + root + ) + ).not.toThrow(); + }); + + test("rejects full app, legacy, chat, billing, and agent imports", () => { + for (const module of [ + "App.tsx", + "main.tsx", + "routeTree.gen.ts", + "routes/auth.$provider.callback.tsx", + "legacy/LegacyDesktopOAuthApp.tsx", + "components/AppleAuthProvider.tsx", + "billing/billingService.ts", + "services/agentService.ts", + "components/Chat.tsx" + ]) { + expect(() => assertAuthBundleIsolation([`${root}/${module}`], root)).toThrow(); + } + expect(() => + assertAuthBundleIsolation(["/fixture/node_modules/@opensecret/react-v1/dist/index.js"], root) + ).toThrow(); + }); +}); diff --git a/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx b/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx new file mode 100644 index 000000000..06854018f --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx @@ -0,0 +1,373 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { StrictMode, type Provider } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { OpenSecretContext, type OpenSecretContextType } from "@mapleai/sdk"; +import { HostedNativeSignInConfirmation } from "@/components/HostedNativeSignInConfirmation"; +import { + markTransportV2DesktopOAuth, + readTransportV2DesktopOAuth, + type DesktopOAuthProvider +} from "@/services/desktopOAuthTransport"; +import { AuthSite } from "../AuthSite"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const nativeSessionId = "00112233445566778899aabbccddeeff"; +const nativeRequestId = "ffeeddccbbaa99887766554433221100"; +const authOrigin = "https://auth.example.com"; +const providerUrl = "https://provider.example.com/authorize?state=fixture-provider-state"; +const originalGlobals = { + window: Object.getOwnPropertyDescriptor(globalThis, "window"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), + localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), + sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage") +}; + +type SdkContext = OpenSecretContextType; +// The linked SDK's development declarations use React 19; this app uses React 18. +// Runtime React is deduplicated by the test preload, as it is by Vite in production. +const SdkProvider = OpenSecretContext.Provider as unknown as Provider; + +function setGlobal(name: string, value: unknown): void { + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); +} + +function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); +} + +function startUrl(provider: DesktopOAuthProvider): string { + const params = new URLSearchParams({ + transport: "v2", + provider, + native_session_id: nativeSessionId, + native_request_id: nativeRequestId + }); + return `${authOrigin}/start?${params.toString()}`; +} + +function callbackUrl(provider: DesktopOAuthProvider = "github"): string { + return `${authOrigin}/auth/${provider}/callback?code=fixture-code&state=opaque-fixture-state#retained-fragment`; +} + +describe("hosted authentication entry", () => { + let renderer: ReactTestRenderer | null; + let sdk: SdkContext; + let initiateGitHubAuth: ReturnType; + let initiateGoogleAuth: ReturnType; + let initiateAppleAuth: ReturnType; + let handleGitHubCallback: ReturnType; + let handleGoogleCallback: ReturnType; + let handleAppleCallback: ReturnType; + let mintNativeHandoffGrant: ReturnType; + + beforeEach(() => { + renderer = null; + const localStorage = new MemoryStorage(); + const sessionStorage = new MemoryStorage(); + setGlobal("localStorage", localStorage); + setGlobal("sessionStorage", sessionStorage); + setGlobal("window", { localStorage, sessionStorage, location: new URL(authOrigin) }); + initiateGitHubAuth = mock(async () => ({ auth_url: providerUrl, state: "fixture-state" })); + initiateGoogleAuth = mock(async () => ({ auth_url: providerUrl, state: "fixture-state" })); + initiateAppleAuth = mock(async () => ({ auth_url: providerUrl, state: "fixture-state" })); + handleGitHubCallback = mock(async () => {}); + handleGoogleCallback = mock(async () => {}); + handleAppleCallback = mock(async () => {}); + mintNativeHandoffGrant = mock(async () => ({ grant: "aaa.bbb.ccc", expires_at: 42 })); + sdk = { + apiUrl: "https://api.example.com", + auth: { loading: false }, + initiateGitHubAuth, + initiateGoogleAuth, + initiateAppleAuth, + handleGitHubCallback, + handleGoogleCallback, + handleAppleCallback, + mintNativeHandoffGrant + } as unknown as SdkContext; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + restoreGlobal("window", originalGlobals.window); + restoreGlobal("navigator", originalGlobals.navigator); + restoreGlobal("localStorage", originalGlobals.localStorage); + restoreGlobal("sessionStorage", originalGlobals.sessionStorage); + }); + + async function renderAt(url: string): Promise { + Object.defineProperty(window, "location", { + configurable: true, + value: new URL(url), + writable: true + }); + await act(async () => { + renderer = create( + + + + + + ); + }); + } + + function expectNoSdkCalls(): void { + for (const method of [ + initiateGitHubAuth, + initiateGoogleAuth, + initiateAppleAuth, + handleGitHubCallback, + handleGoogleCallback, + handleAppleCallback, + mintNativeHandoffGrant + ]) { + expect(method).not.toHaveBeenCalled(); + } + } + + function expectFailureWithoutNavigation(originalUrl: string): void { + expect(window.location.href).toBe(originalUrl); + expect(renderer!.root.findAllByProps({ role: "alert" }).length).toBeGreaterThan(0); + expect(renderer!.root.findAllByType(HostedNativeSignInConfirmation)).toHaveLength(0); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + } + + for (const path of [ + "/desktop-auth?provider=github", + "/desktop-auth?transport=v1&provider=google", + "/start?transport=v2&provider=github", + "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/login", + "/pricing", + "/auth/github/callback?code=fixture-code", + "/auth/google/callback?error=access_denied&state=fixture-state" + ]) { + test(`rejects ${path} without invoking the SDK or navigating`, async () => { + const originalUrl = `${authOrigin}${path}`; + await renderAt(originalUrl); + expectNoSdkCalls(); + expectFailureWithoutNavigation(originalUrl); + }); + } + + test("leaves a complete page inert", async () => { + const originalUrl = `${authOrigin}/complete`; + await renderAt(originalUrl); + expectNoSdkCalls(); + expect(window.location.href).toBe(originalUrl); + expect(renderer!.root.findByProps({ role: "status" }).children.join("")).toContain( + "return to Maple" + ); + }); + + for (const provider of ["github", "google"] as const) { + test(`initiates ${provider} once with its same-origin callback and only opens the provider`, async () => { + await renderAt(startUrl(provider)); + const initiate = provider === "github" ? initiateGitHubAuth : initiateGoogleAuth; + const otherInitiate = provider === "github" ? initiateGoogleAuth : initiateGitHubAuth; + expect(initiate).toHaveBeenCalledTimes(1); + expect(initiate).toHaveBeenCalledWith("", `${authOrigin}/auth/${provider}/callback`); + expect(otherInitiate).not.toHaveBeenCalled(); + expect(initiateAppleAuth).not.toHaveBeenCalled(); + expect(window.location.href).toBe(providerUrl); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + expect(readTransportV2DesktopOAuth(provider)).toMatchObject({ + provider, + nativeSessionId, + nativeRequestId + }); + }); + } + + test("does not initiate the same native attempt twice across a remount", async () => { + const pending = deferred<{ auth_url: string; state: string }>(); + initiateGitHubAuth.mockImplementation(() => pending.promise); + const originalUrl = startUrl("github"); + await renderAt(originalUrl); + await act(async () => renderer?.unmount()); + renderer = null; + await renderAt(originalUrl); + expect(initiateGitHubAuth).toHaveBeenCalledTimes(1); + + await act(async () => pending.resolve({ auth_url: providerUrl, state: "fixture-state" })); + expectFailureWithoutNavigation(originalUrl); + }); + + test("does not navigate when the native target changes during initiation", async () => { + const pending = deferred<{ auth_url: string; state: string }>(); + initiateGoogleAuth.mockImplementation(() => pending.promise); + const originalUrl = startUrl("google"); + await renderAt(originalUrl); + markTransportV2DesktopOAuth({ + provider: "google", + nativeSessionId, + nativeRequestId: "11112222333344445555666677778888" + }); + const replacement = readTransportV2DesktopOAuth("google"); + await act(async () => pending.resolve({ auth_url: providerUrl, state: "fixture-state" })); + expectFailureWithoutNavigation(originalUrl); + expect(readTransportV2DesktopOAuth("google")).toEqual(replacement); + }); + + test("does not redeem a callback without a same-tab native target", async () => { + const originalUrl = callbackUrl(); + await renderAt(originalUrl); + expectNoSdkCalls(); + expectFailureWithoutNavigation(originalUrl); + }); + + test("copies the full callback address only after the user requests it", async () => { + const writeText = mock(async () => {}); + setGlobal("navigator", { clipboard: { writeText } }); + const originalUrl = callbackUrl(); + await renderAt(originalUrl); + expect(writeText).not.toHaveBeenCalled(); + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0); + + await act(async () => renderer!.root.findByType("button").props.onClick()); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(originalUrl); + expect(renderer!.root.findByProps({ role: "status" }).children.join("")).toBe( + "Address copied. Paste it only into the Maple sign-in you started." + ); + expectNoSdkCalls(); + expect(window.location.href).toBe(originalUrl); + }); + + test("preserves the callback address and offers manual copying when clipboard access fails", async () => { + const writeText = mock(async () => { + throw new Error("Fixture clipboard permission denial"); + }); + setGlobal("navigator", { clipboard: { writeText } }); + const originalUrl = callbackUrl("google"); + await renderAt(originalUrl); + expect(writeText).not.toHaveBeenCalled(); + + await act(async () => renderer!.root.findByType("button").props.onClick()); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith(originalUrl); + expect(renderer!.root.findByProps({ role: "status" }).children.join("")).toBe( + "Copy the full address from your browser's address bar instead." + ); + expectNoSdkCalls(); + expect(window.location.href).toBe(originalUrl); + }); + + test("does not consume another provider's pending target", async () => { + markTransportV2DesktopOAuth({ provider: "apple", nativeSessionId, nativeRequestId }); + const pending = readTransportV2DesktopOAuth("apple"); + const originalUrl = callbackUrl("google"); + await renderAt(originalUrl); + expectNoSdkCalls(); + expectFailureWithoutNavigation(originalUrl); + expect(readTransportV2DesktopOAuth("apple")).toEqual(pending); + }); + + for (const provider of ["github", "google"] as const) { + test(`requires native confirmation after a successful ${provider} callback`, async () => { + markTransportV2DesktopOAuth({ provider, nativeSessionId, nativeRequestId }); + const target = readTransportV2DesktopOAuth(provider); + const originalUrl = callbackUrl(provider); + await renderAt(originalUrl); + const callback = { + github: handleGitHubCallback, + google: handleGoogleCallback + }[provider]; + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith("fixture-code", "opaque-fixture-state", ""); + expect(renderer!.root.findByType(HostedNativeSignInConfirmation).props.target).toEqual( + target + ); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + expect(window.location.href).toBe(originalUrl); + }); + } + + for (const url of [callbackUrl("apple"), `${authOrigin}/auth/apple/callback`]) { + test(`keeps the Apple callback passive with popup retry guidance: ${new URL(url).search || "no query"}`, async () => { + markTransportV2DesktopOAuth({ provider: "apple", nativeSessionId, nativeRequestId }); + const target = readTransportV2DesktopOAuth("apple"); + await renderAt(url); + expectNoSdkCalls(); + expectFailureWithoutNavigation(url); + expect(JSON.stringify(renderer!.toJSON())).toContain( + "Apple sign-in must finish in its popup" + ); + expect(readTransportV2DesktopOAuth("apple")).toEqual(target); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + }); + } + + test("preserves the full callback address on an SDK error", async () => { + markTransportV2DesktopOAuth({ provider: "github", nativeSessionId, nativeRequestId }); + handleGitHubCallback.mockImplementation(async () => { + throw new Error("Fixture callback rejection"); + }); + const originalUrl = callbackUrl(); + await renderAt(originalUrl); + expect(handleGitHubCallback).toHaveBeenCalledTimes(1); + expectFailureWithoutNavigation(originalUrl); + }); + + for (const outcome of ["resolve", "reject"] as const) { + test(`preserves a newer pending flow when an old callback ${outcome}s`, async () => { + const pending = deferred(); + handleGitHubCallback.mockImplementation(() => pending.promise); + markTransportV2DesktopOAuth({ provider: "github", nativeSessionId, nativeRequestId }); + const originalUrl = callbackUrl(); + await renderAt(originalUrl); + markTransportV2DesktopOAuth({ + provider: "github", + nativeSessionId, + nativeRequestId: "11112222333344445555666677778888" + }); + const replacement = readTransportV2DesktopOAuth("github"); + await act(async () => { + if (outcome === "resolve") pending.resolve(); + else pending.reject(new Error("Fixture stale callback rejection")); + }); + expectFailureWithoutNavigation(originalUrl); + expect(readTransportV2DesktopOAuth("github")).toEqual(replacement); + }); + } +}); diff --git a/apps/maple-research/frontend/src/auth-site/fixtures/HostedAppleSignIn.case.tsx b/apps/maple-research/frontend/src/auth-site/fixtures/HostedAppleSignIn.case.tsx new file mode 100644 index 000000000..a09e9938d --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/fixtures/HostedAppleSignIn.case.tsx @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { Provider } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { + OpenSecretContext, + installNativeOAuthHandoffCredentials, + prepareNativeOAuthHandoff, + readNativeUserAuth, + type OpenSecretContextType +} from "@mapleai/sdk"; +import { HostedNativeSignInConfirmation } from "@/components/HostedNativeSignInConfirmation"; +import { + clearDesktopOAuthTarget, + markTransportV2DesktopOAuth, + readTransportV2DesktopOAuth, + TRANSPORT_V2_PENDING_TTL_MS, + type TransportV2DesktopOAuthState +} from "@/services/desktopOAuthTransport"; +import { HostedAppleSignIn } from "../HostedAppleSignIn"; + +// Bridge the linked SDK's React 19 development declarations to this React 18 consumer. +// Both use the real, deduplicated React implementation in these tests. +const SdkProvider = OpenSecretContext.Provider as unknown as Provider; + +class MemoryStorage implements Storage { + values = new Map(); + get length() { + return this.values.size; + } + clear() { + this.values.clear(); + } + key(index: number) { + return [...this.values.keys()][index] ?? null; + } + getItem(key: string) { + return this.values.get(key) ?? null; + } + setItem(key: string, value: string) { + this.values.set(key, value); + } + removeItem(key: string) { + this.values.delete(key); + } +} + +const originals = Object.fromEntries( + ["window", "localStorage", "sessionStorage"].map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key) + ]) +); +const nativeTarget = { + provider: "apple" as const, + nativeSessionId: "11".repeat(16), + nativeRequestId: "22".repeat(16) +}; +const fixtureUser = { id: "fixture-user", email: "fixture@example.test" }; +let sequence = 0; + +function credentials() { + const token = (purpose: "access" | "refresh") => + [ + Buffer.from(JSON.stringify({ alg: "ES256K", typ: "JWT" })).toString("base64url"), + Buffer.from( + JSON.stringify({ + aud: `urn:opensecret:internal:transport-v2:user:${purpose}-token`, + sub: fixtureUser.id, + exp: 2_000_000_000, + tf: 2 + }) + ).toString("base64url"), + Buffer.from(new Uint8Array(64).fill(0x5a)).toString("base64url") + ].join("."); + return { accessToken: token("access"), refreshToken: token("refresh") }; +} + +describe("hosted Apple popup and shared native confirmation", () => { + let renderer: ReactTestRenderer | null; + let client: OpenSecretContextType; + let local: MemoryStorage; + let target: TransportV2DesktopOAuthState; + let popupResolve: (value: { authorization: { code: string; state: string } }) => void; + let popupReject: (error: unknown) => void; + let initiate: ReturnType; + let callback: ReturnType; + let mint: ReturnType; + let init: ReturnType; + let signIn: ReturnType; + let stateNumber: number; + + beforeEach(() => { + renderer = null; + stateNumber = 0; + local = new MemoryStorage(); + const session = new MemoryStorage(); + init = mock(() => {}); + signIn = mock( + () => + new Promise<{ authorization: { code: string; state: string } }>((resolve, reject) => { + popupResolve = resolve; + popupReject = reject; + }) + ); + const windowValue = { + localStorage: local, + sessionStorage: session, + location: { origin: "https://auth.example.test", href: "https://auth.example.test/start" }, + AppleID: { auth: { init, signIn } } + }; + for (const [key, value] of Object.entries({ + window: windowValue, + localStorage: local, + sessionStorage: session + })) + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + initiate = mock(async () => ({ + state: `fixture-state-${++stateNumber}`, + auth_url: `https://appleid.apple.com/auth/authorize?nonce=${"aa".repeat(32)}` + })); + callback = mock(async () => {}); + mint = mock(async () => ({ grant: "aaa.bbb.ccc", expires_at: 42 })); + client = { + apiUrl: `https://apple-fixture-${++sequence}.example.test`, + auth: { loading: false, user: { user: fixtureUser } }, + initiateAppleAuth: initiate, + handleAppleCallback: callback, + mintNativeHandoffGrant: mint + } as unknown as OpenSecretContextType; + markTransportV2DesktopOAuth(nativeTarget); + target = readTransportV2DesktopOAuth("apple")!; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + for (const [key, descriptor] of Object.entries(originals)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + const installCredentials = () => { + const prepared = prepareNativeOAuthHandoff(client.apiUrl); + installNativeOAuthHandoffCredentials( + client.apiUrl, + credentials(), + prepared.expectedAuth, + fixtureUser.id + ); + }; + + const renderApple = async () => { + await act(async () => { + renderer = create( + + + + ); + }); + }; + + const button = (label: string) => + renderer!.root.findAllByType("button").find((node) => node.children.join("") === label)!; + + test("prepares a same-origin popup with the existing Services ID, then opens synchronously on click", async () => { + await renderApple(); + expect(initiate).toHaveBeenCalledWith("", "https://auth.example.test/auth/apple/callback"); + expect(init).toHaveBeenCalledWith({ + clientId: "cloud.opensecret.maple.services", + scope: "name email", + redirectURI: "https://auth.example.test/auth/apple/callback", + state: "fixture-state-1", + nonce: "aa".repeat(32), + usePopup: true + }); + expect(signIn).not.toHaveBeenCalled(); + act(() => { + button("Sign in with Apple").props.onClick(); + expect(signIn).toHaveBeenCalledTimes(1); + }); + expect(callback).not.toHaveBeenCalled(); + expect(mint).not.toHaveBeenCalled(); + }); + + for (const error of [ + "popup_blocked_by_browser", + "user_cancelled_authorize", + "popup_closed_by_user" + ]) { + test(`${error} permits preparation of a fresh popup attempt without clearing credentials`, async () => { + installCredentials(); + const before = [...local.values]; + await renderApple(); + act(() => button("Sign in with Apple").props.onClick()); + await act(async () => popupReject({ error })); + expect(button("Try again")).toBeDefined(); + await act(async () => button("Try again").props.onClick()); + expect(initiate).toHaveBeenCalledTimes(2); + expect(init.mock.calls[1][0].state).toBe("fixture-state-2"); + expect(button("Sign in with Apple").props.disabled).toBe(false); + expect(callback).not.toHaveBeenCalled(); + expect(mint).not.toHaveBeenCalled(); + expect([...local.values]).toEqual(before); + }); + } + + test("a mismatched popup state never completes SDK authentication", async () => { + await renderApple(); + act(() => button("Sign in with Apple").props.onClick()); + await act(async () => + popupResolve({ authorization: { code: "fixture-code", state: "other" } }) + ); + expect(callback).not.toHaveBeenCalled(); + expect(button("Try again")).toBeDefined(); + }); + + test("a replaced native attempt cannot accept a late popup", async () => { + await renderApple(); + act(() => button("Sign in with Apple").props.onClick()); + markTransportV2DesktopOAuth({ ...nativeTarget, nativeRequestId: "33".repeat(16) }); + await act(async () => + popupResolve({ + authorization: { + code: "fixture-code", + state: "fixture-state-1" + } + }) + ); + expect(callback).not.toHaveBeenCalled(); + expect(readTransportV2DesktopOAuth("apple")?.nativeRequestId).toBe("33".repeat(16)); + }); + + test("an existing account still requires confirmation and cancellation preserves credentials", async () => { + installCredentials(); + const before = [...local.values]; + await renderApple(); + act(() => button("Sign in with Apple").props.onClick()); + await act(async () => + popupResolve({ + authorization: { + code: "fixture-code", + state: "fixture-state-1" + } + }) + ); + expect(callback).toHaveBeenCalledWith("fixture-code", "fixture-state-1", ""); + expect(renderer!.root.findByType(HostedNativeSignInConfirmation)).toBeDefined(); + expect(JSON.stringify(renderer!.toJSON())).toContain(fixtureUser.email); + expect(mint).not.toHaveBeenCalled(); + act(() => button("Cancel").props.onClick()); + expect(mint).not.toHaveBeenCalled(); + expect([...local.values]).toEqual(before); + expect(window.location.href).toBe("https://auth.example.test/start"); + }); + + test("a newly signed-in account requires consent and retains credentials through mint and manual Open Maple", async () => { + client.auth = { loading: false, user: undefined }; + callback.mockImplementation(async () => { + installCredentials(); + client.auth = { + loading: false, + user: { user: fixtureUser } + } as OpenSecretContextType["auth"]; + }); + await renderApple(); + act(() => button("Sign in with Apple").props.onClick()); + await act(async () => + popupResolve({ + authorization: { + code: "fixture-code", + state: "fixture-state-1" + } + }) + ); + const before = [...local.values]; + expect(mint).not.toHaveBeenCalled(); + await act(async () => button("Continue to Maple").props.onClick()); + expect(mint).toHaveBeenCalledWith(nativeTarget.nativeSessionId, nativeTarget.nativeRequestId); + expect(window.location.href).toBe("cloud.opensecret.maple://auth?handoff_grant=aaa.bbb.ccc"); + expect(readTransportV2DesktopOAuth("apple")).toBeNull(); + window.location.href = "https://auth.example.test/start"; + act(() => button("Open Maple").props.onClick()); + expect(window.location.href).toBe("cloud.opensecret.maple://auth?handoff_grant=aaa.bbb.ccc"); + expect(mint).toHaveBeenCalledTimes(1); + expect([...local.values]).toEqual(before); + }); + + test("confirmation expiry preserves the signed-in browser credentials", async () => { + installCredentials(); + const before = [...local.values]; + clearDesktopOAuthTarget(target); + markTransportV2DesktopOAuth(nativeTarget, Date.now() - TRANSPORT_V2_PENDING_TTL_MS + 20); + const expiring = readTransportV2DesktopOAuth("apple")!; + await act(async () => { + renderer = create( + + + + ); + }); + await act(async () => new Promise((resolve) => setTimeout(resolve, 30))); + expect(JSON.stringify(renderer!.toJSON())).toContain("expired"); + expect(mint).not.toHaveBeenCalled(); + expect(readTransportV2DesktopOAuth("apple")).toBeNull(); + expect(readNativeUserAuth(client.apiUrl).principalId).toBe(fixtureUser.id); + expect([...local.values]).toEqual(before); + }); +}); diff --git a/apps/maple-research/frontend/src/auth-site/fixtures/bootstrap.tsx b/apps/maple-research/frontend/src/auth-site/fixtures/bootstrap.tsx new file mode 100644 index 000000000..031c760ba --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/fixtures/bootstrap.tsx @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { StrictMode, type ComponentType, type ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { OpenSecretProvider } from "@mapleai/sdk"; +import { AuthSite } from "../AuthSite"; +import { markTransportV2DesktopOAuth } from "@/services/desktopOAuthTransport"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + get length(): number { + return this.values.size; + } + clear(): void { + this.values.clear(); + } + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + removeItem(key: string): void { + this.values.delete(key); + } + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +const scenario = process.argv[2]; +assert.ok( + ["cold-start", "cold-callback", "retained-start", "retained-callback"].includes(scenario) +); +const retained = scenario.startsWith("retained-"); +const callback = scenario.endsWith("-callback"); +const apiUrl = "https://bootstrap-api.example.test"; +const authOrigin = "https://auth.example.test"; +const target = { + provider: "github" as const, + nativeSessionId: "00112233445566778899aabbccddeeff", + nativeRequestId: "ffeeddccbbaa99887766554433221100" +}; +const params = new URLSearchParams({ + provider: target.provider, + transport: "v2", + native_session_id: target.nativeSessionId, + native_request_id: target.nativeRequestId +}); +const location = new URL( + callback + ? `${authOrigin}/auth/github/callback?code=fixture-code&state=fixture-state` + : `${authOrigin}/start?${params}` +); +const localStorage = new MemoryStorage(); +const sessionStorage = new MemoryStorage(); +for (const [name, value] of Object.entries({ + localStorage, + sessionStorage, + window: { localStorage, sessionStorage, location } +})) { + Object.defineProperty(globalThis, name, { configurable: true, value }); +} + +const encode = (value: string) => Buffer.from(value).toString("base64url"); +const credentialsKey = `opensecret:transport-v2:auth:v1:${encode(apiUrl)}`; +const continuationKey = `opensecret:transport-v2:oauth-session:v1:${encode(`${apiUrl}\ngithub`)}`; +const pendingKey = "maple_desktop_oauth_pending_v2"; +if (retained) { + const token = (purpose: "access" | "refresh") => + `${encode('{"alg":"ES256K"}')}.${encode( + JSON.stringify({ + aud: `urn:opensecret:internal:transport-v2:user:${purpose}-token`, + sub: "bootstrap-fixture-user", + exp: Math.floor(Date.now() / 1000) + 3_600, + tf: 2 + }) + )}.${encode("synthetic-test-signature")}`; + localStorage.setItem( + credentialsKey, + JSON.stringify({ + version: 1, + api_origin: apiUrl, + cache_namespace_root: null, + user: { + revision: 1, + credentials: { access_token: token("access"), refresh_token: token("refresh") } + }, + platform: { revision: 0, credentials: null } + }) + ); +} +const originalCredentials = localStorage.getItem(credentialsKey); +if (callback) { + markTransportV2DesktopOAuth(target); + // The real SDK consumes this only after resolving its configured API origin. + // It then rejects the deliberately invalid continuation without exchanging a + // provider code or weakening the attested transport for this regression test. + sessionStorage.setItem(continuationKey, "invalid test continuation"); +} + +const requestedUrls: string[] = []; +let releaseBootstrap!: () => void; +const bootstrapResponse = new Promise((resolve) => { + releaseBootstrap = () => resolve(new Response("fixture unavailable", { status: 503 })); +}); +globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : input.toString(); + assert.equal(new URL(url).origin, apiUrl, "SDK must initialize its API origin before use"); + requestedUrls.push(url); + if (retained && requestedUrls.length === 1) return bootstrapResponse; + return new Response("fixture unavailable", { status: 503 }); +}) as typeof fetch; +// Expected SDK failures contain only synthetic fixtures; silence them so the +// parent test exposes assertion failures rather than normal rejection logging. +console.error = () => {}; + +// The linked SDK declarations use React 19; runtime is the frontend's React 18 +// peer, shared by the same test preload used throughout the frontend suite. +const Provider = OpenSecretProvider as unknown as ComponentType<{ + apiUrl: string; + clientId: string; + pcrConfig: { environment: "development" }; + children: ReactNode; +}>; +let renderer: ReactTestRenderer | undefined; +try { + await act(async () => { + renderer = create( + + + + + + ); + }); + if (retained) { + assert.equal(requestedUrls.length, 1, "only retained-session bootstrap may run while pending"); + assert.ok(JSON.stringify(renderer!.toJSON()).includes("Preparing sign-in")); + if (callback) { + assert.equal(sessionStorage.getItem(continuationKey), "invalid test continuation"); + } else { + assert.equal(sessionStorage.getItem(pendingKey), null, "native attempt must not start yet"); + } + await act(async () => releaseBootstrap()); + } + if (callback) { + assert.equal( + sessionStorage.getItem(continuationKey), + null, + "configured callback must reach the SDK continuation boundary" + ); + assert.ok(JSON.stringify(renderer!.toJSON()).includes("could not be completed")); + } else { + assert.ok(sessionStorage.getItem(pendingKey), "native attempt starts after bootstrap"); + assert.equal( + requestedUrls.length, + retained ? 2 : 1, + "OAuth initiation reaches the configured transport" + ); + } + assert.equal( + localStorage.getItem(credentialsKey), + originalCredentials, + "bootstrap failures must retain the existing credentials" + ); +} finally { + await act(async () => renderer?.unmount()); +} +console.log("bootstrap verified"); diff --git a/apps/maple-research/frontend/src/auth-site/main.tsx b/apps/maple-research/frontend/src/auth-site/main.tsx new file mode 100644 index 000000000..70d592c10 --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/main.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { OpenSecretProvider } from "@mapleai/sdk"; +import { openSecretClientConfig } from "@/config/openSecretClientConfig"; +import { AuthSite } from "./AuthSite"; +import "./style.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/apps/maple-research/frontend/src/auth-site/route.test.ts b/apps/maple-research/frontend/src/auth-site/route.test.ts new file mode 100644 index 000000000..c0ecc5eaf --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/route.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "bun:test"; +import { parseAuthSiteRoute } from "./route"; + +const nativeSessionId = "00112233445566778899aabbccddeeff"; +const nativeRequestId = "ffeeddccbbaa99887766554433221100"; +const providers = ["github", "google", "apple"] as const; + +function startParams(provider = "github"): URLSearchParams { + return new URLSearchParams({ + transport: "v2", + provider, + native_session_id: nativeSessionId, + native_request_id: nativeRequestId + }); +} + +function parse(pathname: string, params?: URLSearchParams) { + return parseAuthSiteRoute({ pathname, search: params ? `?${params.toString()}` : "" }); +} + +describe("auth site start routes", () => { + for (const pathname of ["/start", "/desktop-auth"]) { + for (const provider of providers) { + test(`accepts ${provider} V2 initiation through ${pathname}`, () => { + expect(parse(pathname, startParams(provider))).toEqual({ + kind: "start", + provider, + nativeSessionId, + nativeRequestId + }); + }); + } + } + + for (const key of ["transport", "provider", "native_session_id", "native_request_id"]) { + test(`rejects missing or empty ${key}`, () => { + const missing = startParams(); + missing.delete(key); + expect(parse("/start", missing)).toEqual({ kind: "invalid" }); + + const empty = startParams(); + empty.set(key, ""); + expect(parse("/start", empty)).toEqual({ kind: "invalid" }); + }); + + test(`rejects duplicate ${key} even when both values agree`, () => { + const params = startParams(); + params.append(key, params.get(key)!); + expect(parse("/start", params)).toEqual({ kind: "invalid" }); + }); + } + + test("rejects the legacy alias without an explicit V2 target", () => { + expect(parse("/desktop-auth")).toEqual({ kind: "invalid" }); + expect(parse("/desktop-auth", new URLSearchParams({ provider: "github" }))).toEqual({ + kind: "invalid" + }); + + const params = startParams(); + params.set("transport", "v1"); + expect(parse("/desktop-auth", params)).toEqual({ kind: "invalid" }); + }); + + test("does not normalize unsupported transport or provider values", () => { + for (const transport of ["V2", "v2 ", "v3"]) { + const params = startParams(); + params.set("transport", transport); + expect(parse("/start", params)).toEqual({ kind: "invalid" }); + } + for (const provider of ["GitHub", "google ", "microsoft", "github,google"]) { + expect(parse("/start", startParams(provider))).toEqual({ kind: "invalid" }); + } + }); + + for (const key of ["native_session_id", "native_request_id"]) { + test(`requires exactly 32 lowercase hexadecimal characters for ${key}`, () => { + for (const value of [ + nativeSessionId.toUpperCase(), + nativeSessionId.slice(1), + `${nativeSessionId}0`, + `g${nativeSessionId.slice(1)}`, + ` ${nativeSessionId}`, + `${nativeSessionId}\n`, + "00112233-4455-6677-8899-aabbccddeeff" + ]) { + const params = startParams(); + params.set(key, value); + expect(parse("/start", params)).toEqual({ kind: "invalid" }); + } + }); + } + + test("rejects extra query fields rather than accepting caller-chosen destinations or credentials", () => { + for (const [key, value] of [ + ["next", "https://untrusted.example/return"], + ["redirect_uri", "https://untrusted.example/callback"], + ["invite_code", "fixture-invite"], + ["access_token", "fixture-access-token"], + ["refresh_token", "fixture-refresh-token"], + ["unexpected", ""] + ]) { + const params = startParams(); + params.append(key, value); + expect(parse("/start", params)).toEqual({ kind: "invalid" }); + } + + const duplicateInvite = startParams(); + duplicateInvite.append("invite_code", "first"); + duplicateInvite.append("invite_code", "second"); + expect(parse("/desktop-auth", duplicateInvite)).toEqual({ kind: "invalid" }); + }); + + test("detects duplicate keys after percent decoding", () => { + expect( + parseAuthSiteRoute({ + pathname: "/start", + search: `?${startParams().toString()}&%70rovider=google` + }) + ).toEqual({ kind: "invalid" }); + }); +}); + +describe("auth site callback routes", () => { + for (const provider of providers) { + test(`accepts ${provider} callback values without imposing a state format`, () => { + const code = "fixture code/+="; + const state = "opaque:fixture/state+value="; + expect(parse(`/auth/${provider}/callback`, new URLSearchParams({ code, state }))).toEqual({ + kind: "callback", + provider, + code, + state + }); + }); + } + + for (const key of ["code", "state"]) { + test(`rejects missing, empty, or duplicate callback ${key}`, () => { + const params = new URLSearchParams({ code: "fixture-code", state: "fixture-state" }); + params.delete(key); + expect(parse("/auth/github/callback", params)).toEqual({ kind: "invalid" }); + params.set(key, ""); + expect(parse("/auth/github/callback", params)).toEqual({ kind: "invalid" }); + params.set(key, "fixture-value"); + params.append(key, "fixture-value"); + expect(parse("/auth/github/callback", params)).toEqual({ kind: "invalid" }); + }); + } + + test("rejects provider errors even when code and state are also present", () => { + for (const key of ["error", "error_description", "error_uri"]) { + const params = new URLSearchParams({ code: "fixture-code", state: "fixture-state" }); + params.set(key, "fixture-error"); + expect(parse("/auth/google/callback", params)).toEqual({ kind: "invalid" }); + } + expect(parse("/auth/google/callback", new URLSearchParams({ error: "access_denied" }))).toEqual( + { kind: "invalid" } + ); + }); + + test("ignores optional provider metadata without changing the callback identity", () => { + const params = new URLSearchParams({ + code: "fixture-code", + state: "fixture-state", + scope: "openid email profile", + authuser: "0", + prompt: "consent", + provider_metadata: "fixture-extra" + }); + expect(parse("/auth/google/callback", params)).toEqual({ + kind: "callback", + provider: "google", + code: "fixture-code", + state: "fixture-state" + }); + }); +}); + +describe("auth site route boundaries", () => { + test("accepts completion only without query parameters", () => { + expect(parse("/complete")).toEqual({ kind: "complete" }); + expect(parse("/complete", new URLSearchParams({ next: "/start" }))).toEqual({ + kind: "invalid" + }); + }); + + test("rejects unrelated, ambiguous, and noncanonical route paths", () => { + for (const pathname of [ + "/", + "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/login", + "/signup", + "/pricing", + "/start/", + "/desktop-auth/", + "/complete/", + "//start", + "/auth/microsoft/callback", + "/auth/GitHub/callback", + "/auth/github/callback/", + "/auth/github/callback/extra", + "/auth/github%2fcallback" + ]) { + expect(parse(pathname, startParams())).toEqual({ kind: "invalid" }); + } + }); +}); diff --git a/apps/maple-research/frontend/src/auth-site/route.ts b/apps/maple-research/frontend/src/auth-site/route.ts new file mode 100644 index 000000000..7777da009 --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/route.ts @@ -0,0 +1,66 @@ +import { isTransportV2PublicId, type DesktopOAuthProvider } from "@/services/desktopOAuthTransport"; + +export type AuthSiteRoute = + | { + kind: "start"; + provider: DesktopOAuthProvider; + nativeSessionId: string; + nativeRequestId: string; + } + | { kind: "callback"; provider: DesktopOAuthProvider; code: string; state: string } + | { kind: "complete" } + | { kind: "invalid" }; + +const START_PARAMETERS = new Set([ + "transport", + "provider", + "native_session_id", + "native_request_id" +]); + +function singleParameter(params: URLSearchParams, name: string): string | null { + const values = params.getAll(name); + return values.length === 1 && values[0].length > 0 ? values[0] : null; +} + +export function isAuthCallbackPath(pathname: string): boolean { + return /^\/auth\/(github|google|apple)\/callback$/u.test(pathname); +} + +/** An exact route boundary: this entry never falls through to the main app or V1. */ +export function parseAuthSiteRoute(location: Pick): AuthSiteRoute { + const params = new URLSearchParams(location.search); + if (location.pathname === "/start" || location.pathname === "/desktop-auth") { + const provider = singleParameter(params, "provider"); + const nativeSessionId = singleParameter(params, "native_session_id"); + const nativeRequestId = singleParameter(params, "native_request_id"); + if ( + [...params.keys()].some((key) => !START_PARAMETERS.has(key)) || + singleParameter(params, "transport") !== "v2" || + (provider !== "github" && provider !== "google" && provider !== "apple") || + !isTransportV2PublicId(nativeSessionId) || + !isTransportV2PublicId(nativeRequestId) + ) { + return { kind: "invalid" }; + } + return { kind: "start", provider, nativeSessionId, nativeRequestId }; + } + if (isAuthCallbackPath(location.pathname)) { + const provider = location.pathname.split("/")[2] as DesktopOAuthProvider; + const code = singleParameter(params, "code"); + const state = singleParameter(params, "state"); + if ( + !code || + !state || + params.has("error") || + params.has("error_description") || + params.has("error_uri") + ) { + return { kind: "invalid" }; + } + // State is opaque. Its authenticated interpretation belongs to the SDK/backend. + return { kind: "callback", provider, code, state }; + } + if (location.pathname === "/complete" && !params.size) return { kind: "complete" }; + return { kind: "invalid" }; +} diff --git a/apps/maple-research/frontend/src/auth-site/style.css b/apps/maple-research/frontend/src/auth-site/style.css new file mode 100644 index 000000000..fd1afdd80 --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/style.css @@ -0,0 +1,56 @@ +@config "../../tailwind.auth.config.cjs"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@font-face { + font-family: "Manrope"; + src: url("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/fonts/Manrope-VariableFont_wght.ttf") format("truetype"); + font-weight: 200 800; + font-display: swap; +} + +@layer base { + :root { + --app-font-family: "Manrope", sans-serif; + --background: 0 0% 98%; + --foreground: 0 0% 15%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --muted-foreground: 0 0% 45%; + --ring: 17 100% 72%; + --maple-primary: 17 100% 72%; + --maple-primary-container: 17 100% 94%; + --maple-secondary: 237 8% 57%; + } + + body { + margin: 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: var(--app-font-family); + font-size: 14px; + line-height: 1.6; + } +} + +@layer components { + .auth-shell { + display: grid; + min-height: 100dvh; + place-items: center; + padding: 24px; + } + + .auth-card { + display: grid; + gap: 24px; + width: min(100%, 448px); + padding: 32px; + border: 1px solid #e5e5e5; + border-radius: 16px; + background: #fff; + box-shadow: 0 12px 40px rgb(0 0 0 / 4%); + } +} diff --git a/apps/maple-research/frontend/src/auth-site/ui.test.ts b/apps/maple-research/frontend/src/auth-site/ui.test.ts new file mode 100644 index 000000000..a113148b1 --- /dev/null +++ b/apps/maple-research/frontend/src/auth-site/ui.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const frontendDirectory = fileURLToPath(new URL("../..", import.meta.url)); + +describe("hosted auth UI with the real SDK context", () => { + for (const [fixture, cases] of [ + ["AuthSite", 23], + ["HostedAppleSignIn", 9] + ] as const) { + test(`${fixture} runs every case without shared module mocks`, () => { + // Existing frontend suites install process-global SDK mocks. Run these real-context + // cases in clean processes, while keeping them mandatory in the default test suite. + const result = Bun.spawnSync( + [process.execPath, "--no-env-file", "test", `./src/auth-site/fixtures/${fixture}.case.tsx`], + { + cwd: frontendDirectory, + env: { PATH: process.env.PATH, LANG: "C.UTF-8", NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000 + } + ); + const output = + new TextDecoder().decode(result.stdout) + new TextDecoder().decode(result.stderr); + if (result.exitCode !== 0) throw new Error(`${fixture} child tests failed:\n${output}`); + // A missing or undiscovered fixture must fail, even if Bun exits successfully. + expect(output).toMatch(new RegExp(`\\b${cases} pass\\b`)); + expect(output).toMatch(/\b0 fail\b/u); + expect(output).toMatch(new RegExp(`Ran ${cases} tests across 1 file`)); + }, 15_000); + } +}); diff --git a/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx b/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx index 40ad76091..6b5ce833d 100644 --- a/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx +++ b/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx @@ -334,14 +334,17 @@ describe("AppleAuthProvider", () => { }); expect(initiateAppleAuth).toHaveBeenCalledTimes(4); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(1, "invite-one"); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(2, "invite-one"); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(3, "invite-one"); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(4, "invite-two"); + const callbackUrl = "https://trymaple.ai/auth/apple/callback"; + expect(initiateAppleAuth).toHaveBeenNthCalledWith(1, "invite-one", callbackUrl); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(2, "invite-one", callbackUrl); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(3, "invite-one", callbackUrl); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(4, "invite-two", callbackUrl); expect(appleInit).toHaveBeenCalledTimes(4); expect(appleInit.mock.calls[0]?.[0]).toMatchObject({ nonce: "11".repeat(32), - state: "state-one" + state: "state-one", + redirectURI: callbackUrl, + usePopup: true }); expect(appleInit.mock.calls[1]?.[0]).toMatchObject({ nonce: "22".repeat(32), @@ -482,7 +485,7 @@ describe("AppleAuthProvider", () => { expect(JSON.stringify(renderer?.toJSON())).not.toContain("Open Maple"); }); - for (const provider of ["github", "google", "apple"] as const) { + for (const provider of ["github", "google"] as const) { test(`${provider} redirect callback waits for hosted account approval`, async () => { const { markTransportV2DesktopOAuth } = await import("@/services/desktopOAuthTransport"); markTransportV2DesktopOAuth({ @@ -544,6 +547,31 @@ describe("AppleAuthProvider", () => { }); } + test("explains popup-only Apple sign-in without processing a redirect or stored form response", async () => { + sessionStorage.setItem("apple_form_data", JSON.stringify({ code: "unused", state: "unused" })); + Object.assign(window.location, { + search: "?code=unused-code&state=unused-state", + pathname: "/auth/apple/callback" + }); + const rootRoute = createRootRoute(); + const route = callbackRoute.update({ + getParentRoute: () => rootRoute, + path: "/auth/$provider/callback" + } as never); + const router = createRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ["/auth/apple/callback"] }) + }); + await router.load(); + await act(async () => { + renderer = create(); + }); + expect(handleAppleCallback).not.toHaveBeenCalled(); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + expect(JSON.stringify(renderer?.toJSON())).toContain("Apple sign-in uses a popup"); + expect(JSON.stringify(renderer?.toJSON())).toContain("allow popups for this site"); + }); + for (const decision of ["approve", "cancel"] as const) { test(`Apple desktop confirmation survives authentication through the root layout and can ${decision}`, async () => { const { readTransportV2DesktopOAuth } = await import("@/services/desktopOAuthTransport"); @@ -667,6 +695,10 @@ describe("AppleAuthProvider", () => { renderer = create(); }); expect(initiateGoogleAuth).toHaveBeenCalledTimes(1); + expect(initiateGoogleAuth).toHaveBeenCalledWith( + "", + "https://trymaple.ai/auth/google/callback" + ); await act(async () => { await router.navigate({ to: "/desktop-auth", diff --git a/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx b/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx index ba160e960..820bb2cbc 100644 --- a/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx +++ b/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx @@ -4,6 +4,13 @@ import { Button, type ButtonProps } from "./ui/button"; import { Apple } from "./icons/Apple"; import { HostedNativeSignInConfirmation } from "./HostedNativeSignInConfirmation"; import { getBillingService } from "@/billing/billingService"; +import { + getAppleAuthError, + getAppleAuthorizationNonce, + isAppleAuthCancellation, + type AppleAuthorization +} from "@/services/appleOAuth"; +import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; import { clearDesktopOAuthTransport, clearDesktopOAuthTarget, @@ -25,62 +32,7 @@ interface AppleAuthProviderProps { children?: React.ReactNode; } -export interface AppleAuthorization { - code: string; - state: string; - id_token?: string; -} - -declare global { - interface Window { - AppleID: { - auth: { - init: (config: { - clientId: string; - scope: string; - redirectURI: string; - state: string; - nonce: string; - usePopup: boolean; - }) => void; - signIn: () => Promise<{ - authorization: AppleAuthorization; - }>; - }; - }; - } -} - -function getAppleAuthError(value: unknown): Error { - if (value instanceof Error) return value; - if (value && typeof value === "object") { - const error = (value as Record).error; - if (typeof error === "string" && error) return new Error(error); - } - - return new Error("Apple authentication failed"); -} - -function isAppleAuthCancellation(error: Error): boolean { - return error.message === "user_cancelled_authorize" || error.message === "popup_closed_by_user"; -} - -function getAppleAuthorizationNonce(authUrl: string): string { - let url: URL; - try { - url = new URL(authUrl); - } catch { - throw new Error("Apple authorization response did not contain a valid nonce"); - } - - const nonces = url.searchParams.getAll("nonce"); - const nonce = nonces[0]; - if (nonces.length !== 1 || !nonce || !/^[0-9a-f]{64}$/u.test(nonce)) { - throw new Error("Apple authorization response did not contain a valid nonce"); - } - - return nonce; -} +export type { AppleAuthorization } from "@/services/appleOAuth"; export function AppleAuthProvider({ onSuccess, @@ -132,14 +84,16 @@ export function AppleAuthProvider({ }, []); const initializeAppleAuth = async (target: TransportV2DesktopOAuthState | null) => { - if (!window.AppleID) { + const appleId = window.AppleID; + if (!appleId) { throw new Error("Apple Sign In SDK not loaded"); } if (!isNativeOAuthRedirect()) clearDesktopOAuthTransport(); // A retry is a new authorization attempt, so it gets a fresh backend state and nonce. - const initiateResult = await os.initiateAppleAuth(inviteCode || ""); + const redirectURI = getBrowserOAuthCallbackUrl("apple", window.location.origin); + const initiateResult = await os.initiateAppleAuth(inviteCode || "", redirectURI); if (!active.current || (target && !isCurrentDesktopOAuthTarget(target))) return; const nonce = getAppleAuthorizationNonce(initiateResult.auth_url); @@ -150,10 +104,10 @@ export function AppleAuthProvider({ sessionStorage.setItem("selected_plan", selectedPlan); } - window.AppleID.auth.init({ + appleId.auth.init({ clientId: "cloud.opensecret.maple.services", scope: "name email", - redirectURI: window.location.origin + "/auth/apple/callback", + redirectURI, state, nonce, usePopup: true @@ -204,7 +158,9 @@ export function AppleAuthProvider({ // Programmatic Apple sign-in returns one promise that resolves on success and rejects on // failure. It is the only completion channel; document events are intentionally unused. - const authResult = await window.AppleID.auth.signIn(); + const appleId = window.AppleID; + if (!appleId) throw new Error("Apple Sign In SDK not loaded"); + const authResult = await appleId.auth.signIn(); if (!active.current || (target && !isCurrentDesktopOAuthTarget(target))) return; const authorization = authResult?.authorization; if (!authorization?.code || !authorization.state) { diff --git a/apps/maple-research/frontend/src/lib/test/preload.ts b/apps/maple-research/frontend/src/lib/test/preload.ts index cb0ff5c3b..35a03517d 100644 --- a/apps/maple-research/frontend/src/lib/test/preload.ts +++ b/apps/maple-research/frontend/src/lib/test/preload.ts @@ -1 +1,17 @@ -export {}; +import { mock } from "bun:test"; +import { createRequire } from "node:module"; +import * as react from "react"; +import * as jsxRuntime from "react/jsx-runtime"; + +// Mirror Vite's React deduplication for a local SDK link. Keep the SDK real: +// only its copy of the React peer dependency is redirected to this renderer's +// instance. Published SDK installs already resolve the same peer and do nothing. +const frontendRequire = createRequire(import.meta.url); +const sdkRequire = createRequire(frontendRequire.resolve("@mapleai/sdk")); +for (const [name, exports] of [ + ["react", react], + ["react/jsx-runtime", jsxRuntime] +] as const) { + const sdkPath = sdkRequire.resolve(name); + if (sdkPath !== frontendRequire.resolve(name)) mock.module(sdkPath, () => exports); +} diff --git a/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx b/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx index d488b56b6..ea6f75cb9 100644 --- a/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx +++ b/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx @@ -49,7 +49,7 @@ function OAuthCallback() { const redirectTimer = useRef | null>(null); const navigate = useNavigate(); const router = useRouter(); - const { handleGitHubCallback, handleGoogleCallback, handleAppleCallback } = useOpenSecret(); + const { handleGitHubCallback, handleGoogleCallback } = useOpenSecret(); const processedRef = useRef(false); const { provider } = Route.useParams(); @@ -127,38 +127,29 @@ function OAuthCallback() { if (processedRef.current) return; processedRef.current = true; - // Get URL parameters for all OAuth providers + // Browser Apple completion belongs to the popup promise on its initiating page. + // This static route cannot receive Apple's form_post response. + if (provider === "apple") { + handleAuthError( + new Error( + "Apple sign-in uses a popup. Return to the sign-in page, allow popups for this site, and try again." + ) + ); + return; + } + + // Get URL parameters for redirect-based OAuth providers. const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); const state = urlParams.get("state"); - // For Apple, we might get form data instead of URL parameters - // Apple uses form_post with POST request in some scenarios - let appleData = null; - if (provider === "apple" && !code) { - // Check if we have Apple data in sessionStorage from form_post - const appleFormData = sessionStorage.getItem("apple_form_data"); - if (appleFormData) { - try { - appleData = JSON.parse(appleFormData); - sessionStorage.removeItem("apple_form_data"); - } catch (e) { - console.error("Failed to parse Apple form data:", e); - } - } - } - - if ((code && state) || (provider === "apple" && appleData)) { + if (code && state) { try { // Handle the callback based on the provider if (provider === "github") { await handleGitHubCallback(code || "", state || "", ""); } else if (provider === "google") { await handleGoogleCallback(code || "", state || "", ""); - } else if (provider === "apple") { - // This handles the redirect flow (backup for non-popup scenarios) - // Most Apple auth will now be handled client-side in the AppleAuthProvider component - await handleAppleCallback(code || "", state || "", ""); } else { throw new Error(`Unsupported provider: ${provider}`); } @@ -190,7 +181,6 @@ function OAuthCallback() { processCallback(); }, [ - handleAppleCallback, handleAuthError, handleGitHubCallback, handleGoogleCallback, diff --git a/apps/maple-research/frontend/src/routes/desktop-auth.tsx b/apps/maple-research/frontend/src/routes/desktop-auth.tsx index 626a6dc46..f2d087fe1 100644 --- a/apps/maple-research/frontend/src/routes/desktop-auth.tsx +++ b/apps/maple-research/frontend/src/routes/desktop-auth.tsx @@ -1,6 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { useOpenSecret } from "@mapleai/sdk"; +import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2 } from "lucide-react"; import { AppleAuthProvider } from "@/components/AppleAuthProvider"; @@ -91,10 +92,16 @@ function DesktopAuth() { // Initiate appropriate OAuth flow for GitHub and Google let auth_url; if (provider === "github") { - const result = await currentOs.current.initiateGitHubAuth(""); + const result = await currentOs.current.initiateGitHubAuth( + "", + getBrowserOAuthCallbackUrl("github", window.location.origin) + ); auth_url = result.auth_url; } else if (provider === "google") { - const result = await currentOs.current.initiateGoogleAuth(""); + const result = await currentOs.current.initiateGoogleAuth( + "", + getBrowserOAuthCallbackUrl("google", window.location.origin) + ); auth_url = result.auth_url; } else { throw new Error("Unsupported provider"); diff --git a/apps/maple-research/frontend/src/routes/login.tsx b/apps/maple-research/frontend/src/routes/login.tsx index 849504ab9..dfded7622 100644 --- a/apps/maple-research/frontend/src/routes/login.tsx +++ b/apps/maple-research/frontend/src/routes/login.tsx @@ -22,6 +22,7 @@ import { useRouteMeta } from "@/utils/routeMeta"; import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect"; import { startNativeOAuth } from "@/services/nativeOAuthAttempt"; import { clearDesktopOAuthTransport } from "@/services/desktopOAuthTransport"; +import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; type LoginSearchParams = { next?: string; @@ -133,9 +134,11 @@ function LoginPage() { redemptionCode: code }); } else { - // Web flow remains unchanged clearDesktopOAuthTransport(); - const { auth_url } = await os.initiateGitHubAuth(""); + const { auth_url } = await os.initiateGitHubAuth( + "", + getBrowserOAuthCallbackUrl("github", window.location.origin) + ); sessionStorage.removeItem("selected_plan"); if (selected_plan) { sessionStorage.setItem("selected_plan", selected_plan); @@ -166,9 +169,11 @@ function LoginPage() { redemptionCode: code }); } else { - // Web flow remains unchanged clearDesktopOAuthTransport(); - const { auth_url } = await os.initiateGoogleAuth(""); + const { auth_url } = await os.initiateGoogleAuth( + "", + getBrowserOAuthCallbackUrl("google", window.location.origin) + ); sessionStorage.removeItem("selected_plan"); if (selected_plan) { sessionStorage.setItem("selected_plan", selected_plan); diff --git a/apps/maple-research/frontend/src/routes/signup.tsx b/apps/maple-research/frontend/src/routes/signup.tsx index 699c851ba..013e2fc11 100644 --- a/apps/maple-research/frontend/src/routes/signup.tsx +++ b/apps/maple-research/frontend/src/routes/signup.tsx @@ -26,6 +26,7 @@ import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils import { shouldRedirectAuthenticatedSignup } from "@/utils/signupRedirect"; import { startNativeOAuth } from "@/services/nativeOAuthAttempt"; import { clearDesktopOAuthTransport } from "@/services/desktopOAuthTransport"; +import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; type SignupSearchParams = { next?: string; @@ -163,9 +164,11 @@ function SignupPage() { redemptionCode: code }); } else { - // Web flow remains unchanged clearDesktopOAuthTransport(); - const { auth_url } = await os.initiateGitHubAuth(""); + const { auth_url } = await os.initiateGitHubAuth( + "", + getBrowserOAuthCallbackUrl("github", window.location.origin) + ); sessionStorage.removeItem("selected_plan"); if (selected_plan) { sessionStorage.setItem("selected_plan", selected_plan); @@ -196,9 +199,11 @@ function SignupPage() { redemptionCode: code }); } else { - // Web flow remains unchanged clearDesktopOAuthTransport(); - const { auth_url } = await os.initiateGoogleAuth(""); + const { auth_url } = await os.initiateGoogleAuth( + "", + getBrowserOAuthCallbackUrl("google", window.location.origin) + ); sessionStorage.removeItem("selected_plan"); if (selected_plan) { sessionStorage.setItem("selected_plan", selected_plan); diff --git a/apps/maple-research/frontend/src/services/appleOAuth.test.ts b/apps/maple-research/frontend/src/services/appleOAuth.test.ts new file mode 100644 index 000000000..329e4c01d --- /dev/null +++ b/apps/maple-research/frontend/src/services/appleOAuth.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { + getAppleAuthError, + getAppleAuthorizationNonce, + isAppleAuthCancellation +} from "./appleOAuth"; + +describe("shared Apple browser helpers", () => { + test("preserves cancellation codes and provides a useful popup-blocked retry", () => { + for (const code of ["user_cancelled_authorize", "popup_closed_by_user"]) { + expect(isAppleAuthCancellation(getAppleAuthError({ error: code }))).toBe(true); + } + for (const code of ["popup_blocked_by_browser", "popup_blocked"]) { + const error = getAppleAuthError({ error: code }); + expect(error.message).toContain("Allow popups for this site"); + expect(isAppleAuthCancellation(error)).toBe(false); + } + expect(getAppleAuthError({ error: "" }).message).toBe("Apple authentication failed"); + }); + + test("accepts only one canonical backend nonce", () => { + const nonce = "12".repeat(32); + expect( + getAppleAuthorizationNonce(`https://appleid.apple.com/auth/authorize?nonce=${nonce}`) + ).toBe(nonce); + for (const suffix of ["", "?nonce=", "?nonce=ABC", `?nonce=${nonce}&nonce=${nonce}`]) { + expect(() => + getAppleAuthorizationNonce(`https://appleid.apple.com/auth/authorize${suffix}`) + ).toThrow("valid nonce"); + } + }); +}); diff --git a/apps/maple-research/frontend/src/services/appleOAuth.ts b/apps/maple-research/frontend/src/services/appleOAuth.ts new file mode 100644 index 000000000..649ec42ae --- /dev/null +++ b/apps/maple-research/frontend/src/services/appleOAuth.ts @@ -0,0 +1,54 @@ +export interface AppleAuthorization { + code: string; + state: string; + id_token?: string; +} + +declare global { + interface Window { + AppleID?: { + auth: { + init: (config: { + clientId: string; + scope: string; + redirectURI: string; + state: string; + nonce: string; + usePopup: boolean; + }) => void; + signIn: () => Promise<{ authorization: AppleAuthorization }>; + }; + }; + } +} + +export function getAppleAuthError(value: unknown): Error { + let error = value instanceof Error ? value : new Error("Apple authentication failed"); + if (!(value instanceof Error) && value && typeof value === "object") { + const code = (value as Record).error; + if (typeof code === "string" && code) error = new Error(code); + } + if (error.message === "popup_blocked_by_browser" || error.message === "popup_blocked") { + return new Error("Allow popups for this site, then select Sign in with Apple again."); + } + return error; +} + +export function isAppleAuthCancellation(error: Error): boolean { + return error.message === "user_cancelled_authorize" || error.message === "popup_closed_by_user"; +} + +export function getAppleAuthorizationNonce(authUrl: string): string { + let url: URL; + try { + url = new URL(authUrl); + } catch { + throw new Error("Apple authorization response did not contain a valid nonce"); + } + const nonces = url.searchParams.getAll("nonce"); + const nonce = nonces[0]; + if (nonces.length !== 1 || !nonce || !/^[0-9a-f]{64}$/u.test(nonce)) { + throw new Error("Apple authorization response did not contain a valid nonce"); + } + return nonce; +} diff --git a/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts b/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts index 644782d3d..7a8527407 100644 --- a/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts +++ b/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts @@ -98,6 +98,29 @@ describe("desktop OAuth transport selection", () => { expect(parsed.hash).toBe(""); }); + test("changes only the hosted origin when direct auth entry is configured", () => { + const configured = new URL(buildTransportV2DesktopAuthUrl(state, "https://auth.trymaple.ai")); + const existing = new URL(buildTransportV2DesktopAuthUrl(state, "https://trymaple.ai")); + expect(configured.origin).toBe("https://auth.trymaple.ai"); + expect(configured.pathname).toBe("/desktop-auth"); + expect(existing.origin).toBe("https://trymaple.ai"); + expect(existing.pathname).toBe("/desktop-auth"); + expect(configured.search).toBe(existing.search); + expect([...configured.searchParams.keys()]).toEqual([ + "provider", + "transport", + "native_session_id", + "native_request_id" + ]); + expect(configured.hash).toBe(""); + }); + + test("fails closed when the configured entry is a full URL instead of an origin", () => { + expect(() => buildTransportV2DesktopAuthUrl(state, "https://auth.trymaple.ai/start")).toThrow( + "without a path" + ); + }); + test("rejects non-canonical target identifiers", () => { expect(() => buildTransportV2DesktopAuthUrl({ ...state, nativeSessionId: nativeSessionId.toUpperCase() }) diff --git a/apps/maple-research/frontend/src/services/desktopOAuthTransport.ts b/apps/maple-research/frontend/src/services/desktopOAuthTransport.ts index db5b606e1..ed2f97a75 100644 --- a/apps/maple-research/frontend/src/services/desktopOAuthTransport.ts +++ b/apps/maple-research/frontend/src/services/desktopOAuthTransport.ts @@ -1,3 +1,5 @@ +import { getNativeOAuthEntryUrl } from "./oauthConfig"; + export type DesktopOAuthTransport = "v1" | "v2"; export type DesktopOAuthProvider = "github" | "google" | "apple"; @@ -72,18 +74,18 @@ function removeTransportV2PendingState(): void { sessionStorage.removeItem(TRANSPORT_V2_MINT_CLAIM_KEY); } -export function buildTransportV2DesktopAuthUrl({ - provider, - nativeSessionId, - nativeRequestId -}: TransportV2DesktopAuthUrlOptions): string { +export function buildTransportV2DesktopAuthUrl( + { provider, nativeSessionId, nativeRequestId }: TransportV2DesktopAuthUrlOptions, + authOrigin = import.meta.env.VITE_AUTH_ORIGIN, + isDevelopment = import.meta.env.DEV === true +): string { if (!isDesktopOAuthProvider(provider)) { throw new Error("Desktop authentication provider is missing or invalid"); } assertTransportV2PublicId(nativeSessionId, "native session"); assertTransportV2PublicId(nativeRequestId, "native request"); - const url = new URL("https://trymaple.ai/desktop-auth"); + const url = new URL(getNativeOAuthEntryUrl(authOrigin, isDevelopment)); url.searchParams.set("provider", provider); url.searchParams.set("transport", "v2"); url.searchParams.set(TRANSPORT_V2_NATIVE_SESSION_QUERY, nativeSessionId); diff --git a/apps/maple-research/frontend/src/services/nativeOAuthAttempt.test.ts b/apps/maple-research/frontend/src/services/nativeOAuthAttempt.test.ts index dfbf11f81..d094e8db3 100644 --- a/apps/maple-research/frontend/src/services/nativeOAuthAttempt.test.ts +++ b/apps/maple-research/frontend/src/services/nativeOAuthAttempt.test.ts @@ -284,6 +284,29 @@ describe("native OAuth Transport V2 handoff", () => { expect(openerArgs.url).not.toContain("refresh-token"); }); + test("cancels a prepared native attempt when hosted entry configuration is rejected", async () => { + const desktopTransport = await import("./desktopOAuthTransport"); + const buildUrl = spyOn(desktopTransport, "buildTransportV2DesktopAuthUrl").mockImplementation( + () => { + throw new Error("Authentication origin is invalid"); + } + ); + const calls: InvokeCall[] = []; + try { + await expect(startNativeOAuth("github", API_URL, {}, nativeInvoke(calls))).rejects.toThrow( + "Authentication origin is invalid" + ); + expect(calls.map(({ command }) => command)).toEqual([ + "native_oauth_begin", + "native_oauth_cancel" + ]); + expect(calls[1]?.args).toEqual({ request: { nativeOAuthAttempt: ATTEMPT_ID } }); + expect(readPendingNativeOAuthAttempt()).toBeNull(); + } finally { + buildUrl.mockRestore(); + } + }); + test("redeems with the signed grant only and installs through the captured CAS fence", async () => { const calls: InvokeCall[] = []; await beginNativeOAuthAttempt( diff --git a/apps/maple-research/frontend/src/services/nativeOAuthAttempt.ts b/apps/maple-research/frontend/src/services/nativeOAuthAttempt.ts index c61d4b4af..dfd8866fd 100644 --- a/apps/maple-research/frontend/src/services/nativeOAuthAttempt.ts +++ b/apps/maple-research/frontend/src/services/nativeOAuthAttempt.ts @@ -197,12 +197,12 @@ export async function startNativeOAuth( invokeCommand: InvokeCommand = invoke ): Promise { const prepared = await beginNativeOAuthAttempt(apiUrl, navigation, Date.now(), invokeCommand); - const url = buildTransportV2DesktopAuthUrl({ - provider, - nativeSessionId: prepared.sessionId, - nativeRequestId: prepared.requestId - }); try { + const url = buildTransportV2DesktopAuthUrl({ + provider, + nativeSessionId: prepared.sessionId, + nativeRequestId: prepared.requestId + }); await invokeCommand("plugin:opener|open_url", { url }); } catch (error) { await cancelNativeOAuthAttempt(prepared.nativeOAuthAttempt, invokeCommand).catch( diff --git a/apps/maple-research/frontend/src/services/oauthConfig.test.ts b/apps/maple-research/frontend/src/services/oauthConfig.test.ts new file mode 100644 index 000000000..fef2a1ba0 --- /dev/null +++ b/apps/maple-research/frontend/src/services/oauthConfig.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { getBrowserOAuthCallbackUrl, getNativeOAuthEntryUrl } from "./oauthConfig"; + +describe("OAuth origin selection", () => { + test("keeps existing native entry when auth origin is unset or explicitly the apex", () => { + for (const origin of [undefined, "", "https://trymaple.ai", "https://trymaple.ai/"]) { + expect(getNativeOAuthEntryUrl(origin)).toBe("https://trymaple.ai/desktop-auth"); + } + }); + + test("uses the permanent entry alias on a configured auth origin", () => { + expect(getNativeOAuthEntryUrl("https://auth.trymaple.ai")).toBe( + "https://auth.trymaple.ai/desktop-auth" + ); + }); + + test("keeps every browser provider callback on its initiating origin", () => { + for (const provider of ["github", "google", "apple"] as const) { + for (const origin of [ + "https://trymaple.ai", + "https://app.trymaple.ai", + "https://auth.trymaple.ai", + "http://127.0.0.1:35493" + ]) { + expect(getBrowserOAuthCallbackUrl(provider, origin)).toBe( + `${origin}/auth/${provider}/callback` + ); + } + } + }); + + test("allows exact loopback HTTP only for development native entry", () => { + for (const origin of ["http://127.0.0.1:3000", "http://localhost:5173", "http://[::1]:5173"]) { + expect(getNativeOAuthEntryUrl(origin, true)).toBe(`${origin}/desktop-auth`); + expect(() => getNativeOAuthEntryUrl(origin, false)).toThrow("HTTPS"); + } + }); + + test("rejects origin configuration with credentials, navigation data, or insecure hosts", () => { + for (const origin of [ + "https://user:password@auth.trymaple.ai", + "https://auth.trymaple.ai/start", + "https://auth.trymaple.ai/./", + "https://auth.trymaple.ai/%2e/", + "https://auth.trymaple.ai?next=other", + "https://auth.trymaple.ai#callback", + "https://auth.trymaple.ai?", + "https://auth.trymaple.ai#", + " https://auth.trymaple.ai", + "https://auth.trymaple.ai ", + "https://auth.trymaple.ai\\other", + "https://auth.trymaple.ai:bad", + "http://auth.trymaple.ai", + "http://localhost.example.com", + "http://127.1", + "http://2130706433", + "http://127.0.0.2", + "http://localhost.", + "//auth.trymaple.ai", + "cloud.opensecret.maple://auth", + "javascript:alert(1)" + ]) { + expect(() => getNativeOAuthEntryUrl(origin, true)).toThrow(); + } + }); +}); diff --git a/apps/maple-research/frontend/src/services/oauthConfig.ts b/apps/maple-research/frontend/src/services/oauthConfig.ts new file mode 100644 index 000000000..8cc6211b6 --- /dev/null +++ b/apps/maple-research/frontend/src/services/oauthConfig.ts @@ -0,0 +1,50 @@ +type BrowserOAuthProvider = "github" | "google" | "apple"; + +const DEFAULT_NATIVE_OAUTH_ENTRY = "https://trymaple.ai/desktop-auth"; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +function parseOAuthOrigin(value: string, allowLoopbackHttp: boolean): URL { + // Check the original spelling as well as the parsed URL: URL parsing silently + // normalizes paths, backslashes, whitespace, and abbreviated IPv4 addresses. + const match = /^(https?):\/\/(\[[0-9a-fA-F:]+\]|[^\s/?#:@\\]+)(?::[0-9]+)?\/?$/u.exec(value); + if (!match) throw new Error("Authentication origin must be an HTTPS origin without a path"); + + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Authentication origin is invalid"); + } + if ( + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + (url.protocol !== "https:" && + !( + allowLoopbackHttp && + url.protocol === "http:" && + LOOPBACK_HOSTS.has(match[2]) && + url.hostname === match[2] + )) + ) { + throw new Error("Authentication origin must use HTTPS, or exact loopback HTTP in development"); + } + return url; +} + +/** Browser OAuth always returns to the origin that owns its pending SDK session. */ +export function getBrowserOAuthCallbackUrl(provider: BrowserOAuthProvider, origin: string): string { + if (provider !== "github" && provider !== "google" && provider !== "apple") { + throw new Error("Unsupported authentication provider"); + } + return new URL(`/auth/${provider}/callback`, parseOAuthOrigin(origin, true)).toString(); +} + +/** Direct auth entry remains opt-in until the auth cutover has passed its rollout gate. */ +export function getNativeOAuthEntryUrl(configuredOrigin?: string, isDevelopment = false): string { + if (configuredOrigin === undefined || configuredOrigin === "") return DEFAULT_NATIVE_OAUTH_ENTRY; + // The permanent alias works on both the original apex page and the auth site. + return new URL("/desktop-auth", parseOAuthOrigin(configuredOrigin, isDevelopment)).toString(); +} diff --git a/apps/maple-research/frontend/src/vite-env.d.ts b/apps/maple-research/frontend/src/vite-env.d.ts index bca1f1004..ba37a6da3 100644 --- a/apps/maple-research/frontend/src/vite-env.d.ts +++ b/apps/maple-research/frontend/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface ImportMetaEnv { readonly VITE_MAPLE_BILLING_API_URL?: string; readonly VITE_DEV_MODEL_OVERRIDE?: string; readonly VITE_APP_ORIGIN?: string; + readonly VITE_AUTH_ORIGIN?: string; readonly VITE_MARKETING_ORIGIN?: string; } diff --git a/apps/maple-research/frontend/tailwind.auth.config.cjs b/apps/maple-research/frontend/tailwind.auth.config.cjs new file mode 100644 index 000000000..ac9b196da --- /dev/null +++ b/apps/maple-research/frontend/tailwind.auth.config.cjs @@ -0,0 +1,15 @@ +const shared = require("./tailwind.config.cjs"); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + ...shared, + content: [ + "./auth.html", + "./src/auth-site/**/*.{ts,tsx}", + "!./src/auth-site/**/*.test.{ts,tsx}", + "!./src/auth-site/fixtures/**", + "!./src/auth-site/fixtures/**", + "./src/components/ui/button.tsx", + "./src/components/HostedNativeSignInConfirmation.tsx" + ] +}; diff --git a/apps/maple-research/frontend/tsconfig.node.json b/apps/maple-research/frontend/tsconfig.node.json index 0d3d71446..c3bc7d34a 100644 --- a/apps/maple-research/frontend/tsconfig.node.json +++ b/apps/maple-research/frontend/tsconfig.node.json @@ -18,5 +18,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vite.auth.config.ts"] } diff --git a/apps/maple-research/frontend/vite.auth.config.ts b/apps/maple-research/frontend/vite.auth.config.ts new file mode 100644 index 000000000..f8a711b59 --- /dev/null +++ b/apps/maple-research/frontend/vite.auth.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; +import derPlugin from "./vite-der-plugin"; +import { assertAuthBundleIsolation } from "./auth-build-boundary"; + +const ignoreEnvFiles = process.env.MAPLE_IGNORE_VITE_ENV_FILES === "1"; + +export default defineConfig({ + envDir: ignoreEnvFiles ? false : undefined, + publicDir: "public-auth", + plugins: [ + react(), + derPlugin(), + { + name: "auth-entry-html", + generateBundle: { + order: "post", + handler(_options, bundle) { + assertAuthBundleIsolation(this.getModuleIds(), path.resolve(__dirname, "src")); + const entry = bundle["auth.html"]; + if (entry?.type !== "asset") this.error("The dedicated auth HTML entry was not emitted"); + delete bundle["auth.html"]; + entry.fileName = "index.html"; + bundle["index.html"] = entry; + } + }, + configureServer(server) { + server.middlewares.use((request, _response, next) => { + // Vite's development fallback would otherwise serve the full-app index.html. + if (request.headers.accept?.includes("text/html")) request.url = "/auth.html"; + next(); + }); + } + } + ], + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + dedupe: ["react", "react-dom"] + }, + build: { + outDir: "dist-auth", + emptyOutDir: true, + rollupOptions: { input: path.resolve(__dirname, "auth.html") } + }, + server: { host: "127.0.0.1", port: 5174, strictPort: true } +}); diff --git a/apps/maple-research/frontend/vite.config.ts b/apps/maple-research/frontend/vite.config.ts index 7c54b81d3..9fdaf9c61 100644 --- a/apps/maple-research/frontend/vite.config.ts +++ b/apps/maple-research/frontend/vite.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ envDir: ignoreEnvFiles ? path.resolve(__dirname, "src-tauri") : undefined, plugins: [TanStackRouterVite({ autoCodeSplitting: true }), react(), derPlugin()], resolve: { + // A locally linked SDK must use the application's React singleton. + dedupe: ["react", "react-dom"], alias: { "@": path.resolve(__dirname, "./src") } diff --git a/docs/pages-deployments.md b/docs/pages-deployments.md index ddd90c98c..7bba0eee3 100644 --- a/docs/pages-deployments.md +++ b/docs/pages-deployments.md @@ -112,3 +112,83 @@ nix build --no-update-lock-file --no-link --print-build-logs .#checks.x86_64-lin nix flake check --no-update-lock-file MAPLE_WEB_ENVIRONMENT=pr nix develop --no-update-lock-file .#ci -c ./scripts/ci/web.sh ``` + +## Independent auth site + +The auth site has a separate entry point, artifact and publication path. It does +not follow Maple desktop releases or change the existing app publisher. The +source implementation is described in [Auth site](../apps/maple-research/docs/auth-site.md). + +| Lane | Source and configuration | Result | +| --- | --- | --- | +| `Auth Pages CI` | PRs targeting any base, including forks and stacked branches; relevant master pushes; `pr` profile | Offline checks, ordinary frontend checks and web build, separate auth build; no publication | +| `Auth Pages build` | Manual dispatch on protected `master`; `release` profile | `maple-auth-production-RUN-ATTEMPT` artifact containing `maple-auth-dist.tar.gz` and `pages-artifact.json` | +| `Publish Auth Pages` | Separate manual dispatch on protected `master`, selecting the exact successful build run and attempt | Fixed `maple-auth` Pages project, `maple-auth.pages.dev`, `auth-pages-production` Git ref and protected environment, reported URL `https://auth.trymaple.ai` | + +`MAPLE_AUTH_PAGES_PRODUCTION_ENABLED` must be the literal string `true` in both +the workflow and publisher process. It is off when absent, empty or false. +Merging these files starts neither production auth publication nor native-client +entry changes. No auth preview is automatically hosted. The native auth-entry +origin remains the existing apex until a separately authorized rollout changes it. + +`scripts/ci/auth-web.sh` runs the separate `build:auth` recipe. Its Vite entry +is `auth.html`; the final static root is `dist-auth/index.html`. The build uses +the existing fixed `pr` or `release` service profiles. Build/run commands use +Bun's `--no-env-file`, and the auth Vite configuration disables dotenv loading. +Dependency installation uses the frozen frontend lockfile with install lifecycle +scripts disabled. The pinned Bun 1.3.5 installer can still read local dotenv files +despite that flag, consistent with the [upstream installer issue](https://github.com/oven-sh/bun/issues/31450). +Its child-process environment does not propagate back to the shell's fixed build +profile. The build scripts never rename or move managed dotenv files; fresh +production CI checkouts contain no managed workspace dotenv files. The pinned CI +shell provides the Node, Bun and Python runtimes used by these scripts. + +The stacked development dependency may use `file:../../../sdk`. A production +auth build rejects that link before installing dependencies: it requires an exact +stable `@mapleai/sdk` version of at least `4.1.0`, rejects SDK source overrides, +and checks the installed package name/version and that it resolves inside the +frozen `node_modules` installation. The SDK must first be published and the +frontend manifest and lockfile updated to that exact registry version. This +offline gate does not itself publish the SDK or query the registry. + +The auth publisher uses trusted master tooling and the same static archive, +download, Wrangler and credential boundaries described above. It accepts only +the auth build workflow's successful manual master run, current run attempt and +exact current master SHA in the expected repository. The archive name and +`auth-release` manifest profile cannot substitute for an app artifact. It +rechecks the selection before upload and after deployment. The auth production +ref must already exist, and advances without force; a stale build or non-forward +selection fails closed. Operators must create the project, ref, protected +environment, scoped credentials, custom-domain configuration and activation +variable separately. The project must have the fixed identity above and either +no Git source (a Direct Upload project) or an explicit +`source.config.production_deployments_enabled: false`. A present but malformed +Git-source configuration is rejected. The existing app project retains its +requirement for explicitly disabled native Git production builds. + +Only the final auth deploy step receives CF credentials. Its protected +`auth-pages-production` environment uses `deployment: false`, with the same +protection and explicit artifact-SHA status semantics as the app publisher. +The auth path does not write `pages-production` or report the app's public URL. + +Auth response headers come from the trusted publisher's fixed `AUTH_HEADERS` +constant, applied to `/*`: `Cache-Control: no-store, max-age=0`, +`X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer`, +`X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'`. +The publisher creates `_headers` only after re-extracting and checking all +producer asset hashes. Producer `_headers`, redirects and worker configuration +remain forbidden; the app publisher adds no headers. These source rules do not +establish the live Cloudflare cache policy: auth cache bypass, actual response +headers, custom domains, TLS/Access and browser/native handoff must be verified +during the separate dark-publication rehearsal before redirect activation. + +For an unprivileged local auth build (no services or publication): + +```bash +MAPLE_AUTH_ENVIRONMENT=pr nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh +``` + +The Pages offline test target also covers auth provenance, SDK pinning, static +artifact rejection, fixed response headers and destination isolation. A passing +build or publisher test is source evidence; retained-session login, callback +allowlists and deployed handoff behavior require the later rehearsal. diff --git a/flake.nix b/flake.nix index c4e98a561..7942fd7fd 100644 --- a/flake.nix +++ b/flake.nix @@ -195,7 +195,7 @@ actionlint rustToolchain ]; - ciPackages = [ rustupShim ] ++ commonPackages; + ciPackages = [ rustupShim pkgs.nodejs ] ++ commonPackages; linuxTauriPackages = with pkgs; @@ -689,9 +689,9 @@ # actionlint 1.7.10 predates GitHub's supported concurrency.queue key. # Release-gate tests assert that the only intended value is queue: max. for workflow in ./*.yml; do - if [ "$workflow" = ./pages-publish.yml ]; then + if [ "$workflow" = ./pages-publish.yml ] || [ "$workflow" = ./auth-pages-publish.yml ]; then # It also predates environment.deployment. Pages tests require - # false on both publisher jobs; keep this exception file-scoped. + # false on the publisher jobs; keep this exception file-scoped. actionlint -config-file ${./.github/actionlint.yaml} -ignore 'unexpected key "deployment" for "environment" section' "$workflow" else actionlint -config-file ${./.github/actionlint.yaml} -ignore 'unexpected key "queue" for "concurrency" section' "$workflow" diff --git a/scripts/ci/_common.sh b/scripts/ci/_common.sh index df715264a..460ef28b7 100755 --- a/scripts/ci/_common.sh +++ b/scripts/ci/_common.sh @@ -150,6 +150,9 @@ use_pr_environment() { export VITE_OS_FLAGS_BASE_URL="https://flags-dev.opensecret.cloud" export VITE_MAPLE_BILLING_API_URL="https://billing-dev.opensecret.cloud" export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6" + export VITE_AUTH_ORIGIN="https://trymaple.ai" + export VITE_APP_ORIGIN="https://trymaple.ai" + export VITE_MARKETING_ORIGIN="https://www.trymaple.ai" } use_release_environment() { @@ -167,6 +170,9 @@ use_release_environment() { export VITE_OS_FLAGS_BASE_URL="https://flags.opensecret.cloud" export VITE_MAPLE_BILLING_API_URL="https://billing.opensecret.cloud" export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6" + export VITE_AUTH_ORIGIN="https://trymaple.ai" + export VITE_APP_ORIGIN="https://trymaple.ai" + export VITE_MARKETING_ORIGIN="https://www.trymaple.ai" } configure_reproducible_build_metadata() { diff --git a/scripts/ci/auth-web.sh b/scripts/ci/auth-web.sh new file mode 100644 index 000000000..336faeb1e --- /dev/null +++ b/scripts/ci/auth-web.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +case "${MAPLE_AUTH_ENVIRONMENT:-pr}" in + pr) use_pr_environment ;; + release) + # Reject the staged local SDK link before any installation or build work. + python3 -I "${SCRIPT_DIR}/pages_auth_build.py" sdk-pin --frontend "${FRONTEND_DIR}" + use_release_environment + ;; + *) printf 'Unsupported auth build profile; expected pr or release.\n' >&2; exit 1 ;; +esac + +# Build/run subprocesses ignore dotenv files, and auth Vite uses envDir:false. +# Bun 1.3.5 install ignores --no-env-file and may still read local dotenv files; +# install scripts are disabled and its environment cannot change this parent +# shell's fixed build profile. Never move managed dotenv files to work around it. +real_bun="$(command -v bun)" +wrapper_dir="$(mktemp -d)" +trap 'rm -rf -- "$wrapper_dir"' EXIT +printf '#!/usr/bin/env bash\nexec %q --no-env-file "$@"\n' "$real_bun" >"$wrapper_dir/bun" +chmod +x "$wrapper_dir/bun" +export PATH="$wrapper_dir:$PATH" +export MAPLE_BUN_NO_ENV_FILE=1 MAPLE_IGNORE_VITE_ENV_FILES=1 +unset NODE_OPTIONS BUN_OPTIONS BUN_PRELOAD + +print_source_provenance +install_frontend_deps +if [ "${MAPLE_AUTH_ENVIRONMENT:-pr}" = release ]; then + python3 -I "${SCRIPT_DIR}/pages_auth_build.py" sdk-pin --frontend "${FRONTEND_DIR}" --installed +fi +configure_reproducible_build_metadata +cd "${FRONTEND_DIR}" +bun --no-env-file run build:auth +test -f dist-auth/index.html +scrub_host_metadata_files "${FRONTEND_DIR}/dist-auth" + +repro_dir="${TAURI_DIR}/target/reproducibility" +mkdir -p "$repro_dir" +auth_archive="$repro_dir/maple-auth-dist.tar.gz" +archive_tree_as_root_tar_gz "${FRONTEND_DIR}/dist-auth" "$auth_archive" +python3 -I "${SCRIPT_DIR}/pages_auth_build.py" artifact --archive "$auth_archive" +write_sha256_manifest "$repro_dir/auth-final.sha256" "$auth_archive" +print_file_hashes "$auth_archive" diff --git a/scripts/ci/pages_artifact.py b/scripts/ci/pages_artifact.py index 4a737b107..bfac07ad3 100644 --- a/scripts/ci/pages_artifact.py +++ b/scripts/ci/pages_artifact.py @@ -28,6 +28,7 @@ ARCHIVE_NAME = "maple-web-dist.tar.gz" +AUTH_ARCHIVE_NAME = "maple-auth-dist.tar.gz" MANIFEST_NAME = "pages-artifact.json" MAX_MANIFEST_BYTES = 16 * 1024 MAX_ARCHIVE_BYTES = 200 * 1024 * 1024 @@ -74,7 +75,7 @@ def validate_manifest(value: object) -> dict: raise ArtifactError("Invalid artifact manifest fields") if type(value["schema_version"]) is not int or value["schema_version"] != 1: raise ArtifactError("Unsupported artifact manifest version") - if value["profile"] not in ("pr", "release"): + if value["profile"] not in ("pr", "release", "auth-release"): raise ArtifactError("Invalid artifact build profile") if not _hex(value["source_sha"], 40) or not _hex(value["archive_sha256"], 64): raise ArtifactError("Invalid artifact digest or source identity") @@ -150,8 +151,15 @@ def read_preview_zip( expected_run_id: int, expected_run_attempt: int, output_archive: Path, + *, + archive_name: str = ARCHIVE_NAME, + expected_profile: str = "pr", ) -> dict: """Unwrap precisely the two expected GitHub artifact files, without extraction.""" + if (expected_profile, archive_name) not in { + ("pr", ARCHIVE_NAME), ("auth-release", AUTH_ARCHIVE_NAME), + }: + raise ArtifactError("Unsupported artifact archive/profile pair") if not _hex(expected_sha, 40) or not all( _positive_integer(value) for value in (expected_run_id, expected_run_attempt) ): @@ -166,7 +174,7 @@ def read_preview_zip( with zipfile.ZipFile(source) as bundle: entries = bundle.infolist() if len(entries) != 2 or {entry.filename for entry in entries} != { - ARCHIVE_NAME, + archive_name, MANIFEST_NAME, }: raise ArtifactError("Unexpected artifact ZIP entries") @@ -186,7 +194,7 @@ def read_preview_zip( raise ArtifactError("Artifact ZIP entry exceeds size limit") manifest = _load_manifest(bundle.read(MANIFEST_NAME)) if ( - manifest["profile"] != "pr" + manifest["profile"] != expected_profile or manifest["source_sha"] != expected_sha or manifest["run_id"] != expected_run_id or manifest["run_attempt"] != expected_run_attempt @@ -196,7 +204,7 @@ def read_preview_zip( raise ArtifactError("Artifact output already exists") descriptor, name = tempfile.mkstemp(prefix=".pages-archive-", dir=output_archive.parent) staged = Path(name) - with os.fdopen(descriptor, "wb") as target, bundle.open(ARCHIVE_NAME) as archive: + with os.fdopen(descriptor, "wb") as target, bundle.open(archive_name) as archive: actual_digest = _copy_bounded(archive, target, MAX_ARCHIVE_BYTES) if actual_digest != manifest["archive_sha256"]: raise ArtifactError("Artifact archive digest mismatch") @@ -388,7 +396,7 @@ def error(self, message: str) -> None: commands = parser.add_subparsers(dest="command", required=True) manifest = commands.add_parser("manifest", help="Write metadata for a built web archive") manifest.add_argument("--archive", required=True, type=Path) - manifest.add_argument("--profile", required=True, choices=("pr", "release")) + manifest.add_argument("--profile", required=True, choices=("pr", "release", "auth-release")) manifest.add_argument("--sha", required=True) manifest.add_argument("--run-id", required=True, type=int) manifest.add_argument("--run-attempt", required=True, type=int) diff --git a/scripts/ci/pages_auth_build.py b/scripts/ci/pages_auth_build.py new file mode 100644 index 000000000..1c1238ae5 --- /dev/null +++ b/scripts/ci/pages_auth_build.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Offline auth artifact validation and the production SDK dependency gate.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import sys +import tempfile + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pages_artifact import ArtifactError, extract_static, pack_manifest + + +def check_sdk_pin(frontend: Path, *, installed: bool = False) -> str: + manifest = json.loads((frontend / "package.json").read_text()) + version = manifest["dependencies"]["@mapleai/sdk"] + if not isinstance(version, str) or not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version): + raise ValueError("Production auth requires an exact published SDK version; local links are development-only") + if tuple(map(int, version.split("."))) < (4, 1, 0): + raise ValueError("Production auth requires SDK callback-selection support") + for field in ("overrides", "resolutions"): + if any("@mapleai/sdk" in name for name in manifest.get(field, {})): + raise ValueError("Production auth cannot override the pinned SDK source") + if installed: + modules = (frontend / "node_modules").resolve() + sdk = frontend / "node_modules/@mapleai/sdk" + if not sdk.resolve().is_relative_to(modules): + raise ValueError("Production auth SDK resolves outside the frozen dependency installation") + package = json.loads((sdk / "package.json").read_text()) + if package.get("name") != "@mapleai/sdk" or package.get("version") != version: + raise ValueError("Installed SDK does not match the production pin") + return version + + +def check_archive(archive: Path) -> None: + # Reuse the exact extraction boundary used by the credential-bearing publisher. + digest = pack_manifest(archive, "auth-release", "0" * 40, 1, 1)["archive_sha256"] + with tempfile.TemporaryDirectory(prefix="maple-auth-static-check-") as directory: + files = extract_static(archive, Path(directory) / "assets", digest) + print(f"Auth artifact contains {len(files)} validated static files.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + sdk = commands.add_parser("sdk-pin") + sdk.add_argument("--frontend", type=Path, required=True) + sdk.add_argument("--installed", action="store_true") + artifact = commands.add_parser("artifact") + artifact.add_argument("--archive", type=Path, required=True) + args = parser.parse_args() + try: + if args.command == "sdk-pin": + check_sdk_pin(args.frontend, installed=args.installed) + else: + check_archive(args.archive) + except (ArtifactError, ValueError, KeyError, TypeError, OSError): + print("Auth build validation failed: require static assets and an exact published SDK pin for production.", + file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/pages_auth_deploy.py b/scripts/ci/pages_auth_deploy.py new file mode 100644 index 000000000..beb2249f8 --- /dev/null +++ b/scripts/ci/pages_auth_deploy.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Publish a separately authorized, successful master auth build; never app releases.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.error import HTTPError, URLError + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import pages_deploy as pages +from pages_artifact import AUTH_ARCHIVE_NAME, extract_static + +BUILD_WORKFLOW = "auth-pages-build.yml" +REPOSITORY_ID = 923138240 +AUTH_HEADERS = """/* + Cache-Control: no-store, max-age=0 + X-Robots-Tag: noindex, nofollow + Referrer-Policy: no-referrer + X-Frame-Options: DENY + Content-Security-Policy: frame-ancestors 'none' +""" +DESTINATION = pages.Destination("maple-auth", "maple-auth.pages.dev", + "auth-pages-production", "auth-pages-production", + "https://auth.trymaple.ai", AUTH_HEADERS, allow_direct_upload=True) + + +def input_number(value): + pages.require(isinstance(value, str) and re.fullmatch(r"[1-9][0-9]{0,19}", value), + "Invalid auth build identifier") + return pages.number(int(value)) + + +def select_plan(gh, event, target="production"): + pages.require(target == "production" and "workflow_run" not in event, + "Auth publication requires a manual production selection") + repo = gh.get("") + pages.require(gh.repository_id == REPOSITORY_ID and repo["id"] == REPOSITORY_ID + and repo["default_branch"] == "master", "Unexpected auth repository identity") + inputs = event["inputs"] + pages.require(set(inputs) == {"build_run_id", "build_run_attempt"}, "Unexpected auth build inputs") + run_id, attempt = (input_number(inputs[key]) for key in ("build_run_id", "build_run_attempt")) + run = gh.run(run_id, BUILD_WORKFLOW, "workflow_dispatch", attempt) + pages.require(run["id"] == run_id and run["head_branch"] == "master", + "Auth build must be dispatched from master") + if gh.get("/git/ref/heads/master")["object"]["sha"] != run["head_sha"]: + raise pages.Superseded("Auth build no longer matches master; build the current revision") + previous_sha = pages.sha(gh.get(f"/git/ref/heads/{DESTINATION.production_branch}")["object"]["sha"]) + if previous_sha != run["head_sha"]: + pages.require(gh.get(f"/compare/{previous_sha}...{run['head_sha']}")["status"] == "ahead", + "Non-forward auth production change") + artifacts = gh.get(f"/actions/runs/{run_id}/artifacts?per_page=100") + pages.require(type(artifacts["total_count"]) is int and 0 < artifacts["total_count"] <= 100, + "Invalid auth build artifact count") + artifact_name = f"maple-auth-production-{run_id}-{attempt}" + matches = [item for item in artifacts["artifacts"] + if item["name"] == artifact_name and item["expired"] is False] + pages.require(len(matches) == 1, "Expected one auth artifact from this build attempt") + artifact = matches[0] + pages.require(type(artifact["size_in_bytes"]) is int + and 0 < artifact["size_in_bytes"] <= pages.MAX_DOWNLOAD, "Invalid auth artifact size") + return {"target": "production", "profile": "auth-release", "sha": run["head_sha"], + "branch": DESTINATION.production_branch, "previous_sha": previous_sha, + "run_id": run_id, "run_attempt": attempt, "artifact_id": pages.number(artifact["id"]), + "artifact_digest": pages.digest(artifact["digest"])} + + +def prepare(gh, event, state): + plan = select_plan(gh, event) + pages.require(not state.exists() and not state.is_symlink(), "Auth deployment state already exists") + state.mkdir(mode=0o700, parents=False) + archive_digest = pages.download_build_artifact(gh, plan, state, + archive_name=AUTH_ARCHIVE_NAME, + expected_profile="auth-release") + files = extract_static(state / "web.tar.gz", state / "assets", archive_digest) + (state / "plan.json").write_text(json.dumps({"selection": plan, "archive_digest": archive_digest, + "files": files})) + return plan + + +def require_publisher_environment(): + pages.require(os.environ.get("MAPLE_AUTH_PAGES_PRODUCTION_ENABLED") == "true", + "Auth production publication is disabled") + pages.require(os.environ.get("GITHUB_REF") == "refs/heads/master" + and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch", + "Auth publisher must be manually dispatched from protected master") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["prepare", "deploy"]) + parser.add_argument("--state", type=Path, required=True) + args = parser.parse_args() + require_publisher_environment() + checkout_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + pages.require(checkout_sha == pages.sha(os.environ.get("GITHUB_SHA")), + "Auth publisher checkout must match the trusted workflow SHA") + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + gh = pages.GitHub(pages.API("https://api.github.com", os.environ.get("GH_TOKEN", "")), + os.environ["GITHUB_REPOSITORY"], int(os.environ["GITHUB_REPOSITORY_ID"])) + state = args.state.resolve() + pages.require(state.parent == Path(os.environ["RUNNER_TEMP"]).resolve(), + "Auth state must be outside the checkout in runner temp") + if args.command == "prepare": + prepare(gh, event, state) + else: + pages.deploy(gh, event, state, destination=DESTINATION, selector=select_plan) + + +if __name__ == "__main__": + try: + main() + except (pages.Rejected, ValueError, KeyError, TypeError, OSError, + HTTPError, URLError, subprocess.SubprocessError): + print("Auth Pages publisher rejected the operation. Check activation, source, artifact, and destination prerequisites.", + file=sys.stderr) + sys.exit(1) diff --git a/scripts/ci/pages_deploy.py b/scripts/ci/pages_deploy.py index 821086425..3017e5f57 100644 --- a/scripts/ci/pages_deploy.py +++ b/scripts/ci/pages_deploy.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +from dataclasses import dataclass import hashlib import json import os @@ -34,6 +35,23 @@ MAX_DOWNLOAD = 200 * 1024 * 1024 +@dataclass(frozen=True) +class Destination: + """Trusted source configuration, never read from a build artifact.""" + + project: str + subdomain: str + production_branch: str + environment: str + public_url: str + headers: str | None = None + allow_direct_upload: bool = False + + +APP_DESTINATION = Destination(PROJECT, SUBDOMAIN, PRODUCTION_BRANCH, + "pages-production", "https://trymaple.ai") + + class Rejected(ValueError): """Category-only message: never echo attacker-controlled input or API bodies.""" @@ -241,11 +259,7 @@ def prepare(gh, event, target, state): plan = select_plan(gh, event, target) archive = state / "web.tar.gz" if target == "preview": - zipped = state / "artifact.zip" - gh.api.download(gh.root + f"/actions/artifacts/{plan['artifact_id']}/zip", zipped, - expected_digest=plan["artifact_digest"], accept="application/vnd.github+json") - manifest = read_preview_zip(zipped, plan["sha"], plan["run_id"], plan["run_attempt"], archive) - archive_digest = manifest["archive_sha256"] + archive_digest = download_build_artifact(gh, plan, state) else: for key, path in (("archive", archive), ("checksum", state / "web.sha256")): asset = plan[key] @@ -261,14 +275,27 @@ def prepare(gh, event, target, state): return plan -def cloudflare_project(cf, account, target): - project = cf.json(f"/accounts/{account}/pages/projects/{PROJECT}") +def download_build_artifact(gh, plan, state, **artifact_options): + zipped = state / "artifact.zip" + gh.api.download(gh.root + f"/actions/artifacts/{plan['artifact_id']}/zip", zipped, + expected_digest=plan["artifact_digest"], accept="application/vnd.github+json") + manifest = read_preview_zip(zipped, plan["sha"], plan["run_id"], plan["run_attempt"], + state / "web.tar.gz", **artifact_options) + return manifest["archive_sha256"] + + +def cloudflare_project(cf, account, target, destination=APP_DESTINATION): + project = cf.json(f"/accounts/{account}/pages/projects/{destination.project}") require(project.get("success") is True, "Cloudflare project lookup failed") project = project["result"] - require(project["name"] == PROJECT and project["subdomain"] == SUBDOMAIN - and project["production_branch"] == PRODUCTION_BRANCH, "Cloudflare project identity mismatch") + require(project["name"] == destination.project and project["subdomain"] == destination.subdomain + and project["production_branch"] == destination.production_branch, "Cloudflare project identity mismatch") if target == "production": - require(project.get("source", {}).get("config", {}).get("production_deployments_enabled") is False, + source = project.get("source") + no_git_source = destination.allow_direct_upload and source is None + disabled_git_builds = (isinstance(source, dict) and isinstance(source.get("config"), dict) + and source["config"].get("production_deployments_enabled") is False) + require(no_git_source or disabled_git_builds, "Disable Cloudflare automatic production builds before enabling the publisher") return project @@ -301,7 +328,7 @@ def require_clean_wrangler_ancestors(workdir): "Unexpected deployment configuration above Wrangler working directory") -def run_wrangler(plan, assets, account, token, workdir): +def run_wrangler(plan, assets, account, token, workdir, destination=APP_DESTINATION): root = Path(__file__).resolve().parents[2] executable = root / "services/updates/node_modules/wrangler/bin/wrangler.js" require(executable.is_file(), "Pinned Wrangler is not installed") @@ -309,7 +336,7 @@ def run_wrangler(plan, assets, account, token, workdir): require(node is not None, "Pinned Node runtime is not available") require_clean_wrangler_ancestors(workdir) environment = wrangler_environment(account, token, workdir) - command = [node, str(executable), "pages", "deploy", str(assets), "--project-name", PROJECT, + command = [node, str(executable), "pages", "deploy", str(assets), "--project-name", destination.project, "--branch", plan["branch"], "--commit-hash", plan["sha"], "--commit-dirty=false", "--commit-message", f"Maple {plan['profile']} {plan['sha']}", "--no-bundle"] # Never echo Wrangler output: remote errors and artifact names are untrusted. @@ -321,20 +348,20 @@ def run_wrangler(plan, assets, account, token, workdir): results = [result for result in results if result.get("type") == "pages-deploy-detailed"] require(len(results) == 1, "Unexpected Wrangler result") result = results[0] - require(result["pages_project"] == PROJECT and result["environment"] == plan["target"] + require(result["pages_project"] == destination.project and result["environment"] == plan["target"] and result["deployment_trigger"]["metadata"]["commit_hash"] == plan["sha"], "Wrangler deployment mismatch") require(re.fullmatch(r"[0-9a-f-]{36}", result["deployment_id"]), "Invalid deployment ID") - require(re.fullmatch(r"https://[0-9a-f]{8}\." + re.escape(SUBDOMAIN), result["url"]), "Unexpected deployment URL") + require(re.fullmatch(r"https://[0-9a-f]{8}\." + re.escape(destination.subdomain), result["url"]), "Unexpected deployment URL") return result -def verify_deployment(cf, account, plan, result): - path = f"/accounts/{account}/pages/projects/{PROJECT}" +def verify_deployment(cf, account, plan, result, destination=APP_DESTINATION): + path = f"/accounts/{account}/pages/projects/{destination.project}" for _ in range(30): response = cf.json(path + f"/deployments/{result['deployment_id']}") require(response.get("success") is True, "Cloudflare deployment lookup failed") deployment = response["result"] - require(deployment["environment"] == plan["target"] and deployment["project_name"] == PROJECT + require(deployment["environment"] == plan["target"] and deployment["project_name"] == destination.project and deployment["url"] == result["url"] and deployment["deployment_trigger"]["metadata"]["commit_hash"] == plan["sha"] and deployment["deployment_trigger"]["metadata"]["branch"] == plan["branch"], "Cloudflare deployment mismatch") @@ -342,7 +369,7 @@ def verify_deployment(cf, account, plan, result): and deployment["latest_stage"]["status"] == "success"): if plan["target"] == "preview": return - project = cloudflare_project(cf, account, "production") + project = cloudflare_project(cf, account, "production", destination) if project["canonical_deployment"]["id"] == result["deployment_id"]: return require(deployment["latest_stage"]["status"] not in {"failure", "canceled"}, "Cloudflare deployment failed") @@ -350,13 +377,13 @@ def verify_deployment(cf, account, plan, result): raise Rejected("Cloudflare deployment did not become active") -def report(gh, plan, result): - environment = "pages-production" if plan["target"] == "production" else f"pages-{plan['branch']}" +def report(gh, plan, result, destination=APP_DESTINATION): + environment = destination.environment if plan["target"] == "production" else f"pages-{plan['branch']}" deployment = gh.write("/deployments", {"ref": plan["sha"], "environment": environment, "auto_merge": False, "required_contexts": [], "transient_environment": plan["target"] == "preview", "production_environment": plan["target"] == "production", "description": "Verified static Pages artifact"}) - public_url = "https://trymaple.ai" if plan["target"] == "production" else result["url"] + public_url = destination.public_url if plan["target"] == "production" else result["url"] gh.write(f"/deployments/{number(deployment['id'])}/statuses", {"state": "success", "environment_url": public_url, "description": "Cloudflare deployment verified; application smoke is separate", "auto_inactive": True}) if plan.get("pr_number"): @@ -371,35 +398,43 @@ def report(gh, plan, result): gh.write(f"/issues/{plan['pr_number']}/comments", {"body": body}) -def deploy(gh, event, state): +def add_trusted_headers(assets, destination): + """Only trusted publisher source may supply Pages response-header rules.""" + if destination.headers is not None: + with (assets / "_headers").open("x", encoding="utf-8", newline="\n") as output: + output.write(destination.headers) + + +def deploy(gh, event, state, *, destination=APP_DESTINATION, selector=select_plan): saved = json.loads((state / "plan.json").read_text()) plan = saved["selection"] - if select_plan(gh, event, plan["target"]) != plan: + if selector(gh, event, plan["target"]) != plan: raise Superseded("Deployment selection changed before upload") account, token = os.environ.get("CLOUDFLARE_ACCOUNT_ID", ""), os.environ.get("CLOUDFLARE_API_TOKEN", "") require(re.fullmatch(r"[0-9a-f]{32}", account), "Invalid Cloudflare account ID") cf = API("https://api.cloudflare.com/client/v4", token) - cloudflare_project(cf, account, plan["target"]) + cloudflare_project(cf, account, plan["target"], destination) # Re-extract under a new directory; no cache or previously extracted files # can alter what the credential-bearing Wrangler process sees. with tempfile.TemporaryDirectory(prefix="maple-pages-upload-", dir=state.parent) as temp: workspace = Path(temp) files = extract_static(state / "web.tar.gz", workspace / "assets", saved["archive_digest"]) require(files == saved["files"], "Prepared artifact changed") + add_trusted_headers(workspace / "assets", destination) workdir = workspace / "runner" workdir.mkdir() - result = run_wrangler(plan, workspace / "assets", account, token, workdir) - verify_deployment(cf, account, plan, result) + result = run_wrangler(plan, workspace / "assets", account, token, workdir, destination) + verify_deployment(cf, account, plan, result, destination) if plan["target"] == "production": - if select_plan(gh, event, "production") != plan: + if selector(gh, event, "production") != plan: raise Superseded("Release changed during deployment; inspect the deployed result") if plan["previous_sha"] != plan["sha"]: - updated = gh.write(f"/git/refs/heads/{PRODUCTION_BRANCH}", {"sha": plan["sha"], "force": False}, "PATCH") + updated = gh.write(f"/git/refs/heads/{destination.production_branch}", {"sha": plan["sha"], "force": False}, "PATCH") require(updated["object"]["sha"] == plan["sha"], "Production ref update failed") else: - if select_plan(gh, event, "preview") != plan: + if selector(gh, event, "preview") != plan: raise Superseded("Preview changed during deployment; a newer preview is required") - report(gh, plan, result) + report(gh, plan, result, destination) summary = (f"### Maple Pages {plan['target']}\n\n" f"- Source: `{plan['sha']}`; profile: `{plan['profile']}`.\n" f"- Deployment: {result['url']}\n" diff --git a/scripts/ci/test_pages_auth_build.py b/scripts/ci/test_pages_auth_build.py new file mode 100644 index 000000000..4f6d4a407 --- /dev/null +++ b/scripts/ci/test_pages_auth_build.py @@ -0,0 +1,95 @@ +"""Release pin tests use synthetic local packages and never install dependencies.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pages_auth_build import check_sdk_pin + + +class AuthBuildProfileTests(unittest.TestCase): + def test_fixed_profiles_replace_inherited_vite_values_and_keep_apex_entry(self): + common = Path(__file__).resolve().parent / "_common.sh" + shared = {"VITE_CLIENT_ID": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", + "VITE_AUTH_ORIGIN": "https://trymaple.ai", "VITE_APP_ORIGIN": "https://trymaple.ai", + "VITE_MARKETING_ORIGIN": "https://www.trymaple.ai"} + for profile, expected in ( + ("pr", {"VITE_OPEN_SECRET_API_URL": "https://enclave.secretgpt.ai", + "VITE_OPEN_SECRET_PCR_ENVIRONMENT": "development", + "VITE_OS_FLAGS_BASE_URL": "https://flags-dev.opensecret.cloud", + "VITE_MAPLE_BILLING_API_URL": "https://billing-dev.opensecret.cloud"}), + ("release", {"VITE_OPEN_SECRET_API_URL": "https://enclave.trymaple.ai", + "VITE_OPEN_SECRET_PCR_ENVIRONMENT": "production", + "VITE_OS_FLAGS_BASE_URL": "https://flags.opensecret.cloud", + "VITE_MAPLE_BILLING_API_URL": "https://billing.opensecret.cloud"}), + ): + with self.subTest(profile=profile): + expected = {**shared, **expected} + environment = {"PATH": os.environ["PATH"], "VITE_UNEXPECTED": "synthetic", + **{key: "https://inherited.invalid" for key in expected}} + result = subprocess.run( + ["bash", "-c", 'source "$1"; "$2"; "$3" -I -c "$4"', "profile-test", + str(common), f"use_{profile}_environment", sys.executable, + 'import json,os; print(json.dumps({k:v for k,v in os.environ.items() if k.startswith("VITE_")}))'], + env=environment, text=True, capture_output=True, check=True, + ) + self.assertEqual(json.loads(result.stdout), expected) + + +class AuthSDKPinTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.frontend = Path(self.directory.name) / "frontend" + self.frontend.mkdir() + + def manifest(self, version, **extra): + (self.frontend / "package.json").write_text(json.dumps({"dependencies": {"@mapleai/sdk": version}, **extra})) + + def test_exact_callback_capable_versions(self): + for version in ("4.1.0", "4.1.1", "5.0.0"): + self.manifest(version) + self.assertEqual(check_sdk_pin(self.frontend), version) + + def test_local_unpublished_range_and_older_versions_rejected(self): + for version in ("file:../../../sdk", "link:../../../sdk", "workspace:*", "github:owner/sdk", + "^4.1.0", "~4.1.0", "latest", "4.1.0-rc.1", "4.0.9", "04.1.0", None): + with self.subTest(version=version): + self.manifest(version) + with self.assertRaises(ValueError): + check_sdk_pin(self.frontend) + + def test_manifest_overrides_cannot_replace_the_pin(self): + for field in ("overrides", "resolutions"): + self.manifest("4.1.0", **{field: {"@mapleai/sdk": "file:../../../sdk"}}) + with self.assertRaises(ValueError): + check_sdk_pin(self.frontend) + + def test_installed_name_version_and_link_destination_must_match(self): + self.manifest("4.1.0") + sdk = self.frontend / "node_modules/@mapleai/sdk" + sdk.mkdir(parents=True) + for name, version in (("@mapleai/sdk", "4.1.0"), ("wrong", "4.1.0"), ("@mapleai/sdk", "4.0.0")): + (sdk / "package.json").write_text(json.dumps({"name": name, "version": version})) + if name == "@mapleai/sdk" and version == "4.1.0": + self.assertEqual(check_sdk_pin(self.frontend, installed=True), "4.1.0") + else: + with self.assertRaises(ValueError): + check_sdk_pin(self.frontend, installed=True) + (sdk / "package.json").unlink() + sdk.rmdir() + external = Path(self.directory.name) / "sdk" + external.mkdir() + (external / "package.json").write_text(json.dumps({"name": "@mapleai/sdk", "version": "4.1.0"})) + sdk.symlink_to(external, target_is_directory=True) + with self.assertRaises(ValueError): + check_sdk_pin(self.frontend, installed=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_pages_auth_deploy.py b/scripts/ci/test_pages_auth_deploy.py new file mode 100644 index 000000000..054fc3b5f --- /dev/null +++ b/scripts/ci/test_pages_auth_deploy.py @@ -0,0 +1,274 @@ +"""Offline auth provenance, static artifact, and destination boundary regressions.""" + +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import Mock, patch +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import pages_artifact as artifact +import pages_auth_deploy as auth +import pages_deploy as pages + +SHA, OLD_SHA, OTHER_SHA = "a" * 40, "b" * 40, "c" * 40 + + +def write_archive(path, extra=None): + entries = {"index.html": b"Auth", "assets/auth.js": b"export {};"} + entries.update(extra or {}) + with tarfile.open(path, "w:gz") as archive: + for name, content in entries.items(): + entry = tarfile.TarInfo(name) + entry.size = len(content) + archive.addfile(entry, io.BytesIO(content)) + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class MemoryAPI: + def __init__(self, values): + self.values = values + + def json(self, path, method="GET", data=None): + if method != "GET": + raise AssertionError("Selection cannot write remote state") + return copy.deepcopy(self.values[path]) + + +class AuthProvenanceTests(unittest.TestCase): + def setUp(self): + self.root = "/repos/OpenSecretCloud/Maple" + self.run = {"id": 100, "workflow_id": 10, "path": ".github/workflows/auth-pages-build.yml", + "event": "workflow_dispatch", "status": "completed", "conclusion": "success", + "run_attempt": 2, "head_sha": SHA, "head_branch": "master", + "repository": {"id": auth.REPOSITORY_ID}, "head_repository": {"id": auth.REPOSITORY_ID}} + self.asset = {"id": 300, "name": "maple-auth-production-100-2", "expired": False, + "digest": "sha256:" + "d" * 64, "size_in_bytes": 1000} + self.values = {self.root: {"id": auth.REPOSITORY_ID, "default_branch": "master"}, + self.root + "/actions/runs/100": self.run, + self.root + "/actions/workflows/auth-pages-build.yml": {"id": 10}, + self.root + "/git/ref/heads/master": {"object": {"sha": SHA}}, + self.root + "/git/ref/heads/auth-pages-production": {"object": {"sha": OLD_SHA}}, + self.root + f"/compare/{OLD_SHA}...{SHA}": {"status": "ahead"}, + self.root + "/actions/runs/100/artifacts?per_page=100": + {"total_count": 1, "artifacts": [self.asset]}} + self.gh = pages.GitHub(MemoryAPI(self.values), "OpenSecretCloud/Maple", auth.REPOSITORY_ID) + self.event = {"inputs": {"build_run_id": "100", "build_run_attempt": "2"}} + + def select(self): + return auth.select_plan(self.gh, self.event) + + def test_exact_successful_manual_master_build(self): + plan = self.select() + self.assertEqual((plan["profile"], plan["branch"], plan["sha"]), + ("auth-release", "auth-pages-production", SHA)) + self.assertEqual((plan["run_id"], plan["run_attempt"], plan["artifact_id"]), (100, 2, 300)) + + def test_wrong_workflow_event_status_attempt_and_branch(self): + for key, value in (("workflow_id", 12), ("path", ".github/workflows/release.yml"), + ("event", "release"), ("conclusion", "failure"), ("status", "in_progress"), + ("run_attempt", 3), ("head_branch", "feature"), ("id", 200)): + with self.subTest(key=key): + old = self.run[key] + self.run[key] = value + with self.assertRaises(pages.Rejected): + self.select() + self.run[key] = old + + def test_foreign_repository_and_fork_source(self): + for key in ("repository", "head_repository"): + with self.subTest(key=key): + self.run[key]["id"] = 99 + with self.assertRaises(pages.Rejected): + self.select() + self.run[key]["id"] = auth.REPOSITORY_ID + self.gh.repository_id = 99 + with self.assertRaises(pages.Rejected): + self.select() + + def test_source_and_ref_must_be_current_and_forward(self): + self.values[self.root + "/git/ref/heads/master"]["object"]["sha"] = OTHER_SHA + with self.assertRaises(pages.Superseded): + self.select() + self.values[self.root + "/git/ref/heads/master"]["object"]["sha"] = SHA + self.values[self.root + f"/compare/{OLD_SHA}...{SHA}"]["status"] = "behind" + with self.assertRaises(pages.Rejected): + self.select() + self.values[self.root + "/git/ref/heads/auth-pages-production"]["object"]["sha"] = SHA + self.assertEqual(self.select()["previous_sha"], SHA) + + def test_missing_production_ref_is_not_created(self): + del self.values[self.root + "/git/ref/heads/auth-pages-production"] + with self.assertRaises(KeyError): + self.select() + + def test_artifact_must_match_attempt_and_be_single_unexpired_signed(self): + for key, value in (("name", "maple-auth-production-100-1"), ("expired", True), + ("digest", None), ("size_in_bytes", 0), ("size_in_bytes", True)): + with self.subTest(key=key, value=value): + old = self.asset[key] + self.asset[key] = value + with self.assertRaises(pages.Rejected): + self.select() + self.asset[key] = old + self.values[self.root + "/actions/runs/100/artifacts?per_page=100"]["artifacts"].append(self.asset) + with self.assertRaises(pages.Rejected): + self.select() + + def test_manual_input_is_not_a_source_or_destination_override(self): + for value in ("0", "01", "1.0", "100; echo unsafe", 100, True, "1" * 21): + with self.subTest(value=value), self.assertRaises(pages.Rejected): + auth.input_number(value) + self.event["inputs"]["project"] = "maple" + with self.assertRaises(pages.Rejected): + self.select() + del self.event["inputs"]["project"] + self.event["workflow_run"] = self.run + with self.assertRaises(pages.Rejected): + self.select() + + def test_activation_and_protected_dispatch_required(self): + environment = {"MAPLE_AUTH_PAGES_PRODUCTION_ENABLED": "true", "GITHUB_REF": "refs/heads/master", + "GITHUB_EVENT_NAME": "workflow_dispatch"} + with patch.dict(os.environ, environment, clear=True): + auth.require_publisher_environment() + for key, value in (("MAPLE_AUTH_PAGES_PRODUCTION_ENABLED", "TRUE"), + ("MAPLE_AUTH_PAGES_PRODUCTION_ENABLED", "false"), + ("MAPLE_AUTH_PAGES_PRODUCTION_ENABLED", ""), + ("GITHUB_REF", "refs/heads/feature"), ("GITHUB_EVENT_NAME", "workflow_run")): + with self.subTest(key=key, value=value), patch.dict(os.environ, {**environment, key: value}, clear=True): + with self.assertRaises(pages.Rejected): + auth.require_publisher_environment() + + +class AuthArtifactTests(unittest.TestCase): + def test_auth_zip_requires_auth_archive_and_manifest_profile(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "auth.tar.gz" + write_archive(archive) + for archive_name, profile, accepted in ((artifact.AUTH_ARCHIVE_NAME, "auth-release", True), + (artifact.ARCHIVE_NAME, "pr", False), + (artifact.AUTH_ARCHIVE_NAME, "pr", False), + (artifact.AUTH_ARCHIVE_NAME, "release", False)): + with self.subTest(name=archive_name, profile=profile): + zipped = root / "artifact.zip" + with zipfile.ZipFile(zipped, "w") as output: + output.write(archive, archive_name) + output.writestr(artifact.MANIFEST_NAME, json.dumps(artifact.pack_manifest(archive, profile, SHA, 100, 2))) + destination = root / (profile + archive_name) + if accepted: + result = artifact.read_preview_zip(zipped, SHA, 100, 2, destination, + archive_name=artifact.AUTH_ARCHIVE_NAME, expected_profile="auth-release") + self.assertEqual(result["profile"], "auth-release") + else: + with self.assertRaises(artifact.ArtifactError): + artifact.read_preview_zip(zipped, SHA, 100, 2, destination, + archive_name=artifact.AUTH_ARCHIVE_NAME, expected_profile="auth-release") + + def test_producer_cannot_supply_header_or_worker_configuration(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in ("_headers", "_redirects", "_worker.js", "functions/index.js"): + with self.subTest(name=name): + archive = root / "auth.tar.gz" + digest = write_archive(archive, {name: b"untrusted"}) + with self.assertRaises(artifact.ArtifactError): + artifact.extract_static(archive, root / name.replace("/", "-"), digest) + + +class AuthDestinationTests(unittest.TestCase): + def test_exact_trusted_headers_and_app_unchanged(self): + expected = ("/*\n Cache-Control: no-store, max-age=0\n X-Robots-Tag: noindex, nofollow\n" + " Referrer-Policy: no-referrer\n X-Frame-Options: DENY\n" + " Content-Security-Policy: frame-ancestors 'none'\n") + with tempfile.TemporaryDirectory() as directory: + assets = Path(directory) + pages.add_trusted_headers(assets, pages.APP_DESTINATION) + self.assertEqual(list(assets.iterdir()), []) + pages.add_trusted_headers(assets, auth.DESTINATION) + self.assertEqual((assets / "_headers").read_bytes(), expected.encode()) + with self.assertRaises(FileExistsError): + pages.add_trusted_headers(assets, auth.DESTINATION) + + def test_cloudflare_auth_identity_and_disabled_native_builds(self): + project = {"name": "maple-auth", "subdomain": "maple-auth.pages.dev", + "production_branch": "auth-pages-production", + "source": {"config": {"production_deployments_enabled": False}}} + cf = Mock() + cf.json.return_value = {"success": True, "result": project} + pages.cloudflare_project(cf, "a" * 32, "production", auth.DESTINATION) + self.assertIn("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/projects/maple-auth", cf.json.call_args.args[0]) + with self.assertRaises(pages.Rejected): + pages.cloudflare_project(cf, "a" * 32, "production") + project["source"]["config"]["production_deployments_enabled"] = True + with self.assertRaises(pages.Rejected): + pages.cloudflare_project(cf, "a" * 32, "production", auth.DESTINATION) + + def test_report_uses_auth_environment_and_public_url(self): + gh = Mock() + gh.write.return_value = {"id": 9} + pages.report(gh, {"target": "production", "sha": SHA}, {"url": "https://12345678.maple-auth.pages.dev"}, auth.DESTINATION) + self.assertEqual(gh.write.call_args_list[0].args[1]["environment"], "auth-pages-production") + self.assertEqual(gh.write.call_args_list[1].args[1]["environment_url"], "https://auth.trymaple.ai") + + def test_auth_direct_upload_source_policy_is_fail_closed(self): + project = {"name": "maple-auth", "subdomain": "maple-auth.pages.dev", + "production_branch": "auth-pages-production"} + cf = Mock() + cf.json.return_value = {"success": True, "result": project} + pages.cloudflare_project(cf, "a" * 32, "production", auth.DESTINATION) + project["source"] = None + pages.cloudflare_project(cf, "a" * 32, "production", auth.DESTINATION) + for source in ({}, [], "github", False, {"config": None}, {"config": {}}, + {"config": {"production_deployments_enabled": True}}, + {"config": {"production_deployments_enabled": 0}}): + with self.subTest(source=source): + project["source"] = source + with self.assertRaises(pages.Rejected): + pages.cloudflare_project(cf, "a" * 32, "production", auth.DESTINATION) + project.update(name="maple", subdomain="maple-ca8.pages.dev", production_branch="pages-production") + for source in (None, {}): + project["source"] = source + with self.assertRaises(pages.Rejected): + pages.cloudflare_project(cf, "a" * 32, "production") + + def test_deploy_reextracts_adds_headers_and_advances_only_auth_ref(self): + with tempfile.TemporaryDirectory() as directory: + state = Path(directory) / "state" + state.mkdir() + digest = write_archive(state / "web.tar.gz") + files = artifact.extract_static(state / "web.tar.gz", state / "assets", digest) + # Prepared assets are not consumed by the credential-bearing upload. + (state / "assets/index.html").write_text("tampered prepared file") + plan = {"target": "production", "profile": "auth-release", "sha": SHA, + "previous_sha": OLD_SHA, "branch": "auth-pages-production"} + (state / "plan.json").write_text(json.dumps({"selection": plan, "archive_digest": digest, "files": files})) + gh = Mock() + gh.write.side_effect = [{"object": {"sha": SHA}}, {"id": 9}, {}] + selector = Mock(return_value=plan) + + def upload(selected, assets, account, token, workdir, destination): + self.assertEqual(destination, auth.DESTINATION) + self.assertEqual((assets / "index.html").read_bytes(), b"Auth") + self.assertEqual((assets / "_headers").read_text(), auth.AUTH_HEADERS) + return {"url": "https://12345678.maple-auth.pages.dev", "deployment_id": "a" * 36} + + with patch.dict(os.environ, {"CLOUDFLARE_ACCOUNT_ID": "a" * 32, "CLOUDFLARE_API_TOKEN": "synthetic"}, clear=True), \ + patch.object(pages, "API"), patch.object(pages, "cloudflare_project"), \ + patch.object(pages, "run_wrangler", side_effect=upload), patch.object(pages, "verify_deployment"): + pages.deploy(gh, {}, state, destination=auth.DESTINATION, selector=selector) + self.assertEqual(selector.call_count, 2) + update = gh.write.call_args_list[0] + self.assertEqual(update.args, ("/git/refs/heads/auth-pages-production", {"sha": SHA, "force": False}, "PATCH")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_pages_auth_workflows.py b/scripts/ci/test_pages_auth_workflows.py new file mode 100644 index 000000000..145c8e9e0 --- /dev/null +++ b/scripts/ci/test_pages_auth_workflows.py @@ -0,0 +1,237 @@ +"""Regression checks for the independent auth build and publisher authority.""" + +import copy +import unittest + +from test_pages_workflows import normalized, strings, workflow + + +CI = "auth-pages-ci.yml" +BUILD = "auth-pages-build.yml" +PUBLISH = "auth-pages-publish.yml" +CHECK_PAGES = ( + "nix build --no-update-lock-file --no-link --print-build-logs " + ".#checks.x86_64-linux.pages" +) +BUILD_AUTH = "nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh" +ARTIFACT_DIRECTORY = "apps/maple-research/frontend/src-tauri/target/reproducibility" + + +class AuthPagesWorkflowTests(unittest.TestCase): + def assert_no_secrets(self, value): + for text in strings(value): + self.assertNotRegex(text, r"\bsecrets\b") + + def test_ci_includes_stacked_pr_bases_and_forks(self): + ci = workflow(CI) + self.assertEqual(ci["name"], "Auth Pages CI") + self.assertEqual(set(ci["on"]), {"pull_request", "push"}) + # An allowlist of master would silently skip the SDK-based stacked PR. + self.assertNotIn("branches", ci["on"]["pull_request"]) + self.assertNotIn("branches-ignore", ci["on"]["pull_request"]) + self.assertEqual(ci["on"]["push"]["branches"], ["master"]) + self.assertEqual(set(ci["jobs"]), {"auth"}) + self.assertEqual( + normalized(ci["jobs"]["auth"]["if"]), + "github.event_name == 'pull_request' || " + "(github.event_name == 'push' && github.ref == 'refs/heads/master')", + ) + + def test_ci_covers_auth_and_shared_runtime_inputs(self): + events = workflow(CI)["on"] + self.assertEqual(events["pull_request"]["paths"], events["push"]["paths"]) + paths = events["pull_request"]["paths"] + self.assertEqual( + set(paths), + { + ".github/workflows/auth-pages-*.yml", ".github/workflows/pages-tests.yml", + "apps/maple-research/frontend/**", "!apps/maple-research/frontend/src-tauri/**", + "sdk/src/**", "!sdk/src/lib/test/**", "sdk/bun.lock", "sdk/bunfig.toml", + "sdk/package.json", "sdk/tsconfig.build.json", "sdk/tsconfig.json", + "sdk/vite.config.ts", "scripts/prepare-frontend-deps.sh", + "scripts/prepare-typescript-sdk.sh", "scripts/ci/_common.sh", + "scripts/ci/frontend.sh", "scripts/ci/web.sh", + "scripts/ci/auth-web.sh", "scripts/ci/pages_*.py", "scripts/ci/test_pages_*.py", + "flake.nix", "flake.lock", + }, + ) + self.assertLess(paths.index("apps/maple-research/frontend/**"), + paths.index("!apps/maple-research/frontend/src-tauri/**")) + self.assertLess(paths.index("sdk/src/**"), paths.index("!sdk/src/lib/test/**")) + for event in ("pull_request", "push"): + test_paths = workflow("pages-tests.yml")["on"][event]["paths"] + self.assertIn(".github/workflows/auth-pages-*.yml", test_paths) + self.assertIn("scripts/ci/auth-web.sh", test_paths) + + def test_builds_are_unprivileged_and_have_distinct_profiles(self): + for name, job_name, profile in ((CI, "auth", "pr"), (BUILD, "build", "release")): + with self.subTest(workflow=name): + config = workflow(name) + self.assertEqual(config["permissions"], {"contents": "read"}) + self.assert_no_secrets(config) + self.assertNotIn("env", config) + job = config["jobs"][job_name] + self.assertNotIn("permissions", job) + self.assertNotIn("environment", job) + self.assertNotIn("env", job) + steps = job["steps"] + checks = [step for step in steps if step.get("run") == CHECK_PAGES] + builds = [step for step in steps if step.get("run") == BUILD_AUTH] + self.assertEqual(len(checks), 1) + self.assertEqual(len(builds), 1) + self.assertLess(steps.index(checks[0]), steps.index(builds[0])) + self.assertEqual(builds[0]["env"], {"MAPLE_AUTH_ENVIRONMENT": profile}) + self.assertNotIn("if", builds[0]) + for step in steps: + self.assertNotIn("GH_TOKEN", step.get("env", {})) + self.assertNotIn("github.token", " ".join(strings(step.get("env", {})))) + ci_actions = [step.get("uses", "") for step in workflow(CI)["jobs"]["auth"]["steps"]] + self.assertFalse(any(action.startswith("actions/upload-artifact@") for action in ci_actions)) + + def test_ci_runs_shared_frontend_and_app_web_gates_on_stacked_prs(self): + steps = workflow(CI)["jobs"]["auth"]["steps"] + frontend = [step for step in steps if step.get("run") == + "nix develop --no-update-lock-file .#ci -c bash scripts/ci/frontend.sh"] + web = [step for step in steps if step.get("run") == + "nix develop --no-update-lock-file .#ci -c bash scripts/ci/web.sh"] + self.assertEqual(len(frontend), 1) + self.assertEqual(len(web), 1) + self.assertNotIn("if", frontend[0]) + self.assertNotIn("if", web[0]) + self.assertEqual(web[0]["env"], {"MAPLE_WEB_ENVIRONMENT": "pr"}) + self.assertLess(steps.index(frontend[0]), steps.index(web[0])) + + def test_production_build_is_manual_and_master_only(self): + build = workflow(BUILD) + self.assertEqual(build["name"], "Auth Pages build") + self.assertEqual(build["on"], {"workflow_dispatch": None}) + self.assertEqual(set(build["jobs"]), {"build"}) + job = build["jobs"]["build"] + self.assertEqual( + normalized(job["if"]), + "github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'", + ) + checkout = [step for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@")] + self.assertEqual(len(checkout), 1) + self.assertEqual(checkout[0]["with"], + {"ref": "${{ github.sha }}", "persist-credentials": False}) + + def test_production_artifact_is_bound_to_auth_source_run_and_attempt(self): + steps = workflow(BUILD)["jobs"]["build"]["steps"] + descriptions = [step["run"] for step in steps + if "scripts/ci/pages_artifact.py" in step.get("run", "")] + self.assertEqual(len(descriptions), 1) + describe = descriptions[0] + for argument in ( + f'artifact_dir="{ARTIFACT_DIRECTORY}"', + "nix develop --no-update-lock-file .#pages -c python3 -I scripts/ci/pages_artifact.py manifest", + '--archive "$artifact_dir/maple-auth-dist.tar.gz"', + "--profile auth-release", '--sha "$GITHUB_SHA"', + '--run-id "$GITHUB_RUN_ID"', '--run-attempt "$GITHUB_RUN_ATTEMPT"', + '--output "$artifact_dir/pages-artifact.json"', + ): + self.assertIn(argument, describe) + uploads = [step for step in steps + if step.get("uses", "").startswith("actions/upload-artifact@")] + self.assertEqual(len(uploads), 1) + upload = uploads[0]["with"] + self.assertEqual(upload["name"], + "maple-auth-production-${{ github.run_id }}-${{ github.run_attempt }}") + self.assertEqual(upload["path"].splitlines(), [ + f"{ARTIFACT_DIRECTORY}/maple-auth-dist.tar.gz", + f"{ARTIFACT_DIRECTORY}/pages-artifact.json", + ]) + self.assertEqual(upload["if-no-files-found"], "error") + + def test_publisher_is_manual_master_only_and_disabled_by_default(self): + publish = workflow(PUBLISH) + self.assertEqual(publish["name"], "Publish Auth Pages") + self.assertEqual(set(publish["on"]), {"workflow_dispatch"}) + inputs = publish["on"]["workflow_dispatch"]["inputs"] + self.assertEqual(set(inputs), {"build_run_id", "build_run_attempt"}) + for value in inputs.values(): + self.assertEqual(value["type"], "string") + self.assertIs(value["required"], True) + self.assertNotIn("default", value) + self.assertEqual(publish["permissions"], {"contents": "read"}) + self.assertEqual(set(publish["jobs"]), {"production"}) + job = publish["jobs"]["production"] + self.assertEqual( + normalized(job["if"]), + "vars.MAPLE_AUTH_PAGES_PRODUCTION_ENABLED == 'true' && " + "github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'", + ) + self.assertEqual(job["permissions"], + {"contents": "write", "actions": "read", "deployments": "write"}) + self.assertEqual(job["environment"], + {"name": "auth-pages-production", "deployment": False}) + self.assertEqual(job["concurrency"], + {"group": "pages-auth-production", "cancel-in-progress": False}) + app_job = workflow("pages-publish.yml")["jobs"]["production"] + self.assertNotEqual(job["concurrency"]["group"], app_job["concurrency"]["group"]) + self.assertNotEqual(job["environment"]["name"], app_job["environment"]["name"]) + + def test_publisher_executes_only_trusted_code_and_dependencies(self): + job = workflow(PUBLISH)["jobs"]["production"] + steps = job["steps"] + actions = [step["uses"].split("@")[0] for step in steps if "uses" in step] + self.assertEqual(actions, ["actions/checkout", "DeterminateSystems/nix-installer-action"]) + self.assertEqual(steps[0]["with"], + {"ref": "${{ github.sha }}", "persist-credentials": False}) + installs = [step for step in steps if "bun install" in step.get("run", "")] + self.assertEqual(len(installs), 1) + self.assertEqual(installs[0]["working-directory"], "services/updates") + self.assertEqual(installs[0]["run"], + "nix develop --no-update-lock-file ../..#pages -c bun install --frozen-lockfile --ignore-scripts") + self.assertNotIn("env", installs[0]) + for step in steps: + self.assertNotIn("${{", step.get("run", "")) + self.assertNotIn(".#ci", step.get("run", "")) + # Input IDs are parsed from the event by trusted Python, never shell code. + self.assertNotIn("inputs.", " ".join(strings(steps))) + self.assertNotIn("scripts/ci/auth-web.sh", " ".join(strings(steps))) + + def test_credentials_are_scoped_to_preparation_and_final_deployment(self): + publish = workflow(PUBLISH) + self.assertNotIn("env", publish) + job = publish["jobs"]["production"] + self.assertNotIn("env", job) + steps = job["steps"] + common_env = { + "GH_TOKEN": "${{ github.token }}", + "MAPLE_AUTH_PAGES_PRODUCTION_ENABLED": "${{ vars.MAPLE_AUTH_PAGES_PRODUCTION_ENABLED }}", + } + self.assertEqual(steps[-2]["env"], common_env) + self.assertEqual(steps[-1]["env"], { + **common_env, + "CLOUDFLARE_ACCOUNT_ID": "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}", + "CLOUDFLARE_API_TOKEN": "${{ secrets.CLOUDFLARE_API_TOKEN }}", + }) + for step, command in ((steps[-2], "prepare"), (steps[-1], "deploy")): + self.assertEqual(step["run"], + "nix develop --no-update-lock-file .#pages -c python3 -I " + f'scripts/ci/pages_auth_deploy.py {command} --state "$RUNNER_TEMP/maple-auth-pages"') + before_deploy = copy.deepcopy(job) + before_deploy["steps"] = steps[:-1] + self.assert_no_secrets(before_deploy) + for step in steps[:-2]: + self.assertNotIn("env", step) + + def test_actions_are_immutable_without_caches_or_persisted_credentials(self): + for name in (CI, BUILD, PUBLISH): + for job in workflow(name)["jobs"].values(): + for step in job["steps"]: + if "uses" not in step: + continue + with self.subTest(workflow=name, action=step["uses"]): + self.assertRegex(step["uses"], r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[0-9a-f]{40}$") + self.assertNotIn("cache", step["uses"].lower()) + if step["uses"].startswith("actions/checkout@"): + self.assertIs(step["with"]["persist-credentials"], False) + if step["uses"].startswith("DeterminateSystems/nix-installer-action@"): + self.assertEqual(step["with"]["github-token"], "") + + +if __name__ == "__main__": + unittest.main() From 7e8642f35443835ee0efc9e5d1ea48055ff62d87 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:16:15 +0000 Subject: [PATCH 2/5] auth: pin published TypeScript SDK 4.1.1 --- apps/maple-research/docs/auth-site.md | 6 +- apps/maple-research/frontend/bun.lock | 228 +--------------------- apps/maple-research/frontend/package.json | 2 +- docs/pages-deployments.md | 9 +- 4 files changed, 11 insertions(+), 234 deletions(-) diff --git a/apps/maple-research/docs/auth-site.md b/apps/maple-research/docs/auth-site.md index 96520900d..253f73024 100644 --- a/apps/maple-research/docs/auth-site.md +++ b/apps/maple-research/docs/auth-site.md @@ -66,6 +66,6 @@ activation flag, environment, project, and production ref. An app release does not publish the auth site. Provider registration, backend callback allowlists, and traffic redirection are separate rollout steps. -An unpublished local SDK link is supported during stacked development, but -must be replaced with the reviewed published SDK version before merging this -consumer change. The production auth build rejects a local SDK link. +The frontend pins published `@mapleai/sdk` 4.1.1 with a frozen registry lockfile. +Local SDK links remain supported during development; the production auth build +requires an exact published version and rejects local links. diff --git a/apps/maple-research/frontend/bun.lock b/apps/maple-research/frontend/bun.lock index 8862c3a70..ea50d8538 100644 --- a/apps/maple-research/frontend/bun.lock +++ b/apps/maple-research/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maple", "dependencies": { - "@mapleai/sdk": "file:../../../sdk", + "@mapleai/sdk": "4.1.1", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -235,17 +235,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@mapleai/sdk": ["@mapleai/sdk@file:../../../sdk", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "devDependencies": { "@eslint/js": "9.39.5", "@noble/curves": "1.9.7", "@noble/hashes": "1.8.0", "@types/bun": "1.1.13", "@types/react": "19.2.18", "@vitejs/plugin-react": "4.7.0", "ajv": "8.20.0", "eslint": "9.39.5", "eslint-plugin-react-hooks": "5.2.0", "globals": "15.15.0", "prettier": "3.9.6", "typescript": "5.6.3", "typescript-eslint": "8.66.0", "vite": "6.4.3", "vite-plugin-dts": "4.5.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }], - - "@microsoft/api-extractor": ["@microsoft/api-extractor@7.59.1", "", { "dependencies": { "@microsoft/api-extractor-model": "7.33.12", "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.24.1", "@rushstack/rig-package": "0.7.3", "@rushstack/terminal": "0.24.4", "@rushstack/ts-command-line": "5.3.14", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "sha512-GjRUqx1MTY7xuH36urwASkfBPzrdxYG+irVeV7C9JEdRtn509AgMmwit/BhvztQzIoFk9vhFDTVYOp2cjw+9Uw=="], - - "@microsoft/api-extractor-model": ["@microsoft/api-extractor-model@7.33.12", "", { "dependencies": { "@microsoft/tsdoc": "~0.16.0", "@microsoft/tsdoc-config": "~0.18.1", "@rushstack/node-core-library": "5.24.1" } }, "sha512-TdKOYgwf98xLjNW+y3iXIiCf4ZLQbilGAlqkMTTAqqAWfgRXAn9YCSGMsaiB1U7OPRvslUWg6n08EqQPbP93tA=="], - - "@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], - - "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.18.1", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg=="], - - "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@mapleai/sdk": ["@mapleai/sdk@4.1.1", "", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-znbUk+3mfHpeh5fU/ES7B/+p4P0Pyk0/F8b3k0u/GiWE7h1O0yt7TSuX+LdcMpQfCBywtPRasVl16lN6uleKhw=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -363,8 +353,6 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], @@ -415,16 +403,6 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], - "@rushstack/node-core-library": ["@rushstack/node-core-library@5.24.1", "", { "dependencies": { "ajv": "~8.20.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", "import-lazy": "~4.0.0", "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.7.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ZlOrzv92MwnsCXA45qWfDj4L/kTasKghXezu+M2WmdtkbnXyPnZSCovfeBZx1Yc5qm+LkElbLw6IeSSWZDhZUg=="], - - "@rushstack/problem-matcher": ["@rushstack/problem-matcher@0.2.1", "", { "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog=="], - - "@rushstack/rig-package": ["@rushstack/rig-package@0.7.3", "", { "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" } }, "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA=="], - - "@rushstack/terminal": ["@rushstack/terminal@0.24.4", "", { "dependencies": { "@rushstack/node-core-library": "5.24.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-3fRBWK0IMY293lBx5ycgit1DTMUi+nhOjALHlrIad9hQsqzM9Ak+XdBI1gJ/tZPxW+LraeAc4SsmMdcOflBmAQ=="], - - "@rushstack/ts-command-line": ["@rushstack/ts-command-line@5.3.14", "", { "dependencies": { "@rushstack/terminal": "0.24.4", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" } }, "sha512-lT2JKZk2dukBMp4GFOh4RaDfVzpZehGgQOGpzpSliUn317NgEmOOCXyd7/d0eU46HHsbRxizP83GAm39s0lAlg=="], - "@stablelib/aead": ["@stablelib/aead@2.0.0", "", {}, "sha512-U/RMANRxbT/ahIpYsPSiFwDFNjADHdnCFfmo09MO1ai2XmerPAOPtMl0qmX7XVvygnACC6ijKDyHBoT2rGyElg=="], "@stablelib/base64": ["@stablelib/base64@2.0.1", "", {}, "sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ=="], @@ -501,8 +479,6 @@ "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], - "@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], @@ -543,8 +519,6 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], @@ -569,34 +543,12 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], - - "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], - - "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], - - "@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="], - - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="], - - "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="], - - "@vue/language-core": ["@vue/language-core@2.2.0", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^0.4.9", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw=="], - - "@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "alien-signals": ["alien-signals@0.4.14", "", {}, "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q=="], - "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -669,10 +621,6 @@ "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], - - "confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], @@ -683,8 +631,6 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], - "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], @@ -737,12 +683,8 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -753,8 +695,6 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], - "fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -773,8 +713,6 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], - "fs-extra": ["fs-extra@11.3.6", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -789,8 +727,6 @@ "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -817,8 +753,6 @@ "hastscript": ["hastscript@9.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw=="], - "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -827,8 +761,6 @@ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], @@ -863,8 +795,6 @@ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], - "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -879,22 +809,16 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "local-pkg": ["local-pkg@1.2.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1013,12 +937,8 @@ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], @@ -1049,8 +969,6 @@ "parse5": ["parse5@7.2.1", "", { "dependencies": { "entities": "^4.5.0" } }, "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ=="], - "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1069,8 +987,6 @@ "pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="], - "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], - "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -1097,8 +1013,6 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], - "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -1147,8 +1061,6 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -1173,16 +1085,10 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1237,8 +1143,6 @@ "typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="], - "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1257,8 +1161,6 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], "update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="], @@ -1283,10 +1185,6 @@ "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], - "vite-plugin-dts": ["vite-plugin-dts@4.5.4", "", { "dependencies": { "@microsoft/api-extractor": "^7.50.1", "@rollup/pluginutils": "^5.1.4", "@volar/typescript": "^2.4.11", "@vue/language-core": "2.2.0", "compare-versions": "^6.1.1", "debug": "^4.4.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "magic-string": "^0.30.17" }, "peerDependencies": { "typescript": "*", "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg=="], - - "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -1327,30 +1225,10 @@ "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@mapleai/sdk/@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], - - "@mapleai/sdk/@types/bun": ["@types/bun@1.1.13", "", { "dependencies": { "bun-types": "1.1.34" } }, "sha512-KmQxSBgVWCl6RSuerlLGZlIWfdxkKqat0nxN61+qu4y1KDn0Ll3j7v1Pl8GnaL3a/U6GGWVTJh75ap62kR1E8Q=="], - - "@mapleai/sdk/@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], - - "@mapleai/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "@mapleai/sdk/eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="], - "@mapleai/sdk/openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], - "@mapleai/sdk/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], - - "@mapleai/sdk/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - - "@mapleai/sdk/typescript-eslint": ["typescript-eslint@8.66.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.66.0", "@typescript-eslint/parser": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw=="], - "@mapleai/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@microsoft/api-extractor/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@microsoft/tsdoc-config/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@opensecret/react-v1/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1371,14 +1249,6 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@rushstack/node-core-library/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "@rushstack/node-core-library/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@rushstack/terminal/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - - "@rushstack/ts-command-line/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "@types/babel__core/@babel/parser": ["@babel/parser@7.26.9", "", { "dependencies": { "@babel/types": "^7.26.9" }, "bin": "./bin/babel-parser.js" }, "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A=="], @@ -1411,10 +1281,6 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "babel-dead-code-elimination/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -1431,8 +1297,6 @@ "micromark-extension-math/katex": ["katex@0.16.21", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A=="], - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "openai/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -1453,8 +1317,6 @@ "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "vite-plugin-dts/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1465,30 +1327,6 @@ "@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@mapleai/sdk/@types/bun/bun-types": ["bun-types@1.1.34", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-br5QygTEL/TwB4uQOb96Ky22j4Gq2WxWH/8Oqv20fk5HagwKXo/akB+LiYgSfzexCt6kkcUaVm+bKiPl71xPvw=="], - - "@mapleai/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@mapleai/sdk/eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@mapleai/sdk/eslint/@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="], - - "@mapleai/sdk/eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - - "@mapleai/sdk/eslint/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.66.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/type-utils": "8.66.0", "@typescript-eslint/utils": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.66.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.66.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.66.0", "@typescript-eslint/tsconfig-utils": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.66.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA=="], - - "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@rushstack/node-core-library/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], @@ -1505,8 +1343,6 @@ "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "babel-dead-code-elimination/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "babel-dead-code-elimination/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], @@ -1519,8 +1355,6 @@ "babel-dead-code-elimination/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -1531,68 +1365,10 @@ "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@mapleai/sdk/@types/bun/bun-types/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], - - "@mapleai/sdk/eslint/@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.66.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.66.0", "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.66.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], - "babel-dead-code-elimination/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "babel-dead-code-elimination/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@mapleai/sdk/@types/bun/bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="], - "babel-dead-code-elimination/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - - "@mapleai/sdk/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], } } diff --git a/apps/maple-research/frontend/package.json b/apps/maple-research/frontend/package.json index d5eb92043..946449cda 100644 --- a/apps/maple-research/frontend/package.json +++ b/apps/maple-research/frontend/package.json @@ -52,7 +52,7 @@ "yaml": "^2.8.3" }, "dependencies": { - "@mapleai/sdk": "file:../../../sdk", + "@mapleai/sdk": "4.1.1", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/docs/pages-deployments.md b/docs/pages-deployments.md index 7bba0eee3..3ac612e99 100644 --- a/docs/pages-deployments.md +++ b/docs/pages-deployments.md @@ -143,12 +143,13 @@ profile. The build scripts never rename or move managed dotenv files; fresh production CI checkouts contain no managed workspace dotenv files. The pinned CI shell provides the Node, Bun and Python runtimes used by these scripts. -The stacked development dependency may use `file:../../../sdk`. A production -auth build rejects that link before installing dependencies: it requires an exact +The frontend pins published `@mapleai/sdk` 4.1.1. Development may use +`file:../../../sdk`; a production auth build rejects that link before installing +dependencies. It requires an exact stable `@mapleai/sdk` version of at least `4.1.0`, rejects SDK source overrides, and checks the installed package name/version and that it resolves inside the -frozen `node_modules` installation. The SDK must first be published and the -frontend manifest and lockfile updated to that exact registry version. This +frozen `node_modules` installation. Future upgrades must publish the SDK first, +then update the frontend manifest and lockfile to that exact registry version. This offline gate does not itself publish the SDK or query the registry. The auth publisher uses trusted master tooling and the same static archive, From 2500c86589564e98b50c49d24ee05af72d0c51ae Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:46:28 +0000 Subject: [PATCH 3/5] auth: address callback recovery review --- apps/maple-research/docs/auth-site.md | 23 +++- .../src/auth-site/CallbackRecovery.tsx | 29 +---- .../frontend/src/auth-site/HostedCallback.tsx | 2 +- .../frontend/src/auth-site/HostedStart.tsx | 9 +- .../src/auth-site/fixtures/AuthSite.case.tsx | 34 +++-- .../src/components/AppleAuthProvider.test.tsx | 122 ++++++++++++++++++ .../src/routes/auth.$provider.callback.tsx | 21 +++ .../frontend/tailwind.auth.config.cjs | 1 - flake.nix | 2 + 9 files changed, 188 insertions(+), 55 deletions(-) diff --git a/apps/maple-research/docs/auth-site.md b/apps/maple-research/docs/auth-site.md index 253f73024..71eb223a8 100644 --- a/apps/maple-research/docs/auth-site.md +++ b/apps/maple-research/docs/auth-site.md @@ -13,8 +13,10 @@ the legacy V1 bridge. Maple. They do not accept an arbitrary return URL. - `/auth/github/callback` and `/auth/google/callback` complete the pending browser flow and show the existing account confirmation before minting the - native handoff grant. Callback errors keep the address intact for clients - that explicitly ask the user to paste it. + native handoff grant. Callback errors keep the address intact and ask the + user to restart sign-in in Maple. Maple Agent's paste flow uses the configured + default callback on the web app, whose error page provides conditional + paste guidance; it does not use the auth site. - Apple uses its popup API with the existing Services ID `cloud.opensecret.maple.services`. It requires the auth domain and callback to be registered with Apple before live use. A static site cannot process @@ -28,8 +30,12 @@ existing behavior. Completing or cancelling this flow does not sign the user out of the web app or erase their browser credentials. Browser OAuth initiation explicitly selects a callback on the initiating -origin. The backend must allow that exact URL. The legacy V1 bridge remains -part of the web app and continues to use its default callback. +origin. Before the callback-aware backend and this frontend are live together, +verify that each exact current-origin callback equals its provider's default +or an additional allowlist entry. Equivalent routes or trailing-slash redirects +do not satisfy exact membership. Apply the check to development and preview +origins used for rehearsal as well. The legacy V1 bridge remains part of the +web app and continues to use its existing default callback. `VITE_AUTH_ORIGIN` selects the origin for native browser entry, using `/desktop-auth` on that origin. It accepts an HTTPS origin, or exact loopback @@ -45,9 +51,12 @@ From the repository root, use the pinned toolchain: nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh ``` -The default `pr` profile uses development services and ignores local dotenv -files. The script validates and archives the auth-only output. It does not -publish it or change OAuth settings. +The default `pr` profile uses development services. Build/run commands ignore +local dotenv files; the pinned installer limitation is documented in +[Pages deployments](../../../docs/pages-deployments.md#independent-auth-site). +The script validates and archives the auth-only output. It does not publish +it or change OAuth settings. The CI shell pins Node because the TypeScript +and Vite command-line tools invoked by Bun use Node shebangs. For a configured local development session, the frontend also exposes `dev:auth` (loopback port 5174) and `preview:auth`. Preserve any externally diff --git a/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx b/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx index c73af916a..dffea444e 100644 --- a/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx +++ b/apps/maple-research/frontend/src/auth-site/CallbackRecovery.tsx @@ -1,6 +1,3 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; - export function ApplePopupRecovery() { return (

@@ -11,29 +8,9 @@ export function ApplePopupRecovery() { } export function CallbackRecovery() { - const [copyStatus, setCopyStatus] = useState(null); - - const copyAddress = async () => { - try { - // Keep this user initiated: the address contains a one-time authorization code. - await navigator.clipboard.writeText(window.location.href); - setCopyStatus("Address copied. Paste it only into the Maple sign-in you started."); - } catch { - setCopyStatus("Copy the full address from your browser's address bar instead."); - } - }; - return ( -

-

- If Maple Agent asked you to paste a callback URL, copy this page's full address and paste it - into that sign-in window. Otherwise, start a new sign-in in Maple. -

-

Only paste this address into the Maple sign-in you started. Do not share it.

- - {copyStatus &&

{copyStatus}

} -
+

+ Return to Maple and start a new sign-in. You can close this page. +

); } diff --git a/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx b/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx index dcd45bd05..c240961e3 100644 --- a/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx +++ b/apps/maple-research/frontend/src/auth-site/HostedCallback.tsx @@ -29,7 +29,7 @@ export function HostedCallback({ route }: { route: Extract { try { - markTransportV2DesktopOAuth(route); + const handoffInput = { + provider: route.provider, + nativeSessionId: route.nativeSessionId, + nativeRequestId: route.nativeRequestId + }; + markTransportV2DesktopOAuth(handoffInput); const pending = readTransportV2DesktopOAuth(route.provider); if (!pending) throw new Error("Native sign-in is unavailable"); setTarget(pending); if (route.provider === "apple") return; - if (!claimTransportV2DesktopOAuthInitiation(route)) { + if (!claimTransportV2DesktopOAuthInitiation(handoffInput)) { setFailed(true); return; } diff --git a/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx b/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx index 06854018f..b5a39b1f6 100644 --- a/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx +++ b/apps/maple-research/frontend/src/auth-site/fixtures/AuthSite.case.tsx @@ -254,43 +254,41 @@ describe("hosted authentication entry", () => { expectFailureWithoutNavigation(originalUrl); }); - test("copies the full callback address only after the user requests it", async () => { + test("offers restart guidance without a copy action for callbacks without a hosted target", async () => { const writeText = mock(async () => {}); setGlobal("navigator", { clipboard: { writeText } }); const originalUrl = callbackUrl(); await renderAt(originalUrl); expect(writeText).not.toHaveBeenCalled(); - expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0); - - await act(async () => renderer!.root.findByType("button").props.onClick()); - - expect(writeText).toHaveBeenCalledTimes(1); - expect(writeText).toHaveBeenCalledWith(originalUrl); - expect(renderer!.root.findByProps({ role: "status" }).children.join("")).toBe( - "Address copied. Paste it only into the Maple sign-in you started." + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + expect(JSON.stringify(renderer!.toJSON())).toContain( + "Return to Maple and start a new sign-in." ); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("Maple Agent"); expectNoSdkCalls(); expect(window.location.href).toBe(originalUrl); }); - test("preserves the callback address and offers manual copying when clipboard access fails", async () => { + test("offers restart guidance without clipboard access after a hosted callback fails", async () => { const writeText = mock(async () => { throw new Error("Fixture clipboard permission denial"); }); setGlobal("navigator", { clipboard: { writeText } }); + markTransportV2DesktopOAuth({ provider: "google", nativeSessionId, nativeRequestId }); + handleGoogleCallback.mockImplementation(async () => { + throw new Error("Fixture callback rejection"); + }); const originalUrl = callbackUrl("google"); await renderAt(originalUrl); expect(writeText).not.toHaveBeenCalled(); - await act(async () => renderer!.root.findByType("button").props.onClick()); - - expect(writeText).toHaveBeenCalledTimes(1); - expect(writeText).toHaveBeenCalledWith(originalUrl); - expect(renderer!.root.findByProps({ role: "status" }).children.join("")).toBe( - "Copy the full address from your browser's address bar instead." + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + expect(JSON.stringify(renderer!.toJSON())).toContain( + "Return to Maple and start a new sign-in." ); - expectNoSdkCalls(); - expect(window.location.href).toBe(originalUrl); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("Maple Agent"); + expect(handleGoogleCallback).toHaveBeenCalledTimes(1); + expectFailureWithoutNavigation(originalUrl); }); test("does not consume another provider's pending target", async () => { diff --git a/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx b/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx index 6b5ce833d..ea5075aea 100644 --- a/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx +++ b/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx @@ -75,6 +75,10 @@ class FakeDocument extends EventTarget { createElement(): FakeScript { return { async: false, parentNode: null, src: "" }; } + + getElementById(): null { + return null; + } } interface SignInControl { @@ -109,6 +113,7 @@ const { Route: desktopRoute } = await import("@/routes/desktop-auth"); const originalGlobals = { document: Object.getOwnPropertyDescriptor(globalThis, "document"), + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"), window: Object.getOwnPropertyDescriptor(globalThis, "window"), @@ -216,6 +221,7 @@ describe("AppleAuthProvider", () => { } restoreGlobal("scrollTo", originalGlobals.scrollTo); restoreGlobal("document", originalGlobals.document); + restoreGlobal("navigator", originalGlobals.navigator); restoreGlobal("localStorage", originalGlobals.localStorage); restoreGlobal("sessionStorage", originalGlobals.sessionStorage); restoreGlobal("window", originalGlobals.window); @@ -547,6 +553,122 @@ describe("AppleAuthProvider", () => { }); } + async function renderRejectedRedirectCallback(provider: string, search: string) { + const callback = mock(async () => { + throw new Error("Fixture provider callback rejection"); + }); + currentOpenSecret.handleGitHubCallback = callback; + currentOpenSecret.handleGoogleCallback = callback; + const clipboardWrite = mock(async () => {}); + setGlobal("navigator", { clipboard: { writeText: clipboardWrite } }); + const pathname = `/auth/${provider}/callback`; + const relativeUrl = `${pathname}${search}#fixture-fragment`; + const href = `https://trymaple.ai${relativeUrl}`; + Object.assign(window.location, { pathname, search, href, hash: "#fixture-fragment" }); + const rootRoute = createRootRoute(); + const route = callbackRoute.update({ + getParentRoute: () => rootRoute, + path: "/auth/$provider/callback" + } as never); + const router = createRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: [relativeUrl] }) + }); + await router.load(); + await act(async () => { + renderer = create(); + }); + expect(JSON.stringify(renderer?.toJSON())).toContain("Authentication Failed"); + expect(window.location.href).toBe(href); + expect(window.location.search).toBe(search); + expect(router.history.location.href).toBe(relativeUrl); + expect(clipboardWrite).not.toHaveBeenCalled(); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + return callback; + } + + for (const provider of ["github", "google"] as const) { + test(`${provider} callback error offers a collapsed Maple Agent paste hint without taking action`, async () => { + const callback = await renderRejectedRedirectCallback( + provider, + "?code=fixture-code&state=fixture-state" + ); + expect(callback).toHaveBeenCalledWith("fixture-code", "fixture-state", ""); + const details = renderer!.root.findByType("details"); + expect(details.props.open).toBeUndefined(); + expect(details.findByType("summary").children.join("")).toBe( + "Using Maple Agent's paste field?" + ); + expect(details.findByType("p").children.join("")).toBe( + "If Maple Agent explicitly asked you to paste a callback URL, copy the full address from this browser's address bar and paste it only into the sign-in window you started. Do not share this address. Otherwise, start a new sign-in." + ); + }); + } + + for (const scenario of [ + { + name: "Apple callbacks", + provider: "apple", + search: "?code=fixture-code&state=fixture-state" + }, + { name: "missing code", provider: "github", search: "?state=fixture-state" }, + { name: "missing state", provider: "google", search: "?code=fixture-code" }, + { name: "empty code", provider: "github", search: "?code=&state=fixture-state" }, + { name: "empty state", provider: "google", search: "?code=fixture-code&state=" }, + { + name: "duplicate code", + provider: "github", + search: "?code=fixture-code&code=fixture-code&state=fixture-state" + }, + { + name: "duplicate state", + provider: "google", + search: "?code=fixture-code&state=fixture-state&state=fixture-state" + }, + { + name: "an error parameter even when empty", + provider: "github", + search: "?code=fixture-code&state=fixture-state&error=" + }, + { + name: "an error_description parameter", + provider: "google", + search: "?code=fixture-code&state=fixture-state&error_description=fixture-error" + }, + { + name: "an error_uri parameter", + provider: "github", + search: "?code=fixture-code&state=fixture-state&error_uri=https%3A%2F%2Fexample.com%2Ferror" + } + ]) { + test(`does not offer the Maple Agent paste hint for ${scenario.name}`, async () => { + await renderRejectedRedirectCallback(scenario.provider, scenario.search); + expect(renderer!.root.findAllByType("details")).toHaveLength(0); + expect(JSON.stringify(renderer?.toJSON())).not.toContain("Using Maple Agent's paste field?"); + }); + } + + for (const provider of ["github", "google"] as const) { + test(`${provider} hosted native callback errors never offer the Maple Agent paste hint`, async () => { + const { markTransportV2DesktopOAuth, readTransportV2DesktopOAuth } = + await import("@/services/desktopOAuthTransport"); + markTransportV2DesktopOAuth({ + provider, + nativeSessionId: "01".repeat(16), + nativeRequestId: "ab".repeat(16) + }); + const callback = await renderRejectedRedirectCallback( + provider, + "?code=fixture-code&state=fixture-state" + ); + expect(callback).toHaveBeenCalledTimes(1); + // Error handling clears the pending target, but the request was still a native flow. + expect(readTransportV2DesktopOAuth(provider)).toBeNull(); + expect(renderer!.root.findAllByType("details")).toHaveLength(0); + expect(JSON.stringify(renderer?.toJSON())).not.toContain("Using Maple Agent's paste field?"); + }); + } + test("explains popup-only Apple sign-in without processing a redirect or stored form response", async () => { sessionStorage.setItem("apple_form_data", JSON.stringify({ code: "unused", state: "unused" })); Object.assign(window.location, { diff --git a/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx b/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx index ea6f75cb9..a0927c98a 100644 --- a/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx +++ b/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx @@ -203,6 +203,17 @@ function OAuthCallback() { } if (error) { + const callbackParams = new URLSearchParams(window.location.search); + const codes = callbackParams.getAll("code"); + const states = callbackParams.getAll("state"); + const showAgentPasteHint = + (provider === "github" || provider === "google") && + !nativeFlow.requested && + codes.length === 1 && + codes[0].trim().length > 0 && + states.length === 1 && + states[0].trim().length > 0 && + !["error", "error_description", "error_uri"].some((key) => callbackParams.has(key)); return ( @@ -210,6 +221,16 @@ function OAuthCallback() { + {showAgentPasteHint && ( +
+ Using Maple Agent's paste field? +

+ If Maple Agent explicitly asked you to paste a callback URL, copy the full address + from this browser's address bar and paste it only into the sign-in window you + started. Do not share this address. Otherwise, start a new sign-in. +

+
+ )}
+ {status === "complete" ? ( + + ) : ( + + )} +
+ + ); +} diff --git a/apps/maple-auth/src/components/ui/button.tsx b/apps/maple-auth/src/components/ui/button.tsx new file mode 100644 index 000000000..eab0bb6c0 --- /dev/null +++ b/apps/maple-auth/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/utils/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 active:scale-[0.95]", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80", + primary: + "bg-gradient-to-b from-[hsl(var(--maple-primary))] to-[hsl(var(--maple-primary-strong))] text-[hsl(var(--maple-on-primary))]/90 hover:brightness-110", + destructive: + "bg-gradient-to-b from-[hsl(var(--maple-error))] to-[hsl(var(--maple-error)/0.8)] text-destructive-onFilled hover:brightness-110", + outline: + "border border-[hsl(var(--maple-secondary))]/30 bg-transparent text-foreground hover:border-[hsl(var(--maple-primary))]/80 hover:bg-[hsl(var(--maple-primary-container))]/60 dark:border-[hsl(var(--maple-secondary))]/20 dark:hover:border-[hsl(var(--maple-primary))]/60", + secondary: + "bg-gradient-to-b from-[hsl(var(--maple-secondary-container))] to-[hsl(var(--maple-secondary-container)/0.6)] text-[hsl(var(--maple-secondary-700))] hover:brightness-110 dark:from-[hsl(var(--maple-secondary-container))] dark:to-[hsl(var(--maple-secondary-container)/0.4)] dark:text-[hsl(var(--maple-on-secondary))]", + ghost: + "text-foreground hover:bg-[hsl(var(--maple-secondary-container))] dark:hover:bg-[hsl(var(--maple-primary))]/15 dark:hover:text-foreground", + link: "rounded-none text-[hsl(var(--maple-primary))] underline-offset-4 hover:underline" + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, onClick, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + ); + } +); +Button.displayName = "Button"; + +// eslint-disable-next-line react-refresh/only-export-components +export { Button, buttonVariants }; diff --git a/apps/maple-auth/src/config/openSecretClientConfig.ts b/apps/maple-auth/src/config/openSecretClientConfig.ts new file mode 100644 index 000000000..f6cbbab41 --- /dev/null +++ b/apps/maple-auth/src/config/openSecretClientConfig.ts @@ -0,0 +1,66 @@ +import { openSecretPcrEnvironment } from "./openSecretPcrEnvironment"; + +const DEFAULT_OPEN_SECRET_CLIENT_ID = "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"; + +const PCR0_VALUES = [ + // Approved 2026-09-14 (services/opensecret signed history) + "a0ef363874f70bd82af98d8e628bf7f128dfca1f9918e83af494cd17f5d4ff1a0c1b94b235941a6d20138d1152cbe5aa", + // Approved 2026-09-12 (services/opensecret signed history) + "3d2f90b4dac7316e5485e03f4cdf3f2f8ae79fde77442546475eac6c3037916ac54b8bdb26c85a73e9b778a9a5077638", + // Approved 2026-09-05 (services/opensecret signed history) + "a1c09f74133cbcc0e5034b9a8a5ed1a5a567d4922140d69534827b4473c7f385b989ce594e0b11f6b8ec971decb95608", + "ed9109c16f30a470cf0ea2251816789b4ffa510c990118323ce94a2364b9bf05bdb8777959cbac86f5cabc4852e0da71", + "4f2bcdf16c38842e1a45defd944d24ea58bb5bcb76491843223022acfe9eb6f1ff79b2cb9a6b2a9219daf9c7bf40fa37", + "b8ee4b511ef2c9c6ab3e5c0840c5df2218fbb4d9df88254ece7af9462677e55aa5a03838f3ae432d86ca1cb6f992eee7", + "33ffe5cae0f72cfe904bde8019ad98efa0ce5db2800f37c5d4149461023d1f70ea77e4f58ae1327ff46ed6a34045d6e2", + "a1398fa2946b6ed4b96a1a992ee668aef3661329690f87d44cad5b646ce33e3b16a55674b1d6d54d115a5520801b97d6", + "878dc4111e94722f3d33b202dc1368916af2eb486e74b3d94c9dfbcb3d981fa652827ea8e951ddfe06d1cefb482e431c", + "4e242871fecc14933c889908a6a7593de574c2655a47ffa163c5fd7ba41d063152ef441bd555ac7f8569eac4fd7cbc8b", + "095d38ba5c9c7ad1cfe5832d3dd8304b020392867aeef84f47e08b4305b867540b0ff5b2eb7d279de410e19ad937896e", + "72c9a1dc207d919196c78f845c0f5fd4b3b3a690e024a3dd599f160be04875fbe52983773909a9f1584105f7d5103538", + "a5ae21e211fa709dbced7fec7fff0eb86001174365a29271c07d7fb55fd9f37c7e2ddef1b01f39e977ca246277efeef2", + "2520e5067830a34fc457b6360358e3754d53ab855f4a05a81312f2a2ed0bf893c5ec43d1325972efbe3a8f2b79303734", + "a275a3877972c670c4f43e658cc9296838f79a96a26429877f74285c3a088d426583d3e2f6f99cfd96c70fdfa1475266", + "8de5541089649e9edb2cd96fafb90716aa298483447e459708e8840b1f82a557c9d9ff6ae1fd2461b04310e7d9400d7d", + "02a41da2df084fd1dee420d7717bef6dc0120f1d6a0b7fded3f4c7a539be4044b3061c71bc7156731db1fb66494097b0", + "d9638aebacf2bf15ef0ab7d394320a3aa5ebde9f0e8911d2d2a0b49a2792f3825e6f6ca56960a63e91857398125d8038", + "41786ecb8e012b910cd095ad5f8b5acefcaf80df3cf8e909499da45dd594c7c4c28302b5dde551d870555bd389a1e2c4" +]; + +const PCR0_DEV_VALUES = [ + // Approved 2026-09-14 (services/opensecret signed history) + "61d92c66684de563fbc4ce3ece9680966672a065a65dedfd1dcdab149b2d3d3d725ab853dae6dc251dbb3326ff7d1214", + // Approved 2026-09-12 (services/opensecret signed history) + "c5470ea9d2d8499a65fbb9bc936bd7a62a5ef90b54da1504535cb4384f303fb673369d64f1a43f17c8ed8d2a89f09949", + // Approved 2026-09-05 (services/opensecret signed history) + "831eb975dad0b3f3741612acc92f020c0467bd3ac967ddf925fa60eff9e02cf9239ec50c699715fbf9a35e3448e2a117", + "799600ba64a29e360b1651f4ced6c9ca5323094a45294551327b996062c3f21e6fef651e7e3d97ec8d25be87b9935b4f", + "2fd9d4f716fd28336d96bc1a20b18a727c2d18f292577ba99323acfc8fb08959428a123b7acff478994c4f961247a0c7", + "4292db2a90ce5ea6f6e2766e0238a328c81dc060a1f3175bced2e94a10e0490d3ff9125d774dafdff969ac661778e757", + "f58409ae1bc8600c887fef5cc4055149c88c94b41c2b3e268826af7b43a1cdbacffdb2c96bf5972120c6460ab83fe89e", + "6fcdb8086806a96c421c08eaf67cebf164aa898798b6f91b072c884773bc6ed64fe8f5af644fe35411195167b0e4a5f1", + "0042958bde1fdd1bcbd4085ec94456c49e7bc5d2c3368f6f34edd6f339193cb7b53929d299eaf6a220ed5b7691f8618a", + "583ac140e0454dd4766a07c147cb6d90d5430d6bc9c1571da19c781dea4027e1c434273caba584440180ca42c2db84d5", + "4451e47ddb4be8a63492e62bc400e69d924188040805c658334f708e8682d308af3feb16018e98a5589c345d28437a6b", + "5bc5a32791948dc7e315d01ec787307799bb6f70903d14c20dc47f19bb0ef3830eb3b2c5b04b7ae5b04717046b357a14", + "4243170eeb11d38cf9bbee48b754bccfe97385b4639051efe97cda50086784cd32009dcb89a0fb1098558f22dc55b4e6", + "be3de8fa74f42cc5165823da63aa283f1c8dfedb5e27e0bfa281c6dbd12d5b5bfd9bef591200175c44dbf0c504f5b0a5", + "ab7e90e1894f75fbd423f3d0027973b611beff7402bd224dcd0f162968fb9678f08c92970d16a444edc934aa6ecd7d62", + "8ba003e9d552d262cfd40a77eced199cfd0e776d1fd69d5ed6b7e6c6c92b91a0481e965247502f95a20478fe0a8f3de7", + "2e6e8f86a657f6daed8e699c0b74bf02d6c7d6414638b1032e6addd2bca7d208a0e8aa4961eb8e926c54dc58fd63d401", + "6b15f0571de13a6357e646bbe3772a8fe32fdd85b07ba97b3a6f95bdc43023dd9deb5710c26b75346de90157d9ecdd1f", + "2596e528703abda188de27b6995f8d3cc553502e4acbb4d06db1dc8239b428638447adff38ccce358ef9fe34c2e0bccc", + "e36d72989d89818b77ab1012cf875c46c0d7fb5389fd559b9ea0950231ae1cc17dd222e5d19a84363a27a3cd7f268de6" +]; + +export function openSecretClientConfig() { + return { + apiUrl: import.meta.env.VITE_OPEN_SECRET_API_URL, + clientId: import.meta.env.VITE_CLIENT_ID || DEFAULT_OPEN_SECRET_CLIENT_ID, + pcrConfig: { + environment: openSecretPcrEnvironment(), + pcr0Values: [...PCR0_VALUES], + pcr0DevValues: [...PCR0_DEV_VALUES] + } + }; +} diff --git a/apps/maple-auth/src/config/openSecretPcrEnvironment.test.ts b/apps/maple-auth/src/config/openSecretPcrEnvironment.test.ts new file mode 100644 index 000000000..918c1f93e --- /dev/null +++ b/apps/maple-auth/src/config/openSecretPcrEnvironment.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { parseOpenSecretPcrEnvironment } from "./openSecretPcrEnvironment"; + +describe("OpenSecret PCR environment", () => { + test("defaults to production", () => { + expect(parseOpenSecretPcrEnvironment(undefined)).toBe("production"); + }); + + test("accepts the explicit production and development values", () => { + expect(parseOpenSecretPcrEnvironment("production")).toBe("production"); + expect(parseOpenSecretPcrEnvironment("development")).toBe("development"); + }); + + test("rejects unknown values", () => { + for (const value of ["", "dev", "prod", "Development", "staging"]) { + expect(() => parseOpenSecretPcrEnvironment(value)).toThrow( + /VITE_OPEN_SECRET_PCR_ENVIRONMENT/ + ); + } + }); +}); diff --git a/apps/maple-auth/src/config/openSecretPcrEnvironment.ts b/apps/maple-auth/src/config/openSecretPcrEnvironment.ts new file mode 100644 index 000000000..84ad8d2cf --- /dev/null +++ b/apps/maple-auth/src/config/openSecretPcrEnvironment.ts @@ -0,0 +1,11 @@ +import type { PcrEnvironment } from "@mapleai/sdk"; + +export function parseOpenSecretPcrEnvironment(value: string | undefined): PcrEnvironment { + if (value === undefined || value === "production") return "production"; + if (value === "development") return "development"; + throw new Error('VITE_OPEN_SECRET_PCR_ENVIRONMENT must be either "production" or "development"'); +} + +export function openSecretPcrEnvironment(): PcrEnvironment { + return parseOpenSecretPcrEnvironment(import.meta.env.VITE_OPEN_SECRET_PCR_ENVIRONMENT); +} diff --git a/apps/maple-auth/src/lib/test/der-loader.ts b/apps/maple-auth/src/lib/test/der-loader.ts new file mode 100644 index 000000000..923765c93 --- /dev/null +++ b/apps/maple-auth/src/lib/test/der-loader.ts @@ -0,0 +1,35 @@ +type BunPluginApi = { + plugin: (options: { + name: string; + setup: (build: { + onLoad: ( + options: { filter: RegExp }, + callback: (args: { + path: string; + }) => { contents: string; loader?: string } | Promise<{ contents: string; loader?: string }> + ) => void; + }) => void; + }) => void; + file: (path: string) => { arrayBuffer: () => Promise }; +}; + +const bun = (globalThis as unknown as { Bun?: BunPluginApi }).Bun; + +if (bun) { + bun.plugin({ + name: "der-loader", + setup(build) { + build.onLoad({ filter: /\.der$/ }, async (args) => { + const buffer = await bun.file(args.path).arrayBuffer(); + const bytes = new Uint8Array(buffer); + + return { + contents: `export default new Uint8Array([${Array.from(bytes).join(",")}]);`, + loader: "js" + }; + }); + } + }); +} + +export {}; diff --git a/apps/maple-research/frontend/src/services/appleOAuth.test.ts b/apps/maple-auth/src/services/appleOAuth.test.ts similarity index 100% rename from apps/maple-research/frontend/src/services/appleOAuth.test.ts rename to apps/maple-auth/src/services/appleOAuth.test.ts diff --git a/apps/maple-research/frontend/src/services/appleOAuth.ts b/apps/maple-auth/src/services/appleOAuth.ts similarity index 100% rename from apps/maple-research/frontend/src/services/appleOAuth.ts rename to apps/maple-auth/src/services/appleOAuth.ts diff --git a/apps/maple-auth/src/services/desktopOAuthTransport.test.ts b/apps/maple-auth/src/services/desktopOAuthTransport.test.ts new file mode 100644 index 000000000..e10436489 --- /dev/null +++ b/apps/maple-auth/src/services/desktopOAuthTransport.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + TRANSPORT_V2_PENDING_TTL_MS, + buildTransportV2NativeAuthDeepLink, + claimTransportV2DesktopOAuthInitiation, + clearDesktopOAuthTarget, + isNativeOAuthRedirect, + markTransportV2DesktopOAuth, + mintTransportV2NativeAuthDeepLink, + readTransportV2DesktopOAuth +} from "./desktopOAuthTransport"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + + get length(): number { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +let originalLocalStorage: PropertyDescriptor | undefined; +let originalSessionStorage: PropertyDescriptor | undefined; + +beforeEach(() => { + originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: new MemoryStorage(), + writable: true + }); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: new MemoryStorage(), + writable: true + }); +}); + +afterEach(() => { + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } + if (originalSessionStorage) { + Object.defineProperty(globalThis, "sessionStorage", originalSessionStorage); + } else { + Reflect.deleteProperty(globalThis, "sessionStorage"); + } +}); + +describe("hosted V2 handoff state", () => { + const nativeSessionId = "00112233445566778899aabbccddeeff"; + const nativeRequestId = "ffeeddccbbaa99887766554433221100"; + const state = { provider: "github" as const, nativeSessionId, nativeRequestId }; + + test("stores the exact provider and target pair in same-tab state", () => { + markTransportV2DesktopOAuth(state, 1_000); + + expect(isNativeOAuthRedirect()).toBe(true); + expect(readTransportV2DesktopOAuth("github", 1_001)).toEqual({ + ...state, + startedAt: 1_000 + }); + expect(readTransportV2DesktopOAuth("google", 1_001)).toBeNull(); + }); + + test("expires hosted handoff state", () => { + markTransportV2DesktopOAuth(state, 1_000); + + expect( + readTransportV2DesktopOAuth("github", 1_000 + TRANSPORT_V2_PENDING_TTL_MS + 1) + ).toBeNull(); + }); + + test("claims provider initiation once without resetting on a StrictMode remount", () => { + markTransportV2DesktopOAuth(state, 1_000); + expect(claimTransportV2DesktopOAuthInitiation(state, 1_001)).toBe(true); + + markTransportV2DesktopOAuth(state, 1_002); + expect(claimTransportV2DesktopOAuthInitiation(state, 1_003)).toBe(false); + + const replacement = { ...state, nativeRequestId: "11112222333344445555666677778888" }; + markTransportV2DesktopOAuth(replacement, 1_004); + expect(claimTransportV2DesktopOAuthInitiation(replacement, 1_005)).toBe(true); + }); + + test("cannot claim initiation for another target pair", () => { + markTransportV2DesktopOAuth(state, 1_000); + expect(() => + claimTransportV2DesktopOAuthInitiation( + { ...state, nativeRequestId: "11112222333344445555666677778888" }, + 1_001 + ) + ).toThrow("state changed"); + }); + + test("builds a deep link containing only the one-use grant", () => { + const deepLink = buildTransportV2NativeAuthDeepLink("head.payload.c2ln"); + const parsed = new URL(deepLink); + + expect(parsed.protocol).toBe("cloud.opensecret.maple:"); + expect(parsed.hostname).toBe("auth"); + expect([...parsed.searchParams.keys()]).toEqual(["handoff_grant"]); + expect(parsed.searchParams.get("handoff_grant")).toBe("head.payload.c2ln"); + expect(parsed.searchParams.has("native_session_id")).toBe(false); + expect(parsed.searchParams.has("native_request_id")).toBe(false); + expect(parsed.searchParams.has("access_token")).toBe(false); + expect(parsed.searchParams.has("refresh_token")).toBe(false); + }); + + test("mints a grant for the exact stored pair and consumes hosted state", async () => { + markTransportV2DesktopOAuth(state, 1_000); + const calls: string[][] = []; + + const deepLink = await mintTransportV2NativeAuthDeepLink( + { ...state, startedAt: 1_000 }, + async (sessionId, requestId) => { + calls.push([sessionId, requestId]); + return { grant: "head.payload.c2ln" }; + }, + () => true, + () => 1_001 + ); + + expect(calls).toEqual([[nativeSessionId, nativeRequestId]]); + expect(new URL(deepLink).search).toBe("?handoff_grant=head.payload.c2ln"); + expect(readTransportV2DesktopOAuth("github", 1_002)).toBeNull(); + expect(isNativeOAuthRedirect()).toBe(false); + }); + + test("does not mint for a different provider", async () => { + markTransportV2DesktopOAuth(state, 1_000); + let calls = 0; + + await expect( + mintTransportV2NativeAuthDeepLink( + { ...state, provider: "google", startedAt: 1_000 }, + async () => { + calls += 1; + return { grant: "head.payload.c2ln" }; + }, + () => true, + () => 1_001 + ) + ).rejects.toThrow("changed or expired"); + expect(calls).toBe(0); + }); + + test("rejects duplicate approval while a mint is in flight", async () => { + markTransportV2DesktopOAuth(state, 1_000); + const target = { ...state, startedAt: 1_000 }; + let resolve!: (result: { grant: string }) => void; + let calls = 0; + const mint = () => { + calls += 1; + return new Promise<{ grant: string }>((done) => { + resolve = done; + }); + }; + const first = mintTransportV2NativeAuthDeepLink( + target, + mint, + () => true, + () => 1_001 + ); + await expect( + mintTransportV2NativeAuthDeepLink( + target, + mint, + () => true, + () => 1_001 + ) + ).rejects.toThrow("already been submitted"); + resolve({ grant: "head.payload.c2ln" }); + await first; + expect(calls).toBe(1); + }); + + for (const change of ["cancel", "target", "account", "expiry"] as const) { + test(`discards a late mint after ${change} and preserves a newer target`, async () => { + markTransportV2DesktopOAuth(state, 1_000); + const target = { ...state, startedAt: 1_000 }; + let resolve!: (result: { grant: string }) => void; + let ownsAccount = true; + let now = 1_001; + const pending = mintTransportV2NativeAuthDeepLink( + target, + () => + new Promise<{ grant: string }>((done) => { + resolve = done; + }), + () => ownsAccount, + () => now + ); + const replacement = { ...state, nativeRequestId: "11".repeat(16) }; + if (change === "cancel") clearDesktopOAuthTarget(target); + if (change === "target") markTransportV2DesktopOAuth(replacement, 1_002); + if (change === "account") ownsAccount = false; + if (change === "expiry") now += TRANSPORT_V2_PENDING_TTL_MS; + if (change === "target") now = 1_003; + resolve({ grant: "head.payload.c2ln" }); + await expect(pending).rejects.toThrow("changed or expired"); + if (change === "target") { + expect(readTransportV2DesktopOAuth(undefined, 1_003)).toEqual({ + ...replacement, + startedAt: 1_002 + }); + } + }); + } + + test("does not retry after an ambiguous mint failure", async () => { + markTransportV2DesktopOAuth(state, 1_000); + const target = { ...state, startedAt: 1_000 }; + let calls = 0; + const mint = async () => { + calls += 1; + throw new Error("network lost"); + }; + await expect( + mintTransportV2NativeAuthDeepLink( + target, + mint, + () => true, + () => 1_001 + ) + ).rejects.toThrow("network lost"); + await expect( + mintTransportV2NativeAuthDeepLink( + target, + mint, + () => true, + () => 1_002 + ) + ).rejects.toThrow("changed or expired"); + expect(calls).toBe(1); + }); + + test("rejects malformed or padded handoff grants", () => { + expect(() => buildTransportV2NativeAuthDeepLink("not-a-grant")).toThrow(); + expect(() => buildTransportV2NativeAuthDeepLink("head.payload.signature=")).toThrow(); + expect(() => buildTransportV2NativeAuthDeepLink(`YQ.Yg.${"a".repeat(4092)}`)).toThrow(); + }); +}); diff --git a/apps/maple-auth/src/services/desktopOAuthTransport.ts b/apps/maple-auth/src/services/desktopOAuthTransport.ts new file mode 100644 index 000000000..9f0b0de11 --- /dev/null +++ b/apps/maple-auth/src/services/desktopOAuthTransport.ts @@ -0,0 +1,223 @@ +export type DesktopOAuthProvider = "github" | "google" | "apple"; + +const DESKTOP_OAUTH_TRANSPORT_KEY = "maple_desktop_oauth_transport_v1"; +const REDIRECT_TO_NATIVE_KEY = "redirect-to-native"; +const TRANSPORT_V2_PENDING_KEY = "maple_desktop_oauth_pending_v2"; +const TRANSPORT_V2_MINT_CLAIM_KEY = "maple_desktop_oauth_mint_claim_v2"; +const TRANSPORT_V2_INITIATION_CLAIM_KEY = "maple_desktop_oauth_initiation_claim_v2"; + +export const TRANSPORT_V2_PENDING_TTL_MS = 15 * 60 * 1000; +export const TRANSPORT_V2_NATIVE_SESSION_QUERY = "native_session_id"; +export const TRANSPORT_V2_NATIVE_REQUEST_QUERY = "native_request_id"; + +const TRANSPORT_V2_ID_PATTERN = /^[0-9a-f]{32}$/u; +const COMPACT_GRANT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u; +const MAX_HANDOFF_GRANT_LENGTH = 4096; + +export interface TransportV2DesktopOAuthState { + provider: DesktopOAuthProvider; + nativeSessionId: string; + nativeRequestId: string; + startedAt: number; +} + +type NativeHandoffGrantIssuer = ( + nativeSessionId: string, + nativeRequestId: string +) => Promise<{ grant: string }>; + +function isDesktopOAuthProvider(value: unknown): value is DesktopOAuthProvider { + return value === "github" || value === "google" || value === "apple"; +} + +export function isTransportV2PublicId(value: unknown): value is string { + return typeof value === "string" && TRANSPORT_V2_ID_PATTERN.test(value); +} + +function assertTransportV2PublicId(value: unknown, label: string): asserts value is string { + if (!isTransportV2PublicId(value)) { + throw new Error(`Desktop authentication ${label} is missing or invalid`); + } +} + +function pendingClaim(state: TransportV2DesktopOAuthState): string { + return `${state.provider}:${state.nativeSessionId}:${state.nativeRequestId}`; +} + +function hasValidTimestamp(startedAt: unknown, now: number): startedAt is number { + if ( + typeof startedAt !== "number" || + !Number.isSafeInteger(startedAt) || + startedAt < 0 || + !Number.isSafeInteger(now) || + now < 0 + ) { + return false; + } + + const age = now - startedAt; + return age >= 0 && age <= TRANSPORT_V2_PENDING_TTL_MS; +} + +function removeTransportV2PendingState(): void { + sessionStorage.removeItem(TRANSPORT_V2_PENDING_KEY); + sessionStorage.removeItem(TRANSPORT_V2_INITIATION_CLAIM_KEY); + sessionStorage.removeItem(TRANSPORT_V2_MINT_CLAIM_KEY); +} + +export function markTransportV2DesktopOAuth( + state: Omit, + now = Date.now() +): void { + if (!isDesktopOAuthProvider(state.provider)) { + throw new Error("Desktop authentication provider is missing or invalid"); + } + assertTransportV2PublicId(state.nativeSessionId, "native session"); + assertTransportV2PublicId(state.nativeRequestId, "native request"); + if (!Number.isSafeInteger(now) || now < 0) { + throw new Error("Desktop authentication timestamp is invalid"); + } + + const existing = readTransportV2DesktopOAuth(undefined, now); + const nextState: TransportV2DesktopOAuthState = { + ...state, + startedAt: + existing && pendingClaim(existing) === pendingClaim({ ...state, startedAt: now }) + ? existing.startedAt + : now + }; + + if (!existing || pendingClaim(existing) !== pendingClaim(nextState)) { + sessionStorage.removeItem(TRANSPORT_V2_INITIATION_CLAIM_KEY); + sessionStorage.removeItem(TRANSPORT_V2_MINT_CLAIM_KEY); + } + sessionStorage.setItem(TRANSPORT_V2_PENDING_KEY, JSON.stringify(nextState)); + sessionStorage.setItem(DESKTOP_OAUTH_TRANSPORT_KEY, "v2"); + sessionStorage.setItem(REDIRECT_TO_NATIVE_KEY, "true"); +} + +export function readTransportV2DesktopOAuth( + expectedProvider?: DesktopOAuthProvider, + now = Date.now() +): TransportV2DesktopOAuthState | null { + const encoded = sessionStorage.getItem(TRANSPORT_V2_PENDING_KEY); + if (!encoded) return null; + + try { + const parsed = JSON.parse(encoded) as Partial; + if ( + !isDesktopOAuthProvider(parsed.provider) || + !isTransportV2PublicId(parsed.nativeSessionId) || + !isTransportV2PublicId(parsed.nativeRequestId) || + !hasValidTimestamp(parsed.startedAt, now) + ) { + throw new Error("Invalid pending desktop authentication state"); + } + + if (expectedProvider !== undefined && parsed.provider !== expectedProvider) return null; + return parsed as TransportV2DesktopOAuthState; + } catch { + removeTransportV2PendingState(); + return null; + } +} + +export function claimTransportV2DesktopOAuthInitiation( + expected: Omit, + now = Date.now() +): boolean { + const current = readTransportV2DesktopOAuth(expected.provider, now); + if ( + !current || + current.nativeSessionId !== expected.nativeSessionId || + current.nativeRequestId !== expected.nativeRequestId + ) { + throw new Error("Desktop authentication state changed before initiation"); + } + + const claim = pendingClaim(current); + if (sessionStorage.getItem(TRANSPORT_V2_INITIATION_CLAIM_KEY) === claim) { + return false; + } + sessionStorage.setItem(TRANSPORT_V2_INITIATION_CLAIM_KEY, claim); + return true; +} + +export function isNativeOAuthRedirect(): boolean { + return ( + sessionStorage.getItem(REDIRECT_TO_NATIVE_KEY) === "true" && + sessionStorage.getItem(DESKTOP_OAUTH_TRANSPORT_KEY) === "v2" + ); +} + +export function buildTransportV2NativeAuthDeepLink(handoffGrant: string): string { + const grantSegments = handoffGrant.split("."); + if ( + handoffGrant.length === 0 || + handoffGrant.length > MAX_HANDOFF_GRANT_LENGTH || + handoffGrant.trim() !== handoffGrant || + !COMPACT_GRANT_PATTERN.test(handoffGrant) || + grantSegments.some((segment) => segment.length % 4 === 1) + ) { + throw new Error("The desktop authentication grant is missing or invalid"); + } + + const deepLink = new URL("cloud.opensecret.maple://auth"); + deepLink.searchParams.set("handoff_grant", handoffGrant); + return deepLink.toString(); +} + +function sameDesktopOAuthTarget( + left: TransportV2DesktopOAuthState, + right: TransportV2DesktopOAuthState +): boolean { + return pendingClaim(left) === pendingClaim(right) && left.startedAt === right.startedAt; +} + +export function isCurrentDesktopOAuthTarget( + expected: TransportV2DesktopOAuthState, + now = Date.now() +): boolean { + const current = readTransportV2DesktopOAuth(undefined, now); + return isNativeOAuthRedirect() && current !== null && sameDesktopOAuthTarget(current, expected); +} + +/** Clear only this flow, including expired state, without disturbing a newer login. */ +export function clearDesktopOAuthTarget(expected: TransportV2DesktopOAuthState): void { + const encoded = sessionStorage.getItem(TRANSPORT_V2_PENDING_KEY); + if (!encoded) return; + try { + if (!sameDesktopOAuthTarget(JSON.parse(encoded), expected)) return; + } catch { + return; + } + removeTransportV2PendingState(); + sessionStorage.removeItem(DESKTOP_OAUTH_TRANSPORT_KEY); + sessionStorage.removeItem(REDIRECT_TO_NATIVE_KEY); +} + +export async function mintTransportV2NativeAuthDeepLink( + handoffTarget: TransportV2DesktopOAuthState, + mintGrant: NativeHandoffGrantIssuer, + ownsConfirmation: () => boolean, + now: () => number = Date.now +): Promise { + if (!isCurrentDesktopOAuthTarget(handoffTarget, now()) || !ownsConfirmation()) { + throw new Error("Native sign-in changed or expired; please restart login in Maple."); + } + // Persist before sending: a remount or an ambiguous network failure must not mint again. + const claim = `${pendingClaim(handoffTarget)}:${handoffTarget.startedAt}`; + if (sessionStorage.getItem(TRANSPORT_V2_MINT_CLAIM_KEY) === claim) { + throw new Error("Native sign-in has already been submitted; please restart login in Maple."); + } + sessionStorage.setItem(TRANSPORT_V2_MINT_CLAIM_KEY, claim); + try { + const { grant } = await mintGrant(handoffTarget.nativeSessionId, handoffTarget.nativeRequestId); + if (!isCurrentDesktopOAuthTarget(handoffTarget, now()) || !ownsConfirmation()) { + throw new Error("Native sign-in changed or expired; please restart login in Maple."); + } + return buildTransportV2NativeAuthDeepLink(grant); + } finally { + clearDesktopOAuthTarget(handoffTarget); + } +} diff --git a/apps/maple-research/frontend/src/services/oauthConfig.test.ts b/apps/maple-auth/src/services/oauthConfig.test.ts similarity index 59% rename from apps/maple-research/frontend/src/services/oauthConfig.test.ts rename to apps/maple-auth/src/services/oauthConfig.test.ts index fef2a1ba0..cd9708378 100644 --- a/apps/maple-research/frontend/src/services/oauthConfig.test.ts +++ b/apps/maple-auth/src/services/oauthConfig.test.ts @@ -1,19 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { getBrowserOAuthCallbackUrl, getNativeOAuthEntryUrl } from "./oauthConfig"; +import { getBrowserOAuthCallbackUrl } from "./oauthConfig"; describe("OAuth origin selection", () => { - test("keeps existing native entry when auth origin is unset or explicitly the apex", () => { - for (const origin of [undefined, "", "https://trymaple.ai", "https://trymaple.ai/"]) { - expect(getNativeOAuthEntryUrl(origin)).toBe("https://trymaple.ai/desktop-auth"); - } - }); - - test("uses the permanent entry alias on a configured auth origin", () => { - expect(getNativeOAuthEntryUrl("https://auth.trymaple.ai")).toBe( - "https://auth.trymaple.ai/desktop-auth" - ); - }); - test("keeps every browser provider callback on its initiating origin", () => { for (const provider of ["github", "google", "apple"] as const) { for (const origin of [ @@ -29,13 +17,6 @@ describe("OAuth origin selection", () => { } }); - test("allows exact loopback HTTP only for development native entry", () => { - for (const origin of ["http://127.0.0.1:3000", "http://localhost:5173", "http://[::1]:5173"]) { - expect(getNativeOAuthEntryUrl(origin, true)).toBe(`${origin}/desktop-auth`); - expect(() => getNativeOAuthEntryUrl(origin, false)).toThrow("HTTPS"); - } - }); - test("rejects origin configuration with credentials, navigation data, or insecure hosts", () => { for (const origin of [ "https://user:password@auth.trymaple.ai", @@ -60,7 +41,7 @@ describe("OAuth origin selection", () => { "cloud.opensecret.maple://auth", "javascript:alert(1)" ]) { - expect(() => getNativeOAuthEntryUrl(origin, true)).toThrow(); + expect(() => getBrowserOAuthCallbackUrl("github", origin)).toThrow(); } }); }); diff --git a/apps/maple-research/frontend/src/services/oauthConfig.ts b/apps/maple-auth/src/services/oauthConfig.ts similarity index 73% rename from apps/maple-research/frontend/src/services/oauthConfig.ts rename to apps/maple-auth/src/services/oauthConfig.ts index 8cc6211b6..2a8fe4058 100644 --- a/apps/maple-research/frontend/src/services/oauthConfig.ts +++ b/apps/maple-auth/src/services/oauthConfig.ts @@ -1,6 +1,5 @@ type BrowserOAuthProvider = "github" | "google" | "apple"; -const DEFAULT_NATIVE_OAUTH_ENTRY = "https://trymaple.ai/desktop-auth"; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); function parseOAuthOrigin(value: string, allowLoopbackHttp: boolean): URL { @@ -41,10 +40,3 @@ export function getBrowserOAuthCallbackUrl(provider: BrowserOAuthProvider, origi } return new URL(`/auth/${provider}/callback`, parseOAuthOrigin(origin, true)).toString(); } - -/** Direct auth entry remains opt-in until the auth cutover has passed its rollout gate. */ -export function getNativeOAuthEntryUrl(configuredOrigin?: string, isDevelopment = false): string { - if (configuredOrigin === undefined || configuredOrigin === "") return DEFAULT_NATIVE_OAUTH_ENTRY; - // The permanent alias works on both the original apex page and the auth site. - return new URL("/desktop-auth", parseOAuthOrigin(configuredOrigin, isDevelopment)).toString(); -} diff --git a/apps/maple-auth/src/utils/utils.ts b/apps/maple-auth/src/utils/utils.ts new file mode 100644 index 000000000..365058ceb --- /dev/null +++ b/apps/maple-auth/src/utils/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/maple-auth/src/vite-env.d.ts b/apps/maple-auth/src/vite-env.d.ts new file mode 100644 index 000000000..9cea8de03 --- /dev/null +++ b/apps/maple-auth/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly VITE_OPEN_SECRET_API_URL: string; + readonly VITE_CLIENT_ID?: string; + readonly VITE_OPEN_SECRET_PCR_ENVIRONMENT?: "production" | "development"; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/maple-auth/tailwind.config.cjs b/apps/maple-auth/tailwind.config.cjs new file mode 100644 index 000000000..9f38f3e27 --- /dev/null +++ b/apps/maple-auth/tailwind.config.cjs @@ -0,0 +1,139 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + "!./src/**/*.test.{ts,tsx}", + "!./src/**/fixtures/**" + ], + darkMode: "class", + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px" + } + }, + extend: { + screens: { + "landscape-short": { raw: "(orientation: landscape) and (max-height: 500px)" } + }, + fontFamily: { + sans: ["var(--app-font-family)"], + display: ["Array", "sans-serif"], + displayWide: ["Array Wide", "Array", "sans-serif"], + mondwest: ["Mondwest", "sans-serif"] + }, + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))" + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))" + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + onFilled: "hsl(var(--destructive-on-filled))" + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))" + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))" + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))" + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))" + }, + maple: { + primary: { + DEFAULT: "hsl(var(--maple-primary))", + on: "hsl(var(--maple-on-primary))", + container: "hsl(var(--maple-primary-container))", + strong: "hsl(var(--maple-primary-strong))" + }, + secondary: { + DEFAULT: "hsl(var(--maple-secondary))", + on: "hsl(var(--maple-on-secondary))", + container: "hsl(var(--maple-secondary-container))", + 700: "hsl(var(--maple-secondary-700))" + }, + tertiary: { + DEFAULT: "hsl(var(--maple-tertiary))", + on: "hsl(var(--maple-on-tertiary))", + container: "hsl(var(--maple-tertiary-container))" + }, + success: "hsl(var(--maple-success))", + warning: "hsl(var(--maple-warning))", + onWarning: "hsl(var(--maple-on-warning))", + error: "hsl(var(--maple-error))", + info: "hsl(var(--maple-info))", + surface: { + DEFAULT: "hsl(var(--maple-surface))", + dim: "hsl(var(--maple-surface-dim))" + } + }, + marketingNav: { + bg: "hsl(var(--marketing-nav-bg))", + fg: "hsl(var(--marketing-nav-fg))" + }, + /* Matches design neutral swatches (#FAFAFA … #0A0A0A); see index.css */ + neutral: { + 50: "hsl(var(--neutral-50) / )", + 100: "hsl(var(--neutral-100) / )", + 200: "hsl(var(--neutral-200) / )", + 300: "hsl(var(--neutral-300) / )", + 400: "hsl(var(--neutral-400) / )", + 500: "hsl(var(--neutral-500) / )", + 600: "hsl(var(--neutral-600) / )", + 700: "hsl(var(--neutral-700) / )", + 800: "hsl(var(--neutral-800) / )", + 900: "hsl(var(--neutral-900) / )", + 950: "hsl(var(--neutral-950) / )" + } + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)" + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" } + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" } + }, + shimmer: { + "100%": { + transform: "translateX(100%)" + } + } + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + shimmer: "shimmer 2s infinite" + } + } + }, + plugins: [require("tailwindcss-animate")] +}; diff --git a/apps/maple-auth/tsconfig.app.json b/apps/maple-auth/tsconfig.app.json new file mode 100644 index 000000000..bae931e3c --- /dev/null +++ b/apps/maple-auth/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Shadcn wanted this */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + /* Done with shadcn stuff */ + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/apps/maple-auth/tsconfig.json b/apps/maple-auth/tsconfig.json new file mode 100644 index 000000000..2b78387c7 --- /dev/null +++ b/apps/maple-auth/tsconfig.json @@ -0,0 +1,10 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/maple-auth/tsconfig.node.json b/apps/maple-auth/tsconfig.node.json new file mode 100644 index 000000000..42ef74991 --- /dev/null +++ b/apps/maple-auth/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "vite-der-plugin.ts", "auth-build-boundary.ts"] +} diff --git a/apps/maple-auth/vite-der-plugin.ts b/apps/maple-auth/vite-der-plugin.ts new file mode 100644 index 000000000..1dd9e2937 --- /dev/null +++ b/apps/maple-auth/vite-der-plugin.ts @@ -0,0 +1,23 @@ +import fs from "fs"; + +export default function derPlugin() { + return { + name: "vite-der-plugin", + transform(_src: string, id: string) { + if (id.endsWith(".der")) { + // Convert the source to a Uint8Array + // Read the .der file as a buffer + const buffer = fs.readFileSync(id); + + // Convert the buffer to a Uint8Array + const uint8Array = new Uint8Array(buffer); + + // Generate code to create and export a Uint8Array + return { + code: `export default new Uint8Array([${uint8Array.toString()}]);`, + map: null + }; + } + } + }; +} diff --git a/apps/maple-auth/vite.config.ts b/apps/maple-auth/vite.config.ts new file mode 100644 index 000000000..828a5ed5a --- /dev/null +++ b/apps/maple-auth/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; +import derPlugin from "./vite-der-plugin"; +import { assertAuthBundleIsolation } from "./auth-build-boundary"; + +export default defineConfig({ + envDir: process.env.MAPLE_IGNORE_VITE_ENV_FILES === "1" ? false : undefined, + plugins: [ + react(), + derPlugin(), + { + name: "auth-bundle-boundary", + generateBundle() { + assertAuthBundleIsolation(this.getModuleIds(), __dirname); + } + } + ], + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + dedupe: ["react", "react-dom"] + }, + build: { outDir: "dist", emptyOutDir: true }, + server: { host: "127.0.0.1", port: 5174, strictPort: true } +}); diff --git a/apps/maple-research/docs/auth-site.md b/apps/maple-research/docs/auth-site.md deleted file mode 100644 index 71eb223a8..000000000 --- a/apps/maple-research/docs/auth-site.md +++ /dev/null @@ -1,80 +0,0 @@ -# Hosted native sign-in - -The frontend builds two independent static sites. The existing `build` command -produces the Maple web app in `dist`. `build:auth` produces the hosted native -sign-in site in `dist-auth`, using `auth.html` and `src/auth-site/main.tsx`. -The auth entry does not load the web-app router, chat, billing, Agent Mode, or -the legacy V1 bridge. - -## Routes and compatibility - -- `/start` and the permanent `/desktop-auth` alias accept only `transport=v2`, - a supported `provider`, and the native session and request IDs created by - Maple. They do not accept an arbitrary return URL. -- `/auth/github/callback` and `/auth/google/callback` complete the pending - browser flow and show the existing account confirmation before minting the - native handoff grant. Callback errors keep the address intact and ask the - user to restart sign-in in Maple. Maple Agent's paste flow uses the configured - default callback on the web app, whose error page provides conditional - paste guidance; it does not use the auth site. -- Apple uses its popup API with the existing Services ID - `cloud.opensecret.maple.services`. It requires the auth domain and callback - to be registered with Apple before live use. A static site cannot process - Apple's form-post callback. -- `/complete` displays completion guidance. Other paths cannot start a login - or fall through to the web app. - -The SDK retains browser credentials on their current origin. Confirmation, -account ownership, pending-flow expiry, and native grant checks retain their -existing behavior. Completing or cancelling this flow does not sign the user -out of the web app or erase their browser credentials. - -Browser OAuth initiation explicitly selects a callback on the initiating -origin. Before the callback-aware backend and this frontend are live together, -verify that each exact current-origin callback equals its provider's default -or an additional allowlist entry. Equivalent routes or trailing-slash redirects -do not satisfy exact membership. Apply the check to development and preview -origins used for rehearsal as well. The legacy V1 bridge remains part of the -web app and continues to use its existing default callback. - -`VITE_AUTH_ORIGIN` selects the origin for native browser entry, using -`/desktop-auth` on that origin. It accepts an HTTPS origin, or exact loopback -HTTP in development. Its default and the current PR/release build profiles -remain `https://trymaple.ai`; building this change does not switch installed -clients to the auth subdomain. - -## Build and local validation - -From the repository root, use the pinned toolchain: - -```sh -nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-web.sh -``` - -The default `pr` profile uses development services. Build/run commands ignore -local dotenv files; the pinned installer limitation is documented in -[Pages deployments](../../../docs/pages-deployments.md#independent-auth-site). -The script validates and archives the auth-only output. It does not publish -it or change OAuth settings. The CI shell pins Node because the TypeScript -and Vite command-line tools invoked by Bun use Node shebangs. - -For a configured local development session, the frontend also exposes -`dev:auth` (loopback port 5174) and `preview:auth`. Preserve any externally -managed configuration and service ownership. Serve the built `dist-auth` -artifact when claiming artifact smoke evidence. - -The frontend tests cover route validation, callback selection, popup and -handoff behavior. Real provider sign-in, native application opening, and live -response headers need separate runtime rehearsal before traffic is redirected. - -## Independent publication - -See [Pages deployments](../../../docs/pages-deployments.md) for the separate -auth artifact and publisher. Auth publication has its own manual trigger, -activation flag, environment, project, and production ref. An app release -does not publish the auth site. Provider registration, backend callback -allowlists, and traffic redirection are separate rollout steps. - -The frontend pins published `@mapleai/sdk` 4.1.1 with a frozen registry lockfile. -Local SDK links remain supported during development; the production auth build -requires an exact published version and rejects local links. diff --git a/apps/maple-research/frontend/auth-build-boundary.ts b/apps/maple-research/frontend/auth-build-boundary.ts deleted file mode 100644 index c33537e8e..000000000 --- a/apps/maple-research/frontend/auth-build-boundary.ts +++ /dev/null @@ -1,30 +0,0 @@ -import path from "path"; - -const SHARED_AUTH_MODULES = new Set([ - "components/HostedNativeSignInConfirmation.tsx", - "components/ui/button.tsx", - "config/openSecretClientConfig.ts", - "config/openSecretPcrEnvironment.ts", - "services/appleOAuth.ts", - "services/desktopOAuthTransport.ts", - "services/oauthConfig.ts", - "utils/utils.ts" -]); - -/** Fail the dedicated build if a shared import pulls in the application or V1 SDK. */ -export function assertAuthBundleIsolation(moduleIds: Iterable, sourceRoot: string): void { - const prefix = `${path.resolve(sourceRoot).replace(/\\/gu, "/")}/`; - for (const moduleId of moduleIds) { - const id = moduleId.replace(/\\/gu, "/").split("?")[0]; - if (id.includes("/@opensecret/") || id.includes("/@opensecret+")) { - throw new Error("The dedicated auth build must not include the legacy SDK"); - } - if (!id.startsWith(prefix)) continue; - const relative = id.slice(prefix.length); - if (!relative.startsWith("auth-site/") && !SHARED_AUTH_MODULES.has(relative)) { - throw new Error( - `The dedicated auth build imported an unapproved application module: ${relative}` - ); - } - } -} diff --git a/apps/maple-research/frontend/bun.lock b/apps/maple-research/frontend/bun.lock index ea50d8538..1badf54a5 100644 --- a/apps/maple-research/frontend/bun.lock +++ b/apps/maple-research/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maple", "dependencies": { - "@mapleai/sdk": "4.1.1", + "@mapleai/sdk": "4.0.1", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -235,7 +235,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@mapleai/sdk": ["@mapleai/sdk@4.1.1", "", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-znbUk+3mfHpeh5fU/ES7B/+p4P0Pyk0/F8b3k0u/GiWE7h1O0yt7TSuX+LdcMpQfCBywtPRasVl16lN6uleKhw=="], + "@mapleai/sdk": ["@mapleai/sdk@4.0.1", "", { "dependencies": { "@peculiar/x509": "1.14.3", "@stablelib/base64": "2.0.1", "@stablelib/chacha20poly1305": "2.0.1", "@stablelib/random": "2.0.1", "cbor2": "1.12.0", "openai": "5.23.2", "tweetnacl": "1.0.3", "zod": "3.25.76" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3JpFw72xGhGTHqcdU2re3NVUwvYvjPyNLd7pzUe/9v92rnxLm30HNyEPXbNKPyTlBm5BT4qr9scI0LjRQUirzg=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], diff --git a/apps/maple-research/frontend/package.json b/apps/maple-research/frontend/package.json index 946449cda..43fae6e35 100644 --- a/apps/maple-research/frontend/package.json +++ b/apps/maple-research/frontend/package.json @@ -24,12 +24,7 @@ "preformat": "bash ../../../scripts/prepare-frontend-deps.sh", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "preformat:check": "bash ../../../scripts/prepare-frontend-deps.sh", - "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", - "predev:auth": "bash ../../../scripts/prepare-frontend-deps.sh", - "dev:auth": "vite --config vite.auth.config.ts", - "prebuild:auth": "bash ../../../scripts/prepare-frontend-deps.sh", - "build:auth": "tsc -b && vite build --config vite.auth.config.ts", - "preview:auth": "vite preview --config vite.auth.config.ts" + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" }, "resolutions": { "@babel/core": "^7.29.7", @@ -52,7 +47,7 @@ "yaml": "^2.8.3" }, "dependencies": { - "@mapleai/sdk": "4.1.1", + "@mapleai/sdk": "4.0.1", "@opensecret/react-v1": "npm:@opensecret/react@3.4.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts b/apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts deleted file mode 100644 index 698746a2d..000000000 --- a/apps/maple-research/frontend/src/auth-site/buildBoundary.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { assertAuthBundleIsolation } from "../../auth-build-boundary"; - -const root = "/fixture/frontend/src"; - -describe("dedicated auth bundle boundary", () => { - test("accepts the dedicated entry and explicitly shared auth dependencies", () => { - expect(() => - assertAuthBundleIsolation( - [ - `${root}/auth-site/main.tsx`, - `${root}/components/HostedNativeSignInConfirmation.tsx`, - `${root}/services/desktopOAuthTransport.ts`, - "/fixture/node_modules/@mapleai/sdk/dist/index.js" - ], - root - ) - ).not.toThrow(); - }); - - test("rejects full app, legacy, chat, billing, and agent imports", () => { - for (const module of [ - "App.tsx", - "main.tsx", - "routeTree.gen.ts", - "routes/auth.$provider.callback.tsx", - "legacy/LegacyDesktopOAuthApp.tsx", - "components/AppleAuthProvider.tsx", - "billing/billingService.ts", - "services/agentService.ts", - "components/Chat.tsx" - ]) { - expect(() => assertAuthBundleIsolation([`${root}/${module}`], root)).toThrow(); - } - expect(() => - assertAuthBundleIsolation(["/fixture/node_modules/@opensecret/react-v1/dist/index.js"], root) - ).toThrow(); - }); -}); diff --git a/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx b/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx index ea5075aea..40ad76091 100644 --- a/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx +++ b/apps/maple-research/frontend/src/components/AppleAuthProvider.test.tsx @@ -75,10 +75,6 @@ class FakeDocument extends EventTarget { createElement(): FakeScript { return { async: false, parentNode: null, src: "" }; } - - getElementById(): null { - return null; - } } interface SignInControl { @@ -113,7 +109,6 @@ const { Route: desktopRoute } = await import("@/routes/desktop-auth"); const originalGlobals = { document: Object.getOwnPropertyDescriptor(globalThis, "document"), - navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"), sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"), window: Object.getOwnPropertyDescriptor(globalThis, "window"), @@ -221,7 +216,6 @@ describe("AppleAuthProvider", () => { } restoreGlobal("scrollTo", originalGlobals.scrollTo); restoreGlobal("document", originalGlobals.document); - restoreGlobal("navigator", originalGlobals.navigator); restoreGlobal("localStorage", originalGlobals.localStorage); restoreGlobal("sessionStorage", originalGlobals.sessionStorage); restoreGlobal("window", originalGlobals.window); @@ -340,17 +334,14 @@ describe("AppleAuthProvider", () => { }); expect(initiateAppleAuth).toHaveBeenCalledTimes(4); - const callbackUrl = "https://trymaple.ai/auth/apple/callback"; - expect(initiateAppleAuth).toHaveBeenNthCalledWith(1, "invite-one", callbackUrl); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(2, "invite-one", callbackUrl); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(3, "invite-one", callbackUrl); - expect(initiateAppleAuth).toHaveBeenNthCalledWith(4, "invite-two", callbackUrl); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(1, "invite-one"); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(2, "invite-one"); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(3, "invite-one"); + expect(initiateAppleAuth).toHaveBeenNthCalledWith(4, "invite-two"); expect(appleInit).toHaveBeenCalledTimes(4); expect(appleInit.mock.calls[0]?.[0]).toMatchObject({ nonce: "11".repeat(32), - state: "state-one", - redirectURI: callbackUrl, - usePopup: true + state: "state-one" }); expect(appleInit.mock.calls[1]?.[0]).toMatchObject({ nonce: "22".repeat(32), @@ -491,7 +482,7 @@ describe("AppleAuthProvider", () => { expect(JSON.stringify(renderer?.toJSON())).not.toContain("Open Maple"); }); - for (const provider of ["github", "google"] as const) { + for (const provider of ["github", "google", "apple"] as const) { test(`${provider} redirect callback waits for hosted account approval`, async () => { const { markTransportV2DesktopOAuth } = await import("@/services/desktopOAuthTransport"); markTransportV2DesktopOAuth({ @@ -553,147 +544,6 @@ describe("AppleAuthProvider", () => { }); } - async function renderRejectedRedirectCallback(provider: string, search: string) { - const callback = mock(async () => { - throw new Error("Fixture provider callback rejection"); - }); - currentOpenSecret.handleGitHubCallback = callback; - currentOpenSecret.handleGoogleCallback = callback; - const clipboardWrite = mock(async () => {}); - setGlobal("navigator", { clipboard: { writeText: clipboardWrite } }); - const pathname = `/auth/${provider}/callback`; - const relativeUrl = `${pathname}${search}#fixture-fragment`; - const href = `https://trymaple.ai${relativeUrl}`; - Object.assign(window.location, { pathname, search, href, hash: "#fixture-fragment" }); - const rootRoute = createRootRoute(); - const route = callbackRoute.update({ - getParentRoute: () => rootRoute, - path: "/auth/$provider/callback" - } as never); - const router = createRouter({ - routeTree: rootRoute.addChildren([route]), - history: createMemoryHistory({ initialEntries: [relativeUrl] }) - }); - await router.load(); - await act(async () => { - renderer = create(); - }); - expect(JSON.stringify(renderer?.toJSON())).toContain("Authentication Failed"); - expect(window.location.href).toBe(href); - expect(window.location.search).toBe(search); - expect(router.history.location.href).toBe(relativeUrl); - expect(clipboardWrite).not.toHaveBeenCalled(); - expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); - return callback; - } - - for (const provider of ["github", "google"] as const) { - test(`${provider} callback error offers a collapsed Maple Agent paste hint without taking action`, async () => { - const callback = await renderRejectedRedirectCallback( - provider, - "?code=fixture-code&state=fixture-state" - ); - expect(callback).toHaveBeenCalledWith("fixture-code", "fixture-state", ""); - const details = renderer!.root.findByType("details"); - expect(details.props.open).toBeUndefined(); - expect(details.findByType("summary").children.join("")).toBe( - "Using Maple Agent's paste field?" - ); - expect(details.findByType("p").children.join("")).toBe( - "If Maple Agent explicitly asked you to paste a callback URL, copy the full address from this browser's address bar and paste it only into the sign-in window you started. Do not share this address. Otherwise, start a new sign-in." - ); - }); - } - - for (const scenario of [ - { - name: "Apple callbacks", - provider: "apple", - search: "?code=fixture-code&state=fixture-state" - }, - { name: "missing code", provider: "github", search: "?state=fixture-state" }, - { name: "missing state", provider: "google", search: "?code=fixture-code" }, - { name: "empty code", provider: "github", search: "?code=&state=fixture-state" }, - { name: "empty state", provider: "google", search: "?code=fixture-code&state=" }, - { - name: "duplicate code", - provider: "github", - search: "?code=fixture-code&code=fixture-code&state=fixture-state" - }, - { - name: "duplicate state", - provider: "google", - search: "?code=fixture-code&state=fixture-state&state=fixture-state" - }, - { - name: "an error parameter even when empty", - provider: "github", - search: "?code=fixture-code&state=fixture-state&error=" - }, - { - name: "an error_description parameter", - provider: "google", - search: "?code=fixture-code&state=fixture-state&error_description=fixture-error" - }, - { - name: "an error_uri parameter", - provider: "github", - search: "?code=fixture-code&state=fixture-state&error_uri=https%3A%2F%2Fexample.com%2Ferror" - } - ]) { - test(`does not offer the Maple Agent paste hint for ${scenario.name}`, async () => { - await renderRejectedRedirectCallback(scenario.provider, scenario.search); - expect(renderer!.root.findAllByType("details")).toHaveLength(0); - expect(JSON.stringify(renderer?.toJSON())).not.toContain("Using Maple Agent's paste field?"); - }); - } - - for (const provider of ["github", "google"] as const) { - test(`${provider} hosted native callback errors never offer the Maple Agent paste hint`, async () => { - const { markTransportV2DesktopOAuth, readTransportV2DesktopOAuth } = - await import("@/services/desktopOAuthTransport"); - markTransportV2DesktopOAuth({ - provider, - nativeSessionId: "01".repeat(16), - nativeRequestId: "ab".repeat(16) - }); - const callback = await renderRejectedRedirectCallback( - provider, - "?code=fixture-code&state=fixture-state" - ); - expect(callback).toHaveBeenCalledTimes(1); - // Error handling clears the pending target, but the request was still a native flow. - expect(readTransportV2DesktopOAuth(provider)).toBeNull(); - expect(renderer!.root.findAllByType("details")).toHaveLength(0); - expect(JSON.stringify(renderer?.toJSON())).not.toContain("Using Maple Agent's paste field?"); - }); - } - - test("explains popup-only Apple sign-in without processing a redirect or stored form response", async () => { - sessionStorage.setItem("apple_form_data", JSON.stringify({ code: "unused", state: "unused" })); - Object.assign(window.location, { - search: "?code=unused-code&state=unused-state", - pathname: "/auth/apple/callback" - }); - const rootRoute = createRootRoute(); - const route = callbackRoute.update({ - getParentRoute: () => rootRoute, - path: "/auth/$provider/callback" - } as never); - const router = createRouter({ - routeTree: rootRoute.addChildren([route]), - history: createMemoryHistory({ initialEntries: ["/auth/apple/callback"] }) - }); - await router.load(); - await act(async () => { - renderer = create(); - }); - expect(handleAppleCallback).not.toHaveBeenCalled(); - expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); - expect(JSON.stringify(renderer?.toJSON())).toContain("Apple sign-in uses a popup"); - expect(JSON.stringify(renderer?.toJSON())).toContain("allow popups for this site"); - }); - for (const decision of ["approve", "cancel"] as const) { test(`Apple desktop confirmation survives authentication through the root layout and can ${decision}`, async () => { const { readTransportV2DesktopOAuth } = await import("@/services/desktopOAuthTransport"); @@ -817,10 +667,6 @@ describe("AppleAuthProvider", () => { renderer = create(); }); expect(initiateGoogleAuth).toHaveBeenCalledTimes(1); - expect(initiateGoogleAuth).toHaveBeenCalledWith( - "", - "https://trymaple.ai/auth/google/callback" - ); await act(async () => { await router.navigate({ to: "/desktop-auth", diff --git a/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx b/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx index 820bb2cbc..ba160e960 100644 --- a/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx +++ b/apps/maple-research/frontend/src/components/AppleAuthProvider.tsx @@ -4,13 +4,6 @@ import { Button, type ButtonProps } from "./ui/button"; import { Apple } from "./icons/Apple"; import { HostedNativeSignInConfirmation } from "./HostedNativeSignInConfirmation"; import { getBillingService } from "@/billing/billingService"; -import { - getAppleAuthError, - getAppleAuthorizationNonce, - isAppleAuthCancellation, - type AppleAuthorization -} from "@/services/appleOAuth"; -import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; import { clearDesktopOAuthTransport, clearDesktopOAuthTarget, @@ -32,7 +25,62 @@ interface AppleAuthProviderProps { children?: React.ReactNode; } -export type { AppleAuthorization } from "@/services/appleOAuth"; +export interface AppleAuthorization { + code: string; + state: string; + id_token?: string; +} + +declare global { + interface Window { + AppleID: { + auth: { + init: (config: { + clientId: string; + scope: string; + redirectURI: string; + state: string; + nonce: string; + usePopup: boolean; + }) => void; + signIn: () => Promise<{ + authorization: AppleAuthorization; + }>; + }; + }; + } +} + +function getAppleAuthError(value: unknown): Error { + if (value instanceof Error) return value; + if (value && typeof value === "object") { + const error = (value as Record).error; + if (typeof error === "string" && error) return new Error(error); + } + + return new Error("Apple authentication failed"); +} + +function isAppleAuthCancellation(error: Error): boolean { + return error.message === "user_cancelled_authorize" || error.message === "popup_closed_by_user"; +} + +function getAppleAuthorizationNonce(authUrl: string): string { + let url: URL; + try { + url = new URL(authUrl); + } catch { + throw new Error("Apple authorization response did not contain a valid nonce"); + } + + const nonces = url.searchParams.getAll("nonce"); + const nonce = nonces[0]; + if (nonces.length !== 1 || !nonce || !/^[0-9a-f]{64}$/u.test(nonce)) { + throw new Error("Apple authorization response did not contain a valid nonce"); + } + + return nonce; +} export function AppleAuthProvider({ onSuccess, @@ -84,16 +132,14 @@ export function AppleAuthProvider({ }, []); const initializeAppleAuth = async (target: TransportV2DesktopOAuthState | null) => { - const appleId = window.AppleID; - if (!appleId) { + if (!window.AppleID) { throw new Error("Apple Sign In SDK not loaded"); } if (!isNativeOAuthRedirect()) clearDesktopOAuthTransport(); // A retry is a new authorization attempt, so it gets a fresh backend state and nonce. - const redirectURI = getBrowserOAuthCallbackUrl("apple", window.location.origin); - const initiateResult = await os.initiateAppleAuth(inviteCode || "", redirectURI); + const initiateResult = await os.initiateAppleAuth(inviteCode || ""); if (!active.current || (target && !isCurrentDesktopOAuthTarget(target))) return; const nonce = getAppleAuthorizationNonce(initiateResult.auth_url); @@ -104,10 +150,10 @@ export function AppleAuthProvider({ sessionStorage.setItem("selected_plan", selectedPlan); } - appleId.auth.init({ + window.AppleID.auth.init({ clientId: "cloud.opensecret.maple.services", scope: "name email", - redirectURI, + redirectURI: window.location.origin + "/auth/apple/callback", state, nonce, usePopup: true @@ -158,9 +204,7 @@ export function AppleAuthProvider({ // Programmatic Apple sign-in returns one promise that resolves on success and rejects on // failure. It is the only completion channel; document events are intentionally unused. - const appleId = window.AppleID; - if (!appleId) throw new Error("Apple Sign In SDK not loaded"); - const authResult = await appleId.auth.signIn(); + const authResult = await window.AppleID.auth.signIn(); if (!active.current || (target && !isCurrentDesktopOAuthTarget(target))) return; const authorization = authResult?.authorization; if (!authorization?.code || !authorization.state) { diff --git a/apps/maple-research/frontend/src/lib/test/preload.ts b/apps/maple-research/frontend/src/lib/test/preload.ts index 35a03517d..cb0ff5c3b 100644 --- a/apps/maple-research/frontend/src/lib/test/preload.ts +++ b/apps/maple-research/frontend/src/lib/test/preload.ts @@ -1,17 +1 @@ -import { mock } from "bun:test"; -import { createRequire } from "node:module"; -import * as react from "react"; -import * as jsxRuntime from "react/jsx-runtime"; - -// Mirror Vite's React deduplication for a local SDK link. Keep the SDK real: -// only its copy of the React peer dependency is redirected to this renderer's -// instance. Published SDK installs already resolve the same peer and do nothing. -const frontendRequire = createRequire(import.meta.url); -const sdkRequire = createRequire(frontendRequire.resolve("@mapleai/sdk")); -for (const [name, exports] of [ - ["react", react], - ["react/jsx-runtime", jsxRuntime] -] as const) { - const sdkPath = sdkRequire.resolve(name); - if (sdkPath !== frontendRequire.resolve(name)) mock.module(sdkPath, () => exports); -} +export {}; diff --git a/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx b/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx index a0927c98a..d488b56b6 100644 --- a/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx +++ b/apps/maple-research/frontend/src/routes/auth.$provider.callback.tsx @@ -49,7 +49,7 @@ function OAuthCallback() { const redirectTimer = useRef | null>(null); const navigate = useNavigate(); const router = useRouter(); - const { handleGitHubCallback, handleGoogleCallback } = useOpenSecret(); + const { handleGitHubCallback, handleGoogleCallback, handleAppleCallback } = useOpenSecret(); const processedRef = useRef(false); const { provider } = Route.useParams(); @@ -127,29 +127,38 @@ function OAuthCallback() { if (processedRef.current) return; processedRef.current = true; - // Browser Apple completion belongs to the popup promise on its initiating page. - // This static route cannot receive Apple's form_post response. - if (provider === "apple") { - handleAuthError( - new Error( - "Apple sign-in uses a popup. Return to the sign-in page, allow popups for this site, and try again." - ) - ); - return; - } - - // Get URL parameters for redirect-based OAuth providers. + // Get URL parameters for all OAuth providers const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); const state = urlParams.get("state"); - if (code && state) { + // For Apple, we might get form data instead of URL parameters + // Apple uses form_post with POST request in some scenarios + let appleData = null; + if (provider === "apple" && !code) { + // Check if we have Apple data in sessionStorage from form_post + const appleFormData = sessionStorage.getItem("apple_form_data"); + if (appleFormData) { + try { + appleData = JSON.parse(appleFormData); + sessionStorage.removeItem("apple_form_data"); + } catch (e) { + console.error("Failed to parse Apple form data:", e); + } + } + } + + if ((code && state) || (provider === "apple" && appleData)) { try { // Handle the callback based on the provider if (provider === "github") { await handleGitHubCallback(code || "", state || "", ""); } else if (provider === "google") { await handleGoogleCallback(code || "", state || "", ""); + } else if (provider === "apple") { + // This handles the redirect flow (backup for non-popup scenarios) + // Most Apple auth will now be handled client-side in the AppleAuthProvider component + await handleAppleCallback(code || "", state || "", ""); } else { throw new Error(`Unsupported provider: ${provider}`); } @@ -181,6 +190,7 @@ function OAuthCallback() { processCallback(); }, [ + handleAppleCallback, handleAuthError, handleGitHubCallback, handleGoogleCallback, @@ -203,17 +213,6 @@ function OAuthCallback() { } if (error) { - const callbackParams = new URLSearchParams(window.location.search); - const codes = callbackParams.getAll("code"); - const states = callbackParams.getAll("state"); - const showAgentPasteHint = - (provider === "github" || provider === "google") && - !nativeFlow.requested && - codes.length === 1 && - codes[0].trim().length > 0 && - states.length === 1 && - states[0].trim().length > 0 && - !["error", "error_description", "error_uri"].some((key) => callbackParams.has(key)); return ( @@ -221,16 +220,6 @@ function OAuthCallback() { - {showAgentPasteHint && ( -
- Using Maple Agent's paste field? -

- If Maple Agent explicitly asked you to paste a callback URL, copy the full address - from this browser's address bar and paste it only into the sign-in window you - started. Do not share this address. Otherwise, start a new sign-in. -

-
- )}