diff --git a/.agents/skills/develop-maple/SKILL.md b/.agents/skills/develop-maple/SKILL.md index f303618f3..96eef41d6 100644 --- a/.agents/skills/develop-maple/SKILL.md +++ b/.agents/skills/develop-maple/SKILL.md @@ -7,6 +7,11 @@ description: Develop and debug ordinary non-Agent-Mode Maple features and fixes Work from the `MaplePrivacyLabs/Maple` repository root. Treat `justfile`, `apps/maple-research/frontend/package.json`, `flake.nix`, `scripts/ci/`, and `.github/workflows/` as the command sources of truth. Check them again when they disagree with prose documentation. +The standalone hosted native sign-in application lives in `apps/maple-auth`. +For that app, follow its own `AGENTS.md`, package scripts, and Auth CI scripts; +do not place its code in Research or make Auth depend on Research source, +configuration, or dependency installation. Research retains its built-in auth. + ## Route Specialized Work - Use `$validate-maple` for full validation, packaged-app smoke tests, cross-platform builds, or release-artifact verification. diff --git a/.agents/skills/release-maple/SKILL.md b/.agents/skills/release-maple/SKILL.md index 7ce0189a4..df1aadd92 100644 --- a/.agents/skills/release-maple/SKILL.md +++ b/.agents/skills/release-maple/SKILL.md @@ -129,6 +129,19 @@ pinning those consumers first; an intentional local-source release can proceed with the exact monorepo commit recorded. Unrelated SDK source changes do not require a pinned client to upgrade, and this preference adds no release gate. +When preparing an enclave trust or PCR rotation change, review both browser +consumers' embedded fallbacks against the approved development and production +histories: `apps/maple-research/frontend/src/config/openSecretClientConfig.ts` +and `apps/maple-auth/src/config/openSecretClientConfig.ts`. Verify each affected +app's combined app-provided and pinned-SDK roots support its intended approved +enclave measurements when signed-history fetching is unavailable, preserving +development/production separation. Record affected artifacts and any pending rollout +in the handoff. Auth owns a separate SDK pin and publisher: refreshing Research +does not update Auth, and an Auth publication remains a separately authorized +operation under [the Pages guide](../../../docs/pages-deployments.md#independent-auth-site). +This is a compatibility review of each consumer, not a requirement to keep their +lists byte-identical or release them together. + Record the proxy version and inspect its own runtime inputs since `previous_tag`: ```bash diff --git a/.agents/skills/validate-maple/SKILL.md b/.agents/skills/validate-maple/SKILL.md index 7613ed1bf..6fb0b3b61 100644 --- a/.agents/skills/validate-maple/SKILL.md +++ b/.agents/skills/validate-maple/SKILL.md @@ -105,6 +105,23 @@ Use for iOS, Android, signing, updater metadata, installers, entitlements, or di Run commands from the repository root unless the command changes directory explicitly. +### Standalone hosted Auth + +For changes confined to `apps/maple-auth`, use its own guide and checks: + +```bash +nix develop --no-update-lock-file .#ci -c ./scripts/ci/auth-ci.sh +MAPLE_AUTH_ENVIRONMENT=pr nix develop --no-update-lock-file .#ci -c ./scripts/ci/auth-web.sh +``` + +Auth owns its package, registry SDK pin, frozen lockfile, tests, assets, and +`dist` build. Do not install Research dependencies or run its web/native +packaging merely to validate Auth. Shared publisher/workflow changes still +require the repository checks. Real provider callbacks, retained sessions, +manual/native opening, and live edge behavior require separate rehearsal; +a local artifact does not establish those results. Browser smoke must serve +Auth's built `dist` with its own preview command and record its origin. + ### Focused frontend test ```bash diff --git a/.githooks/pre-commit b/.githooks/pre-commit index db395d4d4..5bd18d3b3 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -110,6 +110,9 @@ fi if hook_selected research_frontend || hook_selected research_rust; then run_component maple-research "$REPO_ROOT" ".#ci" "$REPO_ROOT/apps/maple-research/.githooks/pre-commit" fi +if hook_selected auth; then + run_component maple-auth "$REPO_ROOT" ".#ci" "$REPO_ROOT/apps/maple-auth/.githooks/pre-commit" +fi if hook_selected updates; then run_component updates "$REPO_ROOT" ".#ci" "$REPO_ROOT/services/updates/.githooks/pre-commit" fi 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..4f6b6745d --- /dev/null +++ b/.github/workflows/auth-pages-build.yml @@ -0,0 +1,57 @@ +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: Test the standalone auth app + run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-ci.sh + + - 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-auth/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-auth/target/reproducibility/maple-auth-dist.tar.gz + apps/maple-auth/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..324d741d9 --- /dev/null +++ b/.github/workflows/auth-pages-ci.yml @@ -0,0 +1,57 @@ +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-auth/**" + - "scripts/ci/auth-*.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-auth/**" + - "scripts/ci/auth-*.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 the standalone auth app + run: nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-ci.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..167eca11f 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-*.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-*.sh" - "services/updates/package.json" - "services/updates/bun.lock" - "flake.nix" diff --git a/.gitignore b/.gitignore index 44cddd2a0..4271d96be 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ apps/maple-research/frontend/node_modules/ # Build outputs /apps/maple-research/frontend/dist/ +/apps/maple-auth/dist/ +/apps/maple-auth/target/ /apps/maple-research/frontend/build/ # Environment variables diff --git a/AGENTS.md b/AGENTS.md index a2e408d95..7e9b460a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,10 @@ current source and tests take precedence over historical design documents. - `apps/maple-research/`: the existing React/Vite/Tauri Maple application, including desktop Agent Mode. Read its [guide](apps/maple-research/AGENTS.md) for runtime placement, native security, and exact-app validation. +- `apps/maple-auth/`: standalone V2 hosted native sign-in, with its own + package, SDK pin, tests, build, and independent Pages publisher. Read its + [guide](apps/maple-auth/AGENTS.md). Research keeps its built-in auth; do not + introduce cross-app source imports or coupled release triggers. - `apps/maple-agent/`: GPUI desktop-v2 prototype, ACP and proxy CLI. Read its [guide](apps/maple-agent/AGENTS.md) and `$develop-maple-agent`. Its runtime and update discovery are separate from Research and its existing Agent Mode. @@ -97,7 +101,7 @@ release-configuration changes, plus the affected component checks. `./setup-hooks.sh` installs `.githooks/pre-commit`. It classifies staged paths with `scripts/ci/hook_change_detection.py` and runs each affected component's own `.githooks/pre-commit` inside that component's Nix flake, so the tools match -CI: the root `.#ci` shell for Research, `services/updates/`, and repository +CI: the root `.#ci` shell for Research, Auth, `services/updates/`, and repository checks; the component flakes for `apps/maple-agent/`, `sdk/`, `proxy/`, and `services/opensecret?submodules=1`. Without Nix it runs the same commands from `PATH` and warns that results may differ. It runs formatters, Clippy/ESLint, diff --git a/apps/maple-auth/.githooks/pre-commit b/apps/maple-auth/.githooks/pre-commit new file mode 100755 index 000000000..9b9235494 --- /dev/null +++ b/apps/maple-auth/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu +component_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +repo_root=${MAPLE_HOOK_REPO_ROOT:-$(CDPATH= cd -- "$component_dir/../.." && pwd)} +exec bash "$repo_root/scripts/ci/auth-ci.sh" diff --git a/apps/maple-auth/.gitignore b/apps/maple-auth/.gitignore new file mode 100644 index 000000000..7a4790f6c --- /dev/null +++ b/apps/maple-auth/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +target/ +*.tsbuildinfo +.env* +!.env.example diff --git a/apps/maple-auth/.prettierignore b/apps/maple-auth/.prettierignore new file mode 100644 index 000000000..799c7c3ca --- /dev/null +++ b/apps/maple-auth/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +target +bun.lock +*.tsbuildinfo diff --git a/apps/maple-auth/.prettierrc.json b/apps/maple-auth/.prettierrc.json new file mode 100644 index 000000000..48d6e8a7f --- /dev/null +++ b/apps/maple-auth/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "none", + "tabWidth": 2, + "printWidth": 100 +} diff --git a/apps/maple-auth/AGENTS.md b/apps/maple-auth/AGENTS.md new file mode 100644 index 000000000..f0e3d2ba2 --- /dev/null +++ b/apps/maple-auth/AGENTS.md @@ -0,0 +1,27 @@ +# Maple auth app guide + +Read the root guide and `$review-maple-security` for authentication changes. +This application is independent of Research: use its own package.json, +bun.lock, source, public assets, and config. Do not import sibling app files, +parent node_modules, or local SDK source. Consume the exact published SDK pin. +An auth-only change must not alter Research web authentication or client entry +URLs. + +Use the root `.#ci` Nix shell (Bun and Node match CI). See [README.md](README.md) +for development, component checks and the two fixed build profiles. Run +`scripts/ci/auth-ci.sh` for format, lint, type checking, and tests. The root hook +routes this app to `.githooks/pre-commit`. Run the auth build when source, +configuration or dependencies change, and root `nix flake check` for workflow +or shared CI changes. Do not overwrite ignored environment files. + +Preserve V2-only route parsing, same-origin OAuth callbacks, popup-only Apple, +SDK bootstrap ordering, pending target and account ownership checks, one mint +per confirmation, and the manual Open Maple link. Handoff completion clears +only its pending flow; it does not clear SDK credentials. Do not log provider +codes, state, tokens, handoff grants or credential-bearing URLs. Keep storage, +crypto and backend authority in the SDK and OpenSecret. + +The build boundary must reject sibling app code, linked SDK source and the +legacy SDK. Preserve the unprivileged build and trusted independent publisher. +A merge or successful build does not authorize publication or redirects. +Report automated checks separately from real provider/native/browser testing. diff --git a/apps/maple-auth/README.md b/apps/maple-auth/README.md new file mode 100644 index 000000000..6fb554629 --- /dev/null +++ b/apps/maple-auth/README.md @@ -0,0 +1,84 @@ +# Maple hosted authentication + +This standalone React/Vite application handles V2 native sign-in at the hosted +authentication origin. It owns its dependencies, lockfile, source, assets, tests, +build, and independent Pages publication. Its build does not import Research, +its configuration, or the in-tree SDK. The app consumes the published +`@mapleai/sdk` version pinned in its own manifest and lockfile. + +Research keeps its existing browser authentication and legacy native bridge. +This application does not replace web login or change installed clients' entry +URLs. Initial migration traffic reaches it through a separately enabled V2-only +redirect. See the repository [Pages guide](../../docs/pages-deployments.md) for +build and publication controls. + +## Routes and account state + +- `/start` and the permanent `/desktop-auth` alias accept exactly `provider`, + `transport=v2`, `native_session_id`, and `native_request_id`. +- `/auth/github/callback` and `/auth/google/callback` use the same-origin SDK + pending state. OAuth initiation explicitly selects this origin's callback. +- Apple uses its popup flow. `/auth/apple/callback` only explains how to restart + sign-in; it does not exchange a redirect callback or expose a copyable code. +- `/complete` presents completion guidance; other routes fail closed. + +The SDK initializes retained credentials before a hosted flow starts. Native +handoff requires account confirmation and a single grant for the stored native +session and request. Target and account ownership are checked again after +asynchronous work. Cancellation, timeout, or a replacement flow prevents a late +grant from opening the app. The manual Open Maple link remains available after +a successful mint. SDK credentials remain on this origin; finishing a handoff +does not sign out another tab or the user. + +## Develop and validate + +From the repository root, enter the pinned toolchain and install this app only: + +```sh +nix develop .#ci --no-update-lock-file +cd apps/maple-auth +bun install --frozen-lockfile +VITE_OPEN_SECRET_API_URL=https://enclave.secretgpt.ai \ + VITE_OPEN_SECRET_PCR_ENVIRONMENT=development bun --no-env-file run dev +``` + +The server listens on `127.0.0.1:5174`. Actual provider sign-in also requires +approved loopback callback entries and provider configuration. `VITE_*` values +are public build configuration; never put secrets in them. A developer may use +this app's ignored `.env.local`; managed CI builds ignore dotenv files without +modifying them. + +Run the independent validation or build profiles from the repository root: + +```sh +nix develop .#ci --no-update-lock-file -c bash scripts/ci/auth-ci.sh +MAPLE_AUTH_ENVIRONMENT=pr nix develop .#ci --no-update-lock-file -c bash scripts/ci/auth-web.sh +MAPLE_AUTH_ENVIRONMENT=release nix develop .#ci --no-update-lock-file -c bash scripts/ci/auth-web.sh +``` + +The package also exposes `format:check`, `lint`, `typecheck`, `test`, and `build`. +Tests cover route admission, pending handoff ownership and expiry, real SDK +bootstrap, retained sessions, provider UI, cancellation and manual open, and +build isolation. A build rejects modules outside this application (including +linked SDK source or sibling app imports) and the legacy SDK. Output goes to +`dist/`; the reproducible Pages archive and checksum go to +`target/reproducibility/`. + +Builds and unit tests do not prove real provider, browser, native-client, or +production behavior. Publication, DNS/provider settings, redirect activation, +and rollback rehearsals are separate operations. + +## Code ownership + +The initial handoff confirmation, storage helpers, button styling, and public +OpenSecret configuration were copied from Research at +`2500c86589564e98b50c49d24ee05af72d0c51ae` to preserve their tested behavior +without changing Research. The auth copy omits client URL construction and +legacy transport routing. These files are now owned here. There is no live +source dependency between the applications: fixes to these copies, including +approved PCR fallback changes, need an explicit review for each consumer. +Assess lifecycle and security fixes for both copies; their behavior can diverge +where the applications have different requirements, without adding import +coupling or requiring byte-for-byte parity. +Encryption, OAuth callback fencing, credential storage, and handoff API calls +remain in the published SDK rather than duplicated protocol implementations. diff --git a/apps/maple-auth/auth-build-boundary.ts b/apps/maple-auth/auth-build-boundary.ts new file mode 100644 index 000000000..72b5a7554 --- /dev/null +++ b/apps/maple-auth/auth-build-boundary.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; + +function canonicalPath(value: string): string { + const resolved = path.resolve(value); + return (fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved).replace(/\\/gu, "/"); +} + +/** The auth artifact may contain only this application and its installed dependencies. */ +export function assertAuthBundleIsolation(moduleIds: Iterable, appRoot: string): void { + const prefix = `${canonicalPath(appRoot)}/`; + for (const moduleId of moduleIds) { + // Rollup's CommonJS proxy IDs can wrap absolute paths in a virtual prefix. + const id = moduleId.replace(/^\0/u, "").replace(/\\/gu, "/").split("?")[0]; + if (id.includes("/@opensecret/") || id.includes("/@opensecret+")) { + throw new Error("The auth build must not include the legacy SDK"); + } + if (!path.isAbsolute(id)) continue; // Vite and Rollup generated helper modules. + if (!canonicalPath(id).startsWith(prefix)) { + throw new Error(`The auth build imported a module outside its application: ${id}`); + } + } +} diff --git a/apps/maple-auth/bun.lock b/apps/maple-auth/bun.lock new file mode 100644 index 000000000..20f58b868 --- /dev/null +++ b/apps/maple-auth/bun.lock @@ -0,0 +1,713 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@mapleai/auth-site", + "dependencies": { + "@mapleai/sdk": "4.1.1", + "@radix-ui/react-slot": "1.2.4", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "tailwind-merge": "2.6.1", + }, + "devDependencies": { + "@eslint/js": "9.39.4", + "@types/bun": "1.3.13", + "@types/node": "22.19.17", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@types/react-test-renderer": "18.3.1", + "@vitejs/plugin-react": "4.7.0", + "autoprefixer": "10.5.0", + "eslint": "9.39.4", + "eslint-plugin-react-hooks": "5.2.0", + "eslint-plugin-react-refresh": "0.4.26", + "globals": "15.15.0", + "postcss": "8.5.25", + "prettier": "3.8.3", + "react-test-renderer": "18.3.1", + "tailwindcss": "3.4.19", + "tailwindcss-animate": "1.0.7", + "typescript": "5.9.3", + "typescript-eslint": "8.59.0", + "vite": "6.4.3", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@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=="], + + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@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=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@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=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.9.4", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-pkcs8": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.9.4", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-pfx": "^2.9.4", "@peculiar/asn1-pkcs8": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.4", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + + "@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.3", "", { "os": "android", "cpu": "arm" }, "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.3", "", { "os": "android", "cpu": "arm64" }, "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.3", "", { "os": "none", "cpu": "arm64" }, "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA=="], + + "@stablelib/aead": ["@stablelib/aead@2.0.0", "", {}, "sha512-U/RMANRxbT/ahIpYsPSiFwDFNjADHdnCFfmo09MO1ai2XmerPAOPtMl0qmX7XVvygnACC6ijKDyHBoT2rGyElg=="], + + "@stablelib/base64": ["@stablelib/base64@2.0.1", "", {}, "sha512-P2z89A7N1ETt6RxgpVdDT2xlg8cnm3n6td0lY9gyK7EiWK3wdq388yFX/hLknkCC0we05OZAD1rfxlQJUbl5VQ=="], + + "@stablelib/binary": ["@stablelib/binary@2.0.1", "", { "dependencies": { "@stablelib/int": "^2.0.1" } }, "sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w=="], + + "@stablelib/chacha": ["@stablelib/chacha@2.0.1", "", { "dependencies": { "@stablelib/binary": "^2.0.1", "@stablelib/wipe": "^2.0.1" } }, "sha512-lS1FqtNqofxe2vLkRsLli2m3x/XanUyAYRphLhdHumKeIsLbjbCXdCq3Pf/eWiO7G3QlSG5ViqnoVjktzfLWMg=="], + + "@stablelib/chacha20poly1305": ["@stablelib/chacha20poly1305@2.0.1", "", { "dependencies": { "@stablelib/aead": "^2.0.0", "@stablelib/binary": "^2.0.1", "@stablelib/chacha": "^2.0.1", "@stablelib/constant-time": "^2.0.1", "@stablelib/poly1305": "^2.0.1", "@stablelib/wipe": "^2.0.1" } }, "sha512-kOoBsXbDPVRlelzXl+5WViycgM19lD7lF3Bc3KWI+DzId0Stc2HlxAfSc+Xpn3RgqSCl1ZNTXr33LegqBhBBaw=="], + + "@stablelib/constant-time": ["@stablelib/constant-time@2.0.1", "", {}, "sha512-0NWPogffRm+UWBH0+iM5otZmNrVe5OHFIvyoNIVankMAYOQzMwcdVALOVPrB5Ho0dST+Oc3H8/hPh65Z8R/uew=="], + + "@stablelib/int": ["@stablelib/int@2.0.1", "", {}, "sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww=="], + + "@stablelib/poly1305": ["@stablelib/poly1305@2.0.1", "", { "dependencies": { "@stablelib/constant-time": "^2.0.1", "@stablelib/wipe": "^2.0.1" } }, "sha512-D8xfZcL/5zeVARJ9I2fZOUCnZvsJx8G4JDQvD+ecsnaCildPtqZDwqqjtOc4cfHAdScrMjOzUVsssjXUTBw3YQ=="], + + "@stablelib/random": ["@stablelib/random@2.0.1", "", { "dependencies": { "@stablelib/binary": "^2.0.1", "@stablelib/wipe": "^2.0.1" } }, "sha512-W6GAtXEEs7r+dSbuBsvoFmlyL3gLxle41tQkjKu17dDWtDdjhVUbtRfRCQcCUeczwkgjQxMPopgwYEvxXtHXGw=="], + + "@stablelib/wipe": ["@stablelib/wipe@2.0.1", "", {}, "sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg=="], + + "@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.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@types/react-test-renderer": ["@types/react-test-renderer@18.3.1", "", { "dependencies": { "@types/react": "^18" } }, "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA=="], + + "@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=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.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-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.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-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], + + "@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=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "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=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + + "autoprefixer": ["autoprefixer@10.5.0", "", { "dependencies": { "browserslist": "^4.28.2", "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], + + "cbor2": ["cbor2@1.12.0", "", {}, "sha512-3Cco8XQhi27DogSp9Ri6LYNZLi/TBY/JVnDe+mj06NkBjW/ZYOtekaEU4wZ4xcRMNrFkDv8KNtOAqHyDfz3lYg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.427", "", {}, "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.4", "", { "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.5", "@eslint/js": "9.39.4", "@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-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "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=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "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=="], + + "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=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "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.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "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=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "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=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + + "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=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-shallow-renderer": ["react-shallow-renderer@16.15.0", "", { "dependencies": { "object-assign": "^4.1.1", "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA=="], + + "react-test-renderer": ["react-test-renderer@18.3.1", "", { "dependencies": { "react-is": "^18.3.1", "react-shallow-renderer": "^16.15.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA=="], + + "read-cache": ["read-cache@1.0.2", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.63.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.3", "@rollup/rollup-android-arm64": "4.63.3", "@rollup/rollup-darwin-arm64": "4.63.3", "@rollup/rollup-darwin-x64": "4.63.3", "@rollup/rollup-freebsd-arm64": "4.63.3", "@rollup/rollup-freebsd-x64": "4.63.3", "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", "@rollup/rollup-linux-arm-musleabihf": "4.63.3", "@rollup/rollup-linux-arm64-gnu": "4.63.3", "@rollup/rollup-linux-arm64-musl": "4.63.3", "@rollup/rollup-linux-loong64-gnu": "4.63.3", "@rollup/rollup-linux-loong64-musl": "4.63.3", "@rollup/rollup-linux-ppc64-gnu": "4.63.3", "@rollup/rollup-linux-ppc64-musl": "4.63.3", "@rollup/rollup-linux-riscv64-gnu": "4.63.3", "@rollup/rollup-linux-riscv64-musl": "4.63.3", "@rollup/rollup-linux-s390x-gnu": "4.63.3", "@rollup/rollup-linux-x64-gnu": "4.63.3", "@rollup/rollup-linux-x64-musl": "4.63.3", "@rollup/rollup-openbsd-x64": "4.63.3", "@rollup/rollup-openharmony-arm64": "4.63.3", "@rollup/rollup-win32-arm64-msvc": "4.63.3", "@rollup/rollup-win32-ia32-msvc": "4.63.3", "@rollup/rollup-win32-x64-gnu": "4.63.3", "@rollup/rollup-win32-x64-msvc": "4.63.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "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=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "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=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/apps/maple-auth/bunfig.toml b/apps/maple-auth/bunfig.toml new file mode 100644 index 000000000..ad04ab822 --- /dev/null +++ b/apps/maple-auth/bunfig.toml @@ -0,0 +1,12 @@ +[install] +exact = true +frozenLockfile = true +ignoreScripts = true +auto = "disable" +minimumReleaseAge = 604800 +# Our reviewed, protected SDK publisher supports immediate consumer upgrades. +minimumReleaseAgeExcludes = ["@mapleai/sdk"] + +[test] +preload = ["src/lib/test/der-loader.ts"] +root = "./src" diff --git a/apps/maple-auth/eslint.config.js b/apps/maple-auth/eslint.config.js new file mode 100644 index 000000000..2413a7a07 --- /dev/null +++ b/apps/maple-auth/eslint.config.js @@ -0,0 +1,23 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config({ + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + ignores: ["dist/**", "target/**", "node_modules/**"], + languageOptions: { + ecmaVersion: 2020, + globals: { ...globals.browser, ...globals.node } + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] + } +}); diff --git a/apps/maple-auth/index.html b/apps/maple-auth/index.html new file mode 100644 index 000000000..18576ee75 --- /dev/null +++ b/apps/maple-auth/index.html @@ -0,0 +1,14 @@ + + + + + + + + Sign in to Maple + + +
+ + + diff --git a/apps/maple-auth/package.json b/apps/maple-auth/package.json new file mode 100644 index 000000000..ef58f1997 --- /dev/null +++ b/apps/maple-auth/package.json @@ -0,0 +1,49 @@ +{ + "name": "@mapleai/auth-site", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "bun@1.3.5", + "trustedDependencies": [], + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint src auth-build-boundary.ts vite.config.ts vite-der-plugin.ts", + "typecheck": "tsc -b", + "test": "bun --no-env-file test", + "preview": "vite preview" + }, + "dependencies": { + "@mapleai/sdk": "4.1.1", + "@radix-ui/react-slot": "1.2.4", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "tailwind-merge": "2.6.1" + }, + "devDependencies": { + "@eslint/js": "9.39.4", + "@types/bun": "1.3.13", + "@types/node": "22.19.17", + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@types/react-test-renderer": "18.3.1", + "@vitejs/plugin-react": "4.7.0", + "autoprefixer": "10.5.0", + "eslint": "9.39.4", + "eslint-plugin-react-hooks": "5.2.0", + "eslint-plugin-react-refresh": "0.4.26", + "globals": "15.15.0", + "postcss": "8.5.25", + "prettier": "3.8.3", + "react-test-renderer": "18.3.1", + "tailwindcss": "3.4.19", + "tailwindcss-animate": "1.0.7", + "typescript": "5.9.3", + "typescript-eslint": "8.59.0", + "vite": "6.4.3" + } +} diff --git a/apps/maple-auth/postcss.config.js b/apps/maple-auth/postcss.config.js new file mode 100644 index 000000000..ba8073047 --- /dev/null +++ b/apps/maple-auth/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/apps/maple-auth/public/fonts/Manrope-VariableFont_wght.ttf b/apps/maple-auth/public/fonts/Manrope-VariableFont_wght.ttf new file mode 100644 index 000000000..765c1b1f3 Binary files /dev/null and b/apps/maple-auth/public/fonts/Manrope-VariableFont_wght.ttf differ diff --git a/apps/maple-auth/public/maple-logo-dark.svg b/apps/maple-auth/public/maple-logo-dark.svg new file mode 100644 index 000000000..bb539706d --- /dev/null +++ b/apps/maple-auth/public/maple-logo-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/maple-auth/src/auth-site/AuthSite.tsx b/apps/maple-auth/src/auth-site/AuthSite.tsx new file mode 100644 index 000000000..8e65156a8 --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/CallbackRecovery.tsx b/apps/maple-auth/src/auth-site/CallbackRecovery.tsx new file mode 100644 index 000000000..dffea444e --- /dev/null +++ b/apps/maple-auth/src/auth-site/CallbackRecovery.tsx @@ -0,0 +1,16 @@ +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() { + return ( +

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

+ ); +} diff --git a/apps/maple-auth/src/auth-site/HostedAppleSignIn.tsx b/apps/maple-auth/src/auth-site/HostedAppleSignIn.tsx new file mode 100644 index 000000000..fbd42e68f --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/HostedCallback.tsx b/apps/maple-auth/src/auth-site/HostedCallback.tsx new file mode 100644 index 000000000..c240961e3 --- /dev/null +++ b/apps/maple-auth/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; + } + // A hosted callback must still own its native target. Leave its address 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-auth/src/auth-site/HostedStart.tsx b/apps/maple-auth/src/auth-site/HostedStart.tsx new file mode 100644 index 000000000..4dd6d4ef7 --- /dev/null +++ b/apps/maple-auth/src/auth-site/HostedStart.tsx @@ -0,0 +1,71 @@ +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 { + 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(handoffInput)) { + 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-auth/src/auth-site/bootstrap.test.ts b/apps/maple-auth/src/auth-site/bootstrap.test.ts new file mode 100644 index 000000000..9eed23a31 --- /dev/null +++ b/apps/maple-auth/src/auth-site/bootstrap.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const appDirectory = 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/der-loader.ts", + "./src/auth-site/fixtures/bootstrap.tsx", + scenario + ], + { + cwd: appDirectory, + 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-auth/src/auth-site/buildBoundary.test.ts b/apps/maple-auth/src/auth-site/buildBoundary.test.ts new file mode 100644 index 000000000..0d2aa4bb5 --- /dev/null +++ b/apps/maple-auth/src/auth-site/buildBoundary.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { assertAuthBundleIsolation } from "../../auth-build-boundary"; + +const root = "/fixture/apps/maple-auth"; + +describe("standalone auth bundle boundary", () => { + test("accepts its own source, registry dependencies and generated helpers", () => { + expect(() => + assertAuthBundleIsolation( + [ + `${root}/index.html`, + `${root}/src/auth-site/main.tsx`, + `${root}/src/components/HostedNativeSignInConfirmation.tsx`, + `${root}/node_modules/@mapleai/sdk/dist/index.js`, + `\0${root}/node_modules/react/index.js?commonjs-proxy`, + "\0vite/modulepreload-polyfill.js", + "\0commonjsHelpers.js" + ], + root + ) + ).not.toThrow(); + }); + + test("rejects sibling applications, local SDK source and parent node_modules", () => { + for (const module of [ + "/fixture/apps/maple-research/frontend/src/components/ui/button.tsx", + "/fixture/apps/maple-research/frontend/src/auth-site/main.tsx", + "/fixture/apps/maple-agent/src/main.tsx", + "/fixture/sdk/dist/index.js", + "/fixture/node_modules/@mapleai/sdk/dist/index.js", + `${root}/../maple-research/frontend/src/main.tsx`, + `${root}-other/src/main.tsx`, + "\0/fixture/sdk/dist/index.js?commonjs-proxy" + ]) { + expect(() => assertAuthBundleIsolation([module], root)).toThrow("outside its application"); + } + }); + + test("rejects the legacy SDK even if installed inside the app", () => { + for (const module of [ + `${root}/node_modules/@opensecret/react-v1/dist/index.js`, + `${root}/node_modules/.bun/@opensecret+react@3.4.1/node_modules/index.js` + ]) { + expect(() => assertAuthBundleIsolation([module], root)).toThrow("legacy SDK"); + } + }); + + test("rejects a local link hidden inside its node_modules", () => { + const directory = mkdtempSync(path.join(tmpdir(), "maple-auth-boundary-")); + try { + const app = path.join(directory, "app"); + const sdk = path.join(directory, "sdk"); + mkdirSync(path.join(app, "node_modules", "@mapleai"), { recursive: true }); + mkdirSync(sdk); + writeFileSync(path.join(sdk, "index.js"), "export {};\n"); + symlinkSync(sdk, path.join(app, "node_modules", "@mapleai", "sdk")); + expect(() => + assertAuthBundleIsolation( + [path.join(app, "node_modules", "@mapleai", "sdk", "index.js")], + app + ) + ).toThrow("outside its application"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx b/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx new file mode 100644 index 000000000..b5a39b1f6 --- /dev/null +++ b/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx @@ -0,0 +1,371 @@ +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("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.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("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(); + + 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"); + expect(handleGoogleCallback).toHaveBeenCalledTimes(1); + expectFailureWithoutNavigation(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-auth/src/auth-site/fixtures/HostedAppleSignIn.case.tsx b/apps/maple-auth/src/auth-site/fixtures/HostedAppleSignIn.case.tsx new file mode 100644 index 000000000..a09e9938d --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/fixtures/bootstrap.tsx b/apps/maple-auth/src/auth-site/fixtures/bootstrap.tsx new file mode 100644 index 000000000..031c760ba --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/main.tsx b/apps/maple-auth/src/auth-site/main.tsx new file mode 100644 index 000000000..70d592c10 --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/route.test.ts b/apps/maple-auth/src/auth-site/route.test.ts new file mode 100644 index 000000000..c0ecc5eaf --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/route.ts b/apps/maple-auth/src/auth-site/route.ts new file mode 100644 index 000000000..7777da009 --- /dev/null +++ b/apps/maple-auth/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-auth/src/auth-site/style.css b/apps/maple-auth/src/auth-site/style.css new file mode 100644 index 000000000..162b69ad6 --- /dev/null +++ b/apps/maple-auth/src/auth-site/style.css @@ -0,0 +1,56 @@ +@config "../../tailwind.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-auth/src/auth-site/ui.test.ts b/apps/maple-auth/src/auth-site/ui.test.ts new file mode 100644 index 000000000..2d23e785c --- /dev/null +++ b/apps/maple-auth/src/auth-site/ui.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const appDirectory = 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`, () => { + // Keep global DOM and storage fixtures isolated while making every real-context + // case mandatory in the default test suite. + const result = Bun.spawnSync( + [process.execPath, "--no-env-file", "test", `./src/auth-site/fixtures/${fixture}.case.tsx`], + { + cwd: appDirectory, + 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-auth/src/components/HostedNativeSignInConfirmation.tsx b/apps/maple-auth/src/components/HostedNativeSignInConfirmation.tsx new file mode 100644 index 000000000..c42adcb0b --- /dev/null +++ b/apps/maple-auth/src/components/HostedNativeSignInConfirmation.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from "react"; +import { readNativeUserAuth, useOpenSecret } from "@mapleai/sdk"; +import { Button } from "@/components/ui/button"; +import { + clearDesktopOAuthTarget, + isCurrentDesktopOAuthTarget, + isNativeOAuthRedirect, + mintTransportV2NativeAuthDeepLink, + TRANSPORT_V2_PENDING_TTL_MS, + type TransportV2DesktopOAuthState +} from "@/services/desktopOAuthTransport"; + +/** Shared by provider redirects and Apple's popup. Identity comes only from authenticated SDK state. */ +export function HostedNativeSignInConfirmation({ + target +}: { + target: TransportV2DesktopOAuthState; +}) { + const os = useOpenSecret(); + const currentOs = useRef(os); + currentOs.current = os; + const active = useRef(true); + const submitted = useRef(false); + const cancelled = useRef(false); + const [account] = useState(() => { + const user = os.auth.user?.user; + let authority; + try { + authority = readNativeUserAuth(os.apiUrl); + } catch { + return null; + } + if (!user?.id || authority.principalId !== user.id || !authority.credentials) return null; + return { id: user.id, email: user.email, revision: authority.revision, apiUrl: os.apiUrl }; + }); + const [status, setStatus] = useState<"confirm" | "minting" | "complete" | "closed">("confirm"); + const [message, setMessage] = useState(null); + const [deepLink, setDeepLink] = useState(null); + + const ownsAccount = () => { + if (!active.current || cancelled.current || !account) return false; + const current = currentOs.current; + if (current.apiUrl !== account.apiUrl || current.auth.user?.user.id !== account.id) + return false; + try { + const authority = readNativeUserAuth(account.apiUrl); + return authority.principalId === account.id && authority.revision === account.revision; + } catch { + return false; + } + }; + + useEffect(() => { + active.current = true; + const timer = setTimeout( + () => { + submitted.current = true; + clearDesktopOAuthTarget(target); + setDeepLink(null); + setMessage("This sign-in expired. Start a new login in Maple."); + setStatus("closed"); + }, + Math.max(0, target.startedAt + TRANSPORT_V2_PENDING_TTL_MS - Date.now()) + ); + return () => { + active.current = false; + clearTimeout(timer); + // StrictMode immediately reconnects this effect. A real unmount owns no late completion. + queueMicrotask(() => { + if (!active.current) clearDesktopOAuthTarget(target); + }); + }; + }, [target]); + + const cancel = () => { + submitted.current = true; + cancelled.current = true; + clearDesktopOAuthTarget(target); + setDeepLink(null); + setMessage("Sign-in cancelled. You can close this page and return to Maple."); + setStatus("closed"); + }; + + const approve = async () => { + if (submitted.current || !active.current) return; + submitted.current = true; + setStatus("minting"); + try { + const url = await mintTransportV2NativeAuthDeepLink( + target, + os.mintNativeHandoffGrant, + ownsAccount + ); + if (!ownsAccount()) return; + setDeepLink(url); + setStatus("complete"); + window.location.href = url; + } catch { + if (!active.current || cancelled.current) return; + setMessage("This sign-in could not be completed. Start a new login in Maple."); + setStatus("closed"); + } + }; + + const openMaple = () => { + // The target was consumed after minting; a new pending flow invalidates this fallback. + if (!deepLink || !ownsAccount() || isNativeOAuthRedirect()) { + cancel(); + return; + } + window.location.href = deepLink; + }; + + if (!account) { + return

Your account could not be verified. Start a new login in Maple.

; + } + if (status === "closed") return

{message}

; + + return ( +
+
+

Sign in to the Maple app as

+

{account.email || `Account ${account.id}`}

+
+

+ Continue only if you started this login in Maple. Check that Maple shows the same account + before signing in there. +

+
+ + {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-auth/src/services/appleOAuth.test.ts b/apps/maple-auth/src/services/appleOAuth.test.ts new file mode 100644 index 000000000..329e4c01d --- /dev/null +++ b/apps/maple-auth/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-auth/src/services/appleOAuth.ts b/apps/maple-auth/src/services/appleOAuth.ts new file mode 100644 index 000000000..649ec42ae --- /dev/null +++ b/apps/maple-auth/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-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-auth/src/services/oauthConfig.test.ts b/apps/maple-auth/src/services/oauthConfig.test.ts new file mode 100644 index 000000000..cd9708378 --- /dev/null +++ b/apps/maple-auth/src/services/oauthConfig.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { getBrowserOAuthCallbackUrl } from "./oauthConfig"; + +describe("OAuth origin selection", () => { + 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("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(() => getBrowserOAuthCallbackUrl("github", origin)).toThrow(); + } + }); +}); diff --git a/apps/maple-auth/src/services/oauthConfig.ts b/apps/maple-auth/src/services/oauthConfig.ts new file mode 100644 index 000000000..2a8fe4058 --- /dev/null +++ b/apps/maple-auth/src/services/oauthConfig.ts @@ -0,0 +1,42 @@ +type BrowserOAuthProvider = "github" | "google" | "apple"; + +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(); +} 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/docs/pages-deployments.md b/docs/pages-deployments.md index ddd90c98c..ca91a20c6 100644 --- a/docs/pages-deployments.md +++ b/docs/pages-deployments.md @@ -112,3 +112,105 @@ 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 standalone [Auth application](../apps/maple-auth/README.md) owns its +package, registry SDK pin, lockfile, assets, tests, configuration, build, and +publication path under `apps/maple-auth`. It imports no Research source or +configuration and does not use Research's dependency installation. The shared +protocol comes from the published SDK; the small hosted UI/helper copies are +maintained and tested within Auth. Research retains its built-in web auth, +existing SDK pin, and native entry URLs. Neither an Auth change nor an Auth +publication requires a Research release. + +| Lane | Source and configuration | Result | +| --- | --- | --- | +| `Auth Pages CI` | PRs targeting any base, including forks and stacked branches; relevant master pushes; `pr` profile | Offline publisher checks, standalone Auth checks and Auth build; no Research build or 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-ci.sh` installs and checks only Auth. `scripts/ci/auth-web.sh` +builds its `index.html` entry to `apps/maple-auth/dist`, then archives it under +`apps/maple-auth/target/reproducibility/maple-auth-dist.tar.gz`. Both scripts +use Auth's own helper, not Research/Tauri build tooling. Auth-only source edits +select the Auth lane without Research or Agent component checks/packaging. +Shared CI or release infrastructure edits can still select other affected lanes. + +The fixed `pr` profile uses `https://enclave.secretgpt.ai` and development PCRs; +`release` uses `https://enclave.trymaple.ai` and production PCRs. Both use the +existing public Maple client ID. Build/run commands use Bun's `--no-env-file`, +and Auth's Vite configuration disables dotenv loading for these fixed builds. +Dependency installation uses Auth's frozen lockfile with lifecycle scripts +disabled. The pinned Bun 1.3.5 installer can still read local dotenv files +despite that flag; its child cannot alter the shell's fixed build profile. +Scripts never rename or move managed dotenv files. Fresh production checkouts +contain no managed workspace dotenv files. The pinned CI shell provides Node +(required by TypeScript/Vite CLI shebangs), Bun, and Python. + +Auth pins published `@mapleai/sdk` 4.1.1; Research retains its own 4.0.1 pin. +Both fixed Auth profiles reject local SDK links and source overrides, require +an exact stable SDK version of at least 4.1.0, and check the installed package +name/version and resolution inside Auth's own `node_modules`. Future Auth +upgrades publish the SDK first, then update only Auth's manifest and lockfile. +The offline gate neither publishes the SDK nor queries the registry. +The bundle boundary rejects sibling application code, source SDK imports, +and the legacy SDK. + +Before publishing an Auth change related to enclave trust or PCR rotation, +review `apps/maple-auth/src/config/openSecretClientConfig.ts` against the +approved development/production histories. Verify the combined app-provided and +pinned-SDK roots support the intended approved enclave when signed-history fetching +is unavailable, preserving environment separation. Include Research's +separate fallback in the [SDK consumer rollout review](sdk-publishing.md#rolling-an-sdk-fix-out-to-clients). +Record the Auth artifact/publication separately; a Research release does not +refresh the hosted Auth copy, and the two lists need not be byte-identical. + +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 unprivileged local Auth checks and build (no services or publication): + +```bash +nix develop --no-update-lock-file .#ci -c ./scripts/ci/auth-ci.sh +MAPLE_AUTH_ENVIRONMENT=pr nix develop --no-update-lock-file .#ci -c ./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/docs/sdk-publishing.md b/docs/sdk-publishing.md index 0d88ed801..ff73c2765 100644 --- a/docs/sdk-publishing.md +++ b/docs/sdk-publishing.md @@ -114,8 +114,17 @@ next. `proxy` with `cargo update -p maple-sdk --precise X.Y.Z`. The host app and its embedded proxy must resolve the same SDK version. When the SDK adds a user-facing condition, surface it through each client's own safe message - (native clients keep SDK error details private) and update the frontend's - embedded PCR0 roots if the SDK's changed. + (native clients keep SDK error details private). For enclave trust changes, + review both apps' embedded PCR0 fallback lists against the approved + `services/opensecret/pcrDevHistory.json` and `pcrProdHistory.json`: + `apps/maple-research/frontend/src/config/openSecretClientConfig.ts` and + `apps/maple-auth/src/config/openSecretClientConfig.ts`. Review each app's + combined app-provided and pinned-SDK roots for its intended approved enclave + measurements when signed-history fetching is unavailable, keeping development + and production policies separate. + Refresh affected lists and validate the affected app; identical lists or SDK + pins are not required. Record Auth's independently authorized build/publication + when its fallback changes; a Research release does not publish Auth. 4. **Isolated app version bump** with `just update-version X.Y.Z` on its own branch, following `.agents/skills/release-maple/`. 5. **Release** through the release skill when the team decides to ship. If the diff --git a/flake.nix b/flake.nix index c4e98a561..6686262df 100644 --- a/flake.nix +++ b/flake.nix @@ -195,7 +195,9 @@ actionlint rustToolchain ]; - ciPackages = [ rustupShim ] ++ commonPackages; + # Frontend scripts run tsc and Vite through their Node shebangs; pin + # that runtime instead of relying on a hosted runner's global Node. + ciPackages = [ rustupShim pkgs.nodejs ] ++ commonPackages; linuxTauriPackages = with pkgs; @@ -689,9 +691,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/agent_change_detection.py b/scripts/ci/agent_change_detection.py index b4ef09265..b535b36d1 100644 --- a/scripts/ci/agent_change_detection.py +++ b/scripts/ci/agent_change_detection.py @@ -33,6 +33,7 @@ ) KNOWN_INDEPENDENT_PREFIXES = ( "apps/maple-research/", + "apps/maple-auth/", ".agents/", ".github/", ".githooks/", diff --git a/scripts/ci/auth-ci.sh b/scripts/ci/auth-ci.sh new file mode 100755 index 000000000..e40fdc8d6 --- /dev/null +++ b/scripts/ci/auth-ci.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/auth-common.sh" +use_auth_environment pr +prepare_auth_tooling +print_auth_source_provenance +install_auth_deps +cd "$AUTH_APP_DIR" +bun --no-env-file run format:check +bun --no-env-file run lint +bun --no-env-file run typecheck +bun --no-env-file run test diff --git a/scripts/ci/auth-common.sh b/scripts/ci/auth-common.sh new file mode 100755 index 000000000..f5027f07d --- /dev/null +++ b/scripts/ci/auth-common.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Standalone auth tooling. This must not source Research/Tauri build helpers. +set -euo pipefail + +AUTH_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AUTH_REPO_ROOT="$(cd "${AUTH_SCRIPT_DIR}/../.." && pwd)" +AUTH_APP_DIR="${AUTH_REPO_ROOT}/apps/maple-auth" + +use_auth_environment() { + local profile="$1" name + case "$profile" in + pr | release) ;; + *) printf 'Unsupported auth build profile; expected pr or release.\n' >&2; return 1 ;; + esac + while IFS='=' read -r name _; do + case "$name" in VITE_*) unset "$name" ;; esac + done < <(env) + export VITE_CLIENT_ID="ba5a14b5-d915-47b1-b7b1-afda52bc5fc6" + if [ "$profile" = release ]; then + export VITE_OPEN_SECRET_API_URL="https://enclave.trymaple.ai" + export VITE_OPEN_SECRET_PCR_ENVIRONMENT="production" + else + export VITE_OPEN_SECRET_API_URL="https://enclave.secretgpt.ai" + export VITE_OPEN_SECRET_PCR_ENVIRONMENT="development" + fi +} + +prepare_auth_tooling() { + # Build/run subprocesses ignore dotenv files, and auth Vite uses envDir:false. + # Bun 1.3.5 install can still read dotenv despite --no-env-file; scripts are + # disabled and that child cannot alter this shell's fixed profile. Never move + # ignored or externally managed dotenv files to work around that Bun behavior. + local real_bun + real_bun="$(command -v bun)" + AUTH_WRAPPER_DIR="$(mktemp -d)" + trap 'rm -rf -- "$AUTH_WRAPPER_DIR"' EXIT + printf '#!/usr/bin/env bash\nexec %q --no-env-file "$@"\n' "$real_bun" >"$AUTH_WRAPPER_DIR/bun" + chmod +x "$AUTH_WRAPPER_DIR/bun" + export PATH="$AUTH_WRAPPER_DIR:$PATH" + export MAPLE_IGNORE_VITE_ENV_FILES=1 + unset NODE_OPTIONS BUN_OPTIONS BUN_PRELOAD +} + +install_auth_deps() { + # Both profiles use the independent published dependency graph. No source SDK + # preparation, Research node_modules, native toolchain or app dotenv is read. + python3 -I "${AUTH_SCRIPT_DIR}/pages_auth_build.py" sdk-pin --frontend "$AUTH_APP_DIR" + (cd "$AUTH_APP_DIR" && bun --no-env-file install --frozen-lockfile --ignore-scripts) + python3 -I "${AUTH_SCRIPT_DIR}/pages_auth_build.py" sdk-pin --frontend "$AUTH_APP_DIR" --installed +} + +print_auth_source_provenance() { + if ! command -v git >/dev/null 2>&1 || + ! git -C "$AUTH_REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + return 0 + fi + printf 'git-commit %s\n' "$(git -C "$AUTH_REPO_ROOT" rev-parse HEAD)" + printf 'git-tree %s\n' "$(git -C "$AUTH_REPO_ROOT" rev-parse 'HEAD^{tree}')" + if ! git -C "$AUTH_REPO_ROOT" diff --quiet --ignore-submodules --; then + echo 'git-worktree-dirty unstaged' + fi + if ! git -C "$AUTH_REPO_ROOT" diff --cached --quiet --ignore-submodules --; then + echo 'git-worktree-dirty staged' + fi +} diff --git a/scripts/ci/auth-web.sh b/scripts/ci/auth-web.sh new file mode 100755 index 000000000..5930680a8 --- /dev/null +++ b/scripts/ci/auth-web.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/auth-common.sh" +use_auth_environment "${MAPLE_AUTH_ENVIRONMENT:-pr}" +prepare_auth_tooling +print_auth_source_provenance +install_auth_deps +export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-315532800}" +cd "$AUTH_APP_DIR" +bun --no-env-file run build +test -f dist/index.html + +# Normalize only this application's static artifact. No native build tree or +# Research metadata is involved. GNU tar and gzip come from the root CI shell. +find dist \( -name '.DS_Store' -o -name '._*' -o -name 'Thumbs.db' -o -name 'Desktop.ini' \) \ + -type f -exec rm -f -- {} + +repro_dir="$AUTH_APP_DIR/target/reproducibility" +mkdir -p "$repro_dir" +auth_archive="$repro_dir/maple-auth-dist.tar.gz" +( + cd dist + find . -mindepth 1 -print0 \ + | LC_ALL=C sort -z \ + | "${MAPLE_NIX_GNUTAR:-tar}" --null --no-recursion \ + --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -cf - -T - +) | "${MAPLE_NIX_GZIP:-gzip}" -n > "$auth_archive" +python3 -I "${AUTH_SCRIPT_DIR}/pages_auth_build.py" artifact --archive "$auth_archive" +python3 -I - "$auth_archive" "$repro_dir/auth-final.sha256" "$AUTH_REPO_ROOT" <<'PY' +from hashlib import sha256 +from pathlib import Path +import sys +archive, manifest, root = map(Path, sys.argv[1:]) +line = f"{sha256(archive.read_bytes()).hexdigest()} {archive.relative_to(root)}\n" +manifest.write_text(line) +print(line, end="") +PY diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index f3c931d2d..85ef7daed 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -31,7 +31,7 @@ "apps/maple-research/zapstore.yaml", } ) -INERT_PREFIXES = (".agents/", ".githooks/", "docs/", "apps/maple-research/docs/", "apps/maple-research/.githooks/", "services/updates/", "services/opensecret/", "apps/maple-agent/") +INERT_PREFIXES = (".agents/", ".githooks/", "docs/", "apps/maple-research/docs/", "apps/maple-research/.githooks/", "services/updates/", "services/opensecret/", "apps/maple-agent/", "apps/maple-auth/") PURE_FRONTEND_PREFIXES = ("apps/maple-research/frontend/public/", "apps/maple-research/frontend/src/") PURE_FRONTEND_FILES = frozenset({"apps/maple-research/frontend/icon.svg", "apps/maple-research/frontend/index.html"}) SDK_FRONTEND_PREFIXES = ("sdk/src/",) diff --git a/scripts/ci/hook_change_detection.py b/scripts/ci/hook_change_detection.py index b119f7373..2a87e0fb0 100644 --- a/scripts/ci/hook_change_detection.py +++ b/scripts/ci/hook_change_detection.py @@ -16,6 +16,7 @@ OUTPUTS = ( "research_frontend", "research_rust", + "auth", "agent", "sdk_rust", "sdk_ts", @@ -45,6 +46,8 @@ RESEARCH_INERT_PREFIXES = ("apps/maple-research/docs/", "apps/maple-research/.githooks/") RESEARCH_INERT_FILES = frozenset({"apps/maple-research/deny.toml", "apps/maple-research/zapstore.yaml"}) +AUTH_PREFIX = "apps/maple-auth/" + AGENT_PREFIX = "apps/maple-agent/" AGENT_INERT_PREFIXES = ("docs/", ".githooks/") @@ -122,6 +125,11 @@ def classify_path(path: str) -> frozenset[str]: if path.startswith("apps/maple-research/"): return frozenset({"research_frontend", "research_rust"}) + if path.startswith(AUTH_PREFIX): + if path.removeprefix(AUTH_PREFIX).startswith(".githooks/"): + return frozenset() + return frozenset({"auth"}) + if path.startswith(AGENT_PREFIX): relative = path.removeprefix(AGENT_PREFIX) if relative.startswith(AGENT_INERT_PREFIXES): 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_agent_change_detection.py b/scripts/ci/test_agent_change_detection.py index ce9c45cce..4c10de3c8 100644 --- a/scripts/ci/test_agent_change_detection.py +++ b/scripts/ci/test_agent_change_detection.py @@ -40,6 +40,10 @@ def test_shared_rust_runtime_inputs_select_both_desktop_apps(self): def test_research_typescript_and_independent_services_skip_agent(self): for path in ( + "apps/maple-auth/src/main.tsx", + "apps/maple-auth/package.json", + "apps/maple-auth/bun.lock", + "apps/maple-auth/vite.config.ts", "apps/maple-research/frontend/src/main.tsx", "apps/maple-research/frontend/src-tauri/src/lib.rs", "sdk/src/lib/index.ts", "sdk/package.json", "sdk/flake.nix", diff --git a/scripts/ci/test_change_detection.py b/scripts/ci/test_change_detection.py index ede52c32d..3a6e94b72 100644 --- a/scripts/ci/test_change_detection.py +++ b/scripts/ci/test_change_detection.py @@ -47,6 +47,20 @@ def test_agent_component_does_not_trigger_research_packaging(self) -> None: "macos", "linux", "windows", ) + def test_auth_component_does_not_trigger_research_packaging(self) -> None: + for path in ( + "apps/maple-auth/src/main.tsx", "apps/maple-auth/package.json", + "apps/maple-auth/bun.lock", "apps/maple-auth/vite.config.ts", + "apps/maple-auth/.githooks/pre-commit", ".github/workflows/auth-pages-ci.yml", + "scripts/ci/auth-common.sh", "scripts/ci/auth-ci.sh", "scripts/ci/auth-web.sh", + ): + with self.subTest(path=path): + self.assert_routes([path]) + self.assert_routes( + ["apps/maple-auth/src/main.tsx", "apps/maple-research/frontend/src/app.tsx"], + "frontend", + ) + def test_in_tree_rust_runtime_inputs_mark_desktop_lanes(self) -> None: for path in ( "proxy/Cargo.toml", diff --git a/scripts/ci/test_hook_change_detection.py b/scripts/ci/test_hook_change_detection.py index 6e0716132..37b1bf483 100644 --- a/scripts/ci/test_hook_change_detection.py +++ b/scripts/ci/test_hook_change_detection.py @@ -27,6 +27,8 @@ def test_documentation_and_hook_metadata_select_nothing(self) -> None: "justfile", "apps/maple-research/AGENTS.md", "apps/maple-research/.githooks/pre-commit", + "apps/maple-auth/AGENTS.md", + "apps/maple-auth/.githooks/pre-commit", "apps/maple-agent/AGENTS.md", "apps/maple-agent/.githooks/pre-commit", "sdk/README.md", @@ -54,6 +56,16 @@ def test_research_components(self) -> None: "research_rust", ) + def test_auth_component_does_not_select_research(self) -> None: + for path in ("apps/maple-auth/src/main.tsx", "apps/maple-auth/package.json", + "apps/maple-auth/bun.lock", "apps/maple-auth/vite.config.ts"): + with self.subTest(path=path): + self.assert_selects([path], "auth") + self.assert_selects( + ["apps/maple-auth/src/main.tsx", "apps/maple-research/frontend/src/App.tsx"], + "auth", "research_frontend", + ) + def test_agent_component(self) -> None: for path in ("apps/maple-agent/crates/maple-agent/src/agent.rs", "apps/maple-agent/Cargo.lock", "apps/maple-agent/flake.nix"): with self.subTest(path=path): diff --git a/scripts/ci/test_pages_auth_build.py b/scripts/ci/test_pages_auth_build.py new file mode 100644 index 000000000..121e5b174 --- /dev/null +++ b/scripts/ci/test_pages_auth_build.py @@ -0,0 +1,107 @@ +"""Release pin tests use synthetic local packages and never install dependencies.""" + +import json +import os +from pathlib import Path +import subprocess +import shutil +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_need_no_research_tree_and_replace_inherited_vite_values(self): + with tempfile.TemporaryDirectory() as directory: + # Deliberately copy only the auth helper. Sourcing Research/Tauri or + # an SDK checkout would fail in this independent application fixture. + root = Path(directory) + scripts = root / "scripts/ci" + scripts.mkdir(parents=True) + common = scripts / "auth-common.sh" + shutil.copyfile(Path(__file__).resolve().parent / "auth-common.sh", common) + shared = {"VITE_CLIENT_ID": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6"} + for profile, expected in ( + ("pr", {"VITE_OPEN_SECRET_API_URL": "https://enclave.secretgpt.ai", + "VITE_OPEN_SECRET_PCR_ENVIRONMENT": "development"}), + ("release", {"VITE_OPEN_SECRET_API_URL": "https://enclave.trymaple.ai", + "VITE_OPEN_SECRET_PCR_ENVIRONMENT": "production"}), + ): + with self.subTest(profile=profile): + expected = {**shared, **expected} + environment = {"PATH": os.environ["PATH"], "VITE_UNEXPECTED": "synthetic", + "VITE_AUTH_ORIGIN": "https://inherited.invalid", + **{key: "https://inherited.invalid" for key in expected}} + result = subprocess.run( + ["bash", "-c", 'source "$1"; use_auth_environment "$2"; "$3" -I -c "$4"', + "profile-test", str(common), profile, 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) + + def test_unknown_profile_fails_before_any_install_or_build(self): + common = Path(__file__).resolve().parent / "auth-common.sh" + result = subprocess.run( + ["bash", "-c", 'source "$1"; use_auth_environment typo', "profile-test", str(common)], + text=True, capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("expected pr or release", result.stderr) + + +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..8a6d81de0 --- /dev/null +++ b/scripts/ci/test_pages_auth_workflows.py @@ -0,0 +1,231 @@ +"""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" +CHECK_AUTH = "nix develop --no-update-lock-file .#ci -c bash scripts/ci/auth-ci.sh" +ARTIFACT_DIRECTORY = "apps/maple-auth/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_only_auth_and_shared_build_tooling(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-auth/**", "scripts/ci/auth-*.sh", + "scripts/ci/pages_*.py", "scripts/ci/test_pages_*.py", + "flake.nix", "flake.lock", + }, + ) + 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-*.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_builds_test_only_the_standalone_auth_application(self): + for name, job in ((CI, "auth"), (BUILD, "build")): + with self.subTest(workflow=name): + steps = workflow(name)["jobs"][job]["steps"] + checks = [step for step in steps if step.get("run") == CHECK_AUTH] + builds = [step for step in steps if step.get("run") == BUILD_AUTH] + self.assertEqual(len(checks), 1) + self.assertNotIn("if", checks[0]) + self.assertLess(steps.index(checks[0]), steps.index(builds[0])) + commands = " ".join(step.get("run", "") for step in steps) + for research_input in ("maple-research", "scripts/ci/frontend.sh", "scripts/ci/web.sh", + "prepare-frontend-deps", "prepare-typescript-sdk"): + self.assertNotIn(research_input, commands) + + 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()