From d524dfb9a3cb29558bf7c8e1fc604d30a47e8799 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Fri, 8 May 2026 13:46:28 +0800 Subject: [PATCH] Feat/ia merge (#9904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: route fs backed cleanup (#9806) * refactor: shared browser safe helper cleanup (#9810) * refactor: shared browser-safe helper cleanup * style: run eslint autofix * fix: preserve empty url handling * fix: address remaining copilot comments on pr3 * remove loader class * fix: unhandledrejection error (#9774) * fix: resolve sentry promise error (#9786) * fix: resolve sentry promise error * fix: leave fallback when error * fix: improve credential validation handling in GitRepoForm to avoid a loop of re-loading the list of repos and branches (#9820) * add e2e and cli skills (#9818) * add e2e and cli skills * address feedback * address feedback * move to claude * feat: konnect integration proxy url and regex support (#9811) * chore: move konnect sync behind feature flag (#9832) * chore: isolate gRPC proto file preparation behind IPC boundary (#9828) * chore: isolate gRPC proto file preparation behind IPC boundary Move proto temp-file creation out of the renderer by adding a grpc.writeProtoFile IPC handler (main process) and wiring it up in the preload bridge. The renderer's ProtoFilesModal previously called writeProtoFile() directly, pulling node:fs / node:os / node:path into the renderer bundle. It now calls window.main.grpc.writeProtoFile(protoFile._id) instead. Changes: - src/main/ipc/electron.ts: add 'grpc.writeProtoFile' to HandleChannels - src/main/ipc/grpc.ts: export writeProtoFileById helper, add to gRPCBridgeAPI, register ipcMainHandle('grpc.writeProtoFile') - src/entry.preload.ts: wire grpc.writeProtoFile via ipcRenderer.invoke - src/ui/components/modals/proto-files-modal.tsx: remove direct write-proto-file import; use window.main.grpc.writeProtoFile in the directory-import validation loop - config/renderer-node-import-baseline.json: remove 5 stale/resolved baseline entries (proto-directory-loader.tsx x2 already gone; write-proto-file.ts fs/os/path x3 now main-process-only) - src/main/ipc/__tests__/grpc.test.ts: add writeProtoFileById unit tests as contract coverage for the new privileged bridge path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: validate proto syntax in writeProtoFileById IPC handler The directory-import validation loop relied on writeProtoFile for proto content validation, but writeProtoFile only writes the temp file without parsing. Add a protoLoader.load call inside writeProtoFileById so invalid proto syntax throws before the result is returned to the renderer. Also update the test to mock @grpc/proto-loader.load and assert it is called with the correct file path and includeDirs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: konnect integration strips nunjucks templates on sync (#9831) * fix(Git Sync): auto-resolve non-YAML file conflicts to remote during merge (#9798) * fix: filter conflict paths to include only YAML files * fix: enhance conflict resolution by auto-resolving non-YAML files to theirs * fix: keep buffer raw so that binary files are not corrupted Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: enhance merge conflict handling by introducing auto-resolved conflicts for non-YAML files * fix: add test for handling merge conflicts, ensuring only YAML conflicts are returned * fix: prevent HEAD update during auto-resolve of merge conflicts * fix: enhance merge conflict resolution by auto-completing merges when all conflicts are non-YAML --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: konnect integration expressions support (#9830) * Show more specific error when creating mock route fails (#9841) * fix: insomnia-ai-plugin uses securedPath (INS-2244) (#9748) * feat: add custom npm registry mirror setting for plugin installation (#9837) * feat: default user-agent for cURL imports [INS-2416] (#9838) * feat: default user-agent for cURL imports * respect disableAppVersionUserAgent setting * fix: view transition error - [INS-2316] (#9792) * fix: view transition error * fix * change default behavior when delete cloud sync workspaces (#9844) * feat: integrate v3 user endpoints (#9785) * feat: integrate v3 user endpoints * feat: use public sdk for insomnia-api * chore: applied PoLP to workflows (#9840) * chore: resolve GHA warning annotations and reduce CI time [INS-2312] (#9839) * fix: resolves INS-2366 (#9852) * fix: resolves INS-2366 dependency issues * Refactor:use electron store for oauth session (#9851) * move oauth session to electron storage * create electron storage bridge * use electronStorage bridge for managing oauth window handles * fix build * move key to constants * tolerate changing userData folder path * Update packages/insomnia/src/main/ipc/electron-storage.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * init store * fix singleton class * feedback * feedback * Update packages/insomnia/src/main/electron-storage.ts Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> * chore: decouple releases (#9842) * INS-2145 Decouple releases * fix security error * fix * check version * refactor: auth header to main (#9834) * remove deprecated baseUrl * add failing test * fix AI playwright runs * move getAuthHeader to main * address feedback about dynamic import * move oauth 1 + 2 flow to main * handle bad cookie * handle bad apikey * fix imports * block main process imports * extract plugins * fix vite config * console log * move init store * Fix OAuth imports after rebase Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * clean up * Revert config changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * clean up hawk * use bridge * update node require * remove this * define process type * remove 14 * ignore reports folder * fix e2e tests * address feedback * remove unused * tidy constants * feat: add getOAuth2Token IPC bridge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix tests --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: Support pin and unpin websocket and socketio requests (#9865) * support pin websocket and socketio requests * feat(Git Sync): Add support for canonical repository output (#9789) * initial support for canonical repo output (#9739) * Feat/git repo output sync queue (#9790) * feat: implement SyncQueue for serial async task processing * refactor: enhance repo file watcher for improved sync and error handling - Replace NeDB client with a unified disk client for all file operations. - Introduce a serial queue to manage sync tasks and prevent race conditions. - Implement content-hash deduplication to avoid unnecessary file imports. - Add problem tracking for YAML files with conflicts or parse errors. - Streamline watcher start/stop logic and improve notification handling. - Ensure immediate DB→FS flush before git operations to maintain consistency. - Enhance import logic to handle workspace deletions and renames effectively. * refactor: simplify projectRoutableFSClient by removing unused parameters and consolidating logic * feat: add git.db-synced event listener for revalidation in Root component * feat: add button to open local repository folder in ProjectSettingsForm * refactor: remove unused GitProjectNeDBClient * refactor: update imports to use services for workspace and workspaceMeta * refactor: update models usage to services in git repo migration and project settings form * refactor: streamline file watcher initialization and import process * feat: ensure immediate processing of pending debounced imports in RepoFileWatcher * refactor: improve file rename handling in RepoFileWatcher to prevent data loss * feat: enhance RepoFileWatcher to track last written hash and sync mtime for improved file management * refactor: remove unused parameters from upsertDocs in RepoFileWatcher for cleaner code * fix revalidator (#9826) * fix: handle detached HEAD during rebase in getCurrentBranch method (#9843) * fix: handle detached HEAD during rebase in getCurrentBranch method * fix: add return type to getCurrentBranch method * fix: refresh ui after sync (#9848) * fix: (git cli)skip flush problematic files (#9846) * fix: skip flush problematic files * fix * feat: (git cli)ux for invalide status (#9836) * feat: ux for invalide status * update ux * fix * fix * add tab warning * del log * feat(Git Sync): Handle non-origin remotes (#9833) * feat(git): detect non-origin branch tracking and guard sync operations - Add getBranchTrackingRemote(), getRemoteUrl(), getBranchRemoteInfo() to GitVCS - Add getBranchRemoteInfo IPC endpoint with BranchRemoteInfo interface - Add assertBranchOnOrigin() guard to push, pull, fetch, commitAndPush - canPushLoader returns { canPush: false } for non-origin branches - Add unit tests for remote detection methods * feat(git): add support for non-origin branch tracking and display warnings in UI * Show local git repo path [INS-2315] (#9858) * Update the style of local git folder path in project setting modal * Add Git CLI tip in commit changes modal * Repo Migration flow [INS-2256] (#9824) * initial support for canonical repo output (#9739) * feat: enhance git repository migration with concurrency guard and symlink handling * feat: enhance git repository migration with config sanitization and file overwrite handling * feat: implement repo migration version tracking and improve migration idempotency * feat: add runAllGitRepoMigrations function and migration view for Git projects Co-authored-by: Copilot * fix: reset initial migration status to 'default' in MigrationView component * refactor: simplify MigrationView component and update navigation logic * refactor: remove legacy directory structure migration from loadGitRepository function * feat: enhance runAllGitRepoMigrations to return logs and improve error handling in MigrationView * feat: update runAllGitRepoMigrations to return detailed logs and failed projects; enhance MigrationView to handle migration results * feat: optimize runAllGitRepoMigrations by batch-fetching git repositories and improving project filtering * feat: introduce CURRENT_MIGRATION_VERSION constant for migration tracking and update references in git-repo-migration and router * feat: handle failed projects in runAllGitRepoMigrations by converting them to local projects Co-authored-by: Copilot * feat: integrate CURRENT_MIGRATION_VERSION for migration tracking and update router logic to handle migration screen visibility * feat: reorder import statements in ProjectSettingsForm for consistency * feat: update MigrationStatus type and related logic for better error handling * feat: enhance migration logging with detailed error stack and include CURRENT_MIGRATION_VERSION in logs * feat: simplify migration logging messages for clarity and consistency * feat: improve migration check logic to prioritize version stamp over disk layout * feat: add tests for migrateRepoStructureIfNeeded function to ensure migration logic correctness * feat: update migration logic to re-run when old git/ directory exists, ensuring correct migration handling * test: update migration tests to ensure directory existence checks are accurate * refactor: remove redundant useEffect for localStorage in Component * feat: enhance path validation in runAllGitRepoMigrations to prevent path traversal vulnerabilities * feat: enhance path handling in migration functions to prevent directory traversal vulnerabilities * feat: enhance directory traversal protection in moveDirectoryContents function --------- Co-authored-by: James Gatz Co-authored-by: Copilot * fix: Delete old folders (#9867) * refactor: remove unused migration version handling from localStorage * fix: update directory removal logic to handle non-empty directories --------- Co-authored-by: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Co-authored-by: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Co-authored-by: Pavlos Koutoglou Co-authored-by: Copilot * refactor: move sync code to main (#9827) * move sync code to main * improve sync tests * update plan * test: reset cloud sync smoke state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * guard against bad test env * fix skill * remove new test * udpate plan * with proxy * checkpoint * move files * autofix * update plan * make all sync bridge async * fix window imports * refactor: move main-only sync helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix lint * move chunkArray tests * smaller interfaces * move store under vcs * move cloud-sync to main * create a second vcs for pull operations * added a invoke wrapper to remove error prefixes * rebase error --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: update insomnia-plugin-ai (#9862) * fix: bump node-libcurl and add ipv6 tests (#9869) * feat: revamping pre/post scripting sandbox (#9794) * feat: revamping pre/post scripting sandbox * feat: added UI setting to enable/disable specific checks * fix false positives * revert * fix: user can not resolve conflict in app (#9872) * fix: conflict ux * fix * fix * Git server for smoke test [INS-2258] (#9816) * Git server for smoke test * Try to solve flaky test * feat: remove unused Git hook samples and add Credentials tab functionality - Deleted various sample Git hook scripts from the git-server fixture, including post-update, pre-applypatch, pre-commit, pre-merge-commit, pre-push, pre-rebase, pre-receive, prepare-commit-msg, push-to-checkout, sendemail-validate, and update hooks. - Introduced a new PreferencesCredentialsTab class to manage Git credentials within the Insomnia Preferences. - Updated the PreferencesPage to include the new Credentials tab for Git credentials management. - Enhanced the ProjectPage with a method to create a Git Sync project, including branch creation and switching. - Added comprehensive tests for Git Sync functionality, including creating branches, committing changes, and merging branches. - Updated UI components to support new features, including data-testid attributes for better testability. Co-authored-by: Copilot * feat: update path import and add Git sync tests * revert package.json * Update package.json * feat: add new dependencies for Git HTTP mock server and related utilities * refactor: remove commented-out code in addAccessTokenGitCredential function * fix: update export tests to use toHaveLength for file count assertions --------- Co-authored-by: Copilot * feat: import deep-link login experience [INS-2416] (#9860) * refactor: replace node:url with URL in cert and proxy match (#9515) * refactor: import to main (#9809) * squash * re add comments * fix process fork * update base line 18 left * revert * check cert url without node * fix handlerId * exclude url matches cert host from scope * fix rebase * Fix style issue that file list in the middle of commit modal is collapsed [INS-2315] (#9875) * Fix style issue that file list in the middle of commit modal is collapsed * fix: update links to Git Sync documentation in staging modal and project settings form * Chore: playwright dx v2 (#9876) * Update E2E test for git sync [INS-2258] (#9878) * Add more test cases for git sync * tmp * Update package.json * feat: update migration image and urls (#9868) * feat: update migration image and path for improved clarity * feat: update error message and support links in migration view * feat(Git Sync): Downgrade -> Upgrade path (#9882) * feat: add mechanism to flush newer DB workspaces to disk during downgrade * feat: implement effective Git repository ID handling for project connections * feat: enhance Git repository ID handling for improved project queries and updates * Chore: refine e2e docs by agent for agent (#9881) * improve agent docs * added error context note * chore: Security update for dependencies and github workflows (#9884) * chore: resolves INS-2457, INS-2458, INS-2459, and INS-2460. * feat: include app version in window title [INS-2465] (#9888) * feat(Git Sync): improve git migration onboarding UX and local file system access [INS-2462] (#9890) * feat(Migration): enhance migration summary with total projects count and improve UI feedback * fix(Migration): clarify update instructions and improve user messaging * style(ManualCommitForm): adjust text sizes for improved readability * style(StagingModal): adjust layout and spacing for improved UI consistency * fix(ManualCommitForm): update clipboard text to include 'cd' command for easier navigation * fix(ProjectSettingsForm): update repository path copy functionality and add option to open in file system * fix(ManualCommitForm): enhance file system interaction with tooltips for better user guidance * fix(ProjectSettingsForm): add tooltip for 'Open in file system' button to enhance user guidance * fix(GitProjectSyncDropdown): add 'Open folder' action to sync dropdown for easier access to repository path * fix(Component): display relative path of current issue in modal for better context * fix(git-service): count only successfully migrated projects in totalProjects * fix(project-settings-form): platform-aware shell quoting for cd command * fix(git-project-staging-modal): platform-aware shell quoting for cd command * fix(project-settings-form): update aria-label to reflect cd command clipboard content * fix(git-project-staging-modal): update aria-label to reflect shell command clipboard content * fix(ManualCommitForm): replace tooltip with dialog for enhanced information display * fix(MigrationView): update migrated count calculation to reflect total projects --------- Co-authored-by: James Gatz * chore: normalize konnect api responses (#9895) * feat(Git Sync): enhance migration view with best practices note and UI improvements (#9900) * chore: comment out smctl credentials save in workflow (#9898) the command is no longer executed while keeping it in place for future reference. * feat: migrate model imports, base types, org model and helpers (#9802) * fix * fix vcsinstance * fix type issues --------- Co-authored-by: Jack Kavanagh Co-authored-by: James Gatz Co-authored-by: Shelby <13246465+shelby-moore@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Co-authored-by: kwburns-kong Co-authored-by: jeremyjpj0916 <31913027+jeremyjpj0916@users.noreply.github.com> Co-authored-by: Ryan Willis Co-authored-by: Kent Wang Co-authored-by: Alison Sabuwala Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> Co-authored-by: Jay Wu Co-authored-by: Pavlos Koutoglou Co-authored-by: Copilot Co-authored-by: Fares Osman <43153226+fiosman@users.noreply.github.com> Co-authored-by: Bingbing Co-authored-by: Vivek Thuravupala <2700229+godfrzero@users.noreply.github.com> --- .claude/skills/fix-test-cli-ci/SKILL.md | 58 + .codegraph/.gitignore | 16 + .github/workflows/homebrew.yml | 5 +- .github/workflows/release-build.yml | 34 +- .github/workflows/release-publish.yml | 52 +- .github/workflows/release-recurring.yml | 13 +- .github/workflows/release-start.yml | 41 +- .github/workflows/sast.yml | 8 +- .github/workflows/test-cli.yml | 13 +- .github/workflows/test-e2e.yml | 69 +- .github/workflows/test.yml | 12 +- .github/workflows/update-changelog.yml | 4 +- .gitignore | 1 + .vscode/settings.json | 3 +- AGENTS.md | 3 + DEVELOPMENT.md | 2 +- eslint.config.mjs | 70 +- package-lock.json | 313 +++--- package.json | 9 +- packages/insomnia-api/README.md | 2 +- packages/insomnia-api/package.json | 7 +- .../insomnia-api/src/__tests__/user.test.ts | 115 ++ packages/insomnia-api/src/user.ts | 68 +- packages/insomnia-api/vitest.config.ts | 7 + packages/insomnia-inso/package.json | 6 +- packages/insomnia-inso/src/cli.test.ts | 2 + .../src/objects/auth.ts | 2 +- .../src/objects/response.ts | 6 +- .../src/objects/send-request.ts | 5 +- packages/insomnia-smoke-test/README.md | 168 ++- .../fixtures/auth-types.yaml | 95 ++ .../fixtures/git-repo/git-server.git/HEAD | 1 + .../fixtures/git-repo/git-server.git/config | 6 + .../git-repo/git-server.git/description | 1 + .../git-repo/git-server.git/info/exclude | 6 + .../33/57649e085f0f653c60993fc34b0d380c4c2991 | Bin 0 -> 53 bytes .../5a/d28e22767f979da2c198dc6c1003b25964e3da | Bin 0 -> 20 bytes .../73/a07b09e3e790de0277df5ac98e0ad62ecf83d4 | 3 + .../git-repo/git-server.git/refs/heads/master | 1 + .../fixtures/ipv6-collection.yaml | 65 ++ packages/insomnia-smoke-test/package.json | 3 +- .../insomnia-smoke-test/playwright.config.ts | 38 +- .../pages/preferences/credentials-tab.ts | 38 + .../playwright/pages/preferences/index.ts | 9 +- .../playwright/pages/project/index.ts | 27 + .../insomnia-smoke-test/playwright/paths.ts | 2 +- .../insomnia-smoke-test/playwright/test.ts | 20 +- .../server/cloud-sync-api.ts | 20 +- packages/insomnia-smoke-test/server/index.ts | 70 +- .../server/insomnia-api.ts | 56 +- .../tests/smoke/app.test.ts | 2 +- .../smoke/cookie-editor-interactions.test.ts | 20 +- .../tests/smoke/disable-git-sync.test.ts | 101 ++ .../tests/smoke/export.test.ts | 7 +- .../tests/smoke/git-sync.test.ts | 248 +++-- .../tests/smoke/ipv6.test.ts | 40 + .../smoke/pre-request-script-features.test.ts | 169 ++- .../NODE_INTEGRATION_MIGRATION_PR_PLAN.md | 540 +++++++--- .../config/renderer-node-import-baseline.json | 160 +-- packages/insomnia/package.json | 19 +- packages/insomnia/setup-vitest.ts | 2 +- .../src/{models => }/__mocks__/uuid.ts | 0 .../src/__tests__/install-plugin.test.ts | 6 - .../src/account/__tests__/session.test.ts | 240 +++++ packages/insomnia/src/account/crypt.ts | 4 +- packages/insomnia/src/account/session.ts | 19 +- .../insomnia/src/basic-components/modal.tsx | 4 +- .../src/common/__fixtures__/nestedfolders.ts | 5 +- .../common/__tests__/electron-storage.test.ts | 9 + .../insomnia/src/common/__tests__/har.test.ts | 3 +- .../src/common/__tests__/import.test.ts | 32 +- .../src/common/__tests__/misc.test.ts | 28 - .../src/common/__tests__/render.test.ts | 4 +- .../src/common/__tests__/sorting.test.ts | 5 +- .../src/common/__tests__/strings.test.ts | 3 +- packages/insomnia/src/common/compression.ts | 40 + packages/insomnia/src/common/constants.ts | 59 +- packages/insomnia/src/common/har.ts | 14 +- packages/insomnia/src/common/import.ts | 31 +- .../insomnia/src/common/insomnia-fetch.ts | 4 +- packages/insomnia/src/common/insomnia-v5.ts | 6 +- packages/insomnia/src/common/misc.ts | 46 +- .../src/common/organization-storage-rules.ts | 54 + .../insomnia/src/common/path-with-params.ts | 3 + packages/insomnia/src/common/project.ts | 14 +- packages/insomnia/src/common/render.ts | 3 +- packages/insomnia/src/common/send-request.ts | 6 +- packages/insomnia/src/common/settings.ts | 12 + .../src/common/significant-diff-detection.ts | 4 +- packages/insomnia/src/entry.client.tsx | 6 +- .../src/entry.hidden-window-preload.ts | 5 +- packages/insomnia/src/entry.hidden-window.ts | 163 +-- packages/insomnia/src/entry.main.ts | 26 +- packages/insomnia/src/entry.preload.ts | 324 +++--- .../__tests__/git-credentials.test.ts | 3 +- .../__tests__/grpc-request-meta.test.ts | 4 +- .../__tests__/grpc-request.test.ts | 4 +- .../__tests__/index.test.ts | 5 +- .../__tests__/proto-file.test.ts | 4 +- .../__tests__/request-meta.test.ts | 2 - .../__tests__/request.test.ts | 0 .../node-src/database/database-nedb.ts | 8 +- .../node-src/database/database.test.ts | 2 +- .../node-src/database/init-model/index.ts | 5 +- .../database/init-model/response.test.ts | 5 +- .../node-src/database/repair-database.ts | 3 +- .../node-src/services/helpers/index.ts | 3 + .../helpers}/query-all-workspace-urls.test.ts | 5 +- .../helpers/query-all-workspace-urls.ts | 8 +- .../services}/helpers/request-operations.ts | 64 +- .../services}/helpers/response-operations.ts | 38 +- .../insomnia-data/node-src/services/index.ts | 2 + .../node-src/services/mcp-response.ts | 4 +- .../node-src/services/project.ts | 3 +- .../node-src/services/request-version.ts | 7 +- .../node-src/services/response.ts | 4 +- .../node-src/services/socket-io-response.ts | 4 +- .../node-src/services/websocket-response.ts | 4 +- .../src/insomnia-data/node-src/types.d.ts | 1 + .../src/insomnia-data/node-src/types.ts | 8 - .../src/insomnia-data/src/database/types.ts | 2 +- .../src/insomnia-data/src/models/api-spec.ts | 3 +- .../src/models/base-types.ts} | 0 .../src/models/ca-certificate.ts | 2 +- .../src/models/client-certificate.ts | 2 +- .../src/models/cloud-credential.ts | 2 +- .../insomnia-data/src/models/cookie-jar.ts | 2 +- .../insomnia-data/src/models/environment.ts | 2 +- .../src/models/git-credentials.ts | 2 +- .../src/models/git-repository.ts | 11 +- .../src/models/grpc-request-meta.ts | 2 +- .../insomnia-data/src/models/grpc-request.ts | 4 +- .../src/models/index.test.ts} | 2 +- .../src/insomnia-data/src/models/index.ts | 154 ++- .../insomnia-data/src/models/mcp-payload.ts | 2 +- .../insomnia-data/src/models/mcp-request.ts | 5 +- .../insomnia-data/src/models/mcp-response.ts | 5 +- .../insomnia-data/src/models/mock-route.ts | 4 +- .../insomnia-data/src/models/mock-server.ts | 2 +- .../src/models/o-auth-2-token.ts | 2 +- .../src}/models/organization.ts | 2 +- .../insomnia-data/src/models/plugin-data.ts | 2 +- .../src/models}/project.test.ts | 4 +- .../src/insomnia-data/src/models/project.ts | 68 +- .../src/models/proto-directory.ts | 3 +- .../insomnia-data/src/models/proto-file.ts | 2 +- .../src/models/request-group-meta.ts | 2 +- .../insomnia-data/src/models/request-group.ts | 5 +- .../insomnia-data/src/models/request-meta.ts | 3 +- .../src/models/request-version.ts | 2 +- .../src/insomnia-data/src/models/request.ts | 7 +- .../src/insomnia-data/src/models/response.ts | 3 +- .../src/models/runner-test-result.ts | 3 +- .../src/insomnia-data/src/models/settings.ts | 11 +- .../src/models/socket-io-payload.ts | 5 +- .../src/models/socket-io-request-meta.ts | 2 +- .../src/models/socket-io-request.ts | 5 +- .../src/models/socket-io-response.ts | 2 +- .../src/insomnia-data/src/models/stats.ts | 2 +- .../src/insomnia-data/src/models/types.ts | 2 + .../src/models/unit-test-result.ts | 2 +- .../src/models/unit-test-suite.ts | 2 +- .../src/insomnia-data/src/models/unit-test.ts | 2 +- .../insomnia-data/src/models/user-session.ts | 4 +- .../utils}/replace-ids-in-fields.test.ts | 2 +- .../models/utils}/replace-ids-in-fields.ts | 0 .../src/models/websocket-payload.ts | 4 +- .../src/models/websocket-request-meta.ts | 2 +- .../src/models/websocket-request.ts | 5 +- .../src/models/websocket-response.ts | 3 +- .../src/models/workspace-meta.ts | 4 +- .../src/insomnia-data/src/models/workspace.ts | 3 +- .../src/insomnia-data/src/services/index.ts | 5 +- .../src/konnect/__tests__/api.test.ts | 103 +- .../__tests__/expression-parser.test.ts | 173 +++ .../src/konnect/__tests__/sync.test.ts | 392 ++++++- .../src/konnect/__tests__/transform.test.ts | 445 ++++++++ packages/insomnia/src/konnect/api.ts | 70 +- .../insomnia/src/konnect/expression-parser.ts | 103 ++ packages/insomnia/src/konnect/sync.ts | 155 ++- packages/insomnia/src/konnect/transform.ts | 313 ++++++ .../__tests__/sync-initialization.test.ts | 157 +++ packages/insomnia/src/main/analytics.ts | 6 +- .../cloud-sync/core}/__tests__/util.test.ts | 53 +- .../cloud-sync/core}/__tests__/vcs.test.ts | 80 +- .../core}/store/__tests__/index.test.ts | 0 .../cloud-sync/core}/store/drivers/base.ts | 0 .../core}/store/drivers/file-system-driver.ts | 0 .../core}/store/drivers/graceful-rename.ts | 2 +- .../core}/store/drivers/memory-driver.ts | 0 .../store/hooks/__tests__/compress.test.ts | 0 .../cloud-sync/core}/store/hooks/compress.ts | 0 .../cloud-sync/core}/store/index.ts | 0 .../vcs => main/cloud-sync/core}/util.ts | 75 +- .../{sync/vcs => main/cloud-sync/core}/vcs.ts | 67 +- .../src/main/cloud-sync/create-vcs.ts | 18 + .../src/main/cloud-sync/initialization.ts | 71 ++ packages/insomnia/src/main/cloud-sync/ipc.ts | 103 ++ .../cloud-sync}/pull-backend-project.ts | 8 +- packages/insomnia/src/main/cloud-sync/vcs.ts | 157 +++ .../insomnia/src/main/electron-storage.ts | 49 +- packages/insomnia/src/main/git-service.ts | 432 +++++++- .../src/{sync => main}/git/migrations.ts | 18 +- .../__snapshots__/index.test.ts.snap | 87 +- .../src/main/importers/importers/curl.test.ts | 81 +- .../src/main/importers/importers/curl.ts | 17 +- .../main/importers/importers/index.test.ts | 6 + .../src/main/importers/importers/openapi-3.ts | 3 +- packages/insomnia/src/main/install-plugin.ts | 56 +- .../src/main/ipc/__tests__/grpc.test.ts | 30 +- .../insomnia/src/main/ipc/electron-storage.ts | 20 + packages/insomnia/src/main/ipc/electron.ts | 22 +- packages/insomnia/src/main/ipc/grpc.ts | 34 +- packages/insomnia/src/main/ipc/invoke.ts | 26 + packages/insomnia/src/main/ipc/main.ts | 101 +- .../insomnia/src/main/ipc/secret-storage.ts | 12 +- packages/insomnia/src/main/mcp/common.ts | 3 +- .../src/main/mcp/oauth-client-provider.ts | 2 +- .../insomnia/src/main/mcp/transport-stdio.ts | 4 +- .../src/main/mcp/transport-streamable-http.ts | 5 +- packages/insomnia/src/main/mcp/types.ts | 3 +- packages/insomnia/src/main/network/curl.ts | 7 +- .../src/main/network/get-auth-header.ts | 156 +++ .../src/main/network/libcurl-promise.ts | 4 +- packages/insomnia/src/main/network/mcp.ts | 3 +- .../insomnia/src/main/network/multipart.ts | 2 +- .../{ => main}/network/o-auth-1/get-token.ts | 15 +- .../{ => main}/network/o-auth-2/get-token.ts | 201 ++-- .../insomnia/src/main/network/websocket.ts | 3 +- packages/insomnia/src/main/sentry.ts | 3 +- .../src/main/templating-worker-database.ts | 17 +- packages/insomnia/src/main/window-utils.ts | 22 +- .../src/models/helpers/__mocks__/settings.ts | 9 - .../insomnia/src/models/helpers/project.ts | 83 -- packages/insomnia/src/models/index.ts | 187 ---- .../network/__tests__/authentication.test.ts | 20 +- .../is-url-matched-in-no-proxy-rule.test.ts | 125 --- .../src/network/__tests__/multipart.test.ts | 3 +- .../src/network/__tests__/network.test.ts | 49 +- .../__tests__/parse-header-strings.test.ts | 2 +- .../__tests__/url-matches-cert-host.test.ts | 96 ++ .../insomnia/src/network/authentication.ts | 160 +-- .../grpc/__tests__/write-proto-file.test.ts | 1 - .../network/grpc/proto-directory-loader.tsx | 102 -- .../src/network/grpc/write-proto-file.ts | 5 +- .../is-url-matched-in-no-proxy-rule.ts | 50 - .../src/network/multipart-constants.ts | 1 + packages/insomnia/src/network/network.ts | 27 +- .../src/network/o-auth-1/constants.ts | 5 - .../src/network/o-auth-2/constants.ts | 37 - .../insomnia/src/network/o-auth-2/utils.ts | 46 - .../network/parse-header-strings.ts | 23 +- .../insomnia/src/network/unit-test-feature.ts | 3 +- .../src/network/url-matches-cert-host.ts | 23 +- .../plugins/context/__tests__/request.test.ts | 1 - .../insomnia/src/plugins/context/network.ts | 3 +- .../insomnia/src/plugins/context/response.ts | 31 +- packages/insomnia/src/plugins/index.ts | 6 +- packages/insomnia/src/root.tsx | 71 +- packages/insomnia/src/routes/auth.login.tsx | 3 +- ...d-credentials.$cloudCredentialId.update.ts | 3 +- .../src/routes/cloud-credentials.create.tsx | 3 +- packages/insomnia/src/routes/commands.tsx | 9 +- .../src/routes/git-credentials.$id.update.tsx | 3 +- .../src/routes/git-credentials.create.tsx | 3 +- .../insomnia/src/routes/git-migration.$.tsx | 224 ++++ .../src/routes/git.all-connected-repos.tsx | 14 +- .../insomnia/src/routes/import.resources.tsx | 47 +- packages/insomnia/src/routes/import.scan.tsx | 9 +- ...ationId.insomnia-sync.pull-remote-file.tsx | 36 +- ...ganization.$organizationId.permissions.tsx | 5 +- ...ganizationId.project.$projectId._index.tsx | 37 +- ...ganizationId.project.$projectId.delete.tsx | 7 +- ...nId.project.$projectId.list-workspaces.tsx | 6 +- ...ion.$organizationId.project.$projectId.tsx | 17 +- ...ganizationId.project.$projectId.update.tsx | 13 +- ....workspace.$workspaceId.clientcert.new.tsx | 3 +- ...rkspace.$workspaceId.clientcert.update.tsx | 3 +- ...d.workspace.$workspaceId.debug.reorder.tsx | 7 +- ...aceId.debug.request.$requestId.connect.tsx | 17 +- ...eId.debug.request.$requestId.duplicate.tsx | 7 +- ....debug.request.$requestId.grant-access.tsx | 5 +- ...request.$requestId.response.delete-all.tsx | 6 +- ...bug.request.$requestId.response.delete.tsx | 9 +- ...kspaceId.debug.request.$requestId.send.tsx | 72 +- ....$workspaceId.debug.request.$requestId.tsx | 11 +- ...d.debug.request.$requestId.update-meta.tsx | 3 +- ...ebug.request.$requestId.update-payload.tsx | 3 +- ...paceId.debug.request.$requestId.update.tsx | 9 +- ...pace.$workspaceId.debug.request.delete.tsx | 5 +- ...Id.workspace.$workspaceId.debug.runner.tsx | 6 +- ...projectId.workspace.$workspaceId.debug.tsx | 10 +- ...kspaceId.insomnia-sync.branch.checkout.tsx | 4 +- ...orkspaceId.insomnia-sync.branch.create.tsx | 6 +- ...orkspaceId.insomnia-sync.branch.delete.tsx | 6 +- ...workspaceId.insomnia-sync.branch.merge.tsx | 5 +- ...kspaceId.insomnia-sync.create-snapshot.tsx | 7 +- ...space.$workspaceId.insomnia-sync.fetch.tsx | 10 +- ...kspace.$workspaceId.insomnia-sync.pull.tsx | 4 +- ...kspace.$workspaceId.insomnia-sync.push.tsx | 4 +- ...ace.$workspaceId.insomnia-sync.restore.tsx | 4 +- ...ce.$workspaceId.insomnia-sync.rollback.tsx | 4 +- ...space.$workspaceId.insomnia-sync.stage.tsx | 6 +- ...e.$workspaceId.insomnia-sync.sync-data.tsx | 25 +- ...d.workspace.$workspaceId.insomnia-sync.tsx | 13 +- ...ace.$workspaceId.insomnia-sync.unstage.tsx | 6 +- ...Id.mock-server.mock-route.$mockRouteId.tsx | 13 +- ...aceId.spec.generate-request-collection.tsx | 9 +- ...$projectId.workspace.$workspaceId.spec.tsx | 12 +- ....test-suite.$testSuiteId.run-all-tests.tsx | 3 +- ...$testSuiteId.test-result.$testResultId.tsx | 2 +- ...suite.$testSuiteId.test.$testId.delete.tsx | 3 +- ...st-suite.$testSuiteId.test.$testId.run.tsx | 3 +- ...suite.$testSuiteId.test.$testId.update.tsx | 3 +- ...rkspaceId.test.test-suite.$testSuiteId.tsx | 3 +- ...Id.test.test-suite.$testSuiteId.update.tsx | 3 +- ...$projectId.workspace.$workspaceId.test.tsx | 2 +- ...rkspace.$workspaceId.toggle-expand-all.tsx | 3 +- ...ject.$projectId.workspace.$workspaceId.tsx | 83 +- ...Id.project.$projectId.workspace.delete.tsx | 11 +- ...ionId.project.$projectId.workspace.new.tsx | 33 +- ...ization.$organizationId.project._index.tsx | 8 +- ...ganization.$organizationId.project.new.tsx | 3 +- .../src/routes/organization._index.tsx | 5 +- ...zation.sync-organizations-and-projects.tsx | 6 +- packages/insomnia/src/routes/organization.tsx | 9 +- .../src/routes/untracked-projects.tsx | 6 +- packages/insomnia/src/script-executor.ts | 2 +- .../__tests__/require-interceptor.test.ts | 142 +++ .../src/scripting/__tests__/sandbox.test.ts | 267 +++++ .../__tests__/script-security-policy.test.ts | 190 ++++ .../{ => scripting}/require-interceptor.ts | 36 +- packages/insomnia/src/scripting/run-script.ts | 154 +++ packages/insomnia/src/scripting/sandbox.ts | 357 +++++++ .../src/scripting/script-security-policy.ts | 162 +++ .../__schemas__/model-schemas.ts | 6 +- .../src/sync/__schemas__/type-schemas.ts | 13 +- .../src/sync/__tests__/ignore-keys.test.ts | 2 +- .../insomnia/src/sync/access-error.test.ts | 40 + packages/insomnia/src/sync/access-error.ts | 24 + .../git/__tests__/git-repo-migration.test.ts | 145 +++ .../src/sync/git/__tests__/git-vcs.test.ts | 269 ++++- .../sync/git/__tests__/ne-db-client.test.ts | 5 +- .../sync/git/__tests__/parse-git-path.test.ts | 3 +- .../src/sync/git/git-migration-version.ts | 5 + .../src/sync/git/git-repo-migration.ts | 378 +++++++ packages/insomnia/src/sync/git/git-vcs.ts | 135 ++- .../insomnia/src/sync/git/ne-db-client.ts | 5 +- .../insomnia/src/sync/git/parse-git-path.ts | 5 +- .../src/sync/git/project-ne-db-client.ts | 278 ----- .../sync/git/project-routable-fs-client.ts | 79 +- .../insomnia/src/sync/git/providers/custom.ts | 3 +- .../insomnia/src/sync/git/providers/github.ts | 4 +- .../insomnia/src/sync/git/providers/gitlab.ts | 9 +- .../src/sync/git/repo-file-watcher.ts | 999 ++++++++++++++++++ .../insomnia/src/sync/git/sync-queue.test.ts | 143 +++ packages/insomnia/src/sync/git/sync-queue.ts | 61 ++ packages/insomnia/src/sync/git/utils.ts | 3 +- packages/insomnia/src/sync/ignore-keys.ts | 4 +- packages/insomnia/src/sync/types.ts | 15 +- .../sync/vcs/__tests__/insomnia-sync.test.ts | 87 ++ packages/insomnia/src/sync/vcs/errors.ts | 13 + .../sync/vcs/initialize-backend-project.ts | 25 +- .../insomnia/src/sync/vcs/insomnia-sync.ts | 44 +- .../vcs/migrate-projects-into-organization.ts | 3 +- .../vcs/normalize-backend-project-team.ts | 17 - .../insomnia/src/templating/base-extension.ts | 6 +- packages/insomnia/src/templating/types.ts | 3 +- packages/insomnia/src/ui/analytics.ts | 28 + .../.client/codemirror/lint/json-lint.ts | 10 +- .../ui/components/dropdowns/auth-dropdown.tsx | 9 +- .../dropdowns/git-project-sync-dropdown.tsx | 60 +- .../dropdowns/git-sync-dropdown.tsx | 1 + .../dropdowns/preview-mode-dropdown.tsx | 5 +- .../dropdowns/response-history-dropdown.tsx | 3 +- .../dropdowns/workspace-card-dropdown.tsx | 2 +- .../dropdowns/workspace-dropdown.tsx | 4 +- .../dropdowns/workspace-sync-dropdown.tsx | 10 +- .../components/editors/auth/o-auth-1-auth.tsx | 6 +- .../components/editors/auth/o-auth-2-auth.tsx | 17 +- .../editors/body/graph-ql-editor.tsx | 3 +- .../git-credentials/git-repository-select.tsx | 1 + .../git/git-non-origin-branch-banner.tsx | 84 ++ .../ui/components/header-invite-button.tsx | 2 +- .../src/ui/components/header-user-button.tsx | 6 +- .../src/ui/components/mcp/mcp-url-bar.tsx | 3 +- .../components/mocks/mock-response-pane.tsx | 11 +- .../modals/__tests__/import-export.test.ts | 5 +- .../add-request-to-collection-modal.tsx | 12 +- .../cloud-credential-modal.tsx | 3 +- .../modals/export-requests-modal.tsx | 5 +- .../components/modals/git-branches-modal.tsx | 1 + .../modals/git-project-branches-modal.tsx | 5 +- .../modals/git-project-staging-modal.tsx | 150 ++- .../components/modals/git-staging-modal.tsx | 2 +- .../modals/import-modal/import-modal.tsx | 30 +- .../import-modal/import-projects-modal.tsx | 2 +- .../modals/invite-modal/invite-form.tsx | 6 +- .../oauth-authorization-status-modal.tsx | 2 +- .../ui/components/modals/project-modal.tsx | 1 + .../components/modals/proto-files-modal.tsx | 88 +- .../modals/request-settings-modal.tsx | 6 +- .../modals/response-debug-modal.tsx | 3 +- .../ui/components/modals/settings-modal.tsx | 23 +- .../components/modals/sync-delete-modal.tsx | 10 +- .../components/modals/sync-staging-modal.tsx | 8 +- .../components/modals/upgrade-plan-modal.tsx | 12 + .../modals/workspace-duplicate-modal.tsx | 9 +- .../modals/workspace-settings-modal.tsx | 5 +- .../ui/components/panes/grpc-request-pane.tsx | 8 +- .../src/ui/components/panes/request-pane.tsx | 7 +- .../src/ui/components/panes/response-pane.tsx | 30 +- .../ui/components/project/git-repo-form.tsx | 23 +- .../project/project-settings-form.tsx | 73 +- .../project/project-type-warning.tsx | 5 +- .../ui/components/rendered-query-string.tsx | 2 +- .../src/ui/components/request-url-bar.tsx | 3 +- .../settings/cloud-service-credentials.tsx | 3 +- .../ui/components/settings/credentials.tsx | 2 +- .../src/ui/components/settings/folder-path.ts | 77 ++ .../src/ui/components/settings/general.tsx | 4 +- .../ui/components/settings/import-export.tsx | 33 +- .../src/ui/components/settings/plugins.tsx | 101 ++ .../settings/scripting-settings.tsx | 295 ++++++ .../settings/text-array-setting.test.ts | 2 +- .../settings/text-array-setting.tsx | 2 +- .../project-navigation-sidebar-utils.ts | 2 +- .../project-navigation-sidebar.tsx | 2 +- .../project-navigation-sidebar/types.ts | 3 +- .../use-sidebar-drag-and-drop.tsx | 69 +- .../src/ui/components/tabs/tab-list.tsx | 13 +- .../insomnia/src/ui/components/tabs/tab.tsx | 8 +- .../external-vault/external-vault-form.tsx | 3 +- .../external-vault/hashicorp-vault-form.tsx | 3 +- .../templating/tag-editor-arg-sub-form.tsx | 3 +- .../ui/components/templating/tag-editor.tsx | 13 +- .../src/ui/components/toast-notification.tsx | 18 +- .../app/insomnia-event-stream-context.tsx | 10 +- .../ui/context/app/insomnia-tab-context.tsx | 6 +- packages/insomnia/src/ui/database.client.ts | 3 +- .../insomnia/src/ui/hooks/image-cache.tsx | 113 +- .../src/ui/hooks/use-close-connection.ts | 3 +- .../src/ui/hooks/use-filtered-requests.ts | 2 +- .../src/ui/hooks/use-git-file-issues.ts | 93 ++ .../src/ui/hooks/use-insomnia-navigation.ts | 3 +- .../ui/hooks/use-organization-features.tsx | 4 +- packages/insomnia/src/ui/hooks/use-plan.tsx | 10 +- .../insomnia/src/ui/hooks/use-user-service.ts | 2 +- .../insomnia/src/ui/hooks/use-vcs-version.ts | 7 +- .../src/ui/images/git-migration/git.png | Bin 0 -> 52901 bytes .../insomnia/src/ui/organization-utils.ts | 159 +-- .../src/ui/renderer-services-proxy.ts | 25 + .../insomnia/src/ui/spawn-oauth-window.ts | 5 + packages/insomnia/src/ui/sync-utils.ts | 6 +- packages/insomnia/src/utils/router.ts | 33 +- .../src/utils/url/querystring.test.ts | 5 + .../insomnia/src/utils/url/querystring.ts | 19 +- packages/insomnia/src/utils/vault.ts | 4 +- .../vite-plugin-electron-node-require.ts | 2 + packages/insomnia/vite.config.ts | 4 + 460 files changed, 12658 insertions(+), 4215 deletions(-) create mode 100644 .claude/skills/fix-test-cli-ci/SKILL.md create mode 100644 .codegraph/.gitignore create mode 100644 packages/insomnia-api/src/__tests__/user.test.ts create mode 100644 packages/insomnia-api/vitest.config.ts create mode 100644 packages/insomnia-smoke-test/fixtures/auth-types.yaml create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/HEAD create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/config create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/description create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/info/exclude create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/33/57649e085f0f653c60993fc34b0d380c4c2991 create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/5a/d28e22767f979da2c198dc6c1003b25964e3da create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/73/a07b09e3e790de0277df5ac98e0ad62ecf83d4 create mode 100644 packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/refs/heads/master create mode 100644 packages/insomnia-smoke-test/fixtures/ipv6-collection.yaml create mode 100644 packages/insomnia-smoke-test/playwright/pages/preferences/credentials-tab.ts create mode 100644 packages/insomnia-smoke-test/tests/smoke/disable-git-sync.test.ts create mode 100644 packages/insomnia-smoke-test/tests/smoke/ipv6.test.ts rename packages/insomnia/src/{models => }/__mocks__/uuid.ts (100%) create mode 100644 packages/insomnia/src/account/__tests__/session.test.ts create mode 100644 packages/insomnia/src/common/compression.ts create mode 100644 packages/insomnia/src/common/organization-storage-rules.ts create mode 100644 packages/insomnia/src/common/path-with-params.ts rename packages/insomnia/src/{models => insomnia-data}/__tests__/git-credentials.test.ts (98%) rename packages/insomnia/src/{models => insomnia-data}/__tests__/grpc-request-meta.test.ts (94%) rename packages/insomnia/src/{models => insomnia-data}/__tests__/grpc-request.test.ts (95%) rename packages/insomnia/src/{models => insomnia-data}/__tests__/index.test.ts (89%) rename packages/insomnia/src/{models => insomnia-data}/__tests__/proto-file.test.ts (93%) rename packages/insomnia/src/{models => insomnia-data}/__tests__/request-meta.test.ts (93%) rename packages/insomnia/src/{models => insomnia-data}/__tests__/request.test.ts (100%) create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/helpers/index.ts rename packages/insomnia/src/{models/helpers/__tests__ => insomnia-data/node-src/services/helpers}/query-all-workspace-urls.test.ts (94%) rename packages/insomnia/src/{models => insomnia-data/node-src/services}/helpers/query-all-workspace-urls.ts (76%) rename packages/insomnia/src/{models => insomnia-data/node-src/services}/helpers/request-operations.ts (52%) rename packages/insomnia/src/{models => insomnia-data/node-src/services}/helpers/response-operations.ts (79%) create mode 100644 packages/insomnia/src/insomnia-data/node-src/types.d.ts delete mode 100644 packages/insomnia/src/insomnia-data/node-src/types.ts rename packages/insomnia/src/{models/types.ts => insomnia-data/src/models/base-types.ts} (100%) rename packages/insomnia/src/{models/helpers/__tests__/is-model.test.ts => insomnia-data/src/models/index.test.ts} (99%) rename packages/insomnia/src/{ => insomnia-data/src}/models/organization.ts (94%) rename packages/insomnia/src/{models/helpers/__tests__ => insomnia-data/src/models}/project.test.ts (86%) rename packages/insomnia/src/{models/helpers/__tests__ => insomnia-data/src/models/utils}/replace-ids-in-fields.test.ts (97%) rename packages/insomnia/src/{models/helpers => insomnia-data/src/models/utils}/replace-ids-in-fields.ts (100%) create mode 100644 packages/insomnia/src/konnect/__tests__/expression-parser.test.ts create mode 100644 packages/insomnia/src/konnect/__tests__/transform.test.ts create mode 100644 packages/insomnia/src/konnect/expression-parser.ts create mode 100644 packages/insomnia/src/konnect/transform.ts create mode 100644 packages/insomnia/src/main/__tests__/sync-initialization.test.ts rename packages/insomnia/src/{sync/vcs => main/cloud-sync/core}/__tests__/util.test.ts (94%) rename packages/insomnia/src/{sync/vcs => main/cloud-sync/core}/__tests__/vcs.test.ts (93%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/__tests__/index.test.ts (100%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/drivers/base.ts (100%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/drivers/file-system-driver.ts (100%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/drivers/graceful-rename.ts (97%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/drivers/memory-driver.ts (100%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/hooks/__tests__/compress.test.ts (100%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/hooks/compress.ts (100%) rename packages/insomnia/src/{sync => main/cloud-sync/core}/store/index.ts (100%) rename packages/insomnia/src/{sync/vcs => main/cloud-sync/core}/util.ts (86%) rename packages/insomnia/src/{sync/vcs => main/cloud-sync/core}/vcs.ts (97%) create mode 100644 packages/insomnia/src/main/cloud-sync/create-vcs.ts create mode 100644 packages/insomnia/src/main/cloud-sync/initialization.ts create mode 100644 packages/insomnia/src/main/cloud-sync/ipc.ts rename packages/insomnia/src/{sync/vcs => main/cloud-sync}/pull-backend-project.ts (90%) create mode 100644 packages/insomnia/src/main/cloud-sync/vcs.ts rename packages/insomnia/src/{sync => main}/git/migrations.ts (94%) create mode 100644 packages/insomnia/src/main/ipc/electron-storage.ts create mode 100644 packages/insomnia/src/main/ipc/invoke.ts create mode 100644 packages/insomnia/src/main/network/get-auth-header.ts rename packages/insomnia/src/{ => main}/network/o-auth-1/get-token.ts (88%) rename packages/insomnia/src/{ => main}/network/o-auth-2/get-token.ts (78%) delete mode 100644 packages/insomnia/src/models/helpers/__mocks__/settings.ts delete mode 100644 packages/insomnia/src/models/helpers/project.ts delete mode 100644 packages/insomnia/src/models/index.ts delete mode 100644 packages/insomnia/src/network/__tests__/is-url-matched-in-no-proxy-rule.test.ts delete mode 100644 packages/insomnia/src/network/grpc/proto-directory-loader.tsx delete mode 100644 packages/insomnia/src/network/is-url-matched-in-no-proxy-rule.ts create mode 100644 packages/insomnia/src/network/multipart-constants.ts delete mode 100644 packages/insomnia/src/network/o-auth-1/constants.ts delete mode 100644 packages/insomnia/src/network/o-auth-2/constants.ts delete mode 100644 packages/insomnia/src/network/o-auth-2/utils.ts rename packages/insomnia/src/{main => }/network/parse-header-strings.ts (93%) create mode 100644 packages/insomnia/src/routes/git-migration.$.tsx create mode 100644 packages/insomnia/src/scripting/__tests__/require-interceptor.test.ts create mode 100644 packages/insomnia/src/scripting/__tests__/sandbox.test.ts create mode 100644 packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts rename packages/insomnia/src/{ => scripting}/require-interceptor.ts (53%) create mode 100644 packages/insomnia/src/scripting/run-script.ts create mode 100644 packages/insomnia/src/scripting/sandbox.ts create mode 100644 packages/insomnia/src/scripting/script-security-policy.ts rename packages/insomnia/src/{models => sync}/__schemas__/model-schemas.ts (85%) create mode 100644 packages/insomnia/src/sync/access-error.test.ts create mode 100644 packages/insomnia/src/sync/access-error.ts create mode 100644 packages/insomnia/src/sync/git/__tests__/git-repo-migration.test.ts create mode 100644 packages/insomnia/src/sync/git/git-migration-version.ts create mode 100644 packages/insomnia/src/sync/git/git-repo-migration.ts delete mode 100644 packages/insomnia/src/sync/git/project-ne-db-client.ts create mode 100644 packages/insomnia/src/sync/git/repo-file-watcher.ts create mode 100644 packages/insomnia/src/sync/git/sync-queue.test.ts create mode 100644 packages/insomnia/src/sync/git/sync-queue.ts create mode 100644 packages/insomnia/src/sync/vcs/__tests__/insomnia-sync.test.ts create mode 100644 packages/insomnia/src/sync/vcs/errors.ts delete mode 100644 packages/insomnia/src/sync/vcs/normalize-backend-project-team.ts create mode 100644 packages/insomnia/src/ui/components/git/git-non-origin-branch-banner.tsx create mode 100644 packages/insomnia/src/ui/components/settings/folder-path.ts create mode 100644 packages/insomnia/src/ui/components/settings/scripting-settings.tsx create mode 100644 packages/insomnia/src/ui/hooks/use-git-file-issues.ts create mode 100644 packages/insomnia/src/ui/images/git-migration/git.png create mode 100644 packages/insomnia/src/ui/renderer-services-proxy.ts create mode 100644 packages/insomnia/src/ui/spawn-oauth-window.ts diff --git a/.claude/skills/fix-test-cli-ci/SKILL.md b/.claude/skills/fix-test-cli-ci/SKILL.md new file mode 100644 index 0000000000..0349470d4f --- /dev/null +++ b/.claude/skills/fix-test-cli-ci/SKILL.md @@ -0,0 +1,58 @@ +--- +name: fix-test-cli-ci +description: 'Debug failures from the test-cli.yml workflow locally. Use when insomnia-inso bundle tests fail in CI, especially node-vs-electron dependency/runtime mismatches.' +argument-hint: 'Provide the failing test-cli.yml logs, a link to the failing workflow run, or the failing test name from npm run test:bundle -w insomnia-inso' +--- + +# Fix test-cli.yml CI Failures + +## When to Use + +- `.github/workflows/test-cli.yml` or the `Test CLI` workflow failed in CI. +- `inso` bundle tests fail locally or in GitHub Actions. +- You suspect node module/runtime differences between Node.js and Electron. + +## Procedure + +1. Start with the failing CI evidence. + - Use the provided logs or workflow run link to identify whether the failure happened during the `test:bundle` run, the packaged binary run, or the build/package setup before the tests. + - Note the failing test name and the first actionable error message before reproducing locally. +2. Ensure Node.js native dependencies are installed (not Electron-targeted variants): + ```bash + npm run install-libcurl-node + ``` +3. Build `insomnia-inso` to generate `dist`: + ```bash + npm run build -w insomnia-inso + ``` +4. Start the smoke-test echo server used by the CLI tests: + ```bash + npm run serve -w insomnia-smoke-test + ``` +5. In another terminal, run the bundled CLI test suite: + ```bash + npm run test:bundle -w insomnia-inso + ``` +6. If the CI failure was in the packaged binary path or the bundle suite passes locally, run: + ```bash + npm run test:binary -w insomnia-inso + ``` + +## Notes + +- Keep the smoke-test server running while bundle tests execute. +- Common failure patterns: + - Errors involving `@getinsomnia/node-libcurl`, `NODE_MODULE_VERSION`, `dlopen`, or native module loading usually mean the Node-targeted libcurl binary needs to be reinstalled with `npm run install-libcurl-node`. + - Connection failures to `http://localhost:4010` usually mean the smoke-test server is not running. + - Missing `dist` output or missing `binaries/inso` usually means `npm run build -w insomnia-inso` or the package step needs to be rerun. +- Success criteria: + - `npm run test:bundle -w insomnia-inso` exits successfully and Vitest reports the `inso dev bundle` tests as passing. + - If validating the packaged binary path, `npm run test:binary -w insomnia-inso` also exits successfully. + +## Teardown + +- Stop the `npm run serve -w insomnia-smoke-test` process once validation is complete. +- Reinstall the electron-targeted libcurl binary for continued Electron development: + ```bash + npm run install-libcurl-electron + ``` diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000000..9de0f16903 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index 53ed15a0af..8b009a369e 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -7,12 +7,15 @@ on: workflow_dispatch: +permissions: {} + jobs: update_homebrew: timeout-minutes: 5 name: Update Insomnia Formula # must be macos, linux-brew doesn't have casks runs-on: macos-latest + permissions: {} steps: - name: Set up Homebrew id: set-up-homebrew @@ -22,7 +25,7 @@ jobs: - name: Cache Homebrew Bundler RubyGems id: cache - uses: actions/cache@v3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ steps.set-up-homebrew.outputs.gems-path }} key: ${{ runner.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 154f928b1e..517333012e 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -6,6 +6,8 @@ on: - 'release/**' workflow_dispatch: +permissions: {} + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -15,11 +17,10 @@ jobs: generate-sbom-and-upload-assets: runs-on: ubuntu-24.04 permissions: - packages: write - contents: write # publish sbom to GH releases/tag assets + contents: read # Required for actions/checkout steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Perform SCA / SBOM analysis for the entire monorepo code repository # Produces SCA(SBOM and CVE) report @@ -35,6 +36,9 @@ jobs: build-and-upload-release-artifacts: timeout-minutes: 45 runs-on: ${{ matrix.os }} + permissions: + contents: read + packages: read env: INSO_PACKAGE_NAME: insomnia-inso INSO_DOCKER_TAR: inso-docker-image.tar @@ -58,10 +62,10 @@ jobs: # csc_key_password_secret: '' steps: - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -124,7 +128,7 @@ jobs: # smctl will be used in the next step for signing - name: Setup Software Trust Manager if: runner.os == 'Windows' - uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1 + uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1.0.0 with: simple-signing-mode: true env: @@ -137,7 +141,7 @@ jobs: if: runner.os == 'Windows' shell: bash run: | - smctl credentials save ${SM_API_KEY} ${SM_CLIENT_CERT_PASSWORD} + # smctl credentials save ${SM_API_KEY} ${SM_CLIENT_CERT_PASSWORD} NODE_OPTIONS='--max_old_space_size=6144' npm run package:windows:unpacked -w insomnia env: SM_HOST: ${{ vars.DIGICERT_SM_HOST }} @@ -164,7 +168,7 @@ jobs: # this installs smctl as well - name: Code-sign unpacked .exe (Windows only) if: runner.os == 'Windows' - uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1 + uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1.0.0 with: simple-signing-mode: true # If the below 2 parameters are supplied, then smctl executable is invoked to attempt the signing. @@ -242,7 +246,7 @@ jobs: - name: Code-sign inso exe (Windows only) if: runner.os == 'Windows' - uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1 + uses: digicert/code-signing-software-trust-action@9b30180369343eb1ce0dcbebb933cfa3e17b6cc8 # v1.0.0 with: simple-signing-mode: true # If the below 2 parameters are supplied, then smctl executable is invoked to attempt the signing. @@ -273,7 +277,7 @@ jobs: - name: Notarize Inso CLI installer (macOS only) if: runner.os == 'macOS' - uses: lando/notarize-action@b5c3ef16cf2fbcf2af26dc58c90255ec242abeed # v2 + uses: lando/notarize-action@b5c3ef16cf2fbcf2af26dc58c90255ec242abeed # v2.0.2 with: product-path: ./packages/${{ env.INSO_PACKAGE_NAME }}/artifacts/inso-${{ matrix.os }}-${{ env.INSO_VERSION }}.pkg primary-bundle-id: com.insomnia.inso @@ -290,7 +294,7 @@ jobs: - name: Notarize Inso CLI binary (macOS only) if: runner.os == 'macOS' - uses: lando/notarize-action@b5c3ef16cf2fbcf2af26dc58c90255ec242abeed # v2 + uses: lando/notarize-action@b5c3ef16cf2fbcf2af26dc58c90255ec242abeed # v2.0.2 with: product-path: ./packages/${{ env.INSO_PACKAGE_NAME }}/binaries/inso primary-bundle-id: com.insomnia.inso-binary @@ -303,7 +307,7 @@ jobs: - name: Login to Docker Hub if: runner.os == 'Linux' && runner.arch == 'X64' - uses: docker/login-action@3d58c274f17dffee475a5520cbe67f0a882c4dbb # v2.1.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKER_REGISTRY_USER }} password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} @@ -329,7 +333,7 @@ jobs: SYFT_SOURCE_NAME: ${{ env.INSO_DOCKER_TAR }} - name: Upload artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: ignore name: ${{ runner.os }}-${{ runner.arch }}-artifacts @@ -347,7 +351,7 @@ jobs: packages/insomnia-inso/artifacts/* - name: Upload source assets for Sentry - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ runner.os }}-${{ runner.arch }}-sentry path: | @@ -359,6 +363,8 @@ jobs: timeout-minutes: ${{ fromJSON(vars.GHA_DEFAULT_TIMEOUT) }} needs: build-and-upload-release-artifacts runs-on: ubuntu-24.04 + permissions: + pull-requests: write steps: - name: Get release version id: release_version diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 01a70ec5ae..36885fa20c 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -18,6 +18,8 @@ env: INSO_DOCKER_IMAGE: &INSO_DOCKER_IMAGE 'kong/inso' # By default, registry is docker.io NOTARY_REPOSITORY: &NOTARY_REPOSITORY 'kong/notary' # All signatures will be pushed to public notary repository +permissions: {} + jobs: publish: timeout-minutes: 30 @@ -35,15 +37,24 @@ jobs: contents: write # Required to upload assets. Issue: https://github.com/slsa-framework/slsa-github-generator/tree/main/internal/builders/container#known-issues packages: write steps: + - name: Calculate Release Branch + env: + VERSION: ${{ github.event.inputs.version }} + run: | + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + + # Rewrite the release branch to follow our new flow + echo "RELEASE_BRANCH=release/$MAJOR_MINOR" >> $GITHUB_ENV + - name: Checkout branch # Check out the release branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.RELEASE_BRANCH }} fetch-depth: 0 persist-credentials: false - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -57,8 +68,18 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + - name: Compare input version with package.json version + env: + VERSION: ${{ github.event.inputs.version }} + run: | + PKG_VERSION=$(jq .version packages/insomnia-inso/package.json -rj) + if [ "$PKG_VERSION" != "$VERSION" ]; then + echo "Input version $VERSION does not match package.json version $PKG_VERSION" + exit 1 + fi + - name: Download all artifacts from release-build.yml - uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e # v2 + uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 with: github_token: ${{secrets.GITHUB_TOKEN}} workflow: release-build.yml @@ -82,13 +103,13 @@ jobs: ELECTRON_ARTIFACT_BASE64_FILE: ${{runner.temp}}/electron_digests_file.text - name: Calculate CLI Binary base64 file handle - uses: slsa-framework/slsa-github-generator/actions/generator/generic/create-base64-subjects-from-file@5a775b367a56d5bd118a224a811bba288150a563 # v2.0.0 + uses: slsa-framework/slsa-github-generator/actions/generator/generic/create-base64-subjects-from-file@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 id: cli_binary_hashes with: path: ${{ env.CLI_ARTIFACT_BASE64_FILE }} - name: Calculate Electron Binary base64 file handle - uses: slsa-framework/slsa-github-generator/actions/generator/generic/create-base64-subjects-from-file@5a775b367a56d5bd118a224a811bba288150a563 # v2.0.0 + uses: slsa-framework/slsa-github-generator/actions/generator/generic/create-base64-subjects-from-file@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 id: electron_binary_hashes with: path: ${{ env.ELECTRON_ARTIFACT_BASE64_FILE }} @@ -137,7 +158,7 @@ jobs: docker image ls - name: Login to Docker Hub - uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.1.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKER_REGISTRY_USER }} password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} @@ -259,21 +280,6 @@ jobs: --package-type insomnia ${{ env.IS_PRERELEASE == 'true' && '--internal' || '--publish' }} - - name: Configure Git user - uses: Homebrew/actions/git-user-config@266845213695c3047d210b2e8fbc42ecdaf45802 # master - with: - username: ${{ (github.event_name == 'workflow_dispatch' && github.actor) || 'insomnia-infra' }} - - - name: Merge git branch into develop - run: | - remote_repo="https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git checkout develop - git merge --no-ff ${{ env.RELEASE_BRANCH }} - git status - git push "${remote_repo}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - artifact-provenance: needs: [publish] permissions: @@ -290,7 +296,7 @@ jobs: - product: inso binary_artifacts_subject_as_file: ${{ needs.publish.outputs.INSO_BINARY_ARTIFACTS_SUBJECTS_AS_FILE }} # need to use non hash version because of: https://github.com/slsa-framework/slsa-github-generator/issues/3498 - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 with: base64-subjects-as-file: '${{ matrix.binary_artifacts_subject_as_file }}' upload-assets: true @@ -306,7 +312,7 @@ jobs: packages: write # Required for publishing provenance. Issue: https://github.com/slsa-framework/slsa-github-generator/tree/main/internal/builders/container#known-issues # need to use non hash version because of: https://github.com/slsa-framework/slsa-github-generator/issues/3498 contents: write - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0 with: image: *INSO_DOCKER_IMAGE digest: ${{ needs.publish.outputs.INSO_DOCKER_IMAGE_DIGEST }} diff --git a/.github/workflows/release-recurring.yml b/.github/workflows/release-recurring.yml index 2de2a98ba0..983a942f18 100644 --- a/.github/workflows/release-recurring.yml +++ b/.github/workflows/release-recurring.yml @@ -15,6 +15,9 @@ concurrency: cancel-in-progress: true env: PR_NUMBER: ${{ github.event.number }} + +permissions: {} + jobs: build-and-upload-artifacts: timeout-minutes: 45 @@ -23,6 +26,7 @@ jobs: if: ${{ !startsWith(github.head_ref, 'release/') }} runs-on: ${{ matrix.os }} permissions: + contents: read packages: read strategy: fail-fast: false @@ -38,10 +42,10 @@ jobs: build-targets: tar.gz steps: - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -64,6 +68,7 @@ jobs: run: npm --workspaces version prerelease --preid="alpha-pr-$(git rev-parse --short HEAD)" --no-git-tag-version - name: Package + if: ${{ matrix.os != 'windows-latest' }} shell: bash run: NODE_OPTIONS='--max_old_space_size=6144' BUILD_TARGETS='${{ matrix.build-targets }}' npm run app-package @@ -83,7 +88,7 @@ jobs: INSOMNIA_UPDATES_URL: http://localhost:4010 - name: Upload smoke test traces - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: if-no-files-found: ignore @@ -91,7 +96,7 @@ jobs: path: packages/insomnia-smoke-test/traces - name: Upload artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: ignore name: ${{ matrix.os }}-artifacts-${{ github.run_number }} diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml index f2e43c3bb9..7e4889c7be 100644 --- a/.github/workflows/release-start.yml +++ b/.github/workflows/release-start.yml @@ -16,20 +16,24 @@ on: required: false description: force version of the release (e.g. 9.0.0) if previous release was successful, this should auto increment +permissions: {} + jobs: setup-release-branch: timeout-minutes: 5 runs-on: ubuntu-24.04 + permissions: + contents: write steps: - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.GITHUB_TOKEN }} ref: develop fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -49,23 +53,32 @@ jobs: - name: App version (stable, with a specific version) if: github.event.inputs.channel == 'stable' && github.event.inputs.version - run: npm --workspaces version "${{ github.event.inputs.version }}" + env: + VERSION: ${{ github.event.inputs.version }} + run: npm --workspaces version "$VERSION" - name: App version (alpha/beta, patch latest alpha/beta) if: github.event.inputs.channel != 'stable' && !github.event.inputs.version - run: npm --workspaces version --preid "${{ github.event.inputs.channel }}" prerelease + env: + CHANNEL: ${{ github.event.inputs.channel }} + run: npm --workspaces version --preid "$CHANNEL" prerelease - name: App version (alpha/beta, with a specific version) if: github.event.inputs.channel != 'stable' && github.event.inputs.version - run: npm --workspaces version "${{ github.event.inputs.version }}" + env: + VERSION: ${{ github.event.inputs.version }} + run: npm --workspaces version "$VERSION" # ############################################################ - name: Get version shell: bash run: | - echo "RELEASE_VERSION=$(node -e "console.log(require('./packages/insomnia/package.json').version)")" >> $GITHUB_ENV - echo "RELEASE_BRANCH=release/$(node -e "console.log(require('./packages/insomnia/package.json').version)")" >> $GITHUB_ENV + VERSION=$(node -p "require('./packages/insomnia/package.json').version") + MAJOR_MINOR=$(echo $VERSION | cut -d. -f1,2) + + echo "RELEASE_VERSION=$VERSION" >> $GITHUB_ENV + echo "RELEASE_BRANCH=release/$MAJOR_MINOR" >> $GITHUB_ENV - name: Create Branch # Create a branch if it doesn't exist uses: peterjgrainger/action-create-branch@c2800a3a9edbba2218da6861fa46496cf8f3195a # v2.2.0 @@ -75,7 +88,7 @@ jobs: branch: ${{ env.RELEASE_BRANCH }} - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.RELEASE_BRANCH }} persist-credentials: false @@ -95,15 +108,21 @@ jobs: - name: (Re-run) App version (stable, with a specific version) if: github.event.inputs.channel == 'stable' && github.event.inputs.version - run: npm --workspaces version "${{ github.event.inputs.version }}" + env: + VERSION: ${{ github.event.inputs.version }} + run: npm --workspaces version "$VERSION" - name: (Re-run) App version (alpha/beta, patch latest alpha/beta) if: github.event.inputs.channel != 'stable' && !github.event.inputs.version - run: npm --workspaces version --preid "${{ github.event.inputs.channel }}" prerelease + env: + CHANNEL: ${{ github.event.inputs.channel }} + run: npm --workspaces version --preid "$CHANNEL" prerelease - name: (Re-run) App version (alpha/beta, with a specific version) if: github.event.inputs.channel != 'stable' && github.event.inputs.version - run: npm --workspaces version "${{ github.event.inputs.version }}" + env: + VERSION: ${{ github.event.inputs.version }} + run: npm --workspaces version "$VERSION" # ############################################################ diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index 355ddaf8e9..9a562026c4 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -8,20 +8,18 @@ on: - release/* workflow_dispatch: {} +permissions: {} + jobs: semgrep: timeout-minutes: 5 name: Semgrep SAST runs-on: ubuntu-24.04 permissions: - # required for all workflows security-events: write - # only required for workflows in private repositories - actions: read - contents: read if: (github.actor != 'dependabot[bot]') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: Kong/public-shared-actions/security-actions/semgrep@a18abf762d6e2444bcbfd20de70451ea1e3bc1b1 # 4.0.1 diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 0f16d26956..17420043d9 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -15,20 +15,21 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read - packages: read +permissions: {} jobs: Test: timeout-minutes: 10 runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read steps: - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: '.nvmrc' cache: 'npm' @@ -82,7 +83,7 @@ jobs: VERSION: ${{ steps.inso-variables.outputs.inso-version }} - name: Upload Inso CLI artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: if-no-files-found: ignore name: ${{ steps.inso-variables.outputs.pkg-name }} diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 5cb8eb29e8..7bc6812afa 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -15,20 +15,21 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read - packages: read +permissions: {} jobs: - Test: - timeout-minutes: 40 + build: + timeout-minutes: 20 runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read steps: - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .nvmrc cache: npm @@ -45,13 +46,57 @@ jobs: - name: Build app for smoke tests run: NODE_OPTIONS='--max_old_space_size=6144' npm run app-build - - name: Smoke test electron app - run: npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke + - name: Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: app-build + path: packages/insomnia/build/ + retention-days: 1 - - name: Upload smoke test - uses: actions/upload-artifact@v7 + test: + needs: build + timeout-minutes: 25 + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4, 5, 6] + shardTotal: [6] + steps: + - name: Checkout branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + registry-url: 'https://npm.pkg.github.com' + scope: '@kong' + + - name: Install packages + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + + - name: Download build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: app-build + path: packages/insomnia/build/ + + - name: Smoke test electron app (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + run: npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Upload smoke test traces + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: if-no-files-found: ignore - name: ubuntu-smoke-test-traces-${{ github.run_number }} + name: ubuntu-smoke-test-traces-${{ github.run_number }}-shard-${{ matrix.shardIndex }} path: packages/insomnia-smoke-test/traces diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd37d2edae..8577c7e1b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: Test: timeout-minutes: 20 @@ -25,10 +27,10 @@ jobs: packages: read steps: - name: Checkout branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .nvmrc cache: npm @@ -56,14 +58,14 @@ jobs: - name: Checkout base branch (cycle comparison) if: github.event_name == 'pull_request' && always() - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.base.ref }} path: insomnia-base - name: Setup Node (base branch tree) if: github.event_name == 'pull_request' && always() - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: insomnia-base/.nvmrc cache: npm @@ -81,7 +83,7 @@ jobs: - name: Check Circular References if: github.event_name == 'pull_request' && always() - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index b5616891fa..7087c9795e 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -5,6 +5,8 @@ on: release: types: [released] +permissions: {} + jobs: update: runs-on: ubuntu-24.04 @@ -17,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.release.target_commitish }} diff --git a/.gitignore b/.gitignore index 65dcfc33f0..9e48e2a92e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules/ .yarn-integrity .env .idea +.reports *.iml .DS_Store *test-plugins diff --git a/.vscode/settings.json b/.vscode/settings.json index 914fc1596c..184ba76b1b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -43,5 +43,6 @@ ], "[cpp]": { "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd" - } + }, + "editor.formatOnPaste": true } diff --git a/AGENTS.md b/AGENTS.md index 68ad47a843..5c2072a136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,9 @@ Organization - **HTTP Calls:** Use `insomniaFetch()` for Insomnia backend APIs. Use plain `fetch()` for external/third-party APIs. - **Styling:** Tailwind utility classes only. Use `clsx`/`tailwind-merge` for conditionals. Use React Aria for interactive HTML elements. - **Testing:** Use **Vitest** (unit) and **Playwright** (E2E). Co-locate unit tests as `filename.test.ts`. Use `vi.mock()`. Prefer testing logic via loaders over mounting components. +- **E2E tests** live in `packages/insomnia-smoke-test/`. Full docs: [`packages/insomnia-smoke-test/README.md`](packages/insomnia-smoke-test/README.md). +- Run E2E from repo root: `npm run test:smoke:dev` (filter: `npm run test:smoke:dev -- `). +- New test imports: `import { test } from '../../playwright/test'` and `import { expect } from '@playwright/test'`. ## Sensitive Data - **Vault system (AES-GCM):** For environment secrets (`EnvironmentKvPairDataType.SECRET`). diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index feda20c428..7ef1d7102a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -61,7 +61,7 @@ There are a few notable directories inside it: - `/src/ui` React components and styling. - `/src/common` Utilities used across both main and render processes. - `/src/plugins` Logic around installation and usage of plugins. -- `/src/models` DB models used to store user data. +- `/src/insomnia-data` Data models, services and database for managing application state. - `/src/network` Sending requests and performing auth (e.g. OAuth 2). - `/src/templating` Nunjucks and rendering related code. - `/src/sync` and `/src/account` Team sync and account stuff. diff --git a/eslint.config.mjs b/eslint.config.mjs index 867748de13..b605817f4d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,23 @@ import globals from 'globals'; import tseslint from 'typescript-eslint'; const rendererBuiltinSpecifiers = [...builtinModules, ...builtinModules.map(moduleName => `node:${moduleName}`)]; +const generalRestrictedImportPatterns = [ + // Shouldn't import packages by relative path + { + group: ['**/*/insomnia-api/**'], + message: "Please use 'insomnia-api' instead of relative paths", + }, + // Block relative paths to insomnia-data + { + group: ['./**/insomnia-data', './**/insomnia-data/**', '../**/insomnia-data', '../**/insomnia-data/**'], + message: "Please use '~/insomnia-data' instead of relative paths", + }, + // Only allow ~/insomnia-data and ~/insomnia-data/node + { + regex: '^~/insomnia-data/(?!node($|/)).+', + message: "Only '~/insomnia-data' and '~/insomnia-data/node' are allowed", + }, +]; const rendererNodeMigrationOffenders = [ 'packages/insomnia/src/common/misc.ts', 'packages/insomnia/src/common/significant-diff-detection.ts', @@ -86,23 +103,6 @@ export default defineConfig([ 'playwright/no-wait-for-timeout': 'error', }, }, - // nodeIntegration: false section - { - files: [ - 'packages/insomnia/src/ui/**/*.{ts,tsx}', - 'packages/insomnia/src/routes/**/*.{ts,tsx}', - 'packages/insomnia/src/common/**/*.{ts,tsx}', - ], - ignores: rendererNodeRestrictionIgnores, - rules: { - 'no-restricted-imports': [ - 'error', - { - paths: rendererBuiltinSpecifiers, - }, - ], - }, - }, // React hooks section { files: ['packages/insomnia/src/**/*.{ts,tsx}'], @@ -168,23 +168,25 @@ export default defineConfig([ 'no-restricted-imports': [ 'error', { - patterns: [ - // Shouldn't import packages by relative path - { - group: ['**/*/insomnia-api/**'], - message: "Please use 'insomnia-api' instead of relative paths", - }, - // Block relative paths to insomnia-data - { - group: ['./**/insomnia-data', './**/insomnia-data/**', '../**/insomnia-data', '../**/insomnia-data/**'], - message: "Please use '~/insomnia-data' instead of relative paths", - }, - // Only allow ~/insomnia-data and ~/insomnia-data/node - { - regex: '^~/insomnia-data/(?!node($|/)).+', - message: "Only '~/insomnia-data' and '~/insomnia-data/node' are allowed", - }, - ], + patterns: generalRestrictedImportPatterns, + }, + ], + }, + }, + // nodeIntegration: false section + { + files: [ + 'packages/insomnia/src/ui/**/*.{ts,tsx}', + 'packages/insomnia/src/routes/**/*.{ts,tsx}', + 'packages/insomnia/src/common/**/*.{ts,tsx}', + ], + ignores: rendererNodeRestrictionIgnores, + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: rendererBuiltinSpecifiers, + patterns: generalRestrictedImportPatterns, }, ], }, diff --git a/package-lock.json b/package-lock.json index 4d2025743c..c3584a036a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "packages/insomnia-scripting-environment" ], "dependencies": { - "@getinsomnia/node-libcurl": "3.2.1", + "@getinsomnia/node-libcurl": "3.2.2", "ajv": "^8.17.1" }, "devDependencies": { @@ -3527,10 +3527,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@getinsomnia/insomnia-v3-fetch": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@getinsomnia/insomnia-v3-fetch/-/insomnia-v3-fetch-1.0.1.tgz", + "integrity": "sha512-v/0lZ6Fz700xLd+YgqqsAi50HTuZ6l/klc2z8G4v7PgWAtEG2O4tIUY/9yn1WtdH21SQtgBz9kWxIxTPUGtzUQ==", + "license": "Apache-2.0" + }, "node_modules/@getinsomnia/node-libcurl": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@getinsomnia/node-libcurl/-/node-libcurl-3.2.1.tgz", - "integrity": "sha512-QaB81JOAvAxkGUNeXIBrERuUhONf+d46f7ZTyPg6ayc3q48NyAhQUznNcbajynbCJjXRsJBqb0qAY8pK2ShNGQ==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@getinsomnia/node-libcurl/-/node-libcurl-3.2.2.tgz", + "integrity": "sha512-O4FTODtfwvLpnk+pyPHCIINCmu8Io+UaDDmcY2bU73VumAu6p/p/egQ6ndUOOId6OQ7V2jyAppZFgNb+fev/Uw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3572,9 +3578,9 @@ } }, "node_modules/@google/genai": { - "version": "1.48.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.48.0.tgz", - "integrity": "sha512-plonYK4ML2PrxsRD9SeqmFt76eREWkQdPCglOA6aYDzL1AAbE+7PUnT54SvpWGfws13L0AZEqGSpL7+1IPnTxQ==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", + "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -4028,20 +4034,55 @@ } }, "node_modules/@kong/insomnia-plugin-ai": { - "version": "1.0.9", - "resolved": "https://npm.pkg.github.com/download/@kong/insomnia-plugin-ai/1.0.9/1819b2d915bf1dcfc74211c2bb08005bc18813e6", - "integrity": "sha512-9dj+9IsgJlW9fKC3XRAVEI2165jA+m11Gbyx6Z7cbqXne+ksupGrcmU4HnndkRKMCoIlNH10f2VGVgN8n8vhWA==", + "version": "1.0.11", + "resolved": "https://npm.pkg.github.com/download/@kong/insomnia-plugin-ai/1.0.11/787a5587e7bcbcea66430dcec379253644ef00c7", + "integrity": "sha512-qF1+PD950aqDA5l3W7N4VFN3BAeLTzOs62+Ig6dLcP6uv4SNjnTgcRZ3ubRhScfPT+V5hB0qIUbG0We3fFEGvg==", "optional": true, "dependencies": { - "@apidevtools/swagger-parser": "^10.1.0", - "@google/genai": "^1.38.0", + "@apidevtools/swagger-parser": "^12.1.0", + "@google/genai": "^1.49.0", "js-yaml": "^4.1.1", - "node-llama-cpp": "^3.15.0", - "openai": "^6.17.0", + "node-llama-cpp": "^3.18.1", + "openai": "^6.34.0", "zod": "^4.3.6" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "^4.55.3" + "@rollup/rollup-linux-x64-gnu": "^4.60.1" + } + }, + "node_modules/@kong/insomnia-plugin-ai/node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@kong/insomnia-plugin-ai/node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "optional": true, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" } }, "node_modules/@kong/insomnia-plugin-external-vault": { @@ -5125,13 +5166,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", - "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.55.1" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -5165,9 +5206,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -5193,9 +5234,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -5211,9 +5252,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@ravanallc/grpc-server-reflection": { @@ -9040,9 +9081,9 @@ } }, "node_modules/@stoplight/spectral-core": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.21.0.tgz", - "integrity": "sha512-oj4e/FrDLUhBRocIW+lRMKlJ/q/rDZw61HkLbTFsdMd+f/FTkli2xHNB1YC6n1mrMKjjvy7XlUuFkC7XxtgbWw==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.22.0.tgz", + "integrity": "sha512-4hTxMDs4TFUG4/jKjaZttA65gNuV2PCKI9+51I+J4nL6ylo17DlbW+sl6byKnBuV/85HxaV33ri5fEGlp8lTSA==", "license": "Apache-2.0", "dependencies": { "@stoplight/better-ajv-errors": "1.0.3", @@ -9054,17 +9095,17 @@ "@stoplight/types": "~13.6.0", "@types/es-aggregate-error": "^1.0.2", "@types/json-schema": "^7.0.11", - "ajv": "^8.17.1", + "ajv": "^8.18.0", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.1", "es-aggregate-error": "^1.0.7", + "expr-eval-fork": "^3.0.1", "jsonpath-plus": "^10.3.0", - "lodash": "~4.17.23", + "lodash": "^4.18.1", "lodash.topath": "^4.5.2", - "minimatch": "3.1.2", + "minimatch": "^3.1.4", "nimma": "0.2.3", "pony-cause": "^1.1.1", - "simple-eval": "1.0.1", "tslib": "^2.8.1" }, "engines": { @@ -9095,25 +9136,19 @@ "license": "MIT" }, "node_modules/@stoplight/spectral-core/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, - "node_modules/@stoplight/spectral-core/node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/@stoplight/spectral-core/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9150,9 +9185,9 @@ "license": "0BSD" }, "node_modules/@stoplight/spectral-functions": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.1.tgz", - "integrity": "sha512-obu8ZfoHxELOapfGsCJixKZXZcffjg+lSoNuttpmUFuDzVLT3VmH8QkPXfOGOL5Pz80BR35ClNAToDkdnYIURg==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.2.tgz", + "integrity": "sha512-PIfPUgTRo8EtAnL1MIrzhHoUuojSaE8shGSMaHS3BxGyc8d079BE5+TqJa1/WLUb9YT9JQnZ0Aj4xfi8NcJOIw==", "license": "Apache-2.0", "dependencies": { "@stoplight/better-ajv-errors": "1.0.3", @@ -9160,11 +9195,11 @@ "@stoplight/spectral-core": "^1.19.4", "@stoplight/spectral-formats": "^1.8.1", "@stoplight/spectral-runtime": "^1.1.2", - "ajv": "^8.17.1", + "ajv": "^8.18.0", "ajv-draft-04": "~1.0.0", "ajv-errors": "~3.0.0", "ajv-formats": "~2.1.1", - "lodash": "~4.17.21", + "lodash": "^4.18.1", "tslib": "^2.8.1" }, "engines": { @@ -9188,12 +9223,6 @@ } } }, - "node_modules/@stoplight/spectral-functions/node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/@stoplight/spectral-functions/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -9257,9 +9286,9 @@ "license": "0BSD" }, "node_modules/@stoplight/spectral-ruleset-bundler": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.6.3.tgz", - "integrity": "sha512-AQFRO6OCKg8SZJUupnr3+OzI1LrMieDTEUHsYgmaRpNiDRPvzImE3bzM1KyQg99q58kTQyZ8kpr7sG8Lp94RRA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.7.0.tgz", + "integrity": "sha512-PpIdj5Wje0T7ktxY8EUzBWLU0+mGGQHznT8nlQxTMnRhWLNYsm6HvSZDXLtMi+86yqvTuf7loJy6JvLBDzHGAA==", "license": "Apache-2.0", "dependencies": { "@rollup/plugin-commonjs": "~22.0.2", @@ -9275,7 +9304,7 @@ "@stoplight/types": "^13.6.0", "@types/node": "*", "pony-cause": "1.1.1", - "rollup": "~2.79.2", + "rollup": "~2.80.0", "tslib": "^2.8.1", "validate-npm-package-name": "3.0.0" }, @@ -9342,9 +9371,9 @@ "license": "0BSD" }, "node_modules/@stoplight/spectral-rulesets": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.0.tgz", - "integrity": "sha512-l2EY2jiKKLsvnPfGy+pXC0LeGsbJzcQP5G/AojHgf+cwN//VYxW1Wvv4WKFx/CLmLxc42mJYF2juwWofjWYNIQ==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.1.tgz", + "integrity": "sha512-DaaQJioKuYkRsOuKIJfX2ek7G7f6OCU3CI3K7ABaOcTFMiHj29SJLDdb04mCjXZFXMlXHjmCl2ZpKW6heieXpw==", "license": "Apache-2.0", "dependencies": { "@asyncapi/specs": "^6.8.0", @@ -9356,11 +9385,11 @@ "@stoplight/spectral-runtime": "^1.1.2", "@stoplight/types": "^13.6.0", "@types/json-schema": "^7.0.7", - "ajv": "^8.17.1", + "ajv": "^8.18.0", "ajv-formats": "~2.1.1", "json-schema-traverse": "^1.0.0", "leven": "3.1.0", - "lodash": "~4.17.21", + "lodash": "^4.18.1", "tslib": "^2.8.1" }, "engines": { @@ -9384,12 +9413,6 @@ } } }, - "node_modules/@stoplight/spectral-rulesets/node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/@stoplight/spectral-rulesets/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -11419,6 +11442,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -14618,9 +14653,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -16296,6 +16331,15 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -16651,6 +16695,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -17316,6 +17366,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/git-http-backend": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/git-http-backend/-/git-http-backend-1.1.2.tgz", + "integrity": "sha512-Gx7n/kyCEXGFZlCGmbsEsyeyabLs8XWeb+E/6842up7p3PktQS2/8rlNfB6hCagnW0pJ13Tn8E3yhOkKS6ihdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "git-side-band-message": "~0.0.3", + "inherits": "~2.0.1" + } + }, + "node_modules/git-side-band-message": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/git-side-band-message/-/git-side-band-message-0.0.3.tgz", + "integrity": "sha512-4Rq4xm1+zqCkmuHxRbGdA5ActF7F4UfgK8uI0B7ZfSkByZfikRuF7mqHlvqmycvqos7jpXNkgsZK7DThLLHG3w==", + "dev": true, + "license": "BSD" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -19581,12 +19649,6 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "license": "MIT" }, - "node_modules/jshint/node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/jshint/node_modules/minimatch": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", @@ -22865,9 +22927,9 @@ } }, "node_modules/openai": { - "version": "6.33.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz", - "integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==", + "version": "6.34.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz", + "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", "license": "Apache-2.0", "optional": true, "bin": { @@ -23444,13 +23506,13 @@ } }, "node_modules/playwright": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", - "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.1" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -23463,9 +23525,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", - "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -23927,22 +23989,22 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -24871,9 +24933,9 @@ } }, "node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "license": "MIT", "bin": { "rollup": "dist/bin/rollup" @@ -25581,18 +25643,6 @@ ], "license": "MIT" }, - "node_modules/simple-eval": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-eval/-/simple-eval-1.0.1.tgz", - "integrity": "sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==", - "license": "MIT", - "dependencies": { - "jsep": "^1.3.6" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/simple-get": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", @@ -27422,9 +27472,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -29122,7 +29172,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^3.0.2", - "@getinsomnia/node-libcurl": "3.2.1", + "@getinsomnia/node-libcurl": "3.2.2", "@getinsomnia/srp-js": "1.0.0-alpha.1", "@grpc/grpc-js": "^1.13.3", "@grpc/proto-loader": "^0.7.13", @@ -29135,13 +29185,15 @@ "@seald-io/nedb": "^4.1.1", "@segment/analytics-node": "2.2.1", "@sentry/electron": "^6.5.0", - "@stoplight/spectral-core": "^1.20.0", + "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", - "@stoplight/spectral-ruleset-bundler": "1.6.3", - "@stoplight/spectral-rulesets": "^1.22.0", + "@stoplight/spectral-ruleset-bundler": "1.7.0", + "@stoplight/spectral-rulesets": "^1.22.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-virtual": "3.13.12", "@xmldom/xmldom": "^0.9.8", + "acorn": "^8.16.0", + "acorn-walk": "^8.3.5", "ajv": "^8.17.1", "apiconnect-wsdl": "2.0.36", "aws4": "^1.13.2", @@ -29161,10 +29213,11 @@ "decompress": "^4.2.1", "deep-equal": "2.2.3", "diff-match-patch-ts": "^0.6.0", - "dompurify": "^3.2.5", + "dompurify": "^3.4.1", "electron-context-menu": "^3.6.1", "electron-updater": "^6.6.2", "fastq": "^1.19.1", + "fflate": "^0.8.2", "fuzzysort": "^1.9.0", "graphql": "^16.10.0", "graphql-ws": "^5.16.2", @@ -29213,7 +29266,7 @@ "tinykeys": "^3.0.0", "tough-cookie": "^4.1.4", "tweetnacl": "^1.0.3", - "undici": "^7.16.0", + "undici": "^7.25.0", "uuid": "^9.0.1", "vkbeautify": "^0.99.3", "ws": "^8.18.1", @@ -29279,13 +29332,16 @@ "vite": "^7.1.3" }, "optionalDependencies": { - "@kong/insomnia-plugin-ai": "^1.0.9", + "@kong/insomnia-plugin-ai": "^1.0.11", "@kong/insomnia-plugin-external-vault": "0.1.4-dev.20251224090833" } }, "packages/insomnia-api": { "version": "12.5.1-alpha.0", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "@getinsomnia/insomnia-v3-fetch": "^1.0.1" + } }, "packages/insomnia-inso": { "version": "12.5.1-alpha.0", @@ -29293,10 +29349,10 @@ "dependencies": { "@seald-io/nedb": "^4.1.1", "@segment/analytics-node": "^2.2.1", - "@stoplight/spectral-core": "^1.20.0", + "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", - "@stoplight/spectral-ruleset-bundler": "1.6.3", - "@stoplight/spectral-rulesets": "^1.22.0", + "@stoplight/spectral-ruleset-bundler": "1.7.0", + "@stoplight/spectral-rulesets": "^1.22.1", "@stoplight/types": "^14.1.1", "commander": "^12.1.0", "consola": "^2.15.3", @@ -29360,7 +29416,7 @@ "version": "12.5.1-alpha.0", "license": "Apache-2.0", "devDependencies": { - "@playwright/test": "1.55.1", + "@playwright/test": "1.59.1", "@ravanallc/grpc-server-reflection": "^0.1.6", "@types/express": "^4.17.21", "@types/oidc-provider": "^8.8.1", @@ -29370,6 +29426,7 @@ "esbuild-runner": "2.2.2", "express": "^4.21.2", "express-basic-auth": "^1.2.1", + "git-http-backend": "^1.1.2", "graphql": "^16.10.0", "graphql-http": "^1.22.4", "http-errors": "^2.0.0", diff --git a/package.json b/package.json index 08c6b8d063..06d77047f0 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "install-libcurl-node": "node-pre-gyp install --directory node_modules/@getinsomnia/node-libcurl --update-binary --runtime=node --target=24.14.0", "inso-start": "npm start -w insomnia-inso", "inso-package": "npm run build -w insomnia-inso && npm run package -w insomnia-inso", - "watch:app": "cross-env PLAYWRIGHT=1 npm run build:electron-entrypoints -w insomnia && npm run start:dev-server -w insomnia", - "app-build": "cross-env PLAYWRIGHT=1 npm run build -w insomnia", + "watch:app": "npm run build:electron-entrypoints -w insomnia && npm run start:dev-server -w insomnia", + "app-build": "npm run build -w insomnia", "app-package": "npm run package -w insomnia", "test:smoke:dev": "npm run test:dev -w insomnia-smoke-test -- --project=Smoke", "test:smoke:build": "npm run test:build -w insomnia-smoke-test -- --project=Smoke", @@ -76,10 +76,13 @@ "overrides": { "ajv-draft-04": { "ajv": "$ajv" + }, + "jshint": { + "lodash": "4.18.1" } }, "dependencies": { - "@getinsomnia/node-libcurl": "3.2.1", + "@getinsomnia/node-libcurl": "3.2.2", "ajv": "^8.17.1" } } diff --git a/packages/insomnia-api/README.md b/packages/insomnia-api/README.md index 6b7c3c9407..1d25b93eb2 100644 --- a/packages/insomnia-api/README.md +++ b/packages/insomnia-api/README.md @@ -11,5 +11,5 @@ Uses npm workspace, so no need to install. ### Import ```ts -import { getUserProfile, type UserProfileResponse } from 'insomnia-api'; +import { getUserProfile, getEncryptionKeys, type User, type UserEncryptionKeys } from 'insomnia-api'; ``` diff --git a/packages/insomnia-api/package.json b/packages/insomnia-api/package.json index 5ad5265473..1f082aaf72 100644 --- a/packages/insomnia-api/package.json +++ b/packages/insomnia-api/package.json @@ -22,7 +22,10 @@ }, "scripts": { "lint": "eslint . --ext .ts,.tsx --cache", - "type-check": "tsc --noEmit --project tsconfig.json" + "type-check": "tsc --noEmit --project tsconfig.json", + "test": "vitest run" }, - "dependencies": {} + "dependencies": { + "@getinsomnia/insomnia-v3-fetch": "^1.0.1" + } } diff --git a/packages/insomnia-api/src/__tests__/user.test.ts b/packages/insomnia-api/src/__tests__/user.test.ts new file mode 100644 index 0000000000..6fb66d9db7 --- /dev/null +++ b/packages/insomnia-api/src/__tests__/user.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getEncryptionKeys, getUserProfile } from '../user'; + +const { mockFetch } = vi.hoisted(() => ({ + mockFetch: vi.fn(), +})); + +vi.mock('../fetch', () => ({ + fetch: mockFetch, +})); + +describe('getUserProfile', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns first_name and last_name from the API response', async () => { + mockFetch.mockResolvedValue({ + id: 'usr_abc123', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + picture: 'https://example.com/pic.jpg', + }); + + const result = await getUserProfile({ sessionId: 'sess_xyz' }); + + expect(result.first_name).toBe('Jane'); + expect(result.last_name).toBe('Doe'); + }); + + it('passes id through as-is', async () => { + mockFetch.mockResolvedValue({ + id: 'usr_abc123', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + picture: '', + }); + + const result = await getUserProfile({ sessionId: 'sess_xyz' }); + + expect(result.id).toBe('usr_abc123'); + }); + + it('passes picture through as-is', async () => { + mockFetch.mockResolvedValue({ + id: 'usr_abc123', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + picture: 'https://example.com/pic.jpg', + }); + + const result = await getUserProfile({ sessionId: 'sess_xyz' }); + + expect(result.picture).toBe('https://example.com/pic.jpg'); + }); + + it('calls fetch with the correct path and sessionId', async () => { + mockFetch.mockResolvedValue({ + id: 'usr_abc123', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + picture: '', + }); + + await getUserProfile({ sessionId: 'sess_xyz' }); + + expect(mockFetch).toHaveBeenCalledWith({ method: 'GET', path: '/v3/users/me', sessionId: 'sess_xyz' }); + }); +}); + +describe('getEncryptionKeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes encryption key fields through as-is', async () => { + mockFetch.mockResolvedValue({ + public_key: '{"kty":"RSA"}', + enc_private_key: '{"iv":"abc"}', + enc_symmetric_key: '{"iv":"def"}', + salt_enc: 'deadbeef', + enc_driver_key: null, + }); + + const result = await getEncryptionKeys({ sessionId: 'sess_xyz' }); + + expect(result.public_key).toBe('{"kty":"RSA"}'); + expect(result.enc_private_key).toBe('{"iv":"abc"}'); + expect(result.enc_symmetric_key).toBe('{"iv":"def"}'); + expect(result.salt_enc).toBe('deadbeef'); + }); + + it('calls fetch with the correct path and sessionId', async () => { + mockFetch.mockResolvedValue({ + public_key: '', + enc_private_key: '', + enc_symmetric_key: '', + salt_enc: '', + enc_driver_key: '', + }); + + await getEncryptionKeys({ sessionId: 'sess_xyz' }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'GET', + path: '/v3/users/me/encryption-keys', + sessionId: 'sess_xyz', + }); + }); +}); diff --git a/packages/insomnia-api/src/user.ts b/packages/insomnia-api/src/user.ts index 33cf9f78bc..296eb53ff7 100644 --- a/packages/insomnia-api/src/user.ts +++ b/packages/insomnia-api/src/user.ts @@ -1,5 +1,9 @@ +import type { User, UserEncryptionKeys } from '@getinsomnia/insomnia-v3-fetch'; + import { fetch } from './fetch'; +export type { User, UserEncryptionKeys }; + // POST /auth/logout export const logout = ({ sessionId }: { sessionId: string }) => { return fetch({ @@ -9,66 +13,14 @@ export const logout = ({ sessionId }: { sessionId: string }) => { }); }; -// GET /auth/whoami -interface WhoamiResponse { - sessionAge: number; - sessionExpiry: number; - accountId: string; - email: string; - firstName: string; - lastName: string; - created: number; - publicKey: string; - encSymmetricKey: string; - encPrivateKey: string; - saltEnc: string; - isPaymentRequired: boolean; - isTrialing: boolean; - isVerified: boolean; - isAdmin: boolean; - trialEnd: string; - planName: string; - planId: string; - canManageTeams: boolean; - maxTeamMembers: number; -} - -export const whoami = async ({ sessionId }: { sessionId: string }): Promise => { - const response = await fetch({ - method: 'GET', - path: '/auth/whoami', - sessionId, - }); - if (typeof response === 'string') { - throw new TypeError('Unexpected plaintext response: ' + response); - } - if (response && !response?.encSymmetricKey) { - throw new Error('Unexpected response: ' + JSON.stringify(response)); - } - return response; +// GET /v3/users/me +export const getUserProfile = async ({ sessionId }: { sessionId: string }): Promise => { + return await fetch({ method: 'GET', path: '/v3/users/me', sessionId }); }; -// GET /v1/user/profile -export interface UserProfile { - id: string; - email: string; - name: string; - picture: string; - bio: string; - github: string; - linkedin: string; - twitter: string; - identities: any; - given_name: string; - family_name: string; -} - -export const getUserProfile = async ({ sessionId }: { sessionId: string }) => { - return fetch({ - method: 'GET', - path: '/v1/user/profile', - sessionId, - }); +// GET /v3/users/me/encryption-keys +export const getEncryptionKeys = async ({ sessionId }: { sessionId: string }): Promise => { + return fetch({ method: 'GET', path: '/v3/users/me/encryption-keys', sessionId }); }; // GET /v1/billing/current-plan diff --git a/packages/insomnia-api/vitest.config.ts b/packages/insomnia-api/vitest.config.ts new file mode 100644 index 0000000000..4ac6027d57 --- /dev/null +++ b/packages/insomnia-api/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +}); diff --git a/packages/insomnia-inso/package.json b/packages/insomnia-inso/package.json index 188e6cb5bc..7d75429a7e 100644 --- a/packages/insomnia-inso/package.json +++ b/packages/insomnia-inso/package.json @@ -53,10 +53,10 @@ "dependencies": { "@segment/analytics-node": "^2.2.1", "@seald-io/nedb": "^4.1.1", - "@stoplight/spectral-core": "^1.20.0", + "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", - "@stoplight/spectral-ruleset-bundler": "1.6.3", - "@stoplight/spectral-rulesets": "^1.22.0", + "@stoplight/spectral-ruleset-bundler": "1.7.0", + "@stoplight/spectral-rulesets": "^1.22.1", "@stoplight/types": "^14.1.1", "commander": "^12.1.0", "consola": "^2.15.3", diff --git a/packages/insomnia-inso/src/cli.test.ts b/packages/insomnia-inso/src/cli.test.ts index 2c17f56e7f..6db8d86611 100644 --- a/packages/insomnia-inso/src/cli.test.ts +++ b/packages/insomnia-inso/src/cli.test.ts @@ -42,6 +42,8 @@ const shouldReturnSuccessCode = [ '$PWD/packages/insomnia-inso/bin/inso run test -w packages/insomnia-inso/src/examples/folder-inheritance-document.yml spc_a8144e --verbose --disableCertValidation', // run collection + // with auth + '$PWD/packages/insomnia-inso/bin/inso run collection -w packages/insomnia-smoke-test/fixtures/auth-types.yaml wrk_ca4cb9', // export file '$PWD/packages/insomnia-inso/bin/inso run collection -w packages/insomnia-smoke-test/fixtures/simple.yaml -e production wrk_dc393c', // with regex filter diff --git a/packages/insomnia-scripting-environment/src/objects/auth.ts b/packages/insomnia-scripting-environment/src/objects/auth.ts index d878065922..4efa265091 100644 --- a/packages/insomnia-scripting-environment/src/objects/auth.ts +++ b/packages/insomnia-scripting-environment/src/objects/auth.ts @@ -1,4 +1,4 @@ -import type { OAuth1SignatureMethod } from 'insomnia/src/network/o-auth-1/constants'; +import type { OAuth1SignatureMethod } from 'insomnia/src/common/constants'; import type { OAuth2ResponseType, RequestAuthentication } from '~/insomnia-data'; diff --git a/packages/insomnia-scripting-environment/src/objects/response.ts b/packages/insomnia-scripting-environment/src/objects/response.ts index e880a782e9..3eb535a18b 100644 --- a/packages/insomnia-scripting-environment/src/objects/response.ts +++ b/packages/insomnia-scripting-environment/src/objects/response.ts @@ -1,9 +1,10 @@ import { Ajv, type ErrorObject } from 'ajv'; import * as chai from 'chai'; import { RESPONSE_CODE_REASONS } from 'insomnia/src/common/constants'; -import { readCurlResponse } from 'insomnia/src/models/helpers/response-operations'; import type { sendCurlAndWriteTimelineError, sendCurlAndWriteTimelineResponse } from 'insomnia/src/network/network'; +import { services } from '~/insomnia-data'; + import { Cookie, type CookieOptions } from './cookies'; import { CookieList } from './cookies'; import { Header, type HeaderDefinition, HeaderList } from './headers'; @@ -394,8 +395,7 @@ export async function readBodyFromPath( } else if (!response.bodyPath) { return ''; } - const nodejsReadCurlResponse = process.type === 'renderer' ? window.bridge.readCurlResponse : readCurlResponse; - const readResponseResult = await nodejsReadCurlResponse({ + const readResponseResult = await services.helpers.readCurlResponse({ bodyPath: response.bodyPath, bodyCompression: response.bodyCompression, }); diff --git a/packages/insomnia-scripting-environment/src/objects/send-request.ts b/packages/insomnia-scripting-environment/src/objects/send-request.ts index 048a9fbe1d..16614902b8 100644 --- a/packages/insomnia-scripting-environment/src/objects/send-request.ts +++ b/packages/insomnia-scripting-environment/src/objects/send-request.ts @@ -1,9 +1,9 @@ import type { CurlRequestOutput } from 'insomnia/src/main/network/libcurl-promise'; -import { readCurlResponse } from 'insomnia/src/models/helpers/response-operations'; import { Cookie } from 'tough-cookie'; import { v4 as uuidv4 } from 'uuid'; import type { Settings } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import { RequestAuth } from './auth'; import { fromPreRequestAuth } from './auth'; @@ -264,8 +264,7 @@ async function curlOutputToResponse( originalRequest, }); } - const nodejsReadCurlResponse = process.type === 'renderer' ? window.bridge.readCurlResponse : readCurlResponse; - const bodyResult = await nodejsReadCurlResponse({ + const bodyResult = await services.helpers.readCurlResponse({ bodyPath: result.responseBodyPath, bodyCompression: result.patch.bodyCompression, }); diff --git a/packages/insomnia-smoke-test/README.md b/packages/insomnia-smoke-test/README.md index b230ab234b..98011527e2 100644 --- a/packages/insomnia-smoke-test/README.md +++ b/packages/insomnia-smoke-test/README.md @@ -1,144 +1,98 @@ # Insomnia Smoke Tests -[![Playwright](https://img.shields.io/badge/playwright-blue.svg?style=for-the-badge&logo=playwright)](https://github.com/microsoft/playwright) +Playwright E2E tests for the Insomnia desktop app. -This project contains the smoke testing suite for Insomnia App. +> CLI smoke tests: [CLI.md](CLI.md) -> To find more about Inso CLI smoke tests, check [this document](CLI.md). +## Test structure -- [Insomnia Smoke Tests](#insomnia-smoke-tests) - - [Quick-start](#quick-start) - - [Debugging and Developing Tests locally](#debugging-and-developing-tests-locally) - - [Playwright VS Code extension](#playwright-vs-code-extension) - - [Playwright Inspector](#playwright-inspector) - - [Playwright Trace viewer](#playwright-trace-viewer) - - [Additional Log levels](#additional-log-levels) - - [Reproducing CI Failures](#reproducing-ci-failures) - - [Getting traces from CI](#getting-traces-from-ci) - - [Build and package methods](#build-and-package-methods) - - [Non recurring tests](#non-recurring-tests) - - [Refresh certs](#refresh-certs) +``` +tests/ + smoke/ # Main suite — runs on every CI push (Ubuntu only) + critical/ # Single critical-path test — runs on release + migration/ # Data migration tests +``` + +All commands below must be run from the **repo root**. ## Quick-start -Prerequisites: - -- Clone the project -- Run `npm install` - -To run all tests: - -- In one terminal run: `npm run watch:app` OR `npm run dev` -- In another terminal run: `npm run test:smoke:dev` - -To run single tests: - -- Filter by the file or test title, e.g. `npm run test:smoke:dev -- oauth` -- Use playwright UI `npm run test:dev -w insomnia-smoke-test -- --project=Smoke --ui` to show all tests by project - -## Debugging and Developing Tests locally - -### Playwright VS Code extension - -In order to run/debug tests directly from VS Code: - -- Install the [Playwright extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). -- With the extension installed, run on terminal `npm run watch:app`. - -You can trigger tests from the `Testing` tab, or within the test files clicking the run button. - -![editor](docs/imgs/editor.png) - -If no tests appear, you may need to run "Refresh playwright tests". This can be done from the command palette, or by using the button at the top of the `Testing` tab. - -![refresh](docs/imgs/refresh.png) - -### Playwright Inspector - -You can step through tests with playwright inspector: `PWDEBUG=1 npm run test:smoke:dev` - -This is also useful to help create new tests. - -![playwright inspector](docs/imgs/playwright-inspector.jpg) - -### Playwright Trace viewer - -We generate [Playwright Traces](https://playwright.dev/docs/trace-viewer) when tests run. These can be used to debug local and CI test failures. - -![playwright trace viewer](docs/imgs/playwright-trace.jpg) - -To open a local trace viewer for a given test output, run: - -```shell -# Example: -npx playwright show-trace packages/insomnia-smoke-test/traces/app-can-send-requests/trace.zip +```bash +npm install +npm run test:smoke:dev # run all Smoke tests (dev mode) ``` -Alternatively you can upload this trace to [trace.playwright.dev](https://trace.playwright.dev/). +Both the echo server (port 4010) and the Vite dev server start automatically. -### Additional Log levels +**Filter to one test** — pass a substring of the test title or file name: -You can enable additional logging to help you debug tests: +```bash +npm run test:smoke:dev -- oauth +``` -- Playwright logs: `DEBUG=pw:api npm run test:smoke:dev` -- Insomnia console logs: `DEBUG=pw:browser npm run test:smoke:dev` -- WebServer console logs: `DEBUG=pw:WebServer npm run test:smoke:dev` +**Interactive UI:** -## Reproducing CI Failures +```bash +npm run test:smoke:dev -- --ui +``` -### Getting traces from CI +**Step-through debugger:** -Traces from CI execution can be found in the failed CI job's artifacts. +```bash +PWDEBUG=1 npm run test:smoke:dev +``` -![artifacts](docs/imgs/artifacts.png) +## Additional log levels -After downloading the artifacts, these can be extracted and loaded up into the [Trace viewer](#playwright-trace-viewer). +```bash +DEBUG=pw:api npm run test:smoke:dev # Playwright API logs +DEBUG=pw:browser npm run test:smoke:dev # Insomnia console logs +DEBUG=pw:WebServer npm run test:smoke:dev # Web server logs +``` -### Build and package methods +## Traces and error context -It's possible to run the smoke tests for: +On failure, two artifacts are written under `packages/insomnia-smoke-test/traces//`: -- A `build`, the JS bundle that is loaded into an electron client -- A `package`, the executable binary (e.g. `.dmg` or `.exe`) +- **`error-context.md`** — error details, ARIA page snapshot at point of failure, and annotated test source. Read this first. +- **`trace.zip`** — full Playwright trace (network, screenshots, DOM snapshots). -For `build`: +Open a trace: -```shell -# Transpile js bundle +```bash +npx playwright show-trace packages/insomnia-smoke-test/traces//trace.zip +``` + +Or upload to [trace.playwright.dev](https://trace.playwright.dev/). + +CI traces are available as artifacts on failed workflow runs. + +## Build / package modes + +Run against a JS bundle (`build`) or a packaged binary (`package`) instead of the dev watcher: + +```bash +# build mode npm run app-build - -# Run tests npm run test:smoke:build -``` -For `package`: - -```shell -# Build executable in /packages/insomnia/dist +# package mode npm run app-package - -# Run tests npm run test:smoke:package ``` -> Note: for local testing of the packaged app on macOS you need to change the entitlements temporarily to allow unsigned apps to run. You can do this by changing the `com.apple.security.cs.disable-library-validation` key in `entitlements.mac.inherit.plist` file to `true`. (Remember do not commit to the origin repo!) +> macOS package mode: set `com.apple.security.cs.disable-library-validation` to `true` in `entitlements.mac.inherit.plist` to allow unsigned local binaries. Do not commit this change. -Each of the above commands will automatically run the Express server, so you do not need to take any extra steps. +## Non-CI / pre-release tests -### Non recurring tests - -Non recurring / non-CI tests, like pre-release ones, can be run using [Playwright VS Code extension](#playwright-vs-code-extension) or by running `test:dev` against the desired test file: - -```shell -npm run test:dev -w packages/insomnia-smoke-test -- preferences-interactions +```bash +npm run test:dev -w insomnia-smoke-test -- tests/smoke/preferences-interactions.test.ts ``` -### Refresh certs +## Cert refresh -The certs might need to be replaced after 2026 to fix the custom ca cert test +If the custom CA cert test fails after 2026: -```sh -mkcert -install -mkcert localhost -mkcert -CAROOT +```bash +mkcert -install && mkcert localhost && mkcert -CAROOT ``` diff --git a/packages/insomnia-smoke-test/fixtures/auth-types.yaml b/packages/insomnia-smoke-test/fixtures/auth-types.yaml new file mode 100644 index 0000000000..52f7bad6b0 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/auth-types.yaml @@ -0,0 +1,95 @@ +type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: Auth tests +meta: + id: wrk_ca4cb9634c1045479b67b94f61725442 + created: 1776427934242 + modified: 1776427995078 + description: "" +collection: + - url: http://127.0.0.1:4010/auth/basic + name: sends request with basic authentication + meta: + id: req_c29164bea31840a5a68eb67858759705 + created: 1636141100570 + modified: 1636142586648 + isPrivate: false + description: "" + sortKey: 0 + method: GET + headers: + - name: Authorization + value: Basic dXNlcjpwYXNz + disabled: true + authentication: + type: basic + useISO88591: false + username: user + password: pass + disabled: false + settings: + renderRequestBody: true + encodeUrl: true + followRedirects: global + cookies: + send: true + store: true + rebuildPath: true + - url: http://127.0.0.1:4010/auth/oauth1 + name: sends request with oauth1 + meta: + id: req_e713e4eeac1942a089e6c687b66c38ac + created: 1776427942368 + modified: 1776427981837 + isPrivate: false + description: "" + sortKey: -1776427942368 + method: GET + headers: + - name: User-Agent + value: insomnia/12.5.1-alpha.0 + description: "" + disabled: false + authentication: + type: oauth1 + disabled: false + signatureMethod: HMAC-SHA1 + consumerKey: key + tokenKey: key + tokenSecret: secret + privateKey: "" + version: "1.0" + nonce: "" + timestamp: "" + callback: "" + settings: + renderRequestBody: true + encodeUrl: true + followRedirects: global + cookies: + send: true + store: true + rebuildPath: true +cookieJar: + name: Default Jar + meta: + id: jar_0ada3b0255e747a383ddf8848f88f4b2 + created: 1636140994434 + modified: 1637279629638 + cookies: + - id: "429589439757017" + key: foo + value: bar + domain: domain.com + path: / + secure: false + httpOnly: false +environments: + name: Base Environment + meta: + id: env_482f5a98dfe64a948fff489dbd761e42 + created: 1636140994432 + modified: 1636140994432 + isPrivate: false + data: + customValue: fromEnvManager diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/HEAD b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/HEAD new file mode 100644 index 0000000000..cb089cd89a --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/config b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/config new file mode 100644 index 0000000000..64280b806c --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = false + bare = true + symlinks = false + ignorecase = true diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/description b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/description new file mode 100644 index 0000000000..498b267a8c --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/info/exclude b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/info/exclude new file mode 100644 index 0000000000..a5196d1be8 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/33/57649e085f0f653c60993fc34b0d380c4c2991 b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/33/57649e085f0f653c60993fc34b0d380c4c2991 new file mode 100644 index 0000000000000000000000000000000000000000..78c4d86d1a520fa0bb21890968c065128eccf3f8 GIT binary patch literal 53 zcmb>}iuOv4#ExB8mWPDd*S=fSZ2ayChe3XqPh|}^ JgL9jp0RRDg6cYdd literal 0 HcmV?d00001 diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/5a/d28e22767f979da2c198dc6c1003b25964e3da b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/objects/5a/d28e22767f979da2c198dc6c1003b25964e3da new file mode 100644 index 0000000000000000000000000000000000000000..a49384ff604c8486178beafbf3cf566283daef9a GIT binary patch literal 20 bcmb_zZT-bS`؍0RB`$gv&u)t^+E& +}ߴl;]_Gf]ߓGĶ`N=?hD, \ No newline at end of file diff --git a/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/refs/heads/master b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/refs/heads/master new file mode 100644 index 0000000000..1c22a98738 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/git-repo/git-server.git/refs/heads/master @@ -0,0 +1 @@ +73a07b09e3e790de0277df5ac98e0ad62ecf83d4 diff --git a/packages/insomnia-smoke-test/fixtures/ipv6-collection.yaml b/packages/insomnia-smoke-test/fixtures/ipv6-collection.yaml new file mode 100644 index 0000000000..093f2b8dd6 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/ipv6-collection.yaml @@ -0,0 +1,65 @@ +type: collection.insomnia.rest/5.0 +schema_version: "5.1" +name: IPV6 Smoke tests +meta: + id: wrk_92410798721649cc8cd6f7c312a8764d + created: 1736279960080 + modified: 1736279960080 +collection: + - url: http://[::1]:4010/pets/1 + name: send JSON request + meta: + id: req_22084aca139d42c6878afdc1a99db23b + created: 1636141014552 + modified: 1636707449231 + isPrivate: false + method: GET + headers: + - name: test + value: test + settings: + renderRequestBody: true + encodeUrl: true + followRedirects: global + cookies: + send: true + store: true + rebuildPath: true + - url: http://[::1]:4010/file/dummy.csv + name: sends dummy.csv request and shows rich response + meta: + id: req_ed583d913fec40c8b29f51b87b1843ef + created: 1636141038448 + modified: 1636141047337 + isPrivate: false + method: GET + settings: + renderRequestBody: true + encodeUrl: true + followRedirects: global + cookies: + send: true + store: true + rebuildPath: true +cookieJar: + name: Default Jar + meta: + id: jar_0a46909e87a24a05961f3b7c6b591d23 + created: 1636140994434 + modified: 1637279629638 + cookies: + - key: foo + value: bar + domain: domain.com + path: / + creation: 2021-11-18T23:53:05.310Z + id: "429589439757017" +environments: + name: Base Environment + meta: + id: env_6fda0ba942704e71a81a02e2bf5fe5ff + created: 1636140994432 + modified: 1636140994432 + isPrivate: false + data: + customValue: fromEnvManager diff --git a/packages/insomnia-smoke-test/package.json b/packages/insomnia-smoke-test/package.json index 303c651514..43aa9c6aca 100644 --- a/packages/insomnia-smoke-test/package.json +++ b/packages/insomnia-smoke-test/package.json @@ -20,7 +20,7 @@ "serve": "esr server/index.ts" }, "devDependencies": { - "@playwright/test": "1.55.1", + "@playwright/test": "1.59.1", "@ravanallc/grpc-server-reflection": "^0.1.6", "@types/express": "^4.17.21", "@types/oidc-provider": "^8.8.1", @@ -30,6 +30,7 @@ "esbuild-runner": "2.2.2", "express": "^4.21.2", "express-basic-auth": "^1.2.1", + "git-http-backend": "^1.1.2", "graphql": "^16.10.0", "graphql-http": "^1.22.4", "http-errors": "^2.0.0", diff --git a/packages/insomnia-smoke-test/playwright.config.ts b/packages/insomnia-smoke-test/playwright.config.ts index 36f49be05f..a8531d1ddb 100644 --- a/packages/insomnia-smoke-test/playwright.config.ts +++ b/packages/insomnia-smoke-test/playwright.config.ts @@ -1,4 +1,33 @@ +import os from 'node:os'; + import type { PlaywrightTestConfig } from '@playwright/test'; +const isWindows = os.platform() === 'win32'; +const echoServer: PlaywrightTestConfig['webServer'] = { + name: 'Echo server', + command: 'npm run serve', + url: 'http://localhost:4010', + timeout: 15 * 1000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + wait: { + stdout: /Listening at http/, + }, +}; +const viteServer: PlaywrightTestConfig['webServer'] = { + name: 'Vite Server', + cwd: '../../', + command: 'npm run watch:app', + url: 'http://localhost:3334', + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + wait: { + stdout: /VITE\s+ready in/, + }, +}; +const onlyStartWebServerInDev = !process.env.BUNDLE || process.env.BUNDLE === 'dev'; const config: PlaywrightTestConfig = { projects: [ { @@ -20,12 +49,7 @@ const config: PlaywrightTestConfig = { retries: 0, }, ], - webServer: { - command: 'npm run serve', - url: 'http://127.0.0.1:4010', - timeout: 120 * 1000, - reuseExistingServer: !process.env.CI, - }, + webServer: [echoServer, ...(onlyStartWebServerInDev ? [viteServer] : [])], use: { trace: { mode: 'retain-on-failure', @@ -35,7 +59,7 @@ const config: PlaywrightTestConfig = { }, }, reporter: process.env.CI ? [['github'], ['line']] : [['list']], - timeout: process.env.CI ? 60 * 1000 : 20 * 1000, + timeout: process.env.CI || isWindows ? 60 * 1000 : 20 * 1000, forbidOnly: !!process.env.CI, outputDir: 'traces', testDir: 'tests', diff --git a/packages/insomnia-smoke-test/playwright/pages/preferences/credentials-tab.ts b/packages/insomnia-smoke-test/playwright/pages/preferences/credentials-tab.ts new file mode 100644 index 0000000000..17f20b6d03 --- /dev/null +++ b/packages/insomnia-smoke-test/playwright/pages/preferences/credentials-tab.ts @@ -0,0 +1,38 @@ +import type { ElectronApplication, Locator, Page } from '@playwright/test'; + +import { BasePage } from '../base-page'; + +/** + * Component for the **Credentials tab** within Insomnia Preferences. + * + * Handles credential management functionality: + * - Add, edit, and remove credentials + */ +export class PreferencesCredentialsTab extends BasePage { + constructor( + readonly page: Page, + readonly app: ElectronApplication, + ) { + super(page); + } + + get root(): Locator { + return this.page.getByTestId('credentials-settings-tab'); + } + + async addAccessTokenGitCredential() { + await this.page.getByRole('button', { name: 'Create Git Credential' }).click(); + await this.page.getByText('Access Token').click(); + await this.page.getByRole('textbox', { name: 'Author Email' }).click(); + await this.page.getByRole('textbox', { name: 'Author Email' }).fill('a@b.com'); + await this.page.getByRole('textbox', { name: 'Author Name' }).click(); + await this.page.getByRole('textbox', { name: 'Author Name' }).fill('author'); + await this.page.getByRole('textbox', { name: 'Username' }).click(); + await this.page.getByRole('textbox', { name: 'Username' }).fill('username'); + await this.page.getByRole('textbox', { name: 'Git Access Token' }).click(); + await this.page.getByRole('textbox', { name: 'Git Access Token' }).fill('accesstoken'); + await this.page.getByRole('textbox', { name: 'Repository base URL' }).click(); + await this.page.getByRole('textbox', { name: 'Repository base URL' }).fill('http://localhost:4010/git/'); + await this.page.getByRole('button', { name: 'Save Credential' }).click(); + } +} diff --git a/packages/insomnia-smoke-test/playwright/pages/preferences/index.ts b/packages/insomnia-smoke-test/playwright/pages/preferences/index.ts index 995f2f5cfa..d58096b7ad 100644 --- a/packages/insomnia-smoke-test/playwright/pages/preferences/index.ts +++ b/packages/insomnia-smoke-test/playwright/pages/preferences/index.ts @@ -1,25 +1,30 @@ import type { ElectronApplication, Locator, Page } from '@playwright/test'; +import { PreferencesCredentialsTab } from './credentials-tab'; import { PreferencesDataTab } from './data-tab'; -type PreferencesTab = 'Data' | 'General' | 'Themes' | 'Plugins' | 'Other'; +type PreferencesTab = 'Data' | 'General' | 'Themes' | 'Credentials' | 'Plugins' | 'Other'; /** * Page Object for **Insomnia Preferences** modal. * * Composes preference tabs: * - Data tab (import/export) + * - Credentials tab (Git credentials management) * - Other tabs (themes, plugins, etc.) can be added as needed */ export class PreferencesPage { /** Data tab (import/export functionality). */ readonly dataTab: PreferencesDataTab; + /** Credentials tab (Git credentials management). */ + readonly credentialsTab: PreferencesCredentialsTab; constructor( readonly page: Page, readonly app: ElectronApplication, ) { this.dataTab = new PreferencesDataTab(page, app); + this.credentialsTab = new PreferencesCredentialsTab(page, app); } /** The root preferences dialog. */ @@ -43,7 +48,7 @@ export class PreferencesPage { * Closes the preferences modal. */ async closePreferences(): Promise { - await this.page.locator('.app').press('Escape'); + await this.page.getByRole('button', { name: 'Modal Close Button' }).click(); await this.root.waitFor({ state: 'hidden' }); } } diff --git a/packages/insomnia-smoke-test/playwright/pages/project/index.ts b/packages/insomnia-smoke-test/playwright/pages/project/index.ts index b174339a0e..df1d6e81d5 100644 --- a/packages/insomnia-smoke-test/playwright/pages/project/index.ts +++ b/packages/insomnia-smoke-test/playwright/pages/project/index.ts @@ -80,6 +80,33 @@ export class ProjectPage extends BasePage { await this.page.getByRole('button', { name: 'Create', exact: true }).click(); } + async createGitSyncProject(name = 'My Git Project'): Promise { + await this.page.getByRole('button', { name: 'Create new Project' }).click(); + await this.page.getByRole('textbox', { name: 'Project name' }).click(); + await this.page.getByRole('textbox', { name: 'Project name' }).press('ControlOrMeta+a'); + await this.page.getByRole('textbox', { name: 'Project name' }).fill(name); + await this.page.getByText('Git Sync').click(); + await this.page.getByRole('button', { name: 'Access Token author Git' }).click(); + await this.page.getByRole('option', { name: 'Custom Git Credential' }).click(); + await this.page.getByRole('textbox', { name: 'Repository URL' }).click(); + await this.page.getByRole('textbox', { name: 'Repository URL' }).fill('git-server.git'); + await this.page.getByRole('button', { name: 'Show suggestions Branch' }).click(); + await this.page.getByRole('option', { name: 'master' }).click(); + await this.page.getByRole('button', { name: 'Scan for files' }).click(); + await this.page.getByRole('button', { name: 'Create Blank Project' }).click(); + const projectModalCloseButton = this.page.locator('[data-test-id="project-modal-close-button"]'); + await projectModalCloseButton.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); + if (await projectModalCloseButton.isVisible()) { + await projectModalCloseButton.click(); + } + await this.page.getByRole('button', { name: 'Personal workspace' }).click(); + await this.page.getByRole('option', { name: /Magic/ }).locator('span').click(); + await this.page.getByRole('button', { name: /Magic/ }).click(); + await this.page.getByRole('option', { name: 'Personal workspace' }).locator('span').click(); + await this.page.getByText('Git Project').waitFor({ state: 'visible', timeout: 10_000 }); + await this.page.getByText('Git Project').click(); + } + // =========================================================================== // Import Operations // =========================================================================== diff --git a/packages/insomnia-smoke-test/playwright/paths.ts b/packages/insomnia-smoke-test/playwright/paths.ts index 16737e89d9..a4c37f3fbc 100644 --- a/packages/insomnia-smoke-test/playwright/paths.ts +++ b/packages/insomnia-smoke-test/playwright/paths.ts @@ -69,7 +69,7 @@ const hasBinaryBeenBuilt = fs.existsSync(path.resolve(cwd, insomniaBinary)); // NOTE: guard against missing build artifacts if (bundleType() === 'dev' && !hasMainBeenBuilt) { console.error(`ERROR: ${mainPath} not found at ${path.resolve(cwd, mainPath)} - Have you run "npm run watch:app"?`); + Ensure that playwright has run npm run watch:app`); exit(1); } if (bundleType() === 'build' && !hasMainBeenBuilt) { diff --git a/packages/insomnia-smoke-test/playwright/test.ts b/packages/insomnia-smoke-test/playwright/test.ts index df3e107256..8533650ed5 100644 --- a/packages/insomnia-smoke-test/playwright/test.ts +++ b/packages/insomnia-smoke-test/playwright/test.ts @@ -77,18 +77,17 @@ export const test = baseTest.extend<{ insomnia: InsomniaApp; }>({ app: async ({ playwright, trace, dataPath, userConfig }, use, testInfo) => { - invariant(testInfo.config.webServer?.url, 'Requires web server config'); - const webServerUrl = testInfo.config.webServer.url; + const echoServer = 'http://localhost:4010'; const options: EnvOptions = { INSOMNIA_DATA_PATH: dataPath, - INSOMNIA_API_URL: webServerUrl, - INSOMNIA_APP_WEBSITE_URL: webServerUrl + '/website', - INSOMNIA_AI_URL: webServerUrl + '/ai', - INSOMNIA_GITHUB_REST_API_URL: webServerUrl + '/github-api/rest', - INSOMNIA_GITHUB_API_URL: webServerUrl + '/github-api/graphql', - INSOMNIA_GITLAB_API_URL: webServerUrl + '/gitlab-api', - INSOMNIA_UPDATES_URL: webServerUrl || 'https://updates.insomnia.rest', + INSOMNIA_API_URL: echoServer, + INSOMNIA_APP_WEBSITE_URL: echoServer + '/website', + INSOMNIA_AI_URL: echoServer + '/ai', + INSOMNIA_GITHUB_REST_API_URL: echoServer + '/github-api/rest', + INSOMNIA_GITHUB_API_URL: echoServer + '/github-api/graphql', + INSOMNIA_GITLAB_API_URL: echoServer + '/gitlab-api', + INSOMNIA_UPDATES_URL: echoServer || 'https://updates.insomnia.rest', INSOMNIA_MOCK_API_URL: 'https://mock-stage.insomnia.run', INSOMNIA_SKIP_ONBOARDING: String(userConfig.skipOnboarding), INSOMNIA_PUBLIC_KEY: userConfig.publicKey, @@ -98,13 +97,14 @@ export const test = baseTest.extend<{ INSOMNIA_VAULT_SRP_SECRET: userConfig.vaultSrpSecret || '', ...(userConfig.session ? { INSOMNIA_SESSION: JSON.stringify(userConfig.session) } : {}), }; + const { ELECTRON_RUN_AS_NODE: _ignored, ...launchEnv } = process.env; const electronApp = await playwright._electron.launch({ cwd, executablePath, args: bundleType() === 'package' ? ['--no-sandbox'] : ['--no-sandbox', mainPath], env: { - ...process.env, + ...launchEnv, ...options, PLAYWRIGHT: 'true', }, diff --git a/packages/insomnia-smoke-test/server/cloud-sync-api.ts b/packages/insomnia-smoke-test/server/cloud-sync-api.ts index 4f18624be3..991860d96e 100644 --- a/packages/insomnia-smoke-test/server/cloud-sync-api.ts +++ b/packages/insomnia-smoke-test/server/cloud-sync-api.ts @@ -291,6 +291,17 @@ let deletedProjectIds: string[] = []; let cloudSyncApiEnabled = false; let remoteHasNewCommit = false; +const resetCloudSyncTestState = () => { + Object.keys(newSnapshots).forEach(projectId => { + delete newSnapshots[projectId]; + }); + Object.keys(newBlobs).forEach(blobId => { + delete newBlobs[blobId]; + }); + deletedProjectIds = []; + remoteHasNewCommit = false; +}; + const getSnapshotsForProject = (projectId: string) => { const originalSnapshots = projectSnapshots[projectId] || []; const addedSnapshots = newSnapshots[projectId] || []; @@ -308,13 +319,16 @@ export default function setup(app: Application) { } cloudSyncApiEnabled = enabled; if (!enabled) { - // clear the test data when cloud sync is disabled to avoid affecting other tests - deletedProjectIds = []; - remoteHasNewCommit = false; + resetCloudSyncTestState(); } return res.status(200).send(); }); + app.post('/__test-config/cloud-sync/reset', json(), (_req, res) => { + resetCloudSyncTestState(); + return res.status(200).send(); + }); + app.post('/__test-config/cloud-sync/new-commit', json(), (req, res) => { const { enabled = false } = req.body ?? {}; remoteHasNewCommit = !!enabled; diff --git a/packages/insomnia-smoke-test/server/index.ts b/packages/insomnia-smoke-test/server/index.ts index f3536537c5..f814c56017 100644 --- a/packages/insomnia-smoke-test/server/index.ts +++ b/packages/insomnia-smoke-test/server/index.ts @@ -1,13 +1,28 @@ +import { spawn } from 'node:child_process'; import crypto from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { createServer } from 'node:https'; +import { tmpdir } from 'node:os'; import nodePath from 'node:path'; +import type { Duplex } from 'node:stream'; import * as bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import express from 'express'; import { createHandler } from 'graphql-http/lib/use/http'; +interface GitBackendService { + type: string; + cmd: string; + args: string[]; + createStream(): Duplex; +} +// git-http-backend has no @types package; require+cast is the standard workaround +const backend = require('git-http-backend') as ( + url: string, + cb: (err: Error | null, service: GitBackendService) => void, +) => NodeJS.ReadWriteStream; + import { basicAuthRouter } from './basic-auth'; import cloudSyncApi from './cloud-sync-api'; import githubApi from './github-api'; @@ -152,9 +167,56 @@ app.get('/v1/oauth/azure/config', (_req, res) => { }); }); +const GIT_FIXTURE_ROOT = nodePath.join(__dirname, '../fixtures/git-repo'); +let currentGitTmpDir: string | null = null; + +// Create a fresh per-test copy of the git fixture repo +app.post('/v1/test-utils/git/setup', (_req, res) => { + if (currentGitTmpDir && existsSync(currentGitTmpDir)) { + rmSync(currentGitTmpDir, { recursive: true, force: true }); + } + currentGitTmpDir = mkdtempSync(nodePath.join(tmpdir(), 'insomnia-git-')); + cpSync(GIT_FIXTURE_ROOT, currentGitTmpDir, { recursive: true }); + res.json({ success: true }); +}); + +// Remove the per-test copy +app.delete('/v1/test-utils/git/setup', (_req, res) => { + if (currentGitTmpDir && existsSync(currentGitTmpDir)) { + rmSync(currentGitTmpDir, { recursive: true, force: true }); + } + currentGitTmpDir = null; + res.json({ success: true }); +}); + +// Git smart HTTP server backed by git-http-backend — accepts real pushes. +// Falls back to the fixture root if no per-test dir is set up. +app.use('/git', (req, res) => { + const root = currentGitTmpDir ?? GIT_FIXTURE_ROOT; + // req.url has the '/git' prefix stripped by Express, e.g. '/git-server.git/info/refs?...' + const repoPath = nodePath.join(root, req.url.split('?')[0].split('/')[1]); + + req.pipe( + backend(req.url, (err, service) => { + if (err) { + res.status(500).end(err.message); + return; + } + res.setHeader('content-type', service.type); + const ps = spawn(service.cmd, service.args.concat(repoPath), { + env: { ...process.env, GIT_HTTP_EXPORT_ALL: '1' }, + }); + ps.stderr.on('data', d => console.error('[git]', String(d))); + ps.stdout.pipe(service.createStream()).pipe(ps.stdin); + }), + ).pipe(res); +}); + startWebSocketServer( - app.listen(port, () => { + app.listen(port, '::', () => { console.log(`Listening at http://localhost:${port}`); + console.log(`Listening at http://127.0.0.1:${port}`); + console.log(`Listening at http://[::1]:${port}`); console.log(`Listening at ws://localhost:${port}`); }), ); @@ -169,8 +231,10 @@ startWebSocketServer( rejectUnauthorized: false, }, app, - ).listen(httpsPort, () => { + ).listen(httpsPort, '::', () => { console.log(`Listening at https://localhost:${httpsPort}`); + console.log(`Listening at https://127.0.0.1:${httpsPort}`); + console.log(`Listening at https://[::1]:${httpsPort}`); console.log(`Listening at wss://localhost:${httpsPort}`); }), ); diff --git a/packages/insomnia-smoke-test/server/insomnia-api.ts b/packages/insomnia-smoke-test/server/insomnia-api.ts index 4be3328c56..7475854130 100644 --- a/packages/insomnia-smoke-test/server/insomnia-api.ts +++ b/packages/insomnia-smoke-test/server/insomnia-api.ts @@ -81,47 +81,40 @@ let organizationFeatures = { }, }; -const user = { - id: 'email|64f0dd619ab0786da330d83a', +const v3User = { + id: 'acct_64a477e6b59d43a5a607f84b4f73e3ce', email: 'insomnia-user@konghq.com', - name: 'Rick Morty', + first_name: 'Rick', + last_name: 'Morty', picture: '', - bio: 'My BIO', - github: '', - linkedin: '', - twitter: '', - identities: null, - given_name: '', - family_name: '', + emails: [], + encryption_enabled: true, + is_externally_provisioned: false, }; -const whoami = { - sessionExpiry: 4_838_400, - publicKey: { +const v3EncryptionKeys = { + public_key: JSON.stringify({ alg: 'RSA-OAEP-256', e: 'AQAB', ext: true, key_ops: ['encrypt'], kty: 'RSA', n: 'pTQVaUaiqggIldSKm6ib6eFRLLoGj9W-2O4gTbiorR-2b8-ZmKUwQ0F-jgYX71AjYaFn5VjOHOHSP6byNAjN7WzJ6A_Z3tytNraLoZfwK8KdfflOCZiZzQeD3nO8BNgh_zEgCHStU61b6N6bSpCKjbyPkmZcOkJfsz0LJMAxrXvFB-I42WYA2vJKReTJKXeYx4d6L_XGNIoYtmGZit8FldT4AucfQUXgdlKvr4_OZmt6hgjwt_Pjcu-_jO7m589mMWMebfUhjte3Lp1jps0MqTOvgRb0FQf5eoBHnL01OZjvFPDKeqlvoz7II9wFNHIKzSvgAKnyemh6DiyPuIukyQ', - }, - encPrivateKey: { + }), + enc_private_key: JSON.stringify({ iv: '3a1f2bdb8acbf15f469d57a2', t: '904d6b1bc0ece8e5df6fefb9efefda7c', d: '2a7b0c4beb773fa3e3c2158f0bfa654a88c4041184c3b1e01b4ddd2da2c647244a0d66d258b6abb6a9385251bf5d79e6b03ef35bdfafcb400547f8f88adb8bceb7020f2d873d5a74fb5fc561e7bd67cea0a37c49107bf5c96631374dc44ddb1e4a8b5688dc6560fc6143294ed92c3ad8e1696395dfdf15975aa67b9212366dbfcb31191e4f4fe3559c89a92fb1f0f1cc6cbf90d8a062307fce6e7701f6f5169d9247c56dae79b55fba1e10fde562b971ca708c9a4d87e6e9d9e890b88fa0480360420e610c4e41459570e52ae72f349eadf84fc0a68153722de3280becf8a1762e7faebe964f0ad706991c521feda3440d3e1b22f2c221a80490359879bd47c0d059ace81213c74a1e192dbebd8a80cf58c9eb1fe461a971b88d3899baf4c4ef7141623c93fb4a54758f5e1cf9ee35cd00777fa89b24e4ded57219e770de2670619c6e971935c61ae72e3276cf8db49dfa0e91c68222f02d7e0c69b399af505de7e5a90852d83e0a30934b0362db986f3aaefaaf1a96fef3e8165287a3a7f0ee1e072d9dee3aefb86194e1d877d6b34529d45a70ec4573c35a7fe27833c77c3154b0ad02187e4fcecd408bcf4b29a85a5dc358cb479140f4983fcd936141f581764669651530af97d2b7d9416aea7de67e787f3e29ae3eba6672bcd934dc1e308783aa63a4ab46d48d213cf53ad6bd8828011f5bfa3aa5ee24551c694e829b54c93b1dda6c3ddda04756d68a28bec8d044c8af4147680dc5b972d0ca74299b0ab6306b9e7b99bf0557558df120455a272145b7aa792654730f3d670b76d72408f5ce1cf5fbd453d2903fa72cf26397437854ba8abbb731a8107f6a86a01fa98edc81bb42a4c1330f779e7a0fbd1820eaed78e03e40a996e03884b707556be06fd14ee8f4035469210d1d2bb8f58285fc2ab6de3d3cc0e4e1f40c6d9d24b50dc8e2e2374a0aff52031b3736c2982133bb19dd551ce1f953f4ba02b0cf53382c15752e202c138cb42b2322df103ff17fd886dfd5f992b711673cdf16048c4bff19038138b161c2e1783b85fc7b965a91ac4795fcbfebf827940cacdeae57946863aee027df43b36612f3cb8f34dc44396e87c564bf10f5b1a9dfbd6da3d7f4f65024b0b4f8ce51d01c230840941fc4523b17eb1c2522032f410e8328239a11a15ab755c32945ce52966d5bfb4666909ed2ca04d536e4bf92091563dd44d46cbb35e53c2481400058ab3b52a0280d262551073f61db125ee280e2cc1ec0bdf9c4817824261465011e34c2296411384f7f5e16742157c5520f137631edf498aa39c7c32b107e3634cbeb70feea19a233c8bd939d665135c9f7c1bb33cb47edc58bdbbcde9b0b9eb73a46642e4639289a62638fb7813e1eeaadd105c803de8357236f33c4bcf31a876b5867591af8f165eba0b35cf0b0886af17dab35a6a39f8f576387d6ffb9e677ee46fc0f11ff069a2a068fce441ff8f4125095fad228c2bf45c788d641941ed13c0a16fffcafd7c7eff11bb7550c0b7d54eebdbd2066e3bbdb47aaee2b5f1e499726324a40015458c7de1db0abe872594d8e6802deff7ea9518bdb3a3e46f07139267fd67dc570ba8ab04c2b37ce6a34ec73b802c7052a2eef0cae1b0979322ef86395535db80cf2a9a88aa7c2e5cc28a93612a8dafe1982f741d7cec28a866f6c09dba5b99ead24c3df0ca03c6c5afae41f3d39608a8f49b0d6a0b541a159409791c25ede103eb4f79cfbd0cc9c9aa6b591755c1e9fd07b5b9e38ed85b5939e65d127256f6a4c078f8c9d655c4f072f9cbcfb2e1e17eaa83dc62aaab2a6dc3735ee76ce7a215740f795f1fbe7136c7734ae3714438015e8fc383d63775a8abddb23cbc5f906c046bb0b5b31d492a7c151b40ea82c7c966e25820641c55b343b89d6378f90de5983fa76547e9d6c634effdf019a0fd9b6d3e488a5aa94f0710d517ba4f7c1ed82f9f3072612e953e036c0ec7f3c618368362f6da6f3af76056a66aef914805cc8b628f1c11695f760b535ded9ff66727273ae7e12d67a01243d75f22fec8ed1b043122a211c923aa92ecbbe01dd0d7195c3c0e09a2a6ab3eca354963122d5a0ec16e2b2b81b0ddce6ec0a312c492a96a4fd392f1deb6a1f3318541a3f87e5c9e73ee7edd3b855910f412789e25038108e1eaae04dcfb02b4d958c00c630dc8caa87a40798ce7156d2ade882e68832d39fe8f9bce6a995249a7383013a5093c4af55c3b7232de0f2593d82c30b8dabd0784455037f25f6bb66a6d0d8f72bc7be0dee2d0a8af44bb4e143257d873268d331722c3253ea5c004e72daf04c875e2054f2b4b2bca2979fd046a1e835600045edf2f159d851a540a91a1ab8fbcb64594d21942bbaa2160535d32496ba7ce4a76c6bdeb9bb4c5cab7bed1ae26564058d0be125803d7019b83b3953c4b0cc1f8299c4edcf6a5faa4765092412d368b277689900e71fb5d47581057adaa2dd494e0f66dc1aa16f3741973b0d9ffa1728aeafab84b777394a7afae0f8eabaa6b740f1c60ca26469f0c9356ec880ad6f4dc01b99bd14d7a4bb8afc97662a9e68b0155e4cdf3caa3402819ac6ce562c8fe06edb50a31cfd7a', ad: '', - }, - symmetricKey: { - alg: 'A256GCM', - ext: true, - k: 'w62OJNWF4G8iWA8ZrTpModiY8dICyHI7ko1vMLb877g=', - key_ops: ['encrypt', 'decrypt'], - kty: 'oct', - }, - email: 'insomnia-user@konghq.com', - accountId: 'acct_64a477e6b59d43a5a607f84b4f73e3ce', - firstName: 'Rick', - lastName: 'Morty', + }), + enc_symmetric_key: JSON.stringify({ + iv: '3a1f2bdb8acbf15f469d57a2', + t: '904d6b1bc0ece8e5df6fefb9efefda7c', + d: '2a7b0c4beb773fa3e3c2158f0bfa654a88c4041184c3b1e01b4ddd2da2c647244a0d66d258b6abb6a9385251bf5d79e6b03ef35bdfafcb400547f8f88adb8bceb7020f2d873d5a74fb5fc561e7bd67cea0a37c49107bf5c96631374dc44ddb1e4a8b5688dc6560fc6143294ed92c3ad8e1696395dfdf15975aa67b9212366dbfcb31191e4f4fe3559c89a92fb1f0f1cc6cbf90d8a062307fce6e7701f6f5169d9247c56dae79b55fba1e10fde562b971ca708c9a4d87e6e9d9e890b88fa0480360420e610c4e41459570e52ae72f349eadf84fc0a68153722de3280becf8a1762e7faebe964f0ad706991c521feda3440d3e1b22f2c221a80490359879bd47c0d059ace81213c74a1e192dbebd8a80cf58c9eb1fe461a971b88d3899baf4c4ef7141623c93fb4a54758f5e1cf9ee35cd00777fa89b24e4ded57219e770de2670619c6e971935c61ae72e3276cf8db49dfa0e91c68222f02d7e0c69b399af505de7e5a90852d83e0a30934b0362db986f3aaefaaf1a96fef3e8165287a3a7f0ee1e072d9dee3aefb86194e1d877d6b34529d45a70ec4573c35a7fe27833c77c3154b0ad02187e4fcecd408bcf4b29a85a5dc358cb479140f4983fcd936141f581764669651530af97d2b7d9416aea7de67e787f3e29ae3eba6672bcd934dc1e308783aa63a4ab46d48d213cf53ad6bd8828011f5bfa3aa5ee24551c694e829b54c93b1dda6c3ddda04756d68a28bec8d044c8af4147680dc5b972d0ca74299b0ab6306b9e7b99bf0557558df120455a272145b7aa792654730f3d670b76d72408f5ce1cf5fbd453d2903fa72cf26397437854ba8abbb731a8107f6a86a01fa98edc81bb42a4c1330f779e7a0fbd1820eaed78e03e40a996e03884b707556be06fd14ee8f4035469210d1d2bb8f58285fc2ab6de3d3cc0e4e1f40c6d9d24b50dc8e2e2374a0aff52031b3736c2982133bb19dd551ce1f953f4ba02b0cf53382c15752e202c138cb42b2322df103ff17fd886dfd5f992b711673cdf16048c4bff19038138b161c2e1783b85fc7b965a91ac4795fcbfebf827940cacdeae57946863aee027df43b36612f3cb8f34dc44396e87c564bf10f5b1a9dfbd6da3d7f4f65024b0b4f8ce51d01c230840941fc4523b17eb1c2522032f410e8328239a11a15ab755c32945ce52966d5bfb4666909ed2ca04d536e4bf92091563dd44d46cbb35e53c2481400058ab3b52a0280d262551073f61db125ee280e2cc1ec0bdf9c4817824261465011e34c2296411384f7f5e16742157c5520f137631edf498aa39c7c32b107e3634cbeb70feea19a233c8bd939d665135c9f7c1bb33cb47edc58bdbbcde9b0b9eb73a46642e4639289a62638fb7813e1eeaadd105c803de8357236f33c4bcf31a876b5867591af8f165eba0b35cf0b0886af17dab35a6a39f8f576387d6ffb9e677ee46fc0f11ff069a2a068fce441ff8f4125095fad228c2bf45c788d641941ed13c0a16fffcafd7c7eff11bb7550c0b7d54eebdbd2066e3bbdb47aaee2b5f1e499726324a40015458c7de1db0abe872594d8e6802deff7ea9518bdb3a3e46f07139267fd67dc570ba8ab04c2b37ce6a34ec73b802c7052a2eef0cae1b0979322ef86395535db80cf2a9a88aa7c2e5cc28a93612a8dafe1982f741d7cec28a866f6c09dba5b99ead24c3df0ca03c6c5afae41f3d39608a8f49b0d6a0b541a159409791c25ede103eb4f79cfbd0cc9c9aa6b591755c1e9fd07b5b9e38ed85b5939e65d127256f6a4c078f8c9d655c4f072f9cbcfb2e1e17eaa83dc62aaab2a6dc3735ee76ce7a215740f795f1fbe7136c7734ae3714438015e8fc383d63775a8abddb23cbc5f906c046bb0b5b31d492a7c151b40ea82c7c966e25820641c55b343b89d6378f90de5983fa76547e9d6c634effdf019a0fd9b6d3e488a5aa94f0710d517ba4f7c1ed82f9f3072612e953e036c0ec7f3c618368362f6da6f3af76056a66aef914805cc8b628f1c11695f760b535ded9ff66727273ae7e12d67a01243d75f22fec8ed1b043122a211c923aa92ecbbe01dd0d7195c3c0e09a2a6ab3eca354963122d5a0ec16e2b2b81b0ddce6ec0a312c492a96a4fd392f1deb6a1f3318541a3f87e5c9e73ee7edd3b855910f412789e25038108e1eaae04dcfb02b4d958c00c630dc8caa87a40798ce7156d2ade882e68832d39fe8f9bce6a995249a7383013a5093c4af55c3b7232de0f2593d82c30b8dabd0784455037f25f6bb66a6d0d8f72bc7be0dee2d0a8af44bb4e143257d873268d331722c3253ea5c004e72daf04c875e2054f2b4b2bca2979fd046a1e835600045edf2f159d851a540a91a1ab8fbcb64594d21942bbaa2160535d32496ba7ce4a76c6bdeb9bb4c5cab7bed1ae26564058d0be125803d7019b83b3953c4b0cc1f8299c4edcf6a5faa4765092412d368b277689900e71fb5d47581057adaa2dd494e0f66dc1aa16f3741973b0d9ffa1728aeafab84b777394a7afae0f8eabaa6b740f1c60ca26469f0c9356ec880ad6f4dc01b99bd14d7a4bb8afc97662a9e68b0155e4cdf3caa3402819ac6ce562c8fe06edb50a31cfd7a', + ad: '', + }), + salt_enc: '', + enc_driver_key: null, }; const userVerifyA = { @@ -419,13 +412,12 @@ collaboratorsList.total = collaboratorsList.collaborators.length + emailsAndGrou export default function setup(app: Application) { // User - app.get('/v1/user/profile', (_req, res) => { - console.log('GET *'); - res.status(200).send(user); + app.get('/v3/users/me', (_req, res) => { + res.status(200).send(v3User); }); - app.get('/auth/whoami', (_req, res) => { - res.status(200).send(whoami); + app.get('/v3/users/me/encryption-keys', (_req, res) => { + res.status(200).send(v3EncryptionKeys); }); // Vault related diff --git a/packages/insomnia-smoke-test/tests/smoke/app.test.ts b/packages/insomnia-smoke-test/tests/smoke/app.test.ts index 108e5242e2..9d0d444788 100644 --- a/packages/insomnia-smoke-test/tests/smoke/app.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/app.test.ts @@ -120,5 +120,5 @@ test('can send requests', async ({ page, insomnia }) => { await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); await page.getByRole('button', { name: 'Cancel Request' }).click(); - await page.click('text=Request was cancelled'); + await page.getByText('Request was cancelled').click(); }); diff --git a/packages/insomnia-smoke-test/tests/smoke/cookie-editor-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/cookie-editor-interactions.test.ts index 594cb55224..6178646829 100644 --- a/packages/insomnia-smoke-test/tests/smoke/cookie-editor-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/cookie-editor-interactions.test.ts @@ -15,11 +15,11 @@ test.describe('Cookie editor', () => { test('create and send a cookie', async ({ page }) => { // Open cookie editor - await page.click('button:has-text("Cookies")'); + await page.getByRole('button', { name: 'Cookies' }).click(); // Edit existing cookie await page.getByTestId('cookie-test-iteration-0').getByRole('button', { name: 'Edit' }).click(); - await page.click('pre[role="presentation"]:has-text("bar")'); + await page.locator('pre[role="presentation"]').filter({ hasText: 'bar' }).click(); await page.locator('[data-testid="CookieValue"] >> textarea').nth(1).fill('123'); await page.locator('text=Done').nth(1).click(); await page.getByTestId('cookie-test-iteration-0').click(); @@ -37,11 +37,11 @@ test.describe('Cookie editor', () => { await page.locator('text=Done').nth(1).click(); await page.getByTestId('cookie-test-iteration-0').click(); - await page.click('text=Done'); + await page.getByText('Done').click(); // Send http request await page.getByLabel('Request Collection').getByTestId('example http').press('Enter'); - await page.click('[data-testid="request-pane"] button:has-text("Send")'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); // Check in the timeline that the cookie was sent @@ -50,8 +50,8 @@ test.describe('Cookie editor', () => { // Send ws request await page.getByLabel('Request Collection').getByTestId('example websocket').press('Enter'); - await page.click('text=ws://localhost:4010'); - await page.click('[data-testid="request-pane"] >> text=Connect'); + await page.getByText('ws://localhost:4010').click(); + await page.getByTestId('request-pane').getByText('Connect').click(); // Check in the timeline that the cookie was sent await page.getByRole('tab', { name: 'Console' }).click(); @@ -60,7 +60,7 @@ test.describe('Cookie editor', () => { test('support __Host- prefix', async ({ page }) => { // Open cookie editor - await page.click('button:has-text("Cookies")'); + await page.getByRole('button', { name: 'Cookies' }).click(); // Create a new cookie await page.getByRole('button', { name: 'Add Cookie' }).click(); @@ -74,11 +74,11 @@ test.describe('Cookie editor', () => { .locator('text=Raw Cookie String >> input[type="text"]') .fill('__Host-foo=bar; Expires=Tue, 19 Jan 2038 03:14:07 GMT; Secure; Domain=localhost; Path=/'); await page.locator('text=Done').nth(1).click(); - await page.click('text=Done'); + await page.getByText('Done').click(); // Send request await page.getByLabel('Request Collection').getByTestId('example http').press('Enter'); - await page.click('[data-testid="request-pane"] button:has-text("Send")'); + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); // Check in the timeline that the cookie was sent await page.getByRole('tab', { name: 'Console' }).click(); @@ -87,7 +87,7 @@ test.describe('Cookie editor', () => { test('cookie list should update when cookie is updated', async ({ page }) => { // Open cookie editor - await page.click('button:has-text("Cookies")'); + await page.getByRole('button', { name: 'Cookies' }).click(); // Set domain to empty await page.getByTestId('cookie-test-iteration-0').getByRole('button', { name: 'Edit' }).click(); diff --git a/packages/insomnia-smoke-test/tests/smoke/disable-git-sync.test.ts b/packages/insomnia-smoke-test/tests/smoke/disable-git-sync.test.ts new file mode 100644 index 0000000000..4f476cbe31 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/disable-git-sync.test.ts @@ -0,0 +1,101 @@ +import { expect } from '@playwright/test'; + +import { test } from '../../playwright/test'; + +const mockCredentials = { + email: 'insomnia-test@konghq.com', + gitUsername: 'insomnia-test', + username: 'insomnia', + token: '12345', + baseUrl: 'https://fakeurl.com/', +}; + +test.describe('Git Sync', () => { + test.describe('with git sync feature flag disabled', () => { + test.beforeEach(async ({ request }) => { + // Disable git sync feature flag for organization + await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/features', { + data: { + features: { + gitSync: { + enabled: false, + }, + konnectSync: { + enabled: true, + }, + }, + }, + }); + }); + + test.afterEach(async ({ request }) => { + // Re-enable git sync feature flag for organization + await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/features', { + data: { + features: { + gitSync: { + enabled: true, + }, + konnectSync: { + enabled: true, + }, + }, + }, + }); + }); + + test('should disable git sync usage', async ({ page }) => { + await page.getByTestId('settings-button').click(); + await page.getByRole('tab', { name: 'Credentials' }).click(); + await page.getByRole('button', { name: 'Create Git Credential' }).click(); + await page.getByText('Access Token').click(); + await page.getByRole('textbox', { name: 'Author Email' }).fill(mockCredentials.email); + await page.getByRole('textbox', { name: 'Author Name' }).fill(mockCredentials.gitUsername); + await page.getByRole('textbox', { name: 'Username', exact: true }).fill(mockCredentials.username); + await page.getByRole('textbox', { name: 'Git Access Token' }).fill(mockCredentials.token); + await page.getByRole('textbox', { name: 'Repository base URL' }).fill(mockCredentials.baseUrl); + await page.getByRole('button', { name: 'Save Credential' }).click(); + await page.getByRole('button', { name: 'Modal Close Button' }).click(); + await page.getByRole('button', { name: 'Create new Project' }).click(); + await page.getByLabel('Project Type Item: git').click(); + await expect.soft(page.getByLabel('Git Sync Feature Disabled Banner')).toBeVisible(); + + await expect.soft(page.getByLabel('Git Setup Form')).toBeHidden(); + await expect.soft(page.getByRole('button', { name: 'Scan for files' })).toBeDisabled(); + }); + }); + + test.describe('with git storage rule disabled', () => { + test.beforeEach(async ({ request }) => { + // Set storage rule to disable git sync + await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/storage-rule', { + data: { + enableCloudSync: true, + enableGitSync: false, + enableLocalVault: true, + isOverridden: false, + }, + }); + }); + + test.afterEach(async ({ request }) => { + // reset the storage rule after test + await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/storage-rule', { + data: { + enableCloudSync: true, + enableGitSync: true, + enableLocalVault: true, + isOverridden: false, + }, + }); + }); + + test('disable git sync selection', async ({ page }) => { + await page.getByRole('button', { name: 'Create new Project' }).click(); + const banner = page.getByLabel('Project Storage Restriction Banner'); + await expect.soft(banner).toBeVisible(); + await expect.soft(banner).not.toHaveText('Git Sync'); + await expect.soft(page.getByLabel('Project Type: git')).toBeDisabled(); + }); + }); +}); diff --git a/packages/insomnia-smoke-test/tests/smoke/export.test.ts b/packages/insomnia-smoke-test/tests/smoke/export.test.ts index fb0cd9f4d9..563801382d 100644 --- a/packages/insomnia-smoke-test/tests/smoke/export.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/export.test.ts @@ -33,9 +33,8 @@ test.describe('Export', () => { await insomnia.preferencesPage.switchToPreferenceTab('Data'); await insomnia.preferencesPage.dataTab.exportProjectData(tempDir, 'yaml'); await waitForExportFiles(tempDir, 2); - await insomnia.preferencesPage.closePreferences(); const exportedFiles = getExportedFiles(tempDir); - expect.soft(exportedFiles.length).toBe(2); + expect.soft(exportedFiles).toHaveLength(2); const fixtureMap: Record = { 'Collection-A': FIXTURE_FILES[0], 'Collection-B': FIXTURE_FILES[1], @@ -78,7 +77,7 @@ test.describe('Export', () => { await insomnia.preferencesPage.dataTab.exportAllData(tempDir); await insomnia.preferencesPage.closePreferences(); const exportedFiles = getExportedFiles(tempDir).filter((file: string) => !file.includes('scratchpad')); - expect.soft(exportedFiles.length).toBe(2); + expect.soft(exportedFiles).toHaveLength(2); const fixtureMap: Record = { 'Collection-A': FIXTURE_FILES[0], 'Collection-B': FIXTURE_FILES[1], @@ -123,8 +122,6 @@ test.describe('Export', () => { await insomnia.preferencesPage.dataTab.exportProjectData(exportFilePath, 'har'); await waitForExportFiles(tempDir, 1); - await insomnia.preferencesPage.closePreferences(); - const exportedContent = readExportedFile(exportFilePath); const har = JSON.parse(exportedContent); diff --git a/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts index 4f476cbe31..f6d1621214 100644 --- a/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts @@ -1,101 +1,171 @@ import { expect } from '@playwright/test'; +import type { InsomniaApp } from '../../playwright/pages'; import { test } from '../../playwright/test'; -const mockCredentials = { - email: 'insomnia-test@konghq.com', - gitUsername: 'insomnia-test', - username: 'insomnia', - token: '12345', - baseUrl: 'https://fakeurl.com/', -}; - test.describe('Git Sync', () => { - test.describe('with git sync feature flag disabled', () => { - test.beforeEach(async ({ request }) => { - // Disable git sync feature flag for organization - await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/features', { - data: { - features: { - gitSync: { - enabled: false, - }, - konnectSync: { - enabled: true, - }, - }, - }, - }); - }); + test.slow(); - test.afterEach(async ({ request }) => { - // Re-enable git sync feature flag for organization - await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/features', { - data: { - features: { - gitSync: { - enabled: true, - }, - konnectSync: { - enabled: true, - }, - }, - }, - }); - }); - - test('should disable git sync usage', async ({ page }) => { - await page.getByTestId('settings-button').click(); - await page.getByRole('tab', { name: 'Credentials' }).click(); - await page.getByRole('button', { name: 'Create Git Credential' }).click(); - await page.getByText('Access Token').click(); - await page.getByRole('textbox', { name: 'Author Email' }).fill(mockCredentials.email); - await page.getByRole('textbox', { name: 'Author Name' }).fill(mockCredentials.gitUsername); - await page.getByRole('textbox', { name: 'Username', exact: true }).fill(mockCredentials.username); - await page.getByRole('textbox', { name: 'Git Access Token' }).fill(mockCredentials.token); - await page.getByRole('textbox', { name: 'Repository base URL' }).fill(mockCredentials.baseUrl); - await page.getByRole('button', { name: 'Save Credential' }).click(); - await page.getByRole('button', { name: 'Modal Close Button' }).click(); - await page.getByRole('button', { name: 'Create new Project' }).click(); - await page.getByLabel('Project Type Item: git').click(); - await expect.soft(page.getByLabel('Git Sync Feature Disabled Banner')).toBeVisible(); - - await expect.soft(page.getByLabel('Git Setup Form')).toBeHidden(); - await expect.soft(page.getByRole('button', { name: 'Scan for files' })).toBeDisabled(); - }); + test.beforeEach(async ({ insomnia, request }) => { + await request.post('http://127.0.0.1:4010/v1/test-utils/git/setup'); + await addAccessTokenGitCredential(insomnia); + await insomnia.projectPage.createGitSyncProject(); }); - test.describe('with git storage rule disabled', () => { - test.beforeEach(async ({ request }) => { - // Set storage rule to disable git sync - await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/storage-rule', { - data: { - enableCloudSync: true, - enableGitSync: false, - enableLocalVault: true, - isOverridden: false, - }, - }); - }); + test.afterEach(async ({ request }) => { + await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup'); + }); - test.afterEach(async ({ request }) => { - // reset the storage rule after test - await request.post('http://127.0.0.1:4010/v1/test-utils/organizations/storage-rule', { - data: { - enableCloudSync: true, - enableGitSync: true, - enableLocalVault: true, - isOverridden: false, - }, - }); - }); + // Creates a git sync project, opens the Branches modal, creates "branch1", + // and verifies the active branch switches to branch1. + test('Create new branch and switch to it', async ({ page }) => { + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Branches' }).click(); + await page.getByRole('textbox', { name: 'New branch name:' }).click(); + await page.getByRole('textbox', { name: 'New branch name:' }).fill('branch1'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect.soft(page.getByText('branch1 *')).toBeVisible(); + }); - test('disable git sync selection', async ({ page }) => { - await page.getByRole('button', { name: 'Create new Project' }).click(); - const banner = page.getByLabel('Project Storage Restriction Banner'); - await expect.soft(banner).toBeVisible(); - await expect.soft(banner).not.toHaveText('Git Sync'); - await expect.soft(page.getByLabel('Project Type: git')).toBeDisabled(); - }); + // Creates a collection to produce an unstaged change, stages it, commits with message "1", + // then opens History and verifies the commit appears in the log. + test('Commit and check history', async ({ page }) => { + await page.getByRole('button', { name: 'New request collection' }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Collection 1'); + await page.getByRole('textbox', { name: 'File name my_collection' }).click(); + await page.getByRole('textbox', { name: 'File name my_collection' }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'File name my_collection' }).fill('collection_1'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByTestId('git-dropdown').click(); + await expect.soft(page.getByRole('menuitemradio', { name: 'Commit' })).toBeVisible(); + await page.getByRole('menuitemradio', { name: 'Commit' }).click(); + await expect.soft(page.getByLabel('Unstaged changes').locator('span')).toContainText('collection_1.yaml'); + + await page.locator('button[name="Stage all changes"]').click(); + await page.getByRole('textbox', { name: 'Message' }).click(); + await page.getByRole('textbox', { name: 'Message' }).fill('1'); + await page.getByRole('button', { name: 'Commit', exact: true }).click(); + await page.getByTestId('git-dropdown').click(); + await page.getByText('History').click(); + await expect.soft(page.getByLabel('1', { exact: true }).getByRole('rowheader')).toContainText('1'); + }); + + // Creates branch1, commits a new collection on it, switches back to master, + // merges branch1 into master, and verifies the collection is visible on master. + test('Merge branch and verify changes on the other branch has been merged into current branch', async ({ page }) => { + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Branches' }).click(); + await page.getByRole('textbox', { name: 'New branch name:' }).click(); + await page.getByRole('textbox', { name: 'New branch name:' }).fill('branch1'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect.soft(page.getByText('branch1 *')).toBeVisible(); + await page.getByTestId('close-git-project-branches-modal').click(); + await page.getByTestId('git-project-branches-modal-overlay').waitFor({ state: 'hidden' }); + await page.getByRole('button', { name: 'New request collection' }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('collection 1'); + await page.getByRole('textbox', { name: 'File name my_collection' }).click(); + await page.getByRole('textbox', { name: 'File name my_collection' }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'File name my_collection' }).fill('collection_1'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByTestId('project').click(); + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Commit' }).click(); + await page.locator('button[name="Stage all changes"]').click(); + await page.getByRole('textbox', { name: 'Message' }).click(); + await page.getByRole('textbox', { name: 'Message' }).fill('commit 1'); + await page.getByRole('button', { name: 'Commit', exact: true }).click(); + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'master' }).click(); + await page.locator('html').click(); + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Branches' }).click(); + await page.getByLabel('branch1').getByRole('button', { name: 'Merge' }).click(); + await page.getByRole('button', { name: ' Confirm' }).click(); + await page.getByTestId('close-git-project-branches-modal').click(); + await page.getByTestId('git-project-branches-modal-overlay').waitFor({ state: 'hidden' }); + await expect.soft(page.getByText('collection 1')).toBeVisible(); + }); + + // Creates a collection, commits it, then pushes to the remote git server. + // Verifies the "Push completed" toast appears, confirming a successful push. + test('Push committed changes to remote', async ({ page }) => { + await page.getByRole('button', { name: 'New request collection' }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Push Test Collection'); + await page.getByRole('textbox', { name: 'File name my_collection' }).click(); + await page.getByRole('textbox', { name: 'File name my_collection' }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'File name my_collection' }).fill('push_test_collection'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Commit' }).click(); + await page.locator('button[name="Stage all changes"]').click(); + await page.getByRole('textbox', { name: 'Message' }).fill('push test commit'); + await page.getByRole('button', { name: 'Commit', exact: true }).click(); + + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Push' }).click(); + + await expect.soft(page.getByText('Push completed')).toBeVisible(); + }); + + // Creates "branch-to-delete", checks out master, then deletes the branch via the + // two-step PromptButton (Delete → Confirm). Verifies the branch is removed from the list. + test('Delete a branch', async ({ page }) => { + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Branches' }).click(); + await page.getByRole('textbox', { name: 'New branch name:' }).click(); + await page.getByRole('textbox', { name: 'New branch name:' }).fill('branch-to-delete'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect.soft(page.getByText('branch-to-delete *')).toBeVisible(); + + await page.getByRole('row', { name: 'master' }).getByRole('button', { name: 'Checkout' }).click(); + await expect.soft(page.getByText('master *')).toBeVisible(); + + await page.getByRole('row', { name: 'branch-to-delete' }).getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('row', { name: 'branch-to-delete' }).getByRole('button', { name: 'Confirm' }).click(); + + await expect.soft(page.getByRole('row', { name: 'branch-to-delete' })).toBeHidden(); + + await page.getByTestId('close-git-project-branches-modal').click(); + await page.getByTestId('git-project-branches-modal-overlay').waitFor({ state: 'hidden' }); + await expect.soft(page.getByTestId('git-dropdown')).toContainText('master'); + }); + + // Creates a collection to produce an unstaged change, opens the staging modal, + // clicks "Discard all changes" and confirms. Verifies the modal auto-closes, + // indicating all changes were discarded. + test('Discard all unstaged changes', async ({ page }) => { + await page.getByRole('button', { name: 'New request collection' }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).click(); + await page.getByRole('textbox', { name: 'Name', exact: true }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Discard Test Collection'); + await page.getByRole('textbox', { name: 'File name my_collection' }).click(); + await page.getByRole('textbox', { name: 'File name my_collection' }).press('ControlOrMeta+a'); + await page.getByRole('textbox', { name: 'File name my_collection' }).fill('discard_test_collection'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + await page.getByTestId('git-dropdown').click(); + await page.getByRole('menuitemradio', { name: 'Commit' }).click(); + await expect.soft(page.getByLabel('Unstaged changes').locator('span')).toContainText('discard_test_collection.yaml'); + + await page.locator('button[name="Discard all changes"]').click(); + await page.getByTestId('discard-changes-confirm-button').click(); + + // After discarding all changes the staging modal auto-closes + await expect.soft(page.getByLabel('Unstaged changes')).toBeHidden(); }); }); + +async function addAccessTokenGitCredential(insomnia: InsomniaApp) { + await insomnia.statusbar.openPreferences(); + await insomnia.preferencesPage.switchToPreferenceTab('Credentials'); + await insomnia.preferencesPage.credentialsTab.addAccessTokenGitCredential(); + await expect.soft(insomnia.page.getByRole('row', { name: 'Custom Git Credential' })).toBeVisible(); + await insomnia.preferencesPage.closePreferences(); +} diff --git a/packages/insomnia-smoke-test/tests/smoke/ipv6.test.ts b/packages/insomnia-smoke-test/tests/smoke/ipv6.test.ts new file mode 100644 index 0000000000..e91d22a633 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/ipv6.test.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; + +import { test } from '../../playwright/test'; + +test('can send ipv6 requests', async ({ page, insomnia }) => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + + const statusTag = page.locator('[data-testid="response-status-tag"]:visible'); + const responseBody = page.getByTestId('response-pane'); + + await insomnia.projectPage.importFixture('ipv6-collection.yaml'); + + await page.getByLabel('Request Collection') + .getByTestId('send JSON request').press('Enter'); + await expect.soft(page.getByTestId('request-pane') + .getByTestId('OneLineEditor') + .getByText('http://[::1]:4010/pets/1') + ).toBeVisible(); + + await page.getByTestId('request-pane') + .getByRole('button', { name: 'Send' }).click(); + await expect.soft(statusTag).toContainText('200 OK'); + await expect.soft(responseBody).toContainText('"id": "1"'); + + await page + .getByLabel('Request Collection') + .getByTestId('sends dummy.csv request and shows rich response') + .press('Enter'); + await expect.soft(page.getByTestId('request-pane') + .getByTestId('OneLineEditor') + .getByText('http://[::1]:4010/file/dummy.csv') + ).toBeVisible(); + + await page.getByTestId('request-pane') + .getByRole('button', { name: 'Send' }).click(); + await expect.soft(statusTag).toContainText('200 OK'); + await page.getByRole('button', { name: 'Preview' }).click(); + await page.getByRole('menuitem', { name: 'Raw Data' }).click(); + await expect.soft(responseBody).toContainText('a,b,c'); +}); diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts index 2f7a958f69..4881f96758 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts @@ -208,6 +208,7 @@ test.describe('pre-request features tests', () => { }), }; }); + test('run test cases', async ({ page }) => { for (const tc of testCases) { console.log(`Running test case: ${tc.name}`); @@ -230,6 +231,7 @@ test.describe('pre-request features tests', () => { tc.customVerify(bodyJson); } }); + test('send request with content type', async ({ page }) => { await page.getByTestId('settings-button').click(); await page.getByTestId('dataFolders').click(); @@ -660,14 +662,15 @@ test.describe('unhappy paths', () => { await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); // verify - await expect.soft(page.getByTestId('response-pane')).toContainText('my custom error'); + await expect.soft(page.getByTestId('response-pane')).toContainText(`my custom error`); await page.getByRole('tab', { name: 'Scripts' }).click(); await page.getByTestId('CodeEditor').getByRole('textbox').press('ControlOrMeta+a'); await page.keyboard.press('Backspace'); await editor.fill(`insomnia.INVALID_FIELD.set('', '')`); - await page.getByRole('tab', { name: 'Body' }).click(); + // CodeMirror debounces onChange by DEBOUNCE_MILLIS (100ms). + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); // send await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); @@ -678,3 +681,165 @@ test.describe('unhappy paths', () => { .toContainText(`Cannot read properties of undefined (reading 'set')`); }); }); + +test.describe('sandbox features', () => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + + test.beforeEach(async ({ app, page }) => { + const text = await loadFixture('pre-request-collection.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); + + await page.getByLabel('Import').click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + }); + + // Blocked Roots / Scopes group: 'this' is blocked. + test('blocked roots / scopes group', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + + // enter script that accesses a property on 'this'. + await editor.fill(`insomnia.environment.set('result', String(this?.process));`); + + // CodeMirror debounces onChange by DEBOUNCE_MILLIS (100ms). + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + + // verify blocked-root error + await expect + .soft(page.getByTestId('response-pane')) + .toContainText("The script was blocked because it used 'this'."); + + // navigate to Settings → Scripting, disable the "Scopes" blocked roots group + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const scopesSwitch = page.locator('div:has(> h4:has-text("Scopes")) label[data-react-aria-pressable]'); + await scopesSwitch.scrollIntoViewIfNeeded(); + await scopesSwitch.click(); + + await page.locator('.app').press('Escape'); + + // re-send — no sandbox error; this === undefined. + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("The script was blocked because it used 'this'."); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // Blocked Properties / Prototype Mutation group: 'prototype' is blocked. + test('blocked properties / prototype mutation group', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + // enter script that accesses Object.prototype. + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`insomnia.environment.set('result', typeof Object.prototype.toString);`); + + // CodeMirror debounces onChange by DEBOUNCE_MILLIS (100ms). + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + + // verify blocked-property error + await expect + .soft(page.getByTestId('response-pane')) + .toContainText("The script was blocked because it used the property 'prototype'."); + + // navigate to Settings → Scripting, disable the "Prototype Mutation" blocked properties group + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const protoMutationSwitch = page.locator( + 'div:has(> h4:has-text("Prototype Mutation")) label[data-react-aria-pressable]', + ); + await protoMutationSwitch.scrollIntoViewIfNeeded(); + await protoMutationSwitch.click(); + await expect.soft(protoMutationSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + // re-send — prototype access now allowed; Object.prototype.toString is a function + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("The script was blocked because it used the property 'prototype'."); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + // Mask Rules / Runtime APIs group: 'Function' is masked to undefined at runtime. + test('Mask Rules / Runtime APIs group.', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + // enter script that uses the Function constructor, only masked at runtime. + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`const f = new Function('return 42'); insomnia.environment.set('result', f());`); + + // CodeMirror debounces onChange by DEBOUNCE_MILLIS (100ms). + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + // send — Function masked to undefined → V8 uses the identifier name: "Function is not a constructor" + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + + await expect.soft(page.getByTestId('response-pane')).toContainText('Function is not a constructor'); + + // navigate to Settings → Scripting, disable the "Runtime APIs" mask group + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const runtimeApisSwitch = page.locator('div:has(> h4:has-text("Runtime APIs")) label[data-react-aria-pressable]'); + await runtimeApisSwitch.scrollIntoViewIfNeeded(); + await runtimeApisSwitch.click(); + await expect.soft(runtimeApisSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + // re-send — Function is now the real constructor; script returns 42 + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect.soft(page.getByTestId('response-pane')).not.toContainText('Function is not a constructor'); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); + + test('Layered security / unblocked properties resolve undefined', async ({ page }) => { + await page.getByLabel('Request Collection').getByTestId('echo pre-request script result').press('Enter'); + + // enter script that accesses a property on 'process'. + await page.getByRole('tab', { name: 'Scripts' }).click(); + const editor = page.getByTestId('CodeEditor').getByRole('textbox'); + await editor.fill(`insomnia.environment.set('result', String(process?.version));`); + + // CodeMirror debounces onChange by DEBOUNCE_MILLIS (100ms). + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150))); + + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + + // verify blocked-root error + await expect + .soft(page.getByTestId('response-pane')) + .toContainText("The script was blocked because it used 'process'."); + + // navigate to Settings → Scripting, disable only the "Node.js Internals" BLOCKED ROOTS group. + await page.getByTestId('settings-button').click(); + await page.locator('text=Insomnia Preferences').first().click(); + await page.getByRole('tab', { name: 'Scripting' }).click(); + const nodeInternalsSwitch = page.locator( + 'xpath=//h4[normalize-space(text())="Node.js Internals"]/following-sibling::div[1]//label[@data-react-aria-pressable]', + ); + await nodeInternalsSwitch.scrollIntoViewIfNeeded(); + await nodeInternalsSwitch.click(); + await expect.soft(nodeInternalsSwitch).not.toHaveAttribute('data-selected'); + await page.locator('.app').press('Escape'); + + // process?.version === undefined. + await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); + await expect + .soft(page.getByTestId('response-pane')) + .not.toContainText("The script was blocked because it used 'process'."); + await expect.soft(page.locator('[data-testid="response-status-tag"]:visible')).toContainText('200 OK'); + }); +}); diff --git a/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md b/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md index de135d380c..dacf28415e 100644 --- a/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md +++ b/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md @@ -2,6 +2,8 @@ This document breaks the renderer `nodeIntegration: false` migration into deliverable slices that can move in parallel without creating excessive merge conflict risk. +Update: PR 1 through PR 3 are merged. The remaining work is re-scoped around single feature flows instead of broad subsystem buckets so each PR can land with tighter review, clearer ownership, and faster iteration. + The plan assumes the current guardrails are already in place: - renderer import analyzer in `vite.config.ts` @@ -15,6 +17,9 @@ The plan assumes the current guardrails are already in place: 2. If a PR removes offenders, update `config/renderer-node-import-baseline.json` in the same PR. 3. Prefer moving privileged behavior behind existing preload or `window.main` APIs before inventing new bridge surface. 4. Do not combine route cleanup with subsystem redesign unless the route is blocked on the subsystem boundary. +5. Remaining candidates should stay scoped to one user-visible feature flow or one tightly bounded privileged service. +6. Each PR should carry an explicit test automation plan before implementation starts. +7. If a feature depends on sync or storage behavior, land the sync/storage boundary first instead of mixing the dependency into the same PR. ## Reviewer Lanes @@ -26,23 +31,32 @@ The plan assumes the current guardrails are already in place: ## Parallelization Summary -Can start immediately in parallel: +Already merged: - PR 1: Route path-only cleanup - PR 2: Route fs-backed cleanup - PR 3: Shared browser-safe helper cleanup -Can start in parallel with the above if staffed separately: +Next PR candidates: -- PR 4: Renderer-to-main boundary extraction +- Candidate: Sync storage boundary foundation +- Candidate: Import parsing and persistence boundary +- Candidate: gRPC proto asset boundary **(quick win candidate)** +- Candidate: Plugin discovery boundary +- Candidate: Templating bootstrap boundary +- Candidate: OAuth token crypto cleanup **(quick win candidate)** +- Candidate: Response archival and compression boundary +- Candidate: Script executor boundary -Should usually wait until PR 4 is clear enough to avoid duplicated refactors: +Dependencies: -- PR 5: Network and gRPC boundary pass -- PR 6: Sync and storage boundary pass -- PR 7: Plugin and templating boundary pass +- Import depends on sync storage because import creates or updates persisted workspace state. +- Plugin discovery and templating should stay split so one does not become a catch-all runtime PR. +- gRPC proto asset work can likely move ahead once the sync-storage bridge pattern is clear. +- OAuth cleanup looks like the smallest remaining isolated boundary and is a good candidate for a fast iteration. +- Response archival and `script-executor.ts` should stay separate from the feature-scoped candidates unless a later PR proves they are truly part of the same flow. -## PR Board +## Candidate Backlog ## PR 0: Guardrails and Baseline @@ -69,6 +83,8 @@ Suggested reviewers: ## PR 1: Route Path-Only Cleanup +Status: merged + Purpose: - Remove route-local `node:path` usage where existing `window.path` is already sufficient. @@ -108,10 +124,12 @@ Concurrent with: - PR 2 - PR 3 -- PR 4 +- later dependency-clearing candidate ## PR 2: Route FS-Backed Cleanup +Status: merged + Purpose: - Remove route-level `node:fs` and remaining `node:path` usage that touches downloads or file reads. @@ -153,6 +171,8 @@ Concurrent with: ## PR 3: Shared Browser-Safe Helper Cleanup +Status: merged + Purpose: - Remove Node builtin usage from helper modules that should be safe to load in the renderer. @@ -199,35 +219,121 @@ Concurrent with: - PR 1 - PR 2 -- PR 4 +- later dependency-clearing candidate -## PR 4: Renderer-to-Main Boundary Extraction +## Candidate: Sync Storage Boundary Foundation + +Status: candidate Purpose: -- Stop renderer code from pulling `src/main` implementations into the client graph when only pure helpers or types are needed. +- Move local project sync storage so renderer code stops owning filesystem, compression, and VCS-path details. + +Single feature scope: + +- Local project persistence for sync-backed workspaces. Primary files: +- `src/sync/store/drivers/file-system-driver.ts` +- `src/sync/store/drivers/graceful-rename.ts` +- `src/sync/store/hooks/compress.ts` +- `src/sync/store/index.ts` +- `src/sync/vcs/util.ts` +- `src/sync/vcs/vcs.ts` +- `src/sync/vcs/create-vcs.ts` + +Implementation notes: + +- Introduce a narrow storage-oriented bridge for read, write, rename, compression, and VCS-adjacent path work. +- Avoid mixing import, plugin, or network behavior into this PR. +- Move the vcs class entirely to main, simplify it down so its clear what internal state it has, expose its functions over IPC. +- create an event listener in the renderer to handle events from the conflictHandler function passed into vcs. + +Expected risk: high + +Quick win: no + +Suggested reviewers: + +- Sync/storage +- Electron/runtime + +Baseline entries to remove: + +- `src/sync/store/drivers/file-system-driver.ts -> fs/promises` +- `src/sync/store/drivers/file-system-driver.ts -> path` +- `src/sync/store/drivers/graceful-rename.ts -> fs/promises` +- `src/sync/store/hooks/compress.ts -> zlib` +- `src/sync/store/index.ts -> path` +- `src/sync/vcs/util.ts -> crypto` +- `src/sync/vcs/vcs.ts -> crypto` +- `src/sync/vcs/vcs.ts -> path` + +Dependencies: + +- none + +Test automation plan: + +- Extend `src/sync/store/hooks/__tests__/compress.test.ts` and related sync store tests to cover the new privileged boundary behavior. +- Add focused unit coverage for any new main-process sync bridge or IPC handlers. +- Add a renderer-side contract test that proves sync store calls delegate through the bridge instead of importing Node-backed code directly. +- Keep smoke coverage limited to one sync-backed roundtrip so this PR stays quick to iterate. + +Enables: + +- Import parsing and persistence boundary +- gRPC proto asset boundary +- Plugin discovery boundary +- Templating bootstrap boundary + +Out of scope: + +- import parsing or import persistence +- gRPC proto temp-file handling +- plugin discovery or templating runtime changes + +## Candidate: Import Parsing and Persistence Boundary + +Status: candidate + +Purpose: + +- Make import a self-contained feature flow that relies on the sync boundary instead of importing `src/main` helpers or privileged file access into renderer-reachable modules. + +Single feature scope: + +- Scan, parse, and persist imported resources. + +Primary files: + +- `src/common/import.ts` +- `src/routes/import.scan.tsx` +- `src/routes/import.resources.tsx` +- `src/ui/components/modals/import-modal/import-modal.tsx` +- `src/main/importers/convert.ts` - `src/main/importers/importers/curl.ts` - `src/main/importers/importers/openapi-3.ts` - `src/main/importers/importers/swagger-2.ts` - `src/main/network/parse-header-strings.ts` - `src/main/secure-read-file.ts` -Likely implementation: +Implementation notes: -- Move pure helper logic into shared modules outside `src/main`. -- Leave privileged code in `src/main`. -- Update renderer imports to target shared modules or types only. +- Keep importer execution and privileged file reads behind explicit main-process entrypoints. +- Extract pure importer helpers into shared modules only where they are genuinely renderer-safe. +- Make the import flow consume the sync/storage candidate instead of mixing storage work into this candidate. -Expected risk: medium +Expected risk: high + +Quick win: no Suggested reviewers: -- Electron/runtime - Router/UI -- Network/gRPC +- Sync/storage +- Electron/runtime Baseline entries to remove: @@ -242,40 +348,52 @@ Baseline entries to remove: Dependencies: -- none +- Sync storage boundary foundation -Concurrent with: +Test automation plan: -- PR 1 -- PR 3 +- Extend `src/common/__tests__/import.test.ts` to cover scan and persist paths after the boundary cleanup. +- Add route-level coverage for `src/routes/import.scan.tsx` and `src/routes/import.resources.tsx` where the bridge contract changes. +- Keep one UI-level import smoke path for a representative source such as curl or file import. +- Update the renderer-node-import baseline in the same PR once the import offenders are removed. -Blocks or informs: +Enables: -- PR 5 +- import follow-up polish, if needed -## PR 5: Network and gRPC Privileged Boundary Pass +Out of scope: + +- generic OAuth cleanup +- gRPC proto temp files +- plugin loading and templating + +## Candidate: gRPC Proto Asset Boundary + +Status: candidate Purpose: -- Move filesystem and privileged request execution concerns behind explicit APIs instead of direct renderer imports. +- Isolate the gRPC proto file preparation flow behind a privileged boundary without expanding the PR into broader network or response persistence cleanup. + +Single feature scope: + +- Preparing proto directories and temp proto files for gRPC request execution. Primary files: -- `src/network/network.ts` - `src/network/grpc/proto-directory-loader.tsx` - `src/network/grpc/write-proto-file.ts` -- `src/network/o-auth-1/get-token.ts` -- `src/network/o-auth-2/get-token.ts` -- `src/network/o-auth-2/utils.ts` -- `src/network/url-matches-cert-host.ts` -- `src/models/helpers/response-operations.ts` +- any minimal preload or IPC additions needed to support the proto write path -Likely implementation: +Implementation notes: -- Separate pure parsing and formatting code from file/crypto operations. -- Push file reads, writes, temp file creation, and request execution details behind `window.main` or a dedicated network bridge. +- Move proto temp-file creation and path work to main. +- Keep request assembly in the renderer, but remove direct `fs`, `os`, and `path` ownership from the gRPC preparation path. +- Do not mix in generic request execution, OAuth, or response archival work. -Expected risk: high +Expected risk: medium + +Quick win: yes Suggested reviewers: @@ -289,79 +407,34 @@ Baseline entries to remove: - `src/network/grpc/write-proto-file.ts -> fs` - `src/network/grpc/write-proto-file.ts -> os` - `src/network/grpc/write-proto-file.ts -> path` -- `src/network/network.ts -> fs` -- `src/network/network.ts -> path` -- `src/network/o-auth-1/get-token.ts -> crypto` -- `src/network/o-auth-2/get-token.ts -> crypto` -- `src/network/o-auth-2/get-token.ts -> querystring` -- `src/network/o-auth-2/utils.ts -> crypto` -- `src/network/url-matches-cert-host.ts -> url` -- `src/models/helpers/response-operations.ts -> fs` -- `src/models/helpers/response-operations.ts -> zlib` Dependencies: -- recommended after PR 4 +- Sync storage boundary foundation patterns should be available first -Concurrent with: +Test automation plan: -- PR 6 -- PR 7 +- Extend or relocate unit coverage for `write-proto-file` so the privileged file-write path stays directly tested. +- Add a contract test proving the renderer gRPC flow uses the new bridge instead of direct Node imports. +- Keep one gRPC-focused smoke or integration path that confirms proto-backed requests still resolve correctly. -## PR 6: Sync and Storage Boundary Pass +Out of scope: + +- OAuth token helpers +- generic network file IO +- response body archival helpers + +## Candidate: Plugin Discovery Boundary + +Status: candidate Purpose: -- Isolate local project storage, sync, compression, and file rename flows behind explicit privileged services. +- Move plugin discovery and plugin file access onto a privileged boundary without coupling the work to templating bootstrap or unrelated runtime refactors. -Primary files: +Single feature scope: -- `src/sync/store/drivers/file-system-driver.ts` -- `src/sync/store/drivers/graceful-rename.ts` -- `src/sync/store/hooks/compress.ts` -- `src/sync/store/index.ts` -- `src/sync/vcs/util.ts` -- `src/sync/vcs/vcs.ts` -- `src/script-executor.ts` - -Likely implementation: - -- Move filesystem operations and compression into a storage backend boundary. -- Keep renderer-facing sync orchestration on the safe side of that boundary. - -Expected risk: high - -Suggested reviewers: - -- Sync/storage -- Electron/runtime - -Baseline entries to remove: - -- `src/script-executor.ts -> fs/promises` -- `src/sync/store/drivers/file-system-driver.ts -> fs/promises` -- `src/sync/store/drivers/file-system-driver.ts -> path` -- `src/sync/store/drivers/graceful-rename.ts -> fs/promises` -- `src/sync/store/hooks/compress.ts -> zlib` -- `src/sync/store/index.ts -> path` -- `src/sync/vcs/util.ts -> crypto` -- `src/sync/vcs/vcs.ts -> crypto` -- `src/sync/vcs/vcs.ts -> path` - -Dependencies: - -- none, though shared boundary patterns from PR 4 help - -Concurrent with: - -- PR 5 -- PR 7 - -## PR 7: Plugin and Templating Boundary Pass - -Purpose: - -- Redesign plugin and templating runtime boundaries so privileged module loading and filesystem traversal do not live in renderer-reachable code. +- Plugin discovery, metadata loading, and plugin file access. Primary files: @@ -369,16 +442,18 @@ Primary files: - `src/plugins/create.ts` - `src/plugins/index.ts` - `src/utils/plugin.ts` -- `src/templating/base-extension.ts` +- any preload or IPC additions needed for plugin discovery and metadata handoff -Likely implementation: +Implementation notes: -- Decide what plugin discovery and loading must remain privileged. -- Extract pure metadata and UI-facing types from runtime loading logic. -- Push filesystem-backed plugin operations behind explicit APIs. +- Keep plugin metadata and UI-facing types renderer-safe. +- Move plugin discovery and plugin file traversal to main. +- Avoid adding templating bootstrap, plugin install, package management, or unrelated runtime redesign to this candidate. Expected risk: high +Quick win: no + Suggested reviewers: - Plugins/templating @@ -393,19 +468,237 @@ Baseline entries to remove: - `src/plugins/index.ts -> path` - `src/utils/plugin.ts -> fs` - `src/utils/plugin.ts -> path` + +Dependencies: + +- Sync storage boundary foundation should land first +- Prefer after import and gRPC candidate patterns are established so plugin work can reuse the same bridge shape + +Test automation plan: + +- Extend plugin discovery or plugin creation unit tests around the new privileged entrypoints. +- Add a renderer contract test for plugin metadata loading so the UI path remains fast and explicit. +- Keep one plugin-focused smoke path to prove discovery still works end to end. + +Out of scope: + +- templating bootstrap +- package installation and lifecycle management +- generic network cleanup +- import persistence changes + +## Candidate: Templating Bootstrap Boundary + +Status: candidate + +Purpose: + +- Move templating bootstrap helpers off the renderer path without coupling the work to plugin discovery or broader templating redesign. + +Single feature scope: + +- Templating startup and privileged helper access. + +Primary files: + +- `src/templating/base-extension.ts` +- any preload or IPC additions needed for templating bootstrap helpers + +Implementation notes: + +- Keep template evaluation behavior unchanged while moving privileged helper access behind explicit main-process entrypoints. +- Avoid folding plugin discovery or plugin package management into this candidate. +- Prefer to reuse any plugin-side metadata bridge rather than inventing parallel surfaces if the contracts line up cleanly. + +Expected risk: medium + +Quick win: maybe + +Suggested reviewers: + +- Plugins/templating +- Electron/runtime + +Baseline entries to remove: + - `src/templating/base-extension.ts -> crypto` - `src/templating/base-extension.ts -> os` Dependencies: -- none, but coordinate if PR 5 touches shared templating execution +- Prefer after plugin discovery boundary if templating still relies on shared plugin bootstrap behavior -Concurrent with: +Test automation plan: -- PR 5 -- PR 6 +- Extend templating-focused unit tests around the new privileged entrypoints. +- Add a renderer contract test for templating bootstrap helpers. +- Keep one templating smoke path to prove the startup flow still works end to end. -## PR 8: Baseline Ratchet Follow-Ups +Out of scope: + +- plugin discovery +- package installation and lifecycle management +- generic network cleanup + +## Candidate: OAuth Token Crypto Cleanup + +Status: candidate + +Purpose: + +- Remove isolated OAuth crypto usage from renderer-reachable code without pulling in unrelated network or storage work. + +Single feature scope: + +- OAuth token helper crypto operations. + +Primary files: + +- `src/network/o-auth-1/get-token.ts` +- `src/network/o-auth-2/get-token.ts` +- `src/network/o-auth-2/utils.ts` + +Implementation notes: + +- Treat this as a small privileged-helper cleanup, not a broad request-execution refactor. +- Prefer a narrow bridge for hashing, PKCE, and token helper crypto rather than a generic network bridge. +- Keep request sending and response handling out of scope. + +Expected risk: low to medium + +Quick win: yes + +Suggested reviewers: + +- Network/gRPC +- Electron/runtime + +Baseline entries to remove: + +- `src/network/o-auth-1/get-token.ts -> crypto` +- `src/network/o-auth-2/get-token.ts -> crypto` +- `src/network/o-auth-2/utils.ts -> crypto` + +Dependencies: + +- none + +Test automation plan: + +- Extend OAuth unit tests around PKCE and token helper behavior. +- Add a renderer contract test for any new crypto bridge surface. +- Keep this candidate free of smoke-test expansion unless an existing auth smoke path already covers the flow. + +Out of scope: + +- generic request execution +- response archival +- import or sync persistence + +## Candidate: Response Archival and Compression Boundary + +Status: candidate + +Purpose: + +- Isolate response body archival and compression so renderer-safe helpers stop owning file and compression concerns. + +Single feature scope: + +- Response archival, file writes, and compression helpers. + +Primary files: + +- `src/models/helpers/response-operations.ts` + +Implementation notes: + +- Split pure response metadata shaping from privileged file-write and compression behavior. +- Avoid bundling this candidate with sync compression just because both touch compression. +- Reuse existing preload APIs if they are already close to the needed shape. + +Expected risk: medium + +Quick win: maybe + +Suggested reviewers: + +- Network/gRPC +- Electron/runtime + +Baseline entries to remove: + +- `src/models/helpers/response-operations.ts -> fs` +- `src/models/helpers/response-operations.ts -> zlib` + +Dependencies: + +- none, though sync-storage patterns may provide a good template + +Test automation plan: + +- Extend unit tests for response archival helpers around the boundary split. +- Add a renderer contract test for any file-write bridge used by response exports. +- Keep one focused integration path for exporting or persisting a response body. + +Out of scope: + +- sync store compression +- generic request execution +- plugin or templating work + +## Candidate: Script Executor Boundary + +Status: candidate + +Purpose: + +- Move `script-executor.ts` file access behind a privileged boundary without broadening the work into general scripting or plugin changes. + +Single feature scope: + +- Script execution file access. + +Primary files: + +- `src/script-executor.ts` + +Implementation notes: + +- Keep the candidate narrowly focused on the file-access boundary. +- Do not combine this with plugin loading or broader execution-runtime redesign. +- Reuse existing bridge patterns from sync or response file operations where possible. + +Expected risk: medium + +Quick win: maybe + +Suggested reviewers: + +- Electron/runtime +- Sync/storage + +Baseline entries to remove: + +- `src/script-executor.ts -> fs/promises` + +Dependencies: + +- none + +Test automation plan: + +- Extend targeted script-executor tests around the file-access boundary. +- Add a renderer contract test if a new bridge surface is introduced. +- Avoid growing this into a smoke-heavy candidate unless an existing script-execution smoke path already exists. + +Out of scope: + +- plugin runtime redesign +- sync store persistence +- response archival + +## Candidate: Baseline Ratchet Follow-Ups Purpose: @@ -416,7 +709,7 @@ Primary files: - `config/renderer-node-import-baseline.json` - `.reports/renderer-node-imports.json` -Likely implementation: +Implementation notes: - Re-run `npm run update:renderer-node-import-baseline` after each offender-removing PR. - Confirm the baseline only drops entries already removed from the analyzer output. @@ -435,29 +728,35 @@ Concurrent with: - usually folded into the offender-removing PR rather than done separately -## Suggested Merge Order +## Suggested Candidate Sequencing -1. PR 1 -2. PR 2 -3. PR 3 -4. PR 4 -5. PR 5 -6. PR 6 -7. PR 7 +1. Sync storage boundary foundation +2. Import parsing and persistence boundary +3. gRPC proto asset boundary +4. Plugin discovery boundary +5. Templating bootstrap boundary +6. OAuth token crypto cleanup +7. Response archival and compression boundary +8. Script executor boundary Notes: - PR 1, PR 2, and PR 3 should produce the fastest visible reduction in route and helper debt. -- PR 4 is the best candidate for early architectural work because it reduces follow-on churn in PR 5 through PR 7. -- PR 5 through PR 7 are intentionally split by subsystem so they can be assigned to different owners. +- Sync storage remains the dependency-clearing candidate because import should not move until sync storage is on the privileged side of the boundary. +- gRPC proto asset work and OAuth token crypto cleanup look like the clearest quick-win candidates. +- Plugin discovery and templating are intentionally separate candidates. If they naturally converge later, that should be a conscious decision rather than the default plan. +- If a candidate starts collecting unrelated offenders, split it again instead of broadening it. ## Ownership Template For each PR, capture the following in the PR description: - Purpose +- Single feature scope +- Implementation notes - Files in scope - Baseline entries expected to be removed +- Test automation plan - Any new preload or IPC surface added - Any deliberate deferrals to later PRs @@ -469,3 +768,4 @@ This migration is complete when: 2. The baseline file is empty or reduced to intentionally permitted entries. 3. Lint restrictions can be tightened by removing temporary offender exclusions. 4. The main BrowserWindow runs with `nodeIntegration: false` without renderer regressions. +5. Security audit of changes is complete, including the writeResponseBodyToFile preload function. diff --git a/packages/insomnia/config/renderer-node-import-baseline.json b/packages/insomnia/config/renderer-node-import-baseline.json index e8b2d55007..25ee202bf1 100644 --- a/packages/insomnia/config/renderer-node-import-baseline.json +++ b/packages/insomnia/config/renderer-node-import-baseline.json @@ -16,74 +16,6 @@ "importer": "../insomnia-testing/src/run/run.ts", "builtin": "path" }, - { - "importer": "src/common/misc.ts", - "builtin": "path" - }, - { - "importer": "src/common/misc.ts", - "builtin": "zlib" - }, - { - "importer": "src/common/significant-diff-detection.ts", - "builtin": "path" - }, - { - "importer": "src/main/importers/importers/curl.ts", - "builtin": "url" - }, - { - "importer": "src/main/importers/importers/openapi-3.ts", - "builtin": "crypto" - }, - { - "importer": "src/main/importers/importers/openapi-3.ts", - "builtin": "url" - }, - { - "importer": "src/main/importers/importers/swagger-2.ts", - "builtin": "crypto" - }, - { - "importer": "src/main/network/libcurl-promise.ts", - "builtin": "fs" - }, - { - "importer": "src/main/network/libcurl-promise.ts", - "builtin": "path" - }, - { - "importer": "src/main/network/libcurl-promise.ts", - "builtin": "url" - }, - { - "importer": "src/main/network/multipart.ts", - "builtin": "fs" - }, - { - "importer": "src/main/network/multipart.ts", - "builtin": "os" - }, - { - "importer": "src/main/network/multipart.ts", - "builtin": "path" - }, - { - "importer": "src/main/network/parse-header-strings.ts", - "builtin": "url" - }, - { - "importer": "src/main/secure-read-file.ts", - "builtin": "fs" - }, - { - "importer": "src/main/secure-read-file.ts", - "builtin": "os" - }, - { - "importer": "src/main/secure-read-file.ts", - "builtin": "path" - }, { "importer": "src/models/helpers/response-operations.ts", "builtin": "fs" @@ -92,26 +24,6 @@ "importer": "src/models/helpers/response-operations.ts", "builtin": "zlib" }, - { - "importer": "src/network/grpc/proto-directory-loader.tsx", - "builtin": "fs" - }, - { - "importer": "src/network/grpc/proto-directory-loader.tsx", - "builtin": "path" - }, - { - "importer": "src/network/grpc/write-proto-file.ts", - "builtin": "fs" - }, - { - "importer": "src/network/grpc/write-proto-file.ts", - "builtin": "os" - }, - { - "importer": "src/network/grpc/write-proto-file.ts", - "builtin": "path" - }, { "importer": "src/network/network.ts", "builtin": "fs" @@ -120,22 +32,6 @@ "importer": "src/network/network.ts", "builtin": "path" }, - { - "importer": "src/network/o-auth-1/get-token.ts", - "builtin": "crypto" - }, - { - "importer": "src/network/o-auth-2/get-token.ts", - "builtin": "crypto" - }, - { - "importer": "src/network/o-auth-2/get-token.ts", - "builtin": "querystring" - }, - { - "importer": "src/network/o-auth-2/utils.ts", - "builtin": "crypto" - }, { "importer": "src/network/url-matches-cert-host.ts", "builtin": "url" @@ -144,6 +40,10 @@ "importer": "src/plugins/context/response.ts", "builtin": "fs" }, + { + "importer": "src/plugins/context/response.ts", + "builtin": "zlib" + }, { "importer": "src/plugins/create.ts", "builtin": "fs" @@ -160,57 +60,21 @@ "importer": "src/plugins/index.ts", "builtin": "path" }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx", - "builtin": "fs" - }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx", - "builtin": "path" - }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx", - "builtin": "fs" - }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx", - "builtin": "path" - }, { "importer": "src/script-executor.ts", "builtin": "fs/promises" }, { - "importer": "src/sync/store/drivers/file-system-driver.ts", - "builtin": "fs/promises" + "importer": "src/scripting/require-interceptor.ts", + "builtin": "buffer" }, { - "importer": "src/sync/store/drivers/file-system-driver.ts", - "builtin": "path" + "importer": "src/scripting/require-interceptor.ts", + "builtin": "timers" }, { - "importer": "src/sync/store/drivers/graceful-rename.ts", - "builtin": "fs/promises" - }, - { - "importer": "src/sync/store/hooks/compress.ts", - "builtin": "zlib" - }, - { - "importer": "src/sync/store/index.ts", - "builtin": "path" - }, - { - "importer": "src/sync/vcs/util.ts", - "builtin": "crypto" - }, - { - "importer": "src/sync/vcs/vcs.ts", - "builtin": "crypto" - }, - { - "importer": "src/sync/vcs/vcs.ts", - "builtin": "path" + "importer": "src/scripting/require-interceptor.ts", + "builtin": "util" }, { "importer": "src/templating/base-extension.ts", @@ -227,10 +91,6 @@ { "importer": "src/utils/plugin.ts", "builtin": "path" - }, - { - "importer": "src/utils/url/querystring.ts", - "builtin": "url" } ] } diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index adc9945aad..8b4f30ee99 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -43,7 +43,6 @@ "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", "@apidevtools/swagger-parser": "10.1.1", - "ajv": "^8.17.1", "@bufbuild/protobuf": "^1.10.0", "@connectrpc/connect": "^1.6.1", "@connectrpc/connect-node": "^1.6.1", @@ -54,7 +53,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^3.0.2", - "@getinsomnia/node-libcurl": "3.2.1", + "@getinsomnia/node-libcurl": "3.2.2", "@getinsomnia/srp-js": "1.0.0-alpha.1", "@grpc/grpc-js": "^1.13.3", "@grpc/proto-loader": "^0.7.13", @@ -67,13 +66,16 @@ "@seald-io/nedb": "^4.1.1", "@segment/analytics-node": "2.2.1", "@sentry/electron": "^6.5.0", - "@stoplight/spectral-core": "^1.20.0", + "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", - "@stoplight/spectral-ruleset-bundler": "1.6.3", - "@stoplight/spectral-rulesets": "^1.22.0", + "@stoplight/spectral-ruleset-bundler": "1.7.0", + "@stoplight/spectral-rulesets": "^1.22.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-virtual": "3.13.12", "@xmldom/xmldom": "^0.9.8", + "acorn": "^8.16.0", + "acorn-walk": "^8.3.5", + "ajv": "^8.17.1", "apiconnect-wsdl": "2.0.36", "aws4": "^1.13.2", "blakejs": "^1.2.1", @@ -92,10 +94,11 @@ "decompress": "^4.2.1", "deep-equal": "2.2.3", "diff-match-patch-ts": "^0.6.0", - "dompurify": "^3.2.5", + "dompurify": "^3.4.1", "electron-context-menu": "^3.6.1", "electron-updater": "^6.6.2", "fastq": "^1.19.1", + "fflate": "^0.8.2", "fuzzysort": "^1.9.0", "graphql": "^16.10.0", "graphql-ws": "^5.16.2", @@ -144,7 +147,7 @@ "tinykeys": "^3.0.0", "tough-cookie": "^4.1.4", "tweetnacl": "^1.0.3", - "undici": "^7.16.0", + "undici": "^7.25.0", "uuid": "^9.0.1", "vkbeautify": "^0.99.3", "ws": "^8.18.1", @@ -207,7 +210,7 @@ "vite": "^7.1.3" }, "optionalDependencies": { - "@kong/insomnia-plugin-ai": "^1.0.9", + "@kong/insomnia-plugin-ai": "^1.0.11", "@kong/insomnia-plugin-external-vault": "0.1.4-dev.20251224090833" }, "dev": { diff --git a/packages/insomnia/setup-vitest.ts b/packages/insomnia/setup-vitest.ts index 2f6a0c7c35..a10467b41e 100644 --- a/packages/insomnia/setup-vitest.ts +++ b/packages/insomnia/setup-vitest.ts @@ -5,8 +5,8 @@ import { servicesNodeImpl } from '~/insomnia-data/node'; import { nodeLibcurlMock } from './src/__mocks__/@getinsomnia/node-libcurl'; import { electronMock } from './src/__mocks__/electron'; +import { v4Mock } from './src/__mocks__/uuid'; import { mainDatabase } from './src/main/database.main'; -import { v4Mock } from './src/models/__mocks__/uuid'; await initDatabase(mainDatabase, { inMemoryOnly: true }, true); await initServices(servicesNodeImpl); diff --git a/packages/insomnia/src/models/__mocks__/uuid.ts b/packages/insomnia/src/__mocks__/uuid.ts similarity index 100% rename from packages/insomnia/src/models/__mocks__/uuid.ts rename to packages/insomnia/src/__mocks__/uuid.ts diff --git a/packages/insomnia/src/__tests__/install-plugin.test.ts b/packages/insomnia/src/__tests__/install-plugin.test.ts index 75fc8e2e48..91e8a62391 100644 --- a/packages/insomnia/src/__tests__/install-plugin.test.ts +++ b/packages/insomnia/src/__tests__/install-plugin.test.ts @@ -48,12 +48,6 @@ vi.mock('electron', () => ({ }, })); -vi.mock('../models', () => ({ - settings: { - get: vi.fn(() => Promise.resolve({})), - }, -})); - // Mock the entire install-plugin module vi.mock('../main/install-plugin', async () => { const actual = await vi.importActual('../main/install-plugin'); diff --git a/packages/insomnia/src/account/__tests__/session.test.ts b/packages/insomnia/src/account/__tests__/session.test.ts new file mode 100644 index 0000000000..7e5ca8c4a0 --- /dev/null +++ b/packages/insomnia/src/account/__tests__/session.test.ts @@ -0,0 +1,240 @@ +import * as insomniaApi from 'insomnia-api'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as crypt from '../crypt'; +import { + absorbKey, + getCurrentSessionId, + getPrivateKey, + getUserSession, + isLoggedIn, + logout, + setSessionData, +} from '../session'; + +vi.mock('insomnia-api', () => ({ + getUserProfile: vi.fn(), + getEncryptionKeys: vi.fn(), + logout: vi.fn(), +})); + +vi.mock('../crypt', () => ({ + decryptAES: vi.fn(), +})); + +interface MockWindowMain { + loginStateChange: ReturnType; +} + +const getWindowMain = () => (window as unknown as { main: MockWindowMain }).main; + +// Fixtures +const SESSION_ID = 'test-session-id'; +const RAW_KEY = 'raw-key-material'; + +const MOCK_PUBLIC_KEY = { kty: 'RSA', n: 'abc', e: 'AQAB' }; +const MOCK_ENC_PRIVATE_KEY = { iv: 'iv1', t: 't1', d: 'd1', ad: 'ad1' }; +const MOCK_ENC_SYMMETRIC_KEY = { iv: 'iv2', t: 't2', d: 'd2', ad: 'ad2' }; +const MOCK_SYMMETRIC_KEY = { kty: 'oct', k: 'sym-key' }; + +const mockEncryptionKeys = { + public_key: JSON.stringify(MOCK_PUBLIC_KEY), + enc_private_key: JSON.stringify(MOCK_ENC_PRIVATE_KEY), + enc_symmetric_key: JSON.stringify(MOCK_ENC_SYMMETRIC_KEY), + salt_enc: 'salt', + enc_driver_key: '', +}; + +const mockUserProfile = { + id: 'account-123', + created_at: new Date('2026-01-01T00:00:00Z'), + email: 'test@example.com', + first_name: 'Jane', + last_name: 'Doe', + picture: '', + emails: [], + encryption_enabled: false, + is_externally_provisioned: false, +}; + +beforeEach(() => { + vi.mocked(insomniaApi.getUserProfile).mockResolvedValue(mockUserProfile); + vi.mocked(insomniaApi.getEncryptionKeys).mockResolvedValue(mockEncryptionKeys); + vi.mocked(crypt.decryptAES).mockReturnValue(JSON.stringify(MOCK_SYMMETRIC_KEY)); + + vi.stubGlobal('window', { main: { loginStateChange: vi.fn() } }); +}); + +describe('absorbKey', () => { + it('fetches profile and encryption keys with the provided sessionId', async () => { + await absorbKey(SESSION_ID, RAW_KEY); + + expect(insomniaApi.getUserProfile).toHaveBeenCalledWith({ sessionId: SESSION_ID }); + expect(insomniaApi.getEncryptionKeys).toHaveBeenCalledWith({ sessionId: SESSION_ID }); + }); + + it('decrypts the symmetric key using the provided raw key and encSymmetricKey', async () => { + await absorbKey(SESSION_ID, RAW_KEY); + + expect(crypt.decryptAES).toHaveBeenCalledWith(RAW_KEY, MOCK_ENC_SYMMETRIC_KEY); + }); + + it('stores session data with mapped fields from profile and encryption keys', async () => { + await absorbKey(SESSION_ID, RAW_KEY); + + const session = await getUserSession(); + expect(session.id).toBe(SESSION_ID); + expect(session.accountId).toBe(mockUserProfile.id); + expect(session.email).toBe(mockUserProfile.email); + expect(session.firstName).toBe(mockUserProfile.first_name); + expect(session.lastName).toBe(mockUserProfile.last_name); + expect(session.symmetricKey).toEqual(MOCK_SYMMETRIC_KEY); + expect(session.publicKey).toEqual(MOCK_PUBLIC_KEY); + expect(session.encPrivateKey).toEqual(MOCK_ENC_PRIVATE_KEY); + }); + + it('triggers loginStateChange after storing session', async () => { + await absorbKey(SESSION_ID, RAW_KEY); + + expect(getWindowMain().loginStateChange).toHaveBeenCalledOnce(); + }); + + it('falls back to current session id when none is provided', async () => { + // First establish a session so getCurrentSessionId returns something + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + {} as JsonWebKey, + {} as JsonWebKey, + {} as crypt.AESMessage, + ); + + await absorbKey('', RAW_KEY); + + expect(insomniaApi.getUserProfile).toHaveBeenCalledWith({ sessionId: SESSION_ID }); + expect(insomniaApi.getEncryptionKeys).toHaveBeenCalledWith({ sessionId: SESSION_ID }); + }); +}); + +describe('getPrivateKey', () => { + it('decrypts and returns the private key from session, and throws when keys are missing', async () => { + const mockPrivateKey = { kty: 'RSA', d: 'private' }; + vi.mocked(crypt.decryptAES).mockReturnValue(JSON.stringify(mockPrivateKey)); + + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + MOCK_SYMMETRIC_KEY as JsonWebKey, + MOCK_PUBLIC_KEY as JsonWebKey, + MOCK_ENC_PRIVATE_KEY as crypt.AESMessage, + ); + + const privateKey = await getPrivateKey(); + + expect(crypt.decryptAES).toHaveBeenCalledWith(MOCK_SYMMETRIC_KEY, MOCK_ENC_PRIVATE_KEY); + expect(privateKey).toEqual(mockPrivateKey); + + await setSessionData( + '', + '', + '', + '', + '', + null as unknown as JsonWebKey, + {} as JsonWebKey, + null as unknown as crypt.AESMessage, + ); + + await expect(getPrivateKey()).rejects.toThrow("Can't get private key: session is missing keys."); + }); +}); + +describe('isLoggedIn', () => { + it('returns true when a session id exists', async () => { + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + {} as JsonWebKey, + {} as JsonWebKey, + {} as crypt.AESMessage, + ); + expect(await isLoggedIn()).toBe(true); + }); +}); + +describe('logout', () => { + it('calls the logout API with the current session id', async () => { + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + {} as JsonWebKey, + {} as JsonWebKey, + {} as crypt.AESMessage, + ); + + await logout(); + + expect(insomniaApi.logout).toHaveBeenCalledWith({ sessionId: SESSION_ID }); + }); + + it('triggers loginStateChange', async () => { + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + {} as JsonWebKey, + {} as JsonWebKey, + {} as crypt.AESMessage, + ); + + await logout(); + + expect(getWindowMain().loginStateChange).toHaveBeenCalledOnce(); + }); + + it('does not throw if the API call fails', async () => { + vi.mocked(insomniaApi.logout).mockRejectedValue(new Error('network error')); + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + {} as JsonWebKey, + {} as JsonWebKey, + {} as crypt.AESMessage, + ); + + await expect(logout()).resolves.not.toThrow(); + }); +}); + +describe('getCurrentSessionId', () => { + it('returns the current session id', async () => { + await setSessionData( + SESSION_ID, + 'acct', + 'A', + 'B', + 'a@b.com', + {} as JsonWebKey, + {} as JsonWebKey, + {} as crypt.AESMessage, + ); + expect(await getCurrentSessionId()).toBe(SESSION_ID); + }); +}); diff --git a/packages/insomnia/src/account/crypt.ts b/packages/insomnia/src/account/crypt.ts index e7386628a8..40734e84d8 100644 --- a/packages/insomnia/src/account/crypt.ts +++ b/packages/insomnia/src/account/crypt.ts @@ -232,7 +232,7 @@ export async function generateAES256Key() { function _hexToB64Url(h: string) { const bytes = forge.util.hexToBytes(h); - return window.btoa(bytes).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + return btoa(bytes).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } function _b64UrlToBigInt(s: string) { @@ -243,5 +243,5 @@ function _b64UrlToBigInt(s: string) { function _b64UrlToHex(s: string) { const b64 = s.replace(/-/g, '+').replace(/_/g, '/'); - return forge.util.bytesToHex(window.atob(b64)); + return forge.util.bytesToHex(atob(b64)); } diff --git a/packages/insomnia/src/account/session.ts b/packages/insomnia/src/account/session.ts index 6c00d9e3a5..3b9a7f9c58 100644 --- a/packages/insomnia/src/account/session.ts +++ b/packages/insomnia/src/account/session.ts @@ -1,4 +1,4 @@ -import { logout as logoutAPI, whoami } from 'insomnia-api'; +import { getEncryptionKeys, getUserProfile, logout as logoutAPI } from 'insomnia-api'; import type { GitRepository, Project, WorkspaceMeta } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; @@ -21,9 +21,17 @@ export interface SessionData { /** Creates a session from a sessionId and derived symmetric key. */ export async function absorbKey(sessionId: string, key: string) { // Get and store some extra info (salts and keys) - const { publicKey, encPrivateKey, encSymmetricKey, email, accountId, firstName, lastName } = await whoami({ - sessionId: sessionId || (await getCurrentSessionId()), - }); + const sessionIdResolved = sessionId || (await getCurrentSessionId()); + const [profile, keys] = await Promise.all([ + getUserProfile({ sessionId: sessionIdResolved }), + getEncryptionKeys({ sessionId: sessionIdResolved }), + ]); + const { + public_key: publicKey, + enc_private_key: encPrivateKey, + enc_symmetric_key: encSymmetricKey, + } = keys; + const { email, id: accountId, first_name: firstName, last_name: lastName } = profile; const symmetricKeyStr = crypt.decryptAES(key, JSON.parse(encSymmetricKey)); // Store the information for later @@ -217,7 +225,8 @@ async function _removeAllCredentials() { * */ async function _removeGitRepository(repo: GitRepository) { - const projects = await database.find(models.project.type, { gitRepositoryId: repo._id }); + const queryIds = models.project.getQueryableGitRepositoryIds(repo._id); + const projects = await database.find(models.project.type, { gitRepositoryId: { $in: queryIds } }); for (const p of projects) { await services.project.update(p, { gitRepositoryId: models.project.EMPTY_GIT_PROJECT_ID }); } diff --git a/packages/insomnia/src/basic-components/modal.tsx b/packages/insomnia/src/basic-components/modal.tsx index 24f08b0a1b..dcc4575c7e 100644 --- a/packages/insomnia/src/basic-components/modal.tsx +++ b/packages/insomnia/src/basic-components/modal.tsx @@ -9,6 +9,7 @@ interface Props { onClose?: () => void; title?: React.ReactNode; closable?: boolean; + isDismissable?: boolean; className?: string; } @@ -18,6 +19,7 @@ export const Modal: React.FC> = ({ className, title, closable, + isDismissable, children, }) => { return ( @@ -26,7 +28,7 @@ export const Modal: React.FC> = ({ onOpenChange={isOpen => { !isOpen && onClose?.(); }} - isDismissable + isDismissable={isDismissable} className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" > []> = { [workspace.type]: [ diff --git a/packages/insomnia/src/common/__tests__/electron-storage.test.ts b/packages/insomnia/src/common/__tests__/electron-storage.test.ts index 30c0b4621d..30a28a4b59 100644 --- a/packages/insomnia/src/common/__tests__/electron-storage.test.ts +++ b/packages/insomnia/src/common/__tests__/electron-storage.test.ts @@ -108,4 +108,13 @@ describe('Test electron storage()', () => { expect(fs.readFileSync(path.join(basePath, 'foo'), 'utf8')).toEqual('"bar3"'); expect(fs.readFileSync(path.join(basePath, 'another'), 'utf8')).toEqual('10'); }); + + it.each(['', '.', '..', 'foo/bar', 'foo\\bar', 'foo\0bar'])('rejects invalid key %j', key => { + const basePath = `/tmp/insomnia-electronstorage-${Math.random()}`; + const electronStorage = new ElectronStorage(basePath); + + expect(() => electronStorage.getItem(key)).toThrowError('Invalid electron storage key'); + expect(() => electronStorage.setItem(key, 'value')).toThrowError('Invalid electron storage key'); + expect(() => electronStorage.deleteItem(key)).toThrowError('Invalid electron storage key'); + }); }); diff --git a/packages/insomnia/src/common/__tests__/har.test.ts b/packages/insomnia/src/common/__tests__/har.test.ts index 7889c1bdfe..ff635fe996 100644 --- a/packages/insomnia/src/common/__tests__/har.test.ts +++ b/packages/insomnia/src/common/__tests__/har.test.ts @@ -3,10 +3,9 @@ import path from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Cookie, Request, Response } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { database as db } from '../../common/database'; -import * as models from '../../models'; import { exportHar, exportHarResponse, exportHarWithRequest } from '../har'; import { getRenderedRequestAndContext } from '../render'; diff --git a/packages/insomnia/src/common/__tests__/import.test.ts b/packages/insomnia/src/common/__tests__/import.test.ts index 784046eb82..541a8e7c83 100644 --- a/packages/insomnia/src/common/__tests__/import.test.ts +++ b/packages/insomnia/src/common/__tests__/import.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { parse } from 'yaml'; import { EnvironmentKvPairDataType, EnvironmentType, services } from '~/insomnia-data'; @@ -172,6 +172,36 @@ describe('importRaw()', () => { }); }); + it('should call syncNewWorkspaceIfNeeded after importing to a new workspace', async () => { + const fixturePath = path.join(__dirname, '..', '__fixtures__', 'curl', 'complex-input.sh'); + const content = fs.readFileSync(fixturePath, 'utf8').toString(); + + const projectToImportTo = await services.project.create(); + const syncNewWorkspaceIfNeeded = vi.fn(); + + const scanResult = await importUtil.scanResources([ + { + contentStr: content, + }, + ]); + + expect(scanResult[0].type?.id).toBe('curl'); + expect(scanResult[0].errors.length).toBe(0); + + await importUtil.importResourcesToProject({ + projectId: projectToImportTo._id, + syncNewWorkspaceIfNeeded, + }); + + expect(syncNewWorkspaceIfNeeded).toHaveBeenCalledTimes(1); + expect(syncNewWorkspaceIfNeeded).toHaveBeenCalledWith( + expect.objectContaining({ + parentId: projectToImportTo._id, + scope: 'collection', + }), + ); + }); + it('should import a curl request to an existing workspace', async () => { const fixturePath = path.join(__dirname, '..', '__fixtures__', 'curl', 'complex-input.sh'); const content = fs.readFileSync(fixturePath, 'utf8').toString(); diff --git a/packages/insomnia/src/common/__tests__/misc.test.ts b/packages/insomnia/src/common/__tests__/misc.test.ts index 998a155653..5fc7c886ae 100644 --- a/packages/insomnia/src/common/__tests__/misc.test.ts +++ b/packages/insomnia/src/common/__tests__/misc.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { chunkArray } from '../../sync/vcs/vcs'; import { debounce, filterHeaders, @@ -204,33 +203,6 @@ describe('fuzzyMatchAll()', () => { expect(fuzzyMatchAll('wrong this ou', ['testing', 'this', 'out'])).toEqual(null); }); }); -describe('chunkArray()', () => { - it('works with exact divisor', () => { - const chunks = chunkArray([1, 2, 3, 4, 5, 6], 3); - expect(chunks).toEqual([ - [1, 2, 3], - [4, 5, 6], - ]); - }); - - it('works with weird divisor', () => { - const chunks = chunkArray([1, 2, 3, 4, 5, 6], 4); - expect(chunks).toEqual([ - [1, 2, 3, 4], - [5, 6], - ]); - }); - - it('works with empty', () => { - const chunks = chunkArray([], 4); - expect(chunks).toEqual([]); - }); - - it('works with less than one chunk', () => { - const chunks = chunkArray([1, 2], 4); - expect(chunks).toEqual([[1, 2]]); - }); -}); describe('isNotNullOrUndefined', () => { it('should return correctly', () => { diff --git a/packages/insomnia/src/common/__tests__/render.test.ts b/packages/insomnia/src/common/__tests__/render.test.ts index f32bd6bfd9..2497d2154f 100644 --- a/packages/insomnia/src/common/__tests__/render.test.ts +++ b/packages/insomnia/src/common/__tests__/render.test.ts @@ -2,9 +2,9 @@ import { createBuilder } from '@develohpanda/fluent-builder'; import { beforeEach, describe, expect, it } from 'vitest'; import type { Environment, Workspace } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; -import { environmentModelSchema, requestGroupModelSchema } from '../../models/__schemas__/model-schemas'; +import { environmentModelSchema, requestGroupModelSchema } from '../../sync/__schemas__/model-schemas'; import * as renderUtils from '../render'; const envBuilder = createBuilder(environmentModelSchema); diff --git a/packages/insomnia/src/common/__tests__/sorting.test.ts b/packages/insomnia/src/common/__tests__/sorting.test.ts index 3b0be3cd99..d3bc50b239 100644 --- a/packages/insomnia/src/common/__tests__/sorting.test.ts +++ b/packages/insomnia/src/common/__tests__/sorting.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { grpcRequest, request, requestGroup } from '../../models'; +import { models } from '~/insomnia-data'; + import { METHOD_DELETE, METHOD_GET, @@ -18,6 +19,8 @@ import { sortMethodMap, } from '../sorting'; +const { request, requestGroup, grpcRequest } = models; + describe('Sorting methods', () => { it('defaults to ascending metaSortKey aka descending but flipped (* -1)', () => { const unsorted = [ diff --git a/packages/insomnia/src/common/__tests__/strings.test.ts b/packages/insomnia/src/common/__tests__/strings.test.ts index 9833413f16..505b803053 100644 --- a/packages/insomnia/src/common/__tests__/strings.test.ts +++ b/packages/insomnia/src/common/__tests__/strings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import * as models from '../../models'; +import { models } from '~/insomnia-data'; + import { getWorkspaceLabel } from '../get-workspace-label'; import { strings } from '../strings'; diff --git a/packages/insomnia/src/common/compression.ts b/packages/insomnia/src/common/compression.ts new file mode 100644 index 0000000000..7dbf16d5a7 --- /dev/null +++ b/packages/insomnia/src/common/compression.ts @@ -0,0 +1,40 @@ +import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate'; + +const bytesToBase64 = (bytes: Uint8Array) => { + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64'); + } + + let binary = ''; + for (const byte of bytes) { + binary += String.fromCodePoint(byte); + } + + return btoa(binary); +}; + +const base64ToBytes = (input: string) => { + if (typeof Buffer !== 'undefined') { + return Uint8Array.from(Buffer.from(input, 'base64')); + } + + const binary = atob(input); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.codePointAt(index)!; + } + + return bytes; +}; + +export function compressObject(obj: any) { + return bytesToBase64(gzipSync(strToU8(JSON.stringify(obj)))); +} + +export function decompressObject(input: string | null): ObjectType | null { + if (typeof input !== 'string') { + return null; + } + + return JSON.parse(strFromU8(gunzipSync(base64ToBytes(input)))) as ObjectType; +} \ No newline at end of file diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 5e9aab4a41..6e7164225f 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -12,7 +12,8 @@ const env = process[ENV]; export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI; export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID; export const INSOMNIA_GITLAB_API_URL = env.INSOMNIA_GITLAB_API_URL; -export const PLAYWRIGHT = env.PLAYWRIGHT; +export const PLAYWRIGHT_TEST = env.PLAYWRIGHT_TEST; +export const OAUTH_WINDOW_SESSION_ID_KEY = 'current-oauth-session-id'; // App Stuff export const getSkipOnboarding = () => env.INSOMNIA_SKIP_ONBOARDING; @@ -33,12 +34,12 @@ export const getAppBundlePlugins = () => appConfig.bundlePlugins; export const getAppEnvironment = () => process.env.INSOMNIA_ENV || 'production'; export const isDevelopment = () => getAppEnvironment() === 'development'; export const getSegmentWriteKey = () => - appConfig.segmentWriteKeys[isDevelopment() || env.PLAYWRIGHT ? 'development' : 'production']; + appConfig.segmentWriteKeys[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production']; export const getSentryDsn = () => appConfig.sentryDsn; export const getCioWriteKey = () => - appConfig.cio[isDevelopment() || env.PLAYWRIGHT ? 'development' : 'production'].writeKey; + appConfig.cio[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production'].writeKey; export const getCioSiteId = () => - appConfig.cio[isDevelopment() || env.PLAYWRIGHT ? 'development' : 'production'].siteId; + appConfig.cio[isDevelopment() || env.PLAYWRIGHT_TEST ? 'development' : 'production'].siteId; export const getAppBuildDate = () => new Date(process.env.BUILD_DATE ?? '').toLocaleDateString(); export const getBrowserUserAgent = () => @@ -112,6 +113,7 @@ export const getMockServiceBinURL = (mockServer: MockServer, path: string) => { export const getAIServiceURL = () => env.INSOMNIA_AI_URL || 'https://ai-helper.insomnia.rest'; export const getKonnectApiBaseURL = () => env.KONNECT_API_URL || 'https://global.api.konghq.com'; +export const isKonnectSyncEnabled = () => !!env.KONNECT_SYNC_ENABLED; // App website export const getAppWebsiteBaseURL = () => env.INSOMNIA_APP_WEBSITE_URL || 'https://app.insomnia.rest'; @@ -256,6 +258,55 @@ export type AuthTypes = export const HAWK_ALGORITHM_SHA256 = 'sha256'; export const HAWK_ALGORITHM_SHA1 = 'sha1'; +//oauth 1 +export type OAuth1SignatureMethod = 'HMAC-SHA1' | 'RSA-SHA1' | 'HMAC-SHA256' | 'PLAINTEXT'; + +export const SIGNATURE_METHOD_HMAC_SHA1: OAuth1SignatureMethod = 'HMAC-SHA1'; +export const SIGNATURE_METHOD_HMAC_SHA256: OAuth1SignatureMethod = 'HMAC-SHA256'; +export const SIGNATURE_METHOD_RSA_SHA1: OAuth1SignatureMethod = 'RSA-SHA1'; +export const SIGNATURE_METHOD_PLAINTEXT: OAuth1SignatureMethod = 'PLAINTEXT'; + +//oauth 2 +export const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code'; +export const GRANT_TYPE_IMPLICIT = 'implicit'; +export const GRANT_TYPE_PASSWORD = 'password'; +export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; +export const GRANT_TYPE_REFRESH = 'refresh_token'; +export const GRANT_TYPE_MCP_AUTH_FLOW = 'mcp_auth_flow'; + +export type AuthKeys = + | 'access_token' + | 'id_token' + | 'client_id' + | 'client_secret' + | 'audience' + | 'resource' + | 'code_challenge' + | 'code_challenge_method' + | 'code_verifier' + | 'code' + | 'nonce' + | 'error' + | 'error_description' + | 'error_uri' + | 'expires_in' + | 'grant_type' + | 'password' + | 'redirect_uri' + | 'refresh_token' + | 'response_type' + | 'scope' + | 'state' + | 'token_type' + | 'username' + | 'xError' + | 'xResponseId'; + +export const PKCE_CHALLENGE_S256 = 'S256'; +export const PKCE_CHALLENGE_PLAIN = 'plain'; + +export type OAuth2AuthorizationStatusType = 'none' | 'getting_code' | 'getting_token'; + // json-order constants export const JSON_ORDER_PREFIX = '&'; export const JSON_ORDER_SEPARATOR = '~|'; diff --git a/packages/insomnia/src/common/har.ts b/packages/insomnia/src/common/har.ts index 5e65e21b4e..f18893c37b 100644 --- a/packages/insomnia/src/common/har.ts +++ b/packages/insomnia/src/common/har.ts @@ -2,13 +2,9 @@ import clone from 'clone'; import type * as Har from 'har-format'; import { Cookie as ToughCookie } from 'tough-cookie'; -import type { Environment, Request, RequestGroup, Response, Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; +import type { BaseModel, Environment, Request, RequestGroup, Response, Workspace } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; -import type { BaseModel } from '../models'; -import * as models from '../models'; -import { getAuthHeader } from '../network/authentication'; import * as plugins from '../plugins'; import * as pluginApp from '../plugins/context/app'; import * as pluginRequest from '../plugins/context/request'; @@ -307,6 +303,10 @@ export async function exportHarWithRenderedRequest(renderedRequest: RenderedRequ // Set auth header if we have it if (!hasAuthHeader(renderedRequest.headers)) { + const getAuthHeader = + process.type === 'renderer' + ? window.main.getAuthHeader + : (await import('../main/network/get-auth-header')).getAuthHeader; const header = await getAuthHeader(renderedRequest, url); if (header) { @@ -403,7 +403,7 @@ function mapCookie(cookie: ToughCookie) { } async function getResponseContent(response: Response) { - let body = await getBodyBuffer(response); + let body = await services.helpers.getResponseBodyBuffer(response); if (body === null) { body = Buffer.alloc(0); diff --git a/packages/insomnia/src/common/import.ts b/packages/insomnia/src/common/import.ts index efde4d899d..548f0d67d0 100644 --- a/packages/insomnia/src/common/import.ts +++ b/packages/insomnia/src/common/import.ts @@ -2,7 +2,9 @@ import orderedJSON from 'json-order'; import { z, type ZodError } from 'zod/v4'; import type { + AllTypes, ApiSpec, + BaseModel, CookieJar, Environment, EnvironmentKvPairData, @@ -16,21 +18,18 @@ import type { WebSocketRequest, Workspace, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import { insecureReadFile } from '~/main/secure-read-file'; +import { models, services } from '~/insomnia-data'; import type { InsomniaImporter } from '../main/importers/convert'; import type { ImportEntry } from '../main/importers/entities'; -import { pathWithParamsAsPathParameters } from '../main/importers/importers/openapi-3'; import { id as postmanEnvImporterId } from '../main/importers/importers/postman-env'; -import * as models from '../models/index'; -import { type AllTypes, type BaseModel, getModel } from '../models/index'; import { invariant } from '../utils/invariant'; import { parseApiSpec, type ParsedApiSpec } from './api-specs'; import { JSON_ORDER_PREFIX, JSON_ORDER_SEPARATOR } from './constants'; import { database as db } from './database'; import { tryImportV5Data } from './insomnia-v5'; import { generateId } from './misc'; +import { pathWithParamsAsPathParameters } from './path-with-params'; const { isRequest } = models.request; const { isApiSpec } = models.apiSpec; @@ -93,8 +92,12 @@ export async function fetchImportContentFromURI({ uri }: { uri: string }) { return content; } else if (uri.match(/^(file):\/\//)) { const path = uri.replace(/^(file):\/\//, ''); - // allow reading the file as it is chosen by user - return insecureReadFile(path); + const readFileProcessFork = async (path: string) => + process.type === 'renderer' + ? window.main.insecureReadFile({ path }) + : (await import('../main/secure-read-file')).insecureReadFile(path); + + return readFileProcessFork(path); } // Treat everything else as raw text const content = decodeURIComponent(uri); @@ -204,9 +207,9 @@ export async function scanResources(importEntries: ImportEntry[]): Promise({ ...(sessionId ? { 'X-Session-Id': sessionId } : {}), ...(data ? { 'Content-Type': 'application/json' } : {}), ...(organizationId ? { 'X-Insomnia-Org-Id': organizationId } : {}), - ...(PLAYWRIGHT ? { 'X-Mockbin-Test': 'true' } : {}), + ...(PLAYWRIGHT_TEST ? { 'X-Mockbin-Test': 'true' } : {}), }, ...(data ? { body: JSON.stringify(data) } : {}), signal: AbortSignal.timeout(timeout), diff --git a/packages/insomnia/src/common/insomnia-v5.ts b/packages/insomnia/src/common/insomnia-v5.ts index e9d061eebd..893ef46fbd 100644 --- a/packages/insomnia/src/common/insomnia-v5.ts +++ b/packages/insomnia/src/common/insomnia-v5.ts @@ -19,6 +19,7 @@ import { migrateToLatestYaml } from '~/common/insomnia-schema-migrations'; import { INSOMNIA_SCHEMA_VERSION } from '~/common/insomnia-schema-migrations/schema-version'; import type { ApiSpec, + BaseModel, CookieJar, Environment, EnvironmentKvPairData, @@ -38,11 +39,10 @@ import type { Workspace, WorkspaceScope, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { maskVaultEnvironmentData } from '~/utils/environment-utils'; import { invariant } from '~/utils/invariant'; -import * as models from '../models'; import { database } from './database'; import { type Insomnia_GRPCRequest, @@ -62,7 +62,7 @@ import { * Type helper that adds the export type field to any BaseModel * This is used to ensure all exported models have the correct _type field for v5 format */ -type WithExportType = T & { _type: AllExportTypes }; +type WithExportType = T & { _type: AllExportTypes }; /** * Maps request headers from internal format to v5 export format diff --git a/packages/insomnia/src/common/misc.ts b/packages/insomnia/src/common/misc.ts index 1a74942d03..dbd255c9c2 100644 --- a/packages/insomnia/src/common/misc.ts +++ b/packages/insomnia/src/common/misc.ts @@ -1,10 +1,8 @@ -import path from 'node:path'; -import zlib from 'node:zlib'; - import fuzzysort from 'fuzzysort'; import { v4 as uuidv4 } from 'uuid'; import { DEBOUNCE_MILLIS } from './constants'; +export { compressObject, decompressObject } from './compression'; const ESCAPE_REGEX_MATCH = /[-[\]/{}()*+?.\\^$|]/g; @@ -147,20 +145,6 @@ export function fnOrString(v: string | ((...args: any[]) => any), ...args: any[] return v(...args); } -export function compressObject(obj: any) { - const compressed = zlib.gzipSync(JSON.stringify(obj)); - return compressed.toString('base64'); -} - -export function decompressObject(input: string | null): ObjectType | null { - if (typeof input !== 'string') { - return null; - } - - const jsonBuffer = zlib.gunzipSync(Buffer.from(input, 'base64')); - return JSON.parse(jsonBuffer.toString('utf8')) as ObjectType; -} - /** * Escape a dynamic string for use inside of a regular expression * @param str - string to escape @@ -266,34 +250,6 @@ export function unescapeForwardSlash(str: string): string { }); } -export const normalizeFolderPath = (p: string) => { - const normalized = path.normalize(p); - // Preserve filesystem roots as-is (e.g. "/" on POSIX, "C:\" on Windows) - if (normalized === path.parse(normalized).root) { - return normalized; - } - return normalized.replace(/[/\\]+$/, ''); -}; - -export type FolderValidationResult = - | { ok: true; normalizedValue: string } - | { ok: false; error: string }; - -export function validateFolderInput(input: string, existing: string[]): FolderValidationResult { - const trimmed = input.trim(); - if (trimmed === '') { - return { ok: false, error: 'Enter a folder path to add.' }; - } - const normalized = normalizeFolderPath(trimmed); - if (trimmed !== normalized) { - return { ok: false, error: `Invalid folder path format. Did you mean "${normalized}"?` }; - } - if (existing.some(v => normalizeFolderPath(v) === normalized)) { - return { ok: false, error: 'Duplicate folders are not allowed.' }; - } - return { ok: true, normalizedValue: normalized }; -} - export const SECURITY_SETTINGS_PATH_LABEL = "Insomnia Preferences → General → Security"; export function cannotAccessPathError(accessingPath: string): string { diff --git a/packages/insomnia/src/common/organization-storage-rules.ts b/packages/insomnia/src/common/organization-storage-rules.ts new file mode 100644 index 0000000000..e6076490c6 --- /dev/null +++ b/packages/insomnia/src/common/organization-storage-rules.ts @@ -0,0 +1,54 @@ +import { getOrganizationStorageRule, type StorageRules } from 'insomnia-api'; + +import { models, services } from '~/insomnia-data'; +import { invariant } from '~/utils/invariant'; + +const inMemoryStorageRuleCache: Map = new Map(); + +export const DEFAULT_STORAGE_RULES = { + enableCloudSync: true, + enableLocalVault: true, + enableGitSync: true, + isOverridden: false, +}; + +export async function fetchAndCacheOrganizationStorageRule( + organizationId: string | undefined, + forceFetch = false, +): Promise { + invariant(organizationId, 'Organization ID is required'); + + if (models.organization.isScratchpadOrganizationId(organizationId)) { + return { + enableCloudSync: false, + enableLocalVault: true, + enableGitSync: false, + isOverridden: false, + }; + } + + if (!forceFetch) { + const storageRules = inMemoryStorageRuleCache.get(organizationId); + if (storageRules) { + return storageRules; + } + } + + const { id: sessionId } = await services.userSession.getOrCreate(); + + return await getOrganizationStorageRule({ + organizationId, + sessionId, + }).then( + res => { + if (res) { + inMemoryStorageRuleCache.set(organizationId, res); + } + return res || DEFAULT_STORAGE_RULES; + }, + err => { + console.log('[storageRule] Failed to load storage rules', err.message); + return DEFAULT_STORAGE_RULES; + }, + ); +} diff --git a/packages/insomnia/src/common/path-with-params.ts b/packages/insomnia/src/common/path-with-params.ts new file mode 100644 index 0000000000..904dc54101 --- /dev/null +++ b/packages/insomnia/src/common/path-with-params.ts @@ -0,0 +1,3 @@ +const VARIABLE_SEARCH_VALUE = /{([^}]+)}/g; + +export const pathWithParamsAsPathParameters = (path?: string) => path?.replace(VARIABLE_SEARCH_VALUE, ':$1') ?? ''; diff --git a/packages/insomnia/src/common/project.ts b/packages/insomnia/src/common/project.ts index d73d0800ae..460f8dcbe9 100644 --- a/packages/insomnia/src/common/project.ts +++ b/packages/insomnia/src/common/project.ts @@ -14,7 +14,6 @@ import { type WorkspaceMeta, type WorkspaceScope, } from '~/insomnia-data'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; export interface InsomniaFile { id: string; @@ -34,6 +33,10 @@ export interface InsomniaFile { hasUncommittedChanges?: boolean; hasUnpushedChanges?: boolean; gitFilePath?: string | null; + fileIssue?: { + kind: 'conflict' | 'parse-error'; + message: string; + }; } const lockGenerator = () => { @@ -208,8 +211,7 @@ export const getAllRemoteBackendProjectsByProjectId = async ({ teamProjectId: string; organizationId: string; }) => { - const vcs = VCSInstance(); - return vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId }); + return window.main.sync.remoteBackendProjects({ teamId: organizationId, teamProjectId }); }; export const getUnsyncedRemoteWorkspaces = (remoteFiles: InsomniaFile[], workspaces: Workspace[]) => @@ -230,12 +232,10 @@ export async function getAllRemoteFiles({ projectId, organizationId }: { project `remoteId: ${remoteId}`, ); - const vcs = VCSInstance(); - const [allPulledBackendProjectsForRemoteId, allFetchedRemoteBackendProjectsForRemoteId] = await Promise.all([ - vcs.localBackendProjects().then(projects => projects.filter(p => p.id === remoteId)), + window.main.sync.localBackendProjects().then(projects => projects.filter(p => p.id === remoteId)), // Remote backend projects are fetched from the backend since they are not stored locally - vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId }), + window.main.sync.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId }), ]); console.log( `[getAllRemoteFiles] found allPulledBackendProjectsForRemoteId: ${allPulledBackendProjectsForRemoteId.length} and allFetchedRemoteBackendProjectsForRemoteId: ${allFetchedRemoteBackendProjectsForRemoteId.length} for remoteId: ${remoteId}`, diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index f305095d3d..cfcbcccfac 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -13,9 +13,8 @@ import type { WebSocketRequest, Workspace, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; -import * as models from '../models'; import { getOrInheritAuthentication, getOrInheritHeaders } from '../network/network'; import * as templating from '../templating'; import { RenderError } from '../templating/render-error'; diff --git a/packages/insomnia/src/common/send-request.ts b/packages/insomnia/src/common/send-request.ts index d2081fd370..30ef43eb2a 100644 --- a/packages/insomnia/src/common/send-request.ts +++ b/packages/insomnia/src/common/send-request.ts @@ -1,12 +1,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { Environment, Settings, UserUploadEnvironment } from '~/insomnia-data'; +import type { BaseModel, Environment, Settings, UserUploadEnvironment } from '~/insomnia-data'; import { database, initDatabase, services } from '~/insomnia-data'; import { createNedbDatabase } from '~/insomnia-data/node'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; -import type { BaseModel } from '../models'; import { defaultSendActionRuntime, fetchRequestData, @@ -140,7 +138,7 @@ export async function getSendRequestCallbackMemDb( (acc, { name, value }) => ({ ...acc, [name.toLowerCase() || '']: value || '' }), {}, ); - const bodyBuffer = (await getBodyBuffer(res)) as Buffer; + const bodyBuffer = (await services.helpers.getResponseBodyBuffer(res)) as Buffer; const data = bodyBuffer ? bodyBuffer.toString('utf8') : undefined; const testResults = [ diff --git a/packages/insomnia/src/common/settings.ts b/packages/insomnia/src/common/settings.ts index 4d6c3a45a6..6ded268f70 100644 --- a/packages/insomnia/src/common/settings.ts +++ b/packages/insomnia/src/common/settings.ts @@ -163,4 +163,16 @@ export interface Settings { saveVaultKeyToOSSecretManager: boolean; vaultSecretCacheDuration: number; dataFolders: string[]; + // AST and shadowing check. + scriptSandboxEnabled: boolean; + // Wraps the user script in 'use strict', preventing accidental globals and making `this` undefined. + scriptStrictModeEnabled: boolean; + // Names of security rules that have been individually disabled. + disabledSecurityRules: string[]; + // AST blocked-property names that have been individually disabled. + disabledBlockedProperties: string[]; + // AST blocked-root names that have been individually disabled. + disabledBlockedRoots: string[]; + /** Custom npm registry URL for plugin installation (e.g., corporate mirror). Empty string uses the default https://registry.npmjs.org/. */ + npmRegistryUrl: string; } diff --git a/packages/insomnia/src/common/significant-diff-detection.ts b/packages/insomnia/src/common/significant-diff-detection.ts index 117711886c..d121735700 100644 --- a/packages/insomnia/src/common/significant-diff-detection.ts +++ b/packages/insomnia/src/common/significant-diff-detection.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import { isMap, isScalar, isSeq, LineCounter, parse, type ParsedNode, parseDocument } from 'yaml'; import { normalizeScripts } from '~/common/insomnia-schema-migrations/v5.1'; @@ -158,7 +156,7 @@ export function hasSignificantChanges( config: Partial = {}, ): boolean { // Non-YAML files → raw string comparison - if (path.extname(filePath) !== '.yaml') { + if (!filePath.toLowerCase().endsWith('.yaml')) { return originalContent !== modifiedContent; } diff --git a/packages/insomnia/src/entry.client.tsx b/packages/insomnia/src/entry.client.tsx index c9cc607f60..6ed1034bce 100644 --- a/packages/insomnia/src/entry.client.tsx +++ b/packages/insomnia/src/entry.client.tsx @@ -9,12 +9,13 @@ import { HydratedRouter } from 'react-router/dom'; import { insomniaFetch } from '~/common/insomnia-fetch'; import { initDatabase, initServices, services } from '~/insomnia-data'; import { database as clientDatabase } from '~/ui/database.client'; +import { clearOAuthWindowSessionId } from '~/ui/spawn-oauth-window'; import { migrateFromLocalStorage, type SessionData, setSessionData, setVaultSessionData } from './account/session'; import { getInsomniaSession, getInsomniaVaultKey, getInsomniaVaultSalt, getSkipOnboarding } from './common/constants'; -import { initNewOAuthSession } from './network/o-auth-2/get-token'; import { init as initPlugins } from './plugins'; import { applyColorScheme } from './plugins/misc'; +import { registerSyncMergeConflictListener } from './sync/vcs/insomnia-sync'; import { HtmlElementWrapper } from './ui/components/html-element-wrapper'; import { showModal } from './ui/components/modals'; import { AlertModal } from './ui/components/modals/alert-modal'; @@ -42,6 +43,7 @@ configureFetch(options => insomniaFetch({ ...options })); await initPlugins(); await migrateFromLocalStorage(); +registerSyncMergeConflictListener(); try { window.showAlert = options => showModal(AlertModal, options); @@ -129,7 +131,7 @@ if (insomniaSession) { const appSettings = await services.settings.getOrCreate(); if (appSettings.clearOAuth2SessionOnRestart) { - initNewOAuthSession(); + await clearOAuthWindowSessionId(); } applyColorScheme(appSettings); diff --git a/packages/insomnia/src/entry.hidden-window-preload.ts b/packages/insomnia/src/entry.hidden-window-preload.ts index 49aa39f3cb..acd523a175 100644 --- a/packages/insomnia/src/entry.hidden-window-preload.ts +++ b/packages/insomnia/src/entry.hidden-window-preload.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; import type { Compression } from '~/insomnia-data'; +import { servicesProxy } from '~/ui/renderer-services-proxy'; import { asyncTasksAllSettled, @@ -13,7 +14,7 @@ import { stopMonitorAsyncTasks, } from '../../insomnia-scripting-environment/src/objects'; // this will also import lots of node_modules into the preload script, consider moving this file insomnia-scripting-environment -import { requireInterceptor } from './require-interceptor'; +import { requireInterceptor } from './scripting/require-interceptor'; export interface HiddenBrowserWindowToMainBridgeAPI { requireInterceptor: (module: string) => any; @@ -63,7 +64,9 @@ const bridge: HiddenBrowserWindowToMainBridgeAPI = { if (process.contextIsolated) { contextBridge.exposeInMainWorld('bridge', bridge); contextBridge.exposeInMainWorld('Promise', ProxiedPromise); + contextBridge.exposeInMainWorld('_dataServices', servicesProxy); } else { window.bridge = bridge; window.Promise = ProxiedPromise; + window._dataServices = servicesProxy; } diff --git a/packages/insomnia/src/entry.hidden-window.ts b/packages/insomnia/src/entry.hidden-window.ts index 08521a8e3f..5d0065a46a 100644 --- a/packages/insomnia/src/entry.hidden-window.ts +++ b/packages/insomnia/src/entry.hidden-window.ts @@ -1,29 +1,34 @@ import * as Sentry from '@sentry/electron/renderer'; -import * as _ from 'es-toolkit/compat'; import { SENTRY_OPTIONS } from 'insomnia/src/common/sentry'; -import { - initInsomniaObject, - InsomniaObject, - waitForAllTestsDone, -} from '../../insomnia-scripting-environment/src/objects'; -import { - getNewConsole, - mergeClientCertificates, - mergeCookieJar, - mergeRequests, - mergeSettings, - type RequestContext, -} from '../../insomnia-scripting-environment/src/objects'; +import { initServices } from '~/insomnia-data'; + +import type { RequestContext } from '../../insomnia-scripting-environment/src/objects'; +import { runScript } from './scripting/run-script'; +import { type ScriptSecurityPolicy } from './scripting/sandbox'; export interface HiddenBrowserWindowBridgeAPI { - runScript: (options: { script: string; context: RequestContext }) => Promise; + runScript: (options: { + script: string; + context: RequestContext; + securityPolicy?: ScriptSecurityPolicy; + }) => Promise; } Sentry.init({ ...SENTRY_OPTIONS, }); +// Initialize services for hidden renderer process +if (!window._dataServices) { + throw new Error( + 'window._dataServices is not available. This entrypoint must run in an environment with the preload bridge.', + ); +} +initServices(window._dataServices); +// Remove the global services reference after initialization to improve security by preventing unintended access from the global scope. +delete window._dataServices; + window.bridge.onmessage( async (data: { script: string; context: RequestContext }, callback: ({ error }: { error: string }) => void) => { window.bridge.setBusy(true); @@ -38,14 +43,17 @@ window.bridge.onmessage( const result = await window.bridge.Promise.race([timeoutPromise, runScript(data)]); callback(result); } catch (err) { - const errMessage = err.message - ? `Error from Pre-request or after-response script: - -${err.message}` - : err; - const fullErrMessage = `${errMessage} - -${err.stack ? `Stack: ${err.stack}` : ''}`; + const error = err instanceof Error ? err : new Error(String(err)); + if ((error as NodeJS.ErrnoException).code === 'SECURITY_POLICY_VIOLATION') { + console.log('[hidden-window] security policy violation:', error.message); + callback({ error: error.message }); + return; + } + const errMessage = error.message + ? `Error from Pre-request or after-response script:\n${error.message}` + : String(error); + const fullErrMessage = `${errMessage}\n\n${error.stack ? `Stack: ${error.stack}` : ''}`; + console.log('[hidden-window] script error:', errMessage); Sentry.captureException(errMessage, { tags: { source: 'hidden-window', @@ -57,112 +65,3 @@ ${err.stack ? `Stack: ${err.stack}` : ''}`; } }, ); - -// This function is duplicated in scriptExecutor.ts to run in nodejs -// TODO: consider removing this implementation and using only nodejs scripting -const runScript = async ({ script, context }: { script: string; context: RequestContext }): Promise => { - const scriptConsole = getNewConsole(); - - const executionContext = await initInsomniaObject(context, scriptConsole.log); - - const AsyncFunction = (async () => {}).constructor; - const executeScript = AsyncFunction( - 'insomnia', - 'require', - 'console', - '_', - 'setTimeout', - // disable these as they are not supported in web or existing implementation - 'setImmediate', - 'queueMicrotask', - 'process', - 'waitForAllTestsDone', - ` - const $ = insomnia; - window.bridge.resetAsyncTasks(); // exclude unnecessary ones - ${script}; - await waitForAllTestsDone(); - window.bridge.stopMonitorAsyncTasks(); // the next one should not be monitored - await window.bridge.asyncTasksAllSettled(); - return insomnia;`, - ); - - const mutatedInsomniaObject = await executeScript( - executionContext, - window.bridge.requireInterceptor, - scriptConsole, - _, - proxiedSetTimeout, - undefined, - undefined, - undefined, - waitForAllTestsDone, - ); - if (mutatedInsomniaObject == null || !(mutatedInsomniaObject instanceof InsomniaObject)) { - throw new Error('insomnia object is invalid or script returns earlier than expected.'); - } - const mutatedContextObject = mutatedInsomniaObject.toObject(); - const updatedRequest = mergeRequests(context.request, mutatedContextObject.request); - const updatedSettings = mergeSettings(context.settings, mutatedContextObject.request); - const updatedCertificates = mergeClientCertificates( - mutatedContextObject.clientCertificates, - mutatedContextObject.request, - ); - const updatedCookieJar = mergeCookieJar(context.cookieJar, mutatedContextObject.cookieJar); - - return { - ...context, - environment: { - id: context.environment.id, - name: context.environment.name, - data: mutatedContextObject.environment, - }, - baseEnvironment: { - id: context.baseEnvironment.id, - name: context.baseEnvironment.name, - data: mutatedContextObject.baseEnvironment, - }, - iterationData: context.iterationData - ? { - name: context.iterationData.name, - data: mutatedContextObject.iterationData, - } - : undefined, - transientVariables: { - name: context.transientVariables?.name || 'transientVariables', - data: mutatedContextObject.variables, - }, - request: updatedRequest, - execution: mutatedContextObject.execution, - settings: updatedSettings, - clientCertificates: updatedCertificates, - cookieJar: updatedCookieJar, - globals: context.globals && { - id: context.globals.id, - name: context.globals.name, - data: mutatedContextObject.globals, - }, - baseGlobals: context.baseGlobals && { - id: context.baseGlobals.id, - name: context.baseGlobals.name, - data: mutatedContextObject.baseGlobals, - }, - requestTestResults: mutatedContextObject.requestTestResults, - logs: scriptConsole.dumpLogsAsArray(), - parentFolders: mutatedContextObject.parentFolders, - }; -}; - -// proxiedSetTimeout has to be here as callback could be an async task -function proxiedSetTimeout(callback: () => void, ms?: number | undefined) { - let resolveHdl: (value: unknown) => void; - - new Promise(resolve => { - resolveHdl = resolve; - }); - - return setTimeout(() => { - callback(); - resolveHdl(null); - }, ms); -} diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index af42b2568f..abcf0f8c70 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -8,15 +8,15 @@ import contextMenu from 'electron-context-menu'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { configureFetch } from 'insomnia-api'; -import { getCurrentSessionId } from '~/account/session'; import { insomniaFetch } from '~/common/insomnia-fetch'; import type { Project, RemoteProject, Stats } from '~/insomnia-data'; -import { database, initDatabase, initServices, services } from '~/insomnia-data'; +import { database, initDatabase, initServices, models, services } from '~/insomnia-data'; import { servicesNodeImpl } from '~/insomnia-data/node'; import { mainDatabase } from '~/main/database.main'; +import { initElectronStorage } from '~/main/electron-storage'; +import { runGitCredentialsMigration } from '~/main/git/migrations'; import { registerPathHandlers } from '~/main/ipc/path'; import { registerLLMConfigServiceAPI } from '~/main/llm-config-service'; -import { runGitCredentialsMigration } from '~/sync/git/migrations'; import { userDataFolder } from '../config/config.json'; import { getAppVersion, getProductName, isDevelopment } from './common/constants'; @@ -24,8 +24,10 @@ import { isMac } from './common/platform'; import { SegmentEvent, trackSegmentEvent } from './main/analytics'; import { registerInsomniaProtocols } from './main/api.protocol'; import { backupIfNewerVersionAvailable } from './main/backup'; +import { registerSyncHandlers } from './main/cloud-sync/ipc'; import { registerGitServiceAPI } from './main/git-service'; import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron'; +import { registerElectronStorageHandlers } from './main/ipc/electron-storage'; import { registergRPCHandlers } from './main/ipc/grpc'; import { registerMainHandlers } from './main/ipc/main'; import { registerSecretStorageHandlers } from './main/ipc/secret-storage'; @@ -39,14 +41,16 @@ import { initializeSentry, sentryWatchAnalyticsEnabled } from './main/sentry'; import { checkIfRestartNeeded } from './main/squirrel-startup'; import * as updates from './main/updates'; import * as windowUtils from './main/window-utils'; -import * as models from './models/index'; + // Override the Electron userData path // This makes Chromium use this folder for eg localStorage // ensure userData dir change is made before configure sentry SDK (https://docs.sentry.io/platforms/javascript/guides/electron/#app-userdata-directory) const dataPath = process.env.INSOMNIA_DATA_PATH || path.join(app.getPath('userData'), '../', isDevelopment() ? 'insomnia-app' : userDataFolder); + app.setPath('userData', dataPath); +initElectronStorage(dataPath); initializeLogging(); @@ -89,6 +93,8 @@ app.on('ready', async () => { registerCurlHandlers(); registerMcpHandlers(); registerSecretStorageHandlers(); + registerElectronStorageHandlers(); + registerSyncHandlers(); /** * There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching. @@ -121,7 +127,6 @@ app.on('ready', async () => { await backupIfNewerVersionAvailable(); sentryWatchAnalyticsEnabled(); watchProxySettings(); - windowUtils.init(); await runGitCredentialsMigration(); @@ -217,7 +222,7 @@ const _launchApp = async () => { } }); // Disable deep linking in playwright e2e tests in order to run multiple tests in parallel - if (!process.env.PLAYWRIGHT) { + if (!process.env.PLAYWRIGHT_TEST) { // Deep linking logic - https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { @@ -251,15 +256,6 @@ const _launchApp = async () => { } else { window = windowUtils.createWindowsAndReturnMain(); } - // Block imports when not logged in - const isImportDeeplink = url.includes('://app/import'); - const isLoggedIn = (await getCurrentSessionId()) ? true : false; - const shouldShowLoginPrompt = isImportDeeplink && !isLoggedIn; - if (shouldShowLoginPrompt) { - const title = encodeURIComponent('You must be logged in to open this link'); - const message = encodeURIComponent('Please log in and try again.'); - return window.webContents.send('shell:open', `insomnia://app/alert?title=${title}&message=${message}`); - } return window.webContents.send('shell:open', url); }; diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index b8bd3b30b9..ed7b80188a 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -1,10 +1,15 @@ import { contextBridge, ipcRenderer, webUtils as webUtilities } from 'electron'; -import type { Services } from '~/insomnia-data'; +import type { AuthTypeOAuth2, OAuth2Token, RequestHeader } from '~/insomnia-data'; +import { invokeWithNormalizedError } from '~/main/ipc/invoke'; import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service'; import type { GenerateMcpSamplingResponseFunction } from '~/plugins/types'; +import { isUserAbortResolveMergeConflictError, UserAbortResolveMergeConflictError } from '~/sync/vcs/errors'; +import { servicesProxy } from '~/ui/renderer-services-proxy'; +import type { SyncBridgeAPI } from './main/cloud-sync/ipc'; import type { GitServiceAPI } from './main/git-service'; +import type { electronStorageBridgeAPI } from './main/ipc/electron-storage'; import type { gRPCBridgeAPI } from './main/ipc/grpc'; import type { secretStorageBridgeAPI } from './main/ipc/secret-storage'; import type { AIFeatureNames } from './main/llm-config-service'; @@ -12,80 +17,81 @@ import type { CurlBridgeAPI } from './main/network/curl'; import type { McpBridgeAPI } from './main/network/mcp'; import type { SocketIOBridgeAPI } from './main/network/socket-io'; import type { WebSocketBridgeAPI } from './main/network/websocket'; +import type { RenderedRequest } from './templating/types'; import { invariant } from './utils/invariant'; const ports = new Map<'hiddenWindowPort', MessagePort>(); const webSocket: WebSocketBridgeAPI = { - open: options => ipcRenderer.invoke('webSocket.open', options), + open: options => invokeWithNormalizedError('webSocket.open', options), close: options => ipcRenderer.send('webSocket.close', options), closeAll: () => ipcRenderer.send('webSocket.closeAll'), readyState: { - getCurrent: options => ipcRenderer.invoke('webSocket.readyState', options), + getCurrent: options => invokeWithNormalizedError('webSocket.readyState', options), }, event: { - findMany: options => ipcRenderer.invoke('webSocket.event.findMany', options), - send: options => ipcRenderer.invoke('webSocket.event.send', options), + findMany: options => invokeWithNormalizedError('webSocket.event.findMany', options), + send: options => invokeWithNormalizedError('webSocket.event.send', options), }, }; const curl: CurlBridgeAPI = { - open: options => ipcRenderer.invoke('curl.open', options), + open: options => invokeWithNormalizedError('curl.open', options), close: options => ipcRenderer.send('curl.close', options), closeAll: () => ipcRenderer.send('curl.closeAll'), readyState: { - getCurrent: options => ipcRenderer.invoke('curl.readyState', options), + getCurrent: options => invokeWithNormalizedError('curl.readyState', options), }, event: { - findMany: options => ipcRenderer.invoke('curl.event.findMany', options), + findMany: options => invokeWithNormalizedError('curl.event.findMany', options), }, }; const socketIO: SocketIOBridgeAPI = { - open: options => ipcRenderer.invoke('socketIO.open', options), + open: options => invokeWithNormalizedError('socketIO.open', options), readyState: { - getCurrent: options => ipcRenderer.invoke('socketIO.readyState', options), + getCurrent: options => invokeWithNormalizedError('socketIO.readyState', options), }, close: options => ipcRenderer.send('socketIO.close', options), closeAll: () => ipcRenderer.send('socketIO.closeAll'), event: { - findMany: options => ipcRenderer.invoke('socketIO.event.findMany', options), - send: options => ipcRenderer.invoke('socketIO.event.send', options), + findMany: options => invokeWithNormalizedError('socketIO.event.findMany', options), + send: options => invokeWithNormalizedError('socketIO.event.send', options), on: options => ipcRenderer.send('socketIO.event.on', options), off: options => ipcRenderer.send('socketIO.event.off', options), }, }; const mcp: McpBridgeAPI = { - connect: options => ipcRenderer.invoke('mcp.connect', options), - close: options => ipcRenderer.invoke('mcp.close', options), + connect: options => invokeWithNormalizedError('mcp.connect', options), + close: options => invokeWithNormalizedError('mcp.close', options), closeAll: () => ipcRenderer.send('mcp.closeAll'), authConfirmation: confirmed => ipcRenderer.send('mcp.authConfirmed', confirmed), primitive: { - listTools: options => ipcRenderer.invoke('mcp.primitive.listTools', options), - callTool: options => ipcRenderer.invoke('mcp.primitive.callTool', options), - listResources: options => ipcRenderer.invoke('mcp.primitive.listResources', options), - listResourceTemplates: options => ipcRenderer.invoke('mcp.primitive.listResourceTemplates', options), - readResource: options => ipcRenderer.invoke('mcp.primitive.readResource', options), - subscribeResource: options => ipcRenderer.invoke('mcp.primitive.subscribeResource', options), - unsubscribeResource: options => ipcRenderer.invoke('mcp.primitive.unsubscribeResource', options), - listPrompts: options => ipcRenderer.invoke('mcp.primitive.listPrompts', options), - getPrompt: options => ipcRenderer.invoke('mcp.primitive.getPrompt', options), + listTools: options => invokeWithNormalizedError('mcp.primitive.listTools', options), + callTool: options => invokeWithNormalizedError('mcp.primitive.callTool', options), + listResources: options => invokeWithNormalizedError('mcp.primitive.listResources', options), + listResourceTemplates: options => invokeWithNormalizedError('mcp.primitive.listResourceTemplates', options), + readResource: options => invokeWithNormalizedError('mcp.primitive.readResource', options), + subscribeResource: options => invokeWithNormalizedError('mcp.primitive.subscribeResource', options), + unsubscribeResource: options => invokeWithNormalizedError('mcp.primitive.unsubscribeResource', options), + listPrompts: options => invokeWithNormalizedError('mcp.primitive.listPrompts', options), + getPrompt: options => invokeWithNormalizedError('mcp.primitive.getPrompt', options), }, notification: { - rootListChange: options => ipcRenderer.invoke('mcp.notification.rootListChange', options), + rootListChange: options => invokeWithNormalizedError('mcp.notification.rootListChange', options), }, readyState: { - getCurrent: options => ipcRenderer.invoke('mcp.readyState', options), + getCurrent: options => invokeWithNormalizedError('mcp.readyState', options), }, client: { responseElicitationRequest: options => ipcRenderer.send('mcp.client.responseElicitationRequest', options), responseSamplingRequest: options => ipcRenderer.send('mcp.client.responseSamplingRequest', options), - hasRequestResponded: options => ipcRenderer.invoke('mcp.client.hasRequestResponded', options), - cancelRequest: options => ipcRenderer.invoke('mcp.client.cancelRequest', options), + hasRequestResponded: options => invokeWithNormalizedError('mcp.client.hasRequestResponded', options), + cancelRequest: options => invokeWithNormalizedError('mcp.client.cancelRequest', options), }, event: { - findMany: options => ipcRenderer.invoke('mcp.event.findMany', options), - findNotifications: options => ipcRenderer.invoke('mcp.event.findNotifications', options), - findPendingEvents: options => ipcRenderer.invoke('mcp.event.findPendingEvents', options), + findMany: options => invokeWithNormalizedError('mcp.event.findMany', options), + findNotifications: options => invokeWithNormalizedError('mcp.event.findNotifications', options), + findPendingEvents: options => invokeWithNormalizedError('mcp.event.findPendingEvents', options), }, }; @@ -95,72 +101,135 @@ const grpc: gRPCBridgeAPI = { commit: options => ipcRenderer.send('grpc.commit', options), cancel: options => ipcRenderer.send('grpc.cancel', options), closeAll: () => ipcRenderer.send('grpc.closeAll'), - loadMethods: options => ipcRenderer.invoke('grpc.loadMethods', options), - loadMethodsFromReflection: options => ipcRenderer.invoke('grpc.loadMethodsFromReflection', options), + loadMethods: options => invokeWithNormalizedError('grpc.loadMethods', options), + loadMethodsFromReflection: options => invokeWithNormalizedError('grpc.loadMethodsFromReflection', options), + writeProtoFile: protoFileId => invokeWithNormalizedError('grpc.writeProtoFile', protoFileId), }; const secretStorage: secretStorageBridgeAPI = { - setSecret: (key, secret) => ipcRenderer.invoke('secretStorage.setSecret', key, secret), - getSecret: key => ipcRenderer.invoke('secretStorage.getSecret', key), - deleteSecret: key => ipcRenderer.invoke('secretStorage.deleteSecret', key), - encryptString: raw => ipcRenderer.invoke('secretStorage.encryptString', raw), - decryptString: cipherText => ipcRenderer.invoke('secretStorage.decryptString', cipherText), + setSecret: (key, secret) => invokeWithNormalizedError('secretStorage.setSecret', key, secret), + getSecret: key => invokeWithNormalizedError('secretStorage.getSecret', key), + deleteSecret: key => invokeWithNormalizedError('secretStorage.deleteSecret', key), + encryptString: raw => invokeWithNormalizedError('secretStorage.encryptString', raw), + decryptString: cipherText => invokeWithNormalizedError('secretStorage.decryptString', cipherText), +}; + +const electronStorage: electronStorageBridgeAPI = { + getItem: key => invokeWithNormalizedError('electronStorage.getItem', key), + setItem: (key, value) => invokeWithNormalizedError('electronStorage.setItem', key, value), +}; + +const invokeSyncMethod = async (methodName: string, ...args: unknown[]) => { + try { + return (await invokeWithNormalizedError('sync.invoke', methodName, ...args)) as T; + } catch (error) { + if (isUserAbortResolveMergeConflictError(error)) { + throw new UserAbortResolveMergeConflictError( + 'message' in error && typeof error.message === 'string' ? error.message : undefined, + ); + } + + throw error; + } +}; + +const sync: SyncBridgeAPI = { + archiveProject: () => invokeSyncMethod('archiveProject'), + checkout: (...args) => invokeSyncMethod('checkout', ...args), + compareRemoteBranch: () => invokeSyncMethod('compareRemoteBranch'), + fork: (...args) => invokeSyncMethod('fork', ...args), + getActiveBackendProject: () => invokeSyncMethod('getActiveBackendProject'), + getBranchNames: () => invokeSyncMethod('getBranchNames'), + getCurrentBranchName: () => invokeSyncMethod('getCurrentBranchName'), + getHistory: (...args) => invokeSyncMethod('getHistory', ...args), + getHistoryCount: () => invokeSyncMethod('getHistoryCount'), + getRemoteBranchNames: () => invokeSyncMethod('getRemoteBranchNames'), + getVersion: () => invokeSyncMethod('getVersion'), + hasBackendProject: () => invokeSyncMethod('hasBackendProject'), + localBackendProjects: () => invokeSyncMethod('localBackendProjects'), + merge: (...args) => invokeSyncMethod('merge', ...args), + pull: (...args) => invokeSyncMethod('pull', ...args), + pullRemoteBackendProject: options => invokeWithNormalizedError('sync.pullRemoteBackendProject', options), + push: (...args) => invokeSyncMethod('push', ...args), + remoteBackendProjects: (...args) => invokeSyncMethod('remoteBackendProjects', ...args), + removeBackendProjectsForRoot: (...args) => invokeSyncMethod('removeBackendProjectsForRoot', ...args), + removeBranch: (...args) => invokeSyncMethod('removeBranch', ...args), + removeRemoteBranch: (...args) => invokeSyncMethod('removeRemoteBranch', ...args), + rollback: (...args) => invokeSyncMethod('rollback', ...args), + rollbackToLatest: (...args) => invokeSyncMethod('rollbackToLatest', ...args), + resolveConflict: options => ipcRenderer.send('sync.resolveConflict', options), + cancelConflict: options => ipcRenderer.send('sync.cancelConflict', options), + stage: (...args) => invokeSyncMethod('stage', ...args), + status: (...args) => invokeSyncMethod('status', ...args), + switchAndCreateBackendProjectIfNotExist: (...args) => + invokeSyncMethod('switchAndCreateBackendProjectIfNotExist', ...args), + takeSnapshot: (...args) => invokeSyncMethod('takeSnapshot', ...args), + unstage: (...args) => invokeSyncMethod('unstage', ...args), + on: (channel, listener) => { + ipcRenderer.on(channel, listener); + return () => ipcRenderer.removeListener(channel, listener); + }, }; const git: GitServiceAPI = { - loadGitRepository: options => ipcRenderer.invoke('git.loadGitRepository', options), - getGitBranches: options => ipcRenderer.invoke('git.getGitBranches', options), - fetchGitRemoteBranches: options => ipcRenderer.invoke('git.fetchGitRemoteBranches', options), - validateGitRepositoryCredentials: options => ipcRenderer.invoke('git.validateGitRepositoryCredentials', options), - validateGitCredentialById: options => ipcRenderer.invoke('git.validateGitCredentialById', options), - gitFetchAction: options => ipcRenderer.invoke('git.gitFetchAction', options), - gitLogLoader: options => ipcRenderer.invoke('git.gitLogLoader', options), - gitChangesLoader: options => ipcRenderer.invoke('git.gitChangesLoader', options), - canPushLoader: options => ipcRenderer.invoke('git.canPushLoader', options), - cloneGitRepo: options => ipcRenderer.invoke('git.cloneGitRepo', options), - initGitRepoClone: options => ipcRenderer.invoke('git.initGitRepoClone', options), - updateGitRepo: options => ipcRenderer.invoke('git.updateGitRepo', options), - resetGitRepo: options => ipcRenderer.invoke('git.resetGitRepo', options), - commitToGitRepo: options => ipcRenderer.invoke('git.commitToGitRepo', options), - commitAndPushToGitRepo: options => ipcRenderer.invoke('git.commitAndPushToGitRepo', options), - createNewGitBranch: options => ipcRenderer.invoke('git.createNewGitBranch', options), - checkoutGitBranch: options => ipcRenderer.invoke('git.checkoutGitBranch', options), - mergeGitBranch: options => ipcRenderer.invoke('git.mergeGitBranch', options), - deleteGitBranch: options => ipcRenderer.invoke('git.deleteGitBranch', options), - pushToGitRemote: options => ipcRenderer.invoke('git.pushToGitRemote', options), - pullFromGitRemote: options => ipcRenderer.invoke('git.pullFromGitRemote', options), - continueMerge: options => ipcRenderer.invoke('git.continueMerge', options), - discardChanges: options => ipcRenderer.invoke('git.discardChanges', options), - abortMerge: () => ipcRenderer.invoke('git.abortMerge'), - gitStatus: options => ipcRenderer.invoke('git.gitStatus', options), - diff: () => ipcRenderer.invoke('git.diff'), - multipleCommitToGitRepo: options => ipcRenderer.invoke('git.multipleCommitToGitRepo', options), - stageChanges: options => ipcRenderer.invoke('git.stageChanges', options), - unstageChanges: options => ipcRenderer.invoke('git.unstageChanges', options), - diffFileLoader: options => ipcRenderer.invoke('git.diffFileLoader', options), - getRepositoryDirectoryTree: options => ipcRenderer.invoke('git.getRepositoryDirectoryTree', options), - migrateLegacyInsomniaFolderToFile: options => ipcRenderer.invoke('git.migrateLegacyInsomniaFolderToFile', options), + loadGitRepository: options => invokeWithNormalizedError('git.loadGitRepository', options), + getGitBranches: options => invokeWithNormalizedError('git.getGitBranches', options), + fetchGitRemoteBranches: options => invokeWithNormalizedError('git.fetchGitRemoteBranches', options), + getProjectGitFileIssues: options => invokeWithNormalizedError('git.getProjectGitFileIssues', options), + validateGitRepositoryCredentials: options => + invokeWithNormalizedError('git.validateGitRepositoryCredentials', options), + validateGitCredentialById: options => invokeWithNormalizedError('git.validateGitCredentialById', options), + gitFetchAction: options => invokeWithNormalizedError('git.gitFetchAction', options), + gitLogLoader: options => invokeWithNormalizedError('git.gitLogLoader', options), + gitChangesLoader: options => invokeWithNormalizedError('git.gitChangesLoader', options), + canPushLoader: options => invokeWithNormalizedError('git.canPushLoader', options), + cloneGitRepo: options => invokeWithNormalizedError('git.cloneGitRepo', options), + initGitRepoClone: options => invokeWithNormalizedError('git.initGitRepoClone', options), + updateGitRepo: options => invokeWithNormalizedError('git.updateGitRepo', options), + resetGitRepo: options => invokeWithNormalizedError('git.resetGitRepo', options), + commitToGitRepo: options => invokeWithNormalizedError('git.commitToGitRepo', options), + commitAndPushToGitRepo: options => invokeWithNormalizedError('git.commitAndPushToGitRepo', options), + createNewGitBranch: options => invokeWithNormalizedError('git.createNewGitBranch', options), + checkoutGitBranch: options => invokeWithNormalizedError('git.checkoutGitBranch', options), + mergeGitBranch: options => invokeWithNormalizedError('git.mergeGitBranch', options), + deleteGitBranch: options => invokeWithNormalizedError('git.deleteGitBranch', options), + pushToGitRemote: options => invokeWithNormalizedError('git.pushToGitRemote', options), + pullFromGitRemote: options => invokeWithNormalizedError('git.pullFromGitRemote', options), + continueMerge: options => invokeWithNormalizedError('git.continueMerge', options), + discardChanges: options => invokeWithNormalizedError('git.discardChanges', options), + abortMerge: options => invokeWithNormalizedError('git.abortMerge', options), + gitStatus: options => invokeWithNormalizedError('git.gitStatus', options), + diff: () => invokeWithNormalizedError('git.diff'), + multipleCommitToGitRepo: options => invokeWithNormalizedError('git.multipleCommitToGitRepo', options), + stageChanges: options => invokeWithNormalizedError('git.stageChanges', options), + unstageChanges: options => invokeWithNormalizedError('git.unstageChanges', options), + diffFileLoader: options => invokeWithNormalizedError('git.diffFileLoader', options), + getRepositoryDirectoryTree: options => invokeWithNormalizedError('git.getRepositoryDirectoryTree', options), + migrateLegacyInsomniaFolderToFile: options => + invokeWithNormalizedError('git.migrateLegacyInsomniaFolderToFile', options), - listGitProviders: () => ipcRenderer.invoke('git.listGitProviders'), - initSignInToGitProvider: options => ipcRenderer.invoke('git.initSignInToGitProvider', options), - completeSignInToGitProvider: options => ipcRenderer.invoke('git.completeSignInToGitProvider', options), - getGitProviderRepositories: options => ipcRenderer.invoke('git.getGitProviderRepositories', options), - getGitProviderEmails: options => ipcRenderer.invoke('git.getGitProviderEmails', options), - getCurrentBranchByRepositoryId: options => ipcRenderer.invoke('git.getCurrentBranchByRepositoryId', options), + listGitProviders: () => invokeWithNormalizedError('git.listGitProviders'), + initSignInToGitProvider: options => invokeWithNormalizedError('git.initSignInToGitProvider', options), + completeSignInToGitProvider: options => invokeWithNormalizedError('git.completeSignInToGitProvider', options), + getGitProviderRepositories: options => invokeWithNormalizedError('git.getGitProviderRepositories', options), + getGitProviderEmails: options => invokeWithNormalizedError('git.getGitProviderEmails', options), + getCurrentBranchByRepositoryId: options => invokeWithNormalizedError('git.getCurrentBranchByRepositoryId', options), + getBranchRemoteInfo: options => invokeWithNormalizedError('git.getBranchRemoteInfo', options), + runAllGitRepoMigrations: () => invokeWithNormalizedError('git.runAllGitRepoMigrations'), }; const llm: LLMConfigServiceAPI = { - getActiveBackend: () => ipcRenderer.invoke('llm.getActiveBackend'), - setActiveBackend: (backend: LLMBackend) => ipcRenderer.invoke('llm.setActiveBackend', backend), - clearActiveBackend: () => ipcRenderer.invoke('llm.clearActiveBackend'), - getBackendConfig: (backend: LLMBackend) => ipcRenderer.invoke('llm.getBackendConfig', backend), + getActiveBackend: () => invokeWithNormalizedError('llm.getActiveBackend'), + setActiveBackend: (backend: LLMBackend) => invokeWithNormalizedError('llm.setActiveBackend', backend), + clearActiveBackend: () => invokeWithNormalizedError('llm.clearActiveBackend'), + getBackendConfig: (backend: LLMBackend) => invokeWithNormalizedError('llm.getBackendConfig', backend), updateBackendConfig: (backend: LLMBackend, config: Partial) => - ipcRenderer.invoke('llm.updateBackendConfig', backend, config), - getAllConfigurations: () => ipcRenderer.invoke('llm.getAllConfigurations'), - getCurrentConfig: () => ipcRenderer.invoke('llm.getCurrentConfig'), - getAIFeatureEnabled: (feature: AIFeatureNames) => ipcRenderer.invoke('llm.getAIFeatureEnabled', feature), + invokeWithNormalizedError('llm.updateBackendConfig', backend, config), + getAllConfigurations: () => invokeWithNormalizedError('llm.getAllConfigurations'), + getCurrentConfig: () => invokeWithNormalizedError('llm.getCurrentConfig'), + getAIFeatureEnabled: (feature: AIFeatureNames) => invokeWithNormalizedError('llm.getAIFeatureEnabled', feature), setAIFeatureEnabled: (feature: AIFeatureNames, enabled: boolean) => - ipcRenderer.invoke('llm.setAIFeatureEnabled', feature, enabled), + invokeWithNormalizedError('llm.setAIFeatureEnabled', feature, enabled), }; const main: Window['main'] = { @@ -168,33 +237,44 @@ const main: Window['main'] = { addExecutionStep: options => ipcRenderer.send('addExecutionStep', options), completeExecutionStep: options => ipcRenderer.send('completeExecutionStep', options), updateLatestStepName: options => ipcRenderer.send('updateLatestStepName', options), - getExecution: options => ipcRenderer.invoke('getExecution', options), + getExecution: options => invokeWithNormalizedError('getExecution', options), loginStateChange: () => ipcRenderer.send('loginStateChange'), restart: () => ipcRenderer.send('restart'), openInBrowser: options => ipcRenderer.send('openInBrowser', options), openDeepLink: options => ipcRenderer.send('openDeepLink', options), halfSecondAfterAppStart: () => ipcRenderer.send('halfSecondAfterAppStart'), manualUpdateCheck: () => ipcRenderer.send('manualUpdateCheck'), - backup: () => ipcRenderer.invoke('backup'), - restoreBackup: options => ipcRenderer.invoke('restoreBackup', options), - authorizeUserInWindow: options => ipcRenderer.invoke('authorizeUserInWindow', options), - authorizeUserInDefaultBrowser: options => ipcRenderer.invoke('authorizeUserInDefaultBrowser', options), - onDefaultBrowserOAuthRedirect: options => ipcRenderer.invoke('onDefaultBrowserOAuthRedirect', options), - cancelAuthorizationInDefaultBrowser: options => ipcRenderer.invoke('cancelAuthorizationInDefaultBrowser', options), + backup: () => invokeWithNormalizedError('backup'), + restoreBackup: options => invokeWithNormalizedError('restoreBackup', options), + authorizeUserInWindow: options => invokeWithNormalizedError('authorizeUserInWindow', options), + authorizeUserInDefaultBrowser: options => invokeWithNormalizedError('authorizeUserInDefaultBrowser', options), + onDefaultBrowserOAuthRedirect: options => invokeWithNormalizedError('onDefaultBrowserOAuthRedirect', options), + cancelAuthorizationInDefaultBrowser: options => + invokeWithNormalizedError('cancelAuthorizationInDefaultBrowser', options), setMenuBarVisibility: options => ipcRenderer.send('setMenuBarVisibility', options), - multipartBufferToArray: options => ipcRenderer.invoke('multipartBufferToArray', options), + multipartBufferToArray: options => invokeWithNormalizedError('multipartBufferToArray', options), installPlugin: (lookupName: string, allowScopedPackageNames = false) => - ipcRenderer.invoke('installPlugin', lookupName, allowScopedPackageNames), - curlRequest: options => ipcRenderer.invoke('curlRequest', options), + invokeWithNormalizedError('installPlugin', lookupName, allowScopedPackageNames), + initializeWorkspaceBackendProject: options => invokeWithNormalizedError('initializeWorkspaceBackendProject', options), + curlRequest: options => invokeWithNormalizedError('curlRequest', options), cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options), - writeFile: options => ipcRenderer.invoke('writeFile', options), - insecureReadFile: options => ipcRenderer.invoke('insecureReadFile', options), - insecureReadFileWithEncoding: options => ipcRenderer.invoke('insecureReadFileWithEncoding', options), - secureReadFile: options => ipcRenderer.invoke('secureReadFile', options), - parseImport: (...args) => ipcRenderer.invoke('parseImport', ...args), - readDir: options => ipcRenderer.invoke('readDir', options), - readOrCreateDataDir: options => ipcRenderer.invoke('readOrCreateDataDir', options), - lintSpec: options => ipcRenderer.invoke('lintSpec', options), + writeFile: options => invokeWithNormalizedError('writeFile', options), + writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options), + getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise => + invokeWithNormalizedError('getAuthHeader', renderedRequest, url), + getOAuth2Token: ( + requestId: string, + authentication: AuthTypeOAuth2, + forceRefresh?: boolean, + ): Promise => + invokeWithNormalizedError('getOAuth2Token', requestId, authentication, forceRefresh), + insecureReadFile: options => invokeWithNormalizedError('insecureReadFile', options), + insecureReadFileWithEncoding: options => invokeWithNormalizedError('insecureReadFileWithEncoding', options), + secureReadFile: options => invokeWithNormalizedError('secureReadFile', options), + parseImport: (...args) => invokeWithNormalizedError('parseImport', ...args), + readDir: options => invokeWithNormalizedError('readDir', options), + readOrCreateDataDir: options => invokeWithNormalizedError('readOrCreateDataDir', options), + lintSpec: options => invokeWithNormalizedError('lintSpec', options), on: (channel, listener) => { ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); @@ -207,6 +287,8 @@ const main: Window['main'] = { grpc, curl, secretStorage, + electronStorage, + sync, trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options), trackPageView: options => ipcRenderer.send('trackPageView', options), setCurrentOrganizationId: organizationId => ipcRenderer.send('analytics.setOrganizationId', organizationId), @@ -214,14 +296,14 @@ const main: Window['main'] = { showContextMenu: options => ipcRenderer.send('showContextMenu', options), database: { caCertificate: { - create: options => ipcRenderer.invoke('database.caCertificate.create', options), + create: options => invokeWithNormalizedError('database.caCertificate.create', options), }, }, hiddenBrowserWindow: { runScript: options => new Promise(async (resolve, reject) => { const isPortAlive = ports.get('hiddenWindowPort') !== undefined; - await ipcRenderer.invoke('open-channel-to-hidden-browser-window', isPortAlive); + await invokeWithNormalizedError('open-channel-to-hidden-browser-window', isPortAlive); const port = ports.get('hiddenWindowPort'); invariant(port, 'hiddenWindowPort is undefined'); @@ -238,8 +320,9 @@ const main: Window['main'] = { }), }, extractJsonFileFromPostmanDataDumpArchive: archivePath => - ipcRenderer.invoke('extractJsonFileFromPostmanDataDumpArchive', archivePath), - getLocalStorageDataFromFileOrigin: () => ipcRenderer.invoke('getLocalStorageDataFromFileOrigin'), + invokeWithNormalizedError('extractJsonFileFromPostmanDataDumpArchive', archivePath), + syncNewWorkspaceIfNeeded: options => invokeWithNormalizedError('syncNewWorkspaceIfNeeded', options), + getLocalStorageDataFromFileOrigin: () => invokeWithNormalizedError('getLocalStorageDataFromFileOrigin'), generateMockRouteDataFromSpec: ( openApiSpec: string | undefined, specUrl: string | undefined, @@ -248,7 +331,7 @@ const main: Window['main'] = { useDynamicMockResponses: boolean, mockServerAdditionalFiles: string[], ) => - ipcRenderer.invoke( + invokeWithNormalizedError( 'generateMockRouteDataFromSpec', openApiSpec, specUrl, @@ -258,15 +341,15 @@ const main: Window['main'] = { mockServerAdditionalFiles, ), generateCommitsFromDiff: (input: { diff: string; recent_commits: string }) => - ipcRenderer.invoke('generateCommitsFromDiff', input), + invokeWithNormalizedError('generateCommitsFromDiff', input), generateMcpSamplingResponse: (parameters: Parameters[0]) => - ipcRenderer.invoke('generateMcpSamplingResponse', parameters), + invokeWithNormalizedError('generateMcpSamplingResponse', parameters), }; ipcRenderer.on('hidden-browser-window-response-listener', event => { const [port] = event.ports; ports.set('hiddenWindowPort', port); - ipcRenderer.invoke('main-window-script-port-ready'); + invokeWithNormalizedError('main-window-script-port-ready'); }); const path: Window['path'] = { dirname: (p: string) => ipcRenderer.sendSync('path.dirname', p), @@ -275,8 +358,8 @@ const path: Window['path'] = { resolve: (...paths: string[]) => ipcRenderer.sendSync('path.resolve', ...paths), }; const dialog: Window['dialog'] = { - showOpenDialog: options => ipcRenderer.invoke('showOpenDialog', options), - showSaveDialog: options => ipcRenderer.invoke('showSaveDialog', options), + showOpenDialog: options => invokeWithNormalizedError('showOpenDialog', options), + showSaveDialog: options => invokeWithNormalizedError('showSaveDialog', options), }; const app: Window['app'] = { getPath: options => ipcRenderer.sendSync('getPath', options), @@ -289,7 +372,7 @@ const app: Window['app'] = { }; const shell: Window['shell'] = { showItemInFolder: options => ipcRenderer.send('showItemInFolder', options), - openPath: options => ipcRenderer.invoke('openPath', options), + openPath: options => invokeWithNormalizedError('openPath', options), }; const clipboard: Window['clipboard'] = { readText: () => ipcRenderer.sendSync('readText'), @@ -300,22 +383,9 @@ const webUtils: Window['webUtils'] = { getPathForFile: (file: File) => webUtilities.getPathForFile(file), }; const database: Window['database'] = { - invoke: (fnName, ...args) => ipcRenderer.invoke('database.invoke', fnName, ...args), + invoke: (fnName, ...args) => invokeWithNormalizedError('database.invoke', fnName, ...args), }; -const servicesProxy = new Proxy({} as Services, { - get(_target, serviceName: string) { - return new Proxy( - {}, - { - get(_target, methodName: string) { - return (...args: unknown[]) => ipcRenderer.invoke('services.invoke', serviceName, methodName, ...args); - }, - }, - ); - }, -}); - if (process.contextIsolated) { contextBridge.exposeInMainWorld('main', main); contextBridge.exposeInMainWorld('dialog', dialog); diff --git a/packages/insomnia/src/models/__tests__/git-credentials.test.ts b/packages/insomnia/src/insomnia-data/__tests__/git-credentials.test.ts similarity index 98% rename from packages/insomnia/src/models/__tests__/git-credentials.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/git-credentials.test.ts index 09add89702..c5c7cd1025 100644 --- a/packages/insomnia/src/models/__tests__/git-credentials.test.ts +++ b/packages/insomnia/src/insomnia-data/__tests__/git-credentials.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { type BaseGitCredentialsV2, models, services } from '~/insomnia-data'; +import type { BaseGitCredentialsV2 } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; const { init, isGitCredentialsV2, supportsRenewal } = models.gitCredentials; diff --git a/packages/insomnia/src/models/__tests__/grpc-request-meta.test.ts b/packages/insomnia/src/insomnia-data/__tests__/grpc-request-meta.test.ts similarity index 94% rename from packages/insomnia/src/models/__tests__/grpc-request-meta.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/grpc-request-meta.test.ts index d6c58f70b0..2dc7638c05 100644 --- a/packages/insomnia/src/models/__tests__/grpc-request-meta.test.ts +++ b/packages/insomnia/src/insomnia-data/__tests__/grpc-request-meta.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { services } from '~/insomnia-data'; - -import * as models from '../index'; +import { models, services } from '~/insomnia-data'; describe('init()', () => { it('contains all required fields', async () => { diff --git a/packages/insomnia/src/models/__tests__/grpc-request.test.ts b/packages/insomnia/src/insomnia-data/__tests__/grpc-request.test.ts similarity index 95% rename from packages/insomnia/src/models/__tests__/grpc-request.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/grpc-request.test.ts index 676c330f00..9364123673 100644 --- a/packages/insomnia/src/models/__tests__/grpc-request.test.ts +++ b/packages/insomnia/src/insomnia-data/__tests__/grpc-request.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { services } from '~/insomnia-data'; - -import * as models from '../index'; +import { models, services } from '~/insomnia-data'; describe('init()', () => { it('contains all required fields', async () => { diff --git a/packages/insomnia/src/models/__tests__/index.test.ts b/packages/insomnia/src/insomnia-data/__tests__/index.test.ts similarity index 89% rename from packages/insomnia/src/models/__tests__/index.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/index.test.ts index 94b70a010b..bae99f1748 100644 --- a/packages/insomnia/src/models/__tests__/index.test.ts +++ b/packages/insomnia/src/insomnia-data/__tests__/index.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { getModel, mustGetModel } from '../index'; -import * as models from '../index'; +import { models } from '~/insomnia-data'; + +const { getModel, mustGetModel } = models; describe('index', () => { describe('getModel()', () => { diff --git a/packages/insomnia/src/models/__tests__/proto-file.test.ts b/packages/insomnia/src/insomnia-data/__tests__/proto-file.test.ts similarity index 93% rename from packages/insomnia/src/models/__tests__/proto-file.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/proto-file.test.ts index 59e385bde8..92177b609c 100644 --- a/packages/insomnia/src/models/__tests__/proto-file.test.ts +++ b/packages/insomnia/src/insomnia-data/__tests__/proto-file.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { services } from '~/insomnia-data'; - -import * as models from '../index'; +import { models, services } from '~/insomnia-data'; describe('init()', () => { it('contains all required fields', async () => { diff --git a/packages/insomnia/src/models/__tests__/request-meta.test.ts b/packages/insomnia/src/insomnia-data/__tests__/request-meta.test.ts similarity index 93% rename from packages/insomnia/src/models/__tests__/request-meta.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/request-meta.test.ts index 3dd270f400..c765422b59 100644 --- a/packages/insomnia/src/models/__tests__/request-meta.test.ts +++ b/packages/insomnia/src/insomnia-data/__tests__/request-meta.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest'; import { services } from '~/insomnia-data'; -import * as models from '../index'; - describe('create()', () => { it('fails when missing parentId', async () => { expect(() => diff --git a/packages/insomnia/src/models/__tests__/request.test.ts b/packages/insomnia/src/insomnia-data/__tests__/request.test.ts similarity index 100% rename from packages/insomnia/src/models/__tests__/request.test.ts rename to packages/insomnia/src/insomnia-data/__tests__/request.test.ts diff --git a/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts b/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts index 2226493abc..8280dcb079 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts @@ -8,7 +8,9 @@ import NeDB from '@seald-io/nedb'; import { generateId } from '~/common/misc'; import type { + AllTypes, ApiSpec, + BaseModel, ChangeBufferEvent, ChangeListener, ChangeType, @@ -24,9 +26,7 @@ import type { Workspace, WorkspaceMeta, } from '~/insomnia-data'; -import type { AllTypes, BaseModel } from '~/models'; -import { mustGetModel } from '~/models'; -import * as models from '~/models'; +import { models } from '~/insomnia-data'; import { initModel } from './init-model'; import { repairDatabase } from './repair-database'; @@ -109,7 +109,7 @@ export const createNedbDatabase = ( const allDocs: { doc: BaseModel; parentId: string }[] = []; async function collectDescendants(doc: BaseModel): Promise { - const model = mustGetModel(doc.type); + const model = models.mustGetModel(doc.type); idMapping.set(doc._id, generateId(model.prefix)); const validChildTypes = (descendantMap[doc.type] ?? []).filter(t => models.canDuplicate(t)); diff --git a/packages/insomnia/src/insomnia-data/node-src/database/database.test.ts b/packages/insomnia/src/insomnia-data/node-src/database/database.test.ts index 2d5ae024a5..8d2353f1c0 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/database.test.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/database.test.ts @@ -1,7 +1,7 @@ import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BaseModel } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; -import type { BaseModel } from '~/models'; import type { ChangeBufferEvent } from '../..'; import { database as db } from '../..'; diff --git a/packages/insomnia/src/insomnia-data/node-src/database/init-model/index.ts b/packages/insomnia/src/insomnia-data/node-src/database/init-model/index.ts index 2678fd0944..c69ec4827c 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/init-model/index.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/init-model/index.ts @@ -1,7 +1,6 @@ import { generateId } from '~/common/misc'; +import type { AllTypes, BaseModel } from '~/insomnia-data'; import { models } from '~/insomnia-data'; -import type { AllTypes, BaseModel } from '~/models'; -import { getModel } from '~/models'; import { typedKeys } from '~/utils'; import { migrate as migrateCookieJar } from './cookie-jar'; @@ -11,7 +10,7 @@ import { migrate as migrateSettings } from './settings'; import { migrate as migrateWorkspace } from './workspace'; export async function initModel(type: AllTypes, ...sources: Record[]): Promise { - const model = getModel(type); + const model = models.getModel(type); if (!model) { const choices = models diff --git a/packages/insomnia/src/insomnia-data/node-src/database/init-model/response.test.ts b/packages/insomnia/src/insomnia-data/node-src/database/init-model/response.test.ts index 0f09952b97..852f656093 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/init-model/response.test.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/init-model/response.test.ts @@ -5,8 +5,7 @@ import zlib from 'node:zlib'; import { describe, expect, it } from 'vitest'; -import { models } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { initModel } from './index'; @@ -17,7 +16,7 @@ describe('migrate()', () => { const response = await initModel(models.response.type, { bodyPath, }); - const body = (await getBodyBuffer(response)).toString(); + const body = (await services.helpers.getResponseBodyBuffer(response)).toString(); expect(response.bodyCompression).toBe('zip'); expect(body).toBe('Hello World!'); }); diff --git a/packages/insomnia/src/insomnia-data/node-src/database/repair-database.ts b/packages/insomnia/src/insomnia-data/node-src/database/repair-database.ts index 618939a643..053f85f1ff 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/repair-database.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/repair-database.ts @@ -1,6 +1,5 @@ import type { CookieJar, Environment, GitRepository, Workspace } from '~/insomnia-data'; -import { database } from '~/insomnia-data'; -import * as models from '~/models/index'; +import { database, models } from '~/insomnia-data'; import * as apiSpecServices from '../services/api-spec'; diff --git a/packages/insomnia/src/insomnia-data/node-src/services/helpers/index.ts b/packages/insomnia/src/insomnia-data/node-src/services/helpers/index.ts new file mode 100644 index 0000000000..19b8fdc600 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './query-all-workspace-urls'; +export * from './request-operations'; +export * from './response-operations'; diff --git a/packages/insomnia/src/models/helpers/__tests__/query-all-workspace-urls.test.ts b/packages/insomnia/src/insomnia-data/node-src/services/helpers/query-all-workspace-urls.test.ts similarity index 94% rename from packages/insomnia/src/models/helpers/__tests__/query-all-workspace-urls.test.ts rename to packages/insomnia/src/insomnia-data/node-src/services/helpers/query-all-workspace-urls.test.ts index a1628166a1..78cada9c4a 100644 --- a/packages/insomnia/src/models/helpers/__tests__/query-all-workspace-urls.test.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/helpers/query-all-workspace-urls.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; -import * as models from '../../index'; -import { queryAllWorkspaceUrls } from '../query-all-workspace-urls'; +import { queryAllWorkspaceUrls } from './query-all-workspace-urls'; describe('queryAllWorkspaceUrls', () => { it('should return empty array when no requests exist', async () => { diff --git a/packages/insomnia/src/models/helpers/query-all-workspace-urls.ts b/packages/insomnia/src/insomnia-data/node-src/services/helpers/query-all-workspace-urls.ts similarity index 76% rename from packages/insomnia/src/models/helpers/query-all-workspace-urls.ts rename to packages/insomnia/src/insomnia-data/node-src/services/helpers/query-all-workspace-urls.ts index b8ce195c95..6586b0fd71 100644 --- a/packages/insomnia/src/models/helpers/query-all-workspace-urls.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/helpers/query-all-workspace-urls.ts @@ -1,15 +1,15 @@ import type { GrpcRequest, models, Request } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { database as db } from '~/insomnia-data'; +import { invariant } from '~/utils/invariant'; -import { database as db } from '../../common/database'; -import { invariant } from '../../utils/invariant'; +import * as workspaceService from '../workspace'; export const queryAllWorkspaceUrls = async ( workspaceId: string, reqType: typeof models.request.type | typeof models.grpcRequest.type, reqId = 'n/a', ): Promise => { - const workspace = await services.workspace.getById(workspaceId); + const workspace = await workspaceService.getById(workspaceId); invariant(workspace, `Workspace ${workspaceId} not found`); const docs = (await db.getWithDescendants(workspace, [reqType])) as (Request | GrpcRequest)[]; const urls = docs diff --git a/packages/insomnia/src/models/helpers/request-operations.ts b/packages/insomnia/src/insomnia-data/node-src/services/helpers/request-operations.ts similarity index 52% rename from packages/insomnia/src/models/helpers/request-operations.ts rename to packages/insomnia/src/insomnia-data/node-src/services/helpers/request-operations.ts index 9493a96927..6b901a6ff0 100644 --- a/packages/insomnia/src/models/helpers/request-operations.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/helpers/request-operations.ts @@ -1,16 +1,20 @@ import type { GrpcRequest, McpRequest, Request, SocketIORequest, WebSocketRequest } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; -import * as models from '../index'; +import * as grpcRequestService from '../grpc-request'; +import * as mcpRequestService from '../mcp-request'; +import * as requestService from '../request'; +import * as socketIORequestService from '../socket-io-request'; +import * as webSocketRequestService from '../websocket-request'; -export function findByParentId( +export function findRequestByParentId( parentId: string, ): Promise<(Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest)[]> { return Promise.all([ - services.request.findByParentId(parentId), - services.grpcRequest.findByParentId(parentId), - services.webSocketRequest.findByParentId(parentId), - services.socketIORequest.findByParentId(parentId), + requestService.findByParentId(parentId), + grpcRequestService.findByParentId(parentId), + webSocketRequestService.findByParentId(parentId), + socketIORequestService.findByParentId(parentId), ]).then(([requests, grpcRequests, webSocketRequests, socketIORequests]) => [ ...requests, ...grpcRequests, @@ -19,88 +23,88 @@ export function findByParentId( ]); } -export function getById( +export function getRequestById( requestId: string, ): Promise { if (models.grpcRequest.isGrpcRequestId(requestId)) { - return services.grpcRequest.getById(requestId); + return grpcRequestService.getById(requestId); } if (models.webSocketRequest.isWebSocketRequestId(requestId)) { - return services.webSocketRequest.getById(requestId); + return webSocketRequestService.getById(requestId); } if (models.socketIORequest.isSocketIORequestId(requestId)) { - return services.socketIORequest.getById(requestId); + return socketIORequestService.getById(requestId); } if (models.mcpRequest.isMcpRequestId(requestId)) { - return services.mcpRequest.getById(requestId); + return mcpRequestService.getById(requestId); } - return services.request.getById(requestId); + return requestService.getById(requestId); } -export function remove(request: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest) { +export function removeRequest(request: Request | GrpcRequest | WebSocketRequest | SocketIORequest | McpRequest) { if (models.grpcRequest.isGrpcRequest(request)) { - return services.grpcRequest.remove(request); + return grpcRequestService.remove(request); } if (models.webSocketRequest.isWebSocketRequest(request)) { - return services.webSocketRequest.remove(request); + return webSocketRequestService.remove(request); } if (models.socketIORequest.isSocketIORequest(request)) { - return services.socketIORequest.remove(request); + return socketIORequestService.remove(request); } if (models.mcpRequest.isMcpRequest(request)) { - return services.mcpRequest.remove(request); + return mcpRequestService.remove(request); } - return services.request.remove(request); + return requestService.remove(request); } -export function update(request: T, patch: Partial = {}): Promise { +export function updateRequest(request: T, patch: Partial = {}): Promise { // @ts-expect-error -- TSCONVERSION if (models.grpcRequest.isGrpcRequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.grpcRequest.update(request, patch); + return grpcRequestService.update(request, patch); } // @ts-expect-error -- TSCONVERSION if (models.webSocketRequest.isWebSocketRequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.webSocketRequest.update(request, patch); + return webSocketRequestService.update(request, patch); } // @ts-expect-error -- TSCONVERSION if (models.socketIORequest.isSocketIORequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.socketIORequest.update(request, patch); + return socketIORequestService.update(request, patch); } // @ts-expect-error -- TSCONVERSION if (models.mcpRequest.isMcpRequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.mcpRequest.update(request, patch); + return mcpRequestService.update(request, patch); } // @ts-expect-error -- TSCONVERSION - return services.request.update(request, patch); + return requestService.update(request, patch); } -export function duplicate(request: T, patch: Partial = {}): Promise { +export function duplicateRequest(request: T, patch: Partial = {}): Promise { // @ts-expect-error -- TSCONVERSION if (models.grpcRequest.isGrpcRequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.grpcRequest.duplicate(request, patch); + return grpcRequestService.duplicate(request, patch); } // @ts-expect-error -- TSCONVERSION if (models.webSocketRequest.isWebSocketRequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.webSocketRequest.duplicate(request, patch); + return webSocketRequestService.duplicate(request, patch); } // @ts-expect-error -- TSCONVERSION if (models.socketIORequest.isSocketIORequest(request)) { // @ts-expect-error -- TSCONVERSION - return services.socketIORequest.duplicate(request, patch); + return socketIORequestService.duplicate(request, patch); } // @ts-expect-error -- TSCONVERSION - return services.request.duplicate(request, patch); + return requestService.duplicate(request, patch); } diff --git a/packages/insomnia/src/models/helpers/response-operations.ts b/packages/insomnia/src/insomnia-data/node-src/services/helpers/response-operations.ts similarity index 79% rename from packages/insomnia/src/models/helpers/response-operations.ts rename to packages/insomnia/src/insomnia-data/node-src/services/helpers/response-operations.ts index f29aa8c272..4026ab3acb 100644 --- a/packages/insomnia/src/models/helpers/response-operations.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/helpers/response-operations.ts @@ -1,18 +1,17 @@ import fs from 'node:fs'; -import type { Readable } from 'node:stream'; import zlib from 'node:zlib'; -import { database as db } from '~/common/database'; import type { Compression, McpResponse, Response, SocketIOResponse, WebSocketResponse } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; import type { ResponseTimelineEntry } from '~/main/network/libcurl-promise'; -import * as models from '~/models/index'; import { deserializeNDJSON } from '~/utils/ndjson'; +import * as settingsService from '../settings'; + const { isResponse, type: responseType } = models.response; export async function removeResponsesForRequest(requestId: string, environmentId?: string | null) { - const settings = await services.settings.get(); + const settings = await settingsService.get(); const query: Record = { parentId: requestId, }; @@ -70,28 +69,9 @@ export function removeResponse(response: Response | WebSocketResponse | SocketIO return db.remove(response); } -export const getBodyStream = ( - response?: { bodyPath?: string; bodyCompression?: Compression }, - readFailureValue?: string, -): Readable | string | null => { - if (!response?.bodyPath) { - return null; - } - try { - fs.statSync(response?.bodyPath); - } catch (err) { - console.warn('Failed to read response body', err.message); - return readFailureValue === undefined ? null : readFailureValue; - } - if (response?.bodyCompression === 'zip') { - return fs.createReadStream(response?.bodyPath).pipe(zlib.createGunzip()); - } - return fs.createReadStream(response?.bodyPath); -}; - export const readCurlResponse = async (options: { bodyPath?: string; bodyCompression?: Compression }) => { const readFailureMsg = '[main/curlBridgeAPI] failed to read response body message'; - const bodyBufferOrErrMsg = await getBodyBuffer(options, readFailureMsg); + const bodyBufferOrErrMsg = await getResponseBodyBuffer(options, readFailureMsg); // TODO(jackkav): simplify the fail msg and reuse in other getBodyBuffer renderer calls if (!bodyBufferOrErrMsg) { @@ -106,7 +86,7 @@ export const readCurlResponse = async (options: { bodyPath?: string; bodyCompres return { body: bodyBufferOrErrMsg.toString('utf8'), error: '' }; }; -export function getTimeline(response: Response, showBody?: boolean) { +export async function getResponseTimeline(response: Response, showBody?: boolean): Promise { const { timelinePath, bodyPath } = response; if (!timelinePath) { @@ -114,7 +94,7 @@ export function getTimeline(response: Response, showBody?: boolean) { } try { - const rawBuffer = fs.readFileSync(timelinePath); + const rawBuffer = await fs.promises.readFile(timelinePath); const timelineString = rawBuffer.toString(); const timeline = deserializeNDJSON(timelineString); @@ -123,7 +103,7 @@ export function getTimeline(response: Response, showBody?: boolean) { { name: 'DataOut', timestamp: Date.now(), - value: fs.readFileSync(bodyPath).toString(), + value: (await fs.promises.readFile(bodyPath)).toString(), }, ] : []; @@ -135,7 +115,7 @@ export function getTimeline(response: Response, showBody?: boolean) { } } -export const getBodyBuffer = async ( +export const getResponseBodyBuffer = async ( response?: { bodyPath?: string; bodyCompression?: Compression }, readFailureValue?: string, ): Promise => { diff --git a/packages/insomnia/src/insomnia-data/node-src/services/index.ts b/packages/insomnia/src/insomnia-data/node-src/services/index.ts index a914ba3046..f69c65abde 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/index.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/index.ts @@ -8,6 +8,7 @@ import * as gitCredentialsService from './git-credentials'; import * as gitRepositoryService from './git-repository'; import * as grpcRequestService from './grpc-request'; import * as grpcRequestMetaService from './grpc-request-meta'; +import * as helpersService from './helpers'; import * as mcpPayloadService from './mcp-payload'; import * as mcpRequestService from './mcp-request'; import * as mcpResponseService from './mcp-response'; @@ -89,4 +90,5 @@ export const servicesNodeImpl = { webSocketRequest: webSocketRequestService, webSocketRequestMeta: webSocketRequestMetaService, webSocketResponse: webSocketResponseService, + helpers: helpersService, } satisfies Record Promise>>; diff --git a/packages/insomnia/src/insomnia-data/node-src/services/mcp-response.ts b/packages/insomnia/src/insomnia-data/node-src/services/mcp-response.ts index f18bcb81a5..7313dd29cf 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/mcp-response.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/mcp-response.ts @@ -1,7 +1,7 @@ import type { McpResponse } from '~/insomnia-data'; import { database as db, models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; +import * as requestHelpers from './helpers/request-operations'; import * as requestVersionService from './request-version'; import * as settingsService from './settings'; @@ -26,7 +26,7 @@ export async function create(patch: Partial = {}, maxResponses = 20 const { parentId } = patch; // Create request version snapshot - const request = await requestOperations.getById(parentId); + const request = await requestHelpers.getRequestById(parentId); const requestVersion = request ? await requestVersionService.create(request) : null; patch.requestVersionId = requestVersion ? requestVersion._id : null; // Filter responses by environment if setting is enabled diff --git a/packages/insomnia/src/insomnia-data/node-src/services/project.ts b/packages/insomnia/src/insomnia-data/node-src/services/project.ts index 8deb401175..41d57097ce 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/project.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/project.ts @@ -16,8 +16,9 @@ export function getByRemoteId(remoteId: string) { } export function getAllByGitRepositoryIds(gitRepositoryIds: string[]) { + const queryIds = gitRepositoryIds.flatMap(id => models.project.getQueryableGitRepositoryIds(id)); return db.find(type, { - gitRepositoryId: { $in: gitRepositoryIds }, + gitRepositoryId: { $in: queryIds }, }); } diff --git a/packages/insomnia/src/insomnia-data/node-src/services/request-version.ts b/packages/insomnia/src/insomnia-data/node-src/services/request-version.ts index 506f89a517..396df6f427 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/request-version.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/request-version.ts @@ -10,7 +10,8 @@ import type { WebSocketRequest, } from '~/insomnia-data'; import { database, database as db, models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; + +import * as requestHelpers from './helpers/request-operations'; const { isRequest } = models.request; const { type } = models.requestVersion; @@ -84,7 +85,7 @@ export async function restore(requestVersionId: string) { return null; } - const originalRequest = await requestOperations.getById(requestPatch._id); + const originalRequest = await requestHelpers.getRequestById(requestPatch._id); if (!originalRequest) { return null; @@ -97,7 +98,7 @@ export async function restore(requestVersionId: string) { } } - return requestOperations.update(originalRequest, requestPatch); + return requestHelpers.updateRequest(originalRequest, requestPatch); } function _diffRequests( rOld: Request | WebSocketRequest | SocketIORequest | McpRequest | null, diff --git a/packages/insomnia/src/insomnia-data/node-src/services/response.ts b/packages/insomnia/src/insomnia-data/node-src/services/response.ts index e963d6bcc4..d9372aee63 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/response.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/response.ts @@ -1,8 +1,8 @@ import { database as db } from '~/common/database'; import type { Response } from '~/insomnia-data'; import { models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; +import * as requestHelpers from './helpers/request-operations'; import * as requestVersionService from './request-version'; import * as settingsService from './settings'; @@ -46,7 +46,7 @@ export async function create(patch: Partial = {}, maxResponses = 20): const { parentId } = patch; // Create request version snapshot - const request = await requestOperations.getById(parentId); + const request = await requestHelpers.getRequestById(parentId); const requestVersion = request ? await requestVersionService.create(request) : null; patch.requestVersionId = requestVersion ? requestVersion._id : null; // Filter responses by environment if setting is enabled diff --git a/packages/insomnia/src/insomnia-data/node-src/services/socket-io-response.ts b/packages/insomnia/src/insomnia-data/node-src/services/socket-io-response.ts index d8771f0077..03ee4218f3 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/socket-io-response.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/socket-io-response.ts @@ -1,7 +1,7 @@ import type { SocketIOResponse } from '~/insomnia-data'; import { database as db, models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; +import * as requestHelpers from './helpers/request-operations'; import * as requestVersionService from './request-version'; import * as settingsService from './settings'; @@ -30,7 +30,7 @@ export async function create(patch: Partial = {}, maxResponses const { parentId } = patch; // Create request version snapshot - const request = await requestOperations.getById(parentId); + const request = await requestHelpers.getRequestById(parentId); const requestVersion = request ? await requestVersionService.create(request) : null; patch.requestVersionId = requestVersion ? requestVersion._id : null; // Filter responses by environment if setting is enabled diff --git a/packages/insomnia/src/insomnia-data/node-src/services/websocket-response.ts b/packages/insomnia/src/insomnia-data/node-src/services/websocket-response.ts index 0ecddfc77f..58208b8552 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/websocket-response.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/websocket-response.ts @@ -1,7 +1,7 @@ import type { WebSocketResponse } from '~/insomnia-data'; import { database as db, models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; +import * as requestHelpers from './helpers/request-operations'; import * as requestVersionService from './request-version'; import * as settingsService from './settings'; @@ -26,7 +26,7 @@ export async function create(patch: Partial = {}, maxResponse const { parentId } = patch; // Create request version snapshot - const request = await requestOperations.getById(parentId); + const request = await requestHelpers.getRequestById(parentId); const requestVersion = request ? await requestVersionService.create(request) : null; patch.requestVersionId = requestVersion ? requestVersion._id : null; // Filter responses by environment if setting is enabled diff --git a/packages/insomnia/src/insomnia-data/node-src/types.d.ts b/packages/insomnia/src/insomnia-data/node-src/types.d.ts new file mode 100644 index 0000000000..223d8de7de --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/types.d.ts @@ -0,0 +1 @@ +type ServicesNodeImpl = typeof import('./services').servicesNodeImpl; diff --git a/packages/insomnia/src/insomnia-data/node-src/types.ts b/packages/insomnia/src/insomnia-data/node-src/types.ts deleted file mode 100644 index 2908f1ba73..0000000000 --- a/packages/insomnia/src/insomnia-data/node-src/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Keep the Services type tied to the real implementation without introducing a runtime import. -// `import type` from `./services` still gets followed by the bundler in this setup and recreates -// the circular dependency, so we use a type query here instead. -// TODO: Long term, once `src/models/index.ts` is removed from this dependency path and `insomnia-data` -// no longer gets pulled back in through the legacy models barrel, this can go back to a normal -// `import type`-based alias and the lint suppression below can be dropped. -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -export type Services = typeof import('./services').servicesNodeImpl; diff --git a/packages/insomnia/src/insomnia-data/src/database/types.ts b/packages/insomnia/src/insomnia-data/src/database/types.ts index 365a2b70b7..c509e71578 100644 --- a/packages/insomnia/src/insomnia-data/src/database/types.ts +++ b/packages/insomnia/src/insomnia-data/src/database/types.ts @@ -1,7 +1,7 @@ // Database interfaces and types for IoC pattern // This file defines the abstract interface that different database implementations must adhere to. -import type { AllTypes, BaseModel } from '~/models/types'; +import type { AllTypes, BaseModel } from '../models/types'; // Avoid import nedb here to prevent it injecting the node types into renderer process export interface DataStoreOptions { diff --git a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts index 6caca7133d..adc691fd23 100644 --- a/packages/insomnia/src/insomnia-data/src/models/api-spec.ts +++ b/packages/insomnia/src/insomnia-data/src/models/api-spec.ts @@ -1,5 +1,6 @@ import { strings } from '~/common/strings'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; export const name = 'ApiSpec'; diff --git a/packages/insomnia/src/models/types.ts b/packages/insomnia/src/insomnia-data/src/models/base-types.ts similarity index 100% rename from packages/insomnia/src/models/types.ts rename to packages/insomnia/src/insomnia-data/src/models/base-types.ts diff --git a/packages/insomnia/src/insomnia-data/src/models/ca-certificate.ts b/packages/insomnia/src/insomnia-data/src/models/ca-certificate.ts index 0269be9e2e..63c4b5351b 100644 --- a/packages/insomnia/src/insomnia-data/src/models/ca-certificate.ts +++ b/packages/insomnia/src/insomnia-data/src/models/ca-certificate.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'CA Certificate'; diff --git a/packages/insomnia/src/insomnia-data/src/models/client-certificate.ts b/packages/insomnia/src/insomnia-data/src/models/client-certificate.ts index 973d431455..c7ae399fd8 100644 --- a/packages/insomnia/src/insomnia-data/src/models/client-certificate.ts +++ b/packages/insomnia/src/insomnia-data/src/models/client-certificate.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Client Certificate'; diff --git a/packages/insomnia/src/insomnia-data/src/models/cloud-credential.ts b/packages/insomnia/src/insomnia-data/src/models/cloud-credential.ts index 21debac861..cb92b9157f 100644 --- a/packages/insomnia/src/insomnia-data/src/models/cloud-credential.ts +++ b/packages/insomnia/src/insomnia-data/src/models/cloud-credential.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export type CloudProviderName = 'aws' | 'azure' | 'gcp' | 'hashicorp'; diff --git a/packages/insomnia/src/insomnia-data/src/models/cookie-jar.ts b/packages/insomnia/src/insomnia-data/src/models/cookie-jar.ts index ad0ded7fb3..a19c68d03e 100644 --- a/packages/insomnia/src/insomnia-data/src/models/cookie-jar.ts +++ b/packages/insomnia/src/insomnia-data/src/models/cookie-jar.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Cookie Jar'; diff --git a/packages/insomnia/src/insomnia-data/src/models/environment.ts b/packages/insomnia/src/insomnia-data/src/models/environment.ts index add7ddb179..b19ea0e72d 100644 --- a/packages/insomnia/src/insomnia-data/src/models/environment.ts +++ b/packages/insomnia/src/insomnia-data/src/models/environment.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Environment'; export const type = 'Environment'; diff --git a/packages/insomnia/src/insomnia-data/src/models/git-credentials.ts b/packages/insomnia/src/insomnia-data/src/models/git-credentials.ts index c7b3ca83d2..b768088e51 100644 --- a/packages/insomnia/src/insomnia-data/src/models/git-credentials.ts +++ b/packages/insomnia/src/insomnia-data/src/models/git-credentials.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export type OauthProviderName = 'gitlab' | 'github'; diff --git a/packages/insomnia/src/insomnia-data/src/models/git-repository.ts b/packages/insomnia/src/insomnia-data/src/models/git-repository.ts index e2e1a1caa2..f5dab78c7a 100644 --- a/packages/insomnia/src/insomnia-data/src/models/git-repository.ts +++ b/packages/insomnia/src/insomnia-data/src/models/git-repository.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export type OauthProviderName = 'gitlab' | 'github' | 'custom'; @@ -31,6 +31,7 @@ export function init(): BaseGitRepository { hasUncommittedChanges: false, hasUnpushedChanges: false, uriNeedsMigration: true, + repoMigrationVersion: 0, }; } @@ -60,6 +61,14 @@ export interface BaseGitRepository { cachedGitLastAuthor: string | null; hasUnpushedChanges: boolean; uriNeedsMigration: boolean; + /** + * Tracks which version of the on-disk repo structure migration has run. + * When an older app version processes this document via docUpdate it will + * prune this field (since its init() doesn't include it), which causes the + * migration to re-run on the next upgrade — exactly the desired behaviour + * for version-rollback scenarios. + */ + repoMigrationVersion: number; } export const isGitRepository = (model: Pick): model is GitRepository => model.type === type; diff --git a/packages/insomnia/src/insomnia-data/src/models/grpc-request-meta.ts b/packages/insomnia/src/insomnia-data/src/models/grpc-request-meta.ts index 25094d1529..67205cd745 100644 --- a/packages/insomnia/src/insomnia-data/src/models/grpc-request-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/grpc-request-meta.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'gRPC Request Meta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/grpc-request.ts b/packages/insomnia/src/insomnia-data/src/models/grpc-request.ts index cb26275fa7..e5f8f79032 100644 --- a/packages/insomnia/src/insomnia-data/src/models/grpc-request.ts +++ b/packages/insomnia/src/insomnia-data/src/models/grpc-request.ts @@ -1,5 +1,5 @@ -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; export const name = 'gRPC Request'; export const type = 'GrpcRequest'; diff --git a/packages/insomnia/src/models/helpers/__tests__/is-model.test.ts b/packages/insomnia/src/insomnia-data/src/models/index.test.ts similarity index 99% rename from packages/insomnia/src/models/helpers/__tests__/is-model.test.ts rename to packages/insomnia/src/insomnia-data/src/models/index.test.ts index 9a90b9de2b..5af9c8dbfd 100644 --- a/packages/insomnia/src/models/helpers/__tests__/is-model.test.ts +++ b/packages/insomnia/src/insomnia-data/src/models/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { generateId } from '../../../common/misc'; -import * as models from '../../index'; +import * as models from './'; const { isProtoDirectory } = models.protoDirectory; const { isProtoFile } = models.protoFile; diff --git a/packages/insomnia/src/insomnia-data/src/models/index.ts b/packages/insomnia/src/insomnia-data/src/models/index.ts index 9077f06857..f454b30fe7 100644 --- a/packages/insomnia/src/insomnia-data/src/models/index.ts +++ b/packages/insomnia/src/insomnia-data/src/models/index.ts @@ -1,8 +1,11 @@ -// models - export models that define the structure of the data and any related functions such as init, type guards +import type { AllTypes, BaseModel } from './base-types'; import * as dbModels from './db-models'; +// models - export models that define the structure of the data and any related functions such as init, type guards export * from './db-models'; +export * as organization from './organization'; + // Type assertion to ensure dbModels has the expected structure dbModels satisfies Record< string, @@ -21,3 +24,152 @@ dbModels satisfies Record< export const all = () => Object.values(dbModels); export const types = () => all().map(model => model.type); + +export const isValidType = (type: string): type is AllTypes => { + return types().includes(type as AllTypes); +}; +export function canSync(d: BaseModel) { + if (d.isPrivate) { + return false; + } + + const m = getModel(d.type); + + if (!m) { + return false; + } + + return m.canSync || false; +} + +export function getModel(type: string) { + return all().find(m => m.type === type) || null; +} + +export function mustGetModel(type: string) { + const model = getModel(type); + + if (!model) { + throw new Error(`The model type ${type} must exist but could not be found.`); + } + + return model; +} + +export function canDuplicate(type: string) { + const model = getModel(type); + return model ? model.canDuplicate : false; +} + +export function rewriteReferences(doc: T, idMapping: Map): T { + const model = getModel(doc.type); + if (!model) return doc; + return 'rewriteReferences' in model + ? (model.rewriteReferences as unknown as (doc: T, idMapping: Map) => T)(doc, idMapping) + : doc; +} + +// Use function instead of object to avoid issues with circular dependencies +export const getAllDescendantMap = (): Partial> => { + return { + [dbModels.project.type]: [dbModels.workspace.type], + [dbModels.workspace.type]: [ + dbModels.requestGroup.type, + dbModels.request.type, + dbModels.grpcRequest.type, + dbModels.webSocketRequest.type, + dbModels.socketIORequest.type, + dbModels.cookieJar.type, + dbModels.environment.type, + dbModels.apiSpec.type, + dbModels.mockServer.type, + dbModels.unitTestSuite.type, + dbModels.protoDirectory.type, + dbModels.protoFile.type, + dbModels.workspaceMeta.type, + dbModels.runnerTestResult.type, + dbModels.caCertificate.type, + dbModels.clientCertificate.type, + dbModels.mcpRequest.type, + ], + [dbModels.requestGroup.type]: [ + dbModels.requestGroup.type, + dbModels.request.type, + dbModels.grpcRequest.type, + dbModels.webSocketRequest.type, + dbModels.socketIORequest.type, + dbModels.runnerTestResult.type, + dbModels.requestGroupMeta.type, + dbModels.oAuth2Token.type, + ], + [dbModels.request.type]: [ + dbModels.requestMeta.type, + dbModels.response.type, + dbModels.requestVersion.type, + dbModels.oAuth2Token.type, + ], + [dbModels.grpcRequest.type]: [dbModels.grpcRequestMeta.type], + [dbModels.webSocketRequest.type]: [ + dbModels.webSocketPayload.type, + dbModels.webSocketResponse.type, + dbModels.requestMeta.type, + ], + [dbModels.socketIORequest.type]: [ + dbModels.socketIOPayload.type, + dbModels.socketIOResponse.type, + dbModels.requestMeta.type, + ], + [dbModels.mcpRequest.type]: [dbModels.mcpPayload.type, dbModels.mcpResponse.type], + [dbModels.mockServer.type]: [dbModels.mockRoute.type], + [dbModels.environment.type]: [dbModels.environment.type], + [dbModels.unitTestSuite.type]: [dbModels.unitTest.type, dbModels.unitTestResult.type], + [dbModels.unitTest.type]: [dbModels.unitTestResult.type], + [dbModels.protoDirectory.type]: [dbModels.protoDirectory.type, dbModels.protoFile.type], + }; +}; + +let childToParentMap: Partial> | undefined; + +const getChildToParentMap = () => { + if (childToParentMap) { + return childToParentMap; + } + const childToParents: Partial> = {}; + for (const [parent, children] of Object.entries(getAllDescendantMap())) { + for (const child of children) { + if (!childToParents[child]) childToParents[child] = []; + childToParents[child].push(parent as AllTypes); + } + } + childToParentMap = childToParents; + return childToParents; +}; + +export const generateDescendantMap = (queryTypes: AllTypes[]): Partial> => { + const result: Partial> = {}; + + const visited = new Set(); + const collectAncestors = (child: AllTypes) => { + if (!child || visited.has(child)) { + return; + } + visited.add(child); + const parentMap = getChildToParentMap(); + const parents = parentMap[child]; + if (parents?.length) { + for (const p of parents) { + if (!result[p]) { + result[p] = []; + } + result[p].push(child); + collectAncestors(p); + } + } + }; + + for (const type of queryTypes) { + collectAncestors(type); + } + + return result; +}; diff --git a/packages/insomnia/src/insomnia-data/src/models/mcp-payload.ts b/packages/insomnia/src/insomnia-data/src/models/mcp-payload.ts index e1764a0b12..2efba57fb1 100644 --- a/packages/insomnia/src/insomnia-data/src/models/mcp-payload.ts +++ b/packages/insomnia/src/insomnia-data/src/models/mcp-payload.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'MCP Payload'; diff --git a/packages/insomnia/src/insomnia-data/src/models/mcp-request.ts b/packages/insomnia/src/insomnia-data/src/models/mcp-request.ts index 81eac0d90a..99b323dc9a 100644 --- a/packages/insomnia/src/insomnia-data/src/models/mcp-request.ts +++ b/packages/insomnia/src/insomnia-data/src/models/mcp-request.ts @@ -1,9 +1,8 @@ import type { Root } from '@modelcontextprotocol/sdk/types.js'; -import type { RequestAuthentication, RequestHeader } from '~/insomnia-data'; -import type { BaseModel } from '~/models/types'; - +import type { BaseModel } from './base-types'; import type { EnvironmentKvPairData } from './environment'; +import type { RequestAuthentication, RequestHeader } from './request'; export const name = 'MCP Request'; export const type = 'McpRequest'; diff --git a/packages/insomnia/src/insomnia-data/src/models/mcp-response.ts b/packages/insomnia/src/insomnia-data/src/models/mcp-response.ts index f200cbc09f..75f4f60afb 100644 --- a/packages/insomnia/src/insomnia-data/src/models/mcp-response.ts +++ b/packages/insomnia/src/insomnia-data/src/models/mcp-response.ts @@ -1,7 +1,6 @@ -import type { ResponseHeader } from '~/insomnia-data'; -import type { BaseModel } from '~/models/types'; - +import type { BaseModel } from './base-types'; import { type McpTransportType, TRANSPORT_TYPES } from './mcp-request'; +import type { ResponseHeader } from './response'; export const name = 'Mcp Response'; export const type = 'McpResponse'; diff --git a/packages/insomnia/src/insomnia-data/src/models/mock-route.ts b/packages/insomnia/src/insomnia-data/src/models/mock-route.ts index 8a295fd4b0..8a4c7534a4 100644 --- a/packages/insomnia/src/insomnia-data/src/models/mock-route.ts +++ b/packages/insomnia/src/insomnia-data/src/models/mock-route.ts @@ -1,5 +1,5 @@ -import type { RequestHeader } from '~/insomnia-data'; -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; +import type { RequestHeader } from './request'; export const name = 'Mock Route'; diff --git a/packages/insomnia/src/insomnia-data/src/models/mock-server.ts b/packages/insomnia/src/insomnia-data/src/models/mock-server.ts index 3a275c9a42..34ee536b5b 100644 --- a/packages/insomnia/src/insomnia-data/src/models/mock-server.ts +++ b/packages/insomnia/src/insomnia-data/src/models/mock-server.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Mock Server'; diff --git a/packages/insomnia/src/insomnia-data/src/models/o-auth-2-token.ts b/packages/insomnia/src/insomnia-data/src/models/o-auth-2-token.ts index 3f08318434..23b99e6ba8 100644 --- a/packages/insomnia/src/insomnia-data/src/models/o-auth-2-token.ts +++ b/packages/insomnia/src/insomnia-data/src/models/o-auth-2-token.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'OAuth 2.0 Token'; diff --git a/packages/insomnia/src/models/organization.ts b/packages/insomnia/src/insomnia-data/src/models/organization.ts similarity index 94% rename from packages/insomnia/src/models/organization.ts rename to packages/insomnia/src/insomnia-data/src/models/organization.ts index 4d7166166e..1a0dbcfbf0 100644 --- a/packages/insomnia/src/models/organization.ts +++ b/packages/insomnia/src/insomnia-data/src/models/organization.ts @@ -1,4 +1,4 @@ -import { type Organization, type PersonalPlanType } from 'insomnia-api'; +import type { Organization, PersonalPlanType } from 'insomnia-api'; export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad'; export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID; diff --git a/packages/insomnia/src/insomnia-data/src/models/plugin-data.ts b/packages/insomnia/src/insomnia-data/src/models/plugin-data.ts index 214fb7af3d..4498d25001 100644 --- a/packages/insomnia/src/insomnia-data/src/models/plugin-data.ts +++ b/packages/insomnia/src/insomnia-data/src/models/plugin-data.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'PluginData'; diff --git a/packages/insomnia/src/models/helpers/__tests__/project.test.ts b/packages/insomnia/src/insomnia-data/src/models/project.test.ts similarity index 86% rename from packages/insomnia/src/models/helpers/__tests__/project.test.ts rename to packages/insomnia/src/insomnia-data/src/models/project.test.ts index 943b710668..22090aa4f0 100644 --- a/packages/insomnia/src/models/helpers/__tests__/project.test.ts +++ b/packages/insomnia/src/insomnia-data/src/models/project.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { sortProjects } from '../project'; +import { models } from '~/insomnia-data'; const defaultOrgProject = { name: 'a', remoteId: 'proj_team_123456789345678987654', _id: 'not important' }; @@ -11,7 +11,7 @@ const remote0 = { name: '0', remoteId: 'notNull', _id: 'remote0' }; describe('sortProjects', () => { it('sorts projects by default > local > remote > name', () => { const unSortedProjects = [remoteA, defaultOrgProject, remoteB, remote0]; - const result = sortProjects(unSortedProjects); + const result = models.project.sortProjects(unSortedProjects); const sortedProjects = [defaultOrgProject, remote0, remoteA, remoteB]; expect(result).toEqual(sortedProjects); diff --git a/packages/insomnia/src/insomnia-data/src/models/project.ts b/packages/insomnia/src/insomnia-data/src/models/project.ts index b5692ca73f..f6909a0964 100644 --- a/packages/insomnia/src/insomnia-data/src/models/project.ts +++ b/packages/insomnia/src/insomnia-data/src/models/project.ts @@ -1,8 +1,8 @@ import type { StorageRules } from 'insomnia-api'; -import type { BaseModel } from '~/models/types'; +import { generateId } from '~/common/misc'; -import { generateId } from '../../../common/misc'; +import type { BaseModel } from './base-types'; export const name = 'Project'; export const type = 'Project'; @@ -15,10 +15,69 @@ export const SCRATCHPAD_PROJECT_ID = `${prefix}_scratchpad`; // This is used to identify Git Projects that are not connected to a remote yet export const EMPTY_GIT_PROJECT_ID = 'empty'; +// Prefix used when encoding a GitRepository._id into gitRepositoryId for downgrade protection. +// The real GitRepository doc has _id = 'git_xxx'. We store 'gr_xxx' on the project so that the +// old app's getById('gr_xxx') returns null — preventing it from touching the git folder. +// The new app swaps the prefix back to recover the real ID. +export const PROTECTED_GIT_REPO_PREFIX = 'gr_'; +const REAL_GIT_REPO_PREFIX = 'git_'; + +/** + * Decode a raw gitRepositoryId string to the real GitRepository._id. + * Handles both the protected ('gr_xxx') and legacy ('git_xxx') formats. + */ +export function decodeRepoId(id: string): string { + if (id.startsWith(PROTECTED_GIT_REPO_PREFIX)) { + return REAL_GIT_REPO_PREFIX + id.slice(PROTECTED_GIT_REPO_PREFIX.length); + } + return id; +} + +/** + * Given a connected GitProject, return the real GitRepository._id. + * Returns null when the project is not connected (gitRepositoryId is 'empty'). + * + * Handles two formats: + * - 'gr_xxx' → protected (new format) → returns 'git_xxx' + * - 'git_xxx' → legacy (pre-migration) → returns 'git_xxx' as-is + */ +export function getEffectiveRepoId(project: GitProject): string | null { + const id = project.gitRepositoryId; + if (id === EMPTY_GIT_PROJECT_ID) return null; + return decodeRepoId(id); +} + +/** + * Encode a real GitRepository._id ('git_xxx') into the protected format ('gr_xxx') + * that is stored on the project's gitRepositoryId field. + */ +export function toProtectedRepoId(gitRepositoryId: string): string { + if (gitRepositoryId.startsWith(REAL_GIT_REPO_PREFIX)) { + return PROTECTED_GIT_REPO_PREFIX + gitRepositoryId.slice(REAL_GIT_REPO_PREFIX.length); + } + return gitRepositoryId; // already protected or unexpected format — pass through +} + +/** + * Return all values that may be stored in Project.gitRepositoryId for a given real + * GitRepository._id, covering both legacy ('git_xxx') and protected ('gr_xxx') forms. + * Use this when building DB queries that must match projects regardless of which + * storage format they were written with. + */ +export function getQueryableGitRepositoryIds(gitRepositoryId: string): string[] { + const realId = decodeRepoId(gitRepositoryId); + const protectedId = toProtectedRepoId(realId); + return Array.from(new Set([realId, protectedId])); +} + export function isEmptyGitProject(project: Project) { return project.gitRepositoryId === EMPTY_GIT_PROJECT_ID; } +export function isConnectedGitProject(project: Project): project is GitProject { + return isGitProject(project) && getEffectiveRepoId(project) !== null; +} + export const isScratchpadProject = (project: Pick) => project._id === SCRATCHPAD_PROJECT_ID; export const isLocalProject = (project: Pick): project is LocalProject => project.remoteId === null; @@ -77,6 +136,11 @@ export function isDefaultOrganizationProject(project: Project) { return project.remoteId?.startsWith('proj_team') || project.remoteId?.startsWith('proj_org'); } +export const sortProjects = (projects: T[]) => [ + ...projects.filter(project => isDefaultOrganizationProject(project)).sort((a, b) => a.name.localeCompare(b.name)), + ...projects.filter(project => !isDefaultOrganizationProject(project)).sort((a, b) => a.name.localeCompare(b.name)), +]; + export function getDefaultProjectStorageType( storageRules: StorageRules, project?: Project, diff --git a/packages/insomnia/src/insomnia-data/src/models/proto-directory.ts b/packages/insomnia/src/insomnia-data/src/models/proto-directory.ts index 9f2e0bb6c3..43a39e689d 100644 --- a/packages/insomnia/src/insomnia-data/src/models/proto-directory.ts +++ b/packages/insomnia/src/insomnia-data/src/models/proto-directory.ts @@ -1,5 +1,6 @@ import { generateId } from '~/common/misc'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; export const name = 'Proto Directory'; diff --git a/packages/insomnia/src/insomnia-data/src/models/proto-file.ts b/packages/insomnia/src/insomnia-data/src/models/proto-file.ts index 61191a2f5b..f268f71f6f 100644 --- a/packages/insomnia/src/insomnia-data/src/models/proto-file.ts +++ b/packages/insomnia/src/insomnia-data/src/models/proto-file.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Proto File'; diff --git a/packages/insomnia/src/insomnia-data/src/models/request-group-meta.ts b/packages/insomnia/src/insomnia-data/src/models/request-group-meta.ts index 8d05acdd28..aea2369ab7 100644 --- a/packages/insomnia/src/insomnia-data/src/models/request-group-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/request-group-meta.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Folder Meta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/request-group.ts b/packages/insomnia/src/insomnia-data/src/models/request-group.ts index 2299bb52f8..74fe874600 100644 --- a/packages/insomnia/src/insomnia-data/src/models/request-group.ts +++ b/packages/insomnia/src/insomnia-data/src/models/request-group.ts @@ -1,8 +1,7 @@ -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; - +import type { BaseModel } from './base-types'; import type { EnvironmentKvPairData, EnvironmentType } from './environment'; import type { RequestAuthentication, RequestHeader } from './request'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; export const name = 'Folder'; diff --git a/packages/insomnia/src/insomnia-data/src/models/request-meta.ts b/packages/insomnia/src/insomnia-data/src/models/request-meta.ts index 51fd608055..065881ba82 100644 --- a/packages/insomnia/src/insomnia-data/src/models/request-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/request-meta.ts @@ -1,5 +1,6 @@ import { PREVIEW_MODE_FRIENDLY, type PreviewMode } from '~/common/constants'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; export const name = 'Request Meta'; export const type = 'RequestMeta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/request-version.ts b/packages/insomnia/src/insomnia-data/src/models/request-version.ts index 9f6e03de55..a7f5321d3d 100644 --- a/packages/insomnia/src/insomnia-data/src/models/request-version.ts +++ b/packages/insomnia/src/insomnia-data/src/models/request-version.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; /* When viewing a specific request, the user can click the Send button to test-send it. Each time the user sends the request, the parameters may differ—they might edit the body, headers, and so on—and Insomnia records every sent request as history. diff --git a/packages/insomnia/src/insomnia-data/src/models/request.ts b/packages/insomnia/src/insomnia-data/src/models/request.ts index ced255404a..9073956156 100644 --- a/packages/insomnia/src/insomnia-data/src/models/request.ts +++ b/packages/insomnia/src/insomnia-data/src/models/request.ts @@ -15,12 +15,13 @@ import { OperationTypeNode } from 'graphql'; +import type { OAuth1SignatureMethod } from '~/common/constants'; import { METHOD_GET } from '~/common/constants'; -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; -import type { OAuth1SignatureMethod } from '~/network/o-auth-1/constants'; import { getOperationType } from '~/utils/graph-ql'; +import type { BaseModel } from './base-types'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; + export const name = 'Request'; export const type = 'Request'; diff --git a/packages/insomnia/src/insomnia-data/src/models/response.ts b/packages/insomnia/src/insomnia-data/src/models/response.ts index 57743a844e..8a92abd526 100644 --- a/packages/insomnia/src/insomnia-data/src/models/response.ts +++ b/packages/insomnia/src/insomnia-data/src/models/response.ts @@ -1,6 +1,5 @@ -import type { BaseModel } from '~/models/types'; - import type { RequestTestResult } from '../../../../../insomnia-scripting-environment/src/objects'; +import type { BaseModel } from './base-types'; export const name = 'Response'; diff --git a/packages/insomnia/src/insomnia-data/src/models/runner-test-result.ts b/packages/insomnia/src/insomnia-data/src/models/runner-test-result.ts index e1a9c01ea0..ca2e785123 100644 --- a/packages/insomnia/src/insomnia-data/src/models/runner-test-result.ts +++ b/packages/insomnia/src/insomnia-data/src/models/runner-test-result.ts @@ -1,6 +1,5 @@ -import type { BaseModel } from '~/models/types'; - import type { RequestTestResult } from '../../../../../insomnia-scripting-environment/src/objects'; +import type { BaseModel } from './base-types'; export const name = 'Runner Test Result'; diff --git a/packages/insomnia/src/insomnia-data/src/models/settings.ts b/packages/insomnia/src/insomnia-data/src/models/settings.ts index a5a5cd193a..16e5199462 100644 --- a/packages/insomnia/src/insomnia-data/src/models/settings.ts +++ b/packages/insomnia/src/insomnia-data/src/models/settings.ts @@ -1,7 +1,8 @@ import { getAppDefaultDarkTheme, getAppDefaultLightTheme, getAppDefaultTheme } from '~/common/constants'; import * as hotkeys from '~/common/hotkeys'; import { HttpVersions, type Settings as BaseSettings, UpdateChannel } from '~/common/settings'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; export type Settings = BaseModel & BaseSettings; export const name = 'Settings'; @@ -15,7 +16,7 @@ export type ThemeSettings = Pick): model is Settings => model.type === type; // force vertical layout for playwright tests to avoid horizontal scrolling issues -const forceVerticalLayout = process.env.PLAYWRIGHT ? true : false; +const forceVerticalLayout = process.env.PLAYWRIGHT_TEST ? true : false; export function init(): BaseSettings { return { @@ -76,5 +77,11 @@ export function init(): BaseSettings { // The duration in mins for which the external vault secret is cached vaultSecretCacheDuration: 30, dataFolders: [], + scriptSandboxEnabled: true, + scriptStrictModeEnabled: true, + disabledSecurityRules: [], + disabledBlockedProperties: [], + disabledBlockedRoots: [], + npmRegistryUrl: '', }; } diff --git a/packages/insomnia/src/insomnia-data/src/models/socket-io-payload.ts b/packages/insomnia/src/insomnia-data/src/models/socket-io-payload.ts index 62dcb9b1eb..61da3db3a2 100644 --- a/packages/insomnia/src/insomnia-data/src/models/socket-io-payload.ts +++ b/packages/insomnia/src/insomnia-data/src/models/socket-io-payload.ts @@ -1,8 +1,9 @@ import { v4 as uuidv4 } from 'uuid'; import { CONTENT_TYPE_JSON } from '~/common/constants'; -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; export const name = 'SocketIO Payload'; diff --git a/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts b/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts index 2804cac343..4a09d5064c 100644 --- a/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Socket.IO Request Meta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/socket-io-request.ts b/packages/insomnia/src/insomnia-data/src/models/socket-io-request.ts index e1ecfe05ff..a84afff3fb 100644 --- a/packages/insomnia/src/insomnia-data/src/models/socket-io-request.ts +++ b/packages/insomnia/src/insomnia-data/src/models/socket-io-request.ts @@ -1,7 +1,6 @@ -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; - +import type { BaseModel } from './base-types'; import type { RequestAuthentication, RequestHeader, RequestParameter, RequestPathParameter } from './request'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; export const name = 'Socket.IO Request'; diff --git a/packages/insomnia/src/insomnia-data/src/models/socket-io-response.ts b/packages/insomnia/src/insomnia-data/src/models/socket-io-response.ts index 73a0227b3b..91ab40e860 100644 --- a/packages/insomnia/src/insomnia-data/src/models/socket-io-response.ts +++ b/packages/insomnia/src/insomnia-data/src/models/socket-io-response.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'SocketIO Response'; diff --git a/packages/insomnia/src/insomnia-data/src/models/stats.ts b/packages/insomnia/src/insomnia-data/src/models/stats.ts index 1ff7d7d3b6..83a7515ec8 100644 --- a/packages/insomnia/src/insomnia-data/src/models/stats.ts +++ b/packages/insomnia/src/insomnia-data/src/models/stats.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Stats'; diff --git a/packages/insomnia/src/insomnia-data/src/models/types.ts b/packages/insomnia/src/insomnia-data/src/models/types.ts index f411200d6d..c1ed45b3c9 100644 --- a/packages/insomnia/src/insomnia-data/src/models/types.ts +++ b/packages/insomnia/src/insomnia-data/src/models/types.ts @@ -1,3 +1,5 @@ +export type { BaseModel, AllTypes } from './base-types'; + // flat re-exports for convenient consumer access, only export types that are needed outside of this package export type { ApiSpec } from './api-spec'; export type { CaCertificate } from './ca-certificate'; diff --git a/packages/insomnia/src/insomnia-data/src/models/unit-test-result.ts b/packages/insomnia/src/insomnia-data/src/models/unit-test-result.ts index 1dc27069e6..1108a1b3f1 100644 --- a/packages/insomnia/src/insomnia-data/src/models/unit-test-result.ts +++ b/packages/insomnia/src/insomnia-data/src/models/unit-test-result.ts @@ -1,6 +1,6 @@ import type { TestResults } from 'insomnia-testing'; -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Unit Test Result'; diff --git a/packages/insomnia/src/insomnia-data/src/models/unit-test-suite.ts b/packages/insomnia/src/insomnia-data/src/models/unit-test-suite.ts index 8b666490e3..5b7d93c3d9 100644 --- a/packages/insomnia/src/insomnia-data/src/models/unit-test-suite.ts +++ b/packages/insomnia/src/insomnia-data/src/models/unit-test-suite.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Unit Test Suite'; diff --git a/packages/insomnia/src/insomnia-data/src/models/unit-test.ts b/packages/insomnia/src/insomnia-data/src/models/unit-test.ts index f44b7e4d3c..1d4299790e 100644 --- a/packages/insomnia/src/insomnia-data/src/models/unit-test.ts +++ b/packages/insomnia/src/insomnia-data/src/models/unit-test.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Unit Test'; diff --git a/packages/insomnia/src/insomnia-data/src/models/user-session.ts b/packages/insomnia/src/insomnia-data/src/models/user-session.ts index 35f32dc7b2..256742bf38 100644 --- a/packages/insomnia/src/insomnia-data/src/models/user-session.ts +++ b/packages/insomnia/src/insomnia-data/src/models/user-session.ts @@ -1,5 +1,7 @@ import type { AESMessage } from '~/account/crypt'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; + export interface BaseUserSession { accountId: string; id: string; diff --git a/packages/insomnia/src/models/helpers/__tests__/replace-ids-in-fields.test.ts b/packages/insomnia/src/insomnia-data/src/models/utils/replace-ids-in-fields.test.ts similarity index 97% rename from packages/insomnia/src/models/helpers/__tests__/replace-ids-in-fields.test.ts rename to packages/insomnia/src/insomnia-data/src/models/utils/replace-ids-in-fields.test.ts index 8caa438b0a..1dcddacc98 100644 --- a/packages/insomnia/src/models/helpers/__tests__/replace-ids-in-fields.test.ts +++ b/packages/insomnia/src/insomnia-data/src/models/utils/replace-ids-in-fields.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { replaceIdsInFields } from '../replace-ids-in-fields'; +import { replaceIdsInFields } from './replace-ids-in-fields'; describe('replaceIdsInFields', () => { const idMapping = new Map([ diff --git a/packages/insomnia/src/models/helpers/replace-ids-in-fields.ts b/packages/insomnia/src/insomnia-data/src/models/utils/replace-ids-in-fields.ts similarity index 100% rename from packages/insomnia/src/models/helpers/replace-ids-in-fields.ts rename to packages/insomnia/src/insomnia-data/src/models/utils/replace-ids-in-fields.ts diff --git a/packages/insomnia/src/insomnia-data/src/models/websocket-payload.ts b/packages/insomnia/src/insomnia-data/src/models/websocket-payload.ts index 9b3cf46219..5483f294a3 100644 --- a/packages/insomnia/src/insomnia-data/src/models/websocket-payload.ts +++ b/packages/insomnia/src/insomnia-data/src/models/websocket-payload.ts @@ -1,5 +1,5 @@ -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; export const name = 'WebSocket Payload'; diff --git a/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts b/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts index ead0dc9e67..a51dd0d515 100644 --- a/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'WebSocket Request Meta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/websocket-request.ts b/packages/insomnia/src/insomnia-data/src/models/websocket-request.ts index 74ec1737af..1213d09b59 100644 --- a/packages/insomnia/src/insomnia-data/src/models/websocket-request.ts +++ b/packages/insomnia/src/insomnia-data/src/models/websocket-request.ts @@ -1,7 +1,6 @@ -import { replaceIdsInFields } from '~/models/helpers/replace-ids-in-fields'; -import type { BaseModel } from '~/models/types'; - +import type { BaseModel } from './base-types'; import type { RequestAuthentication, RequestHeader, RequestParameter, RequestPathParameter } from './request'; +import { replaceIdsInFields } from './utils/replace-ids-in-fields'; export const name = 'WebSocket Request'; diff --git a/packages/insomnia/src/insomnia-data/src/models/websocket-response.ts b/packages/insomnia/src/insomnia-data/src/models/websocket-response.ts index 2b877a4e2f..636255da39 100644 --- a/packages/insomnia/src/insomnia-data/src/models/websocket-response.ts +++ b/packages/insomnia/src/insomnia-data/src/models/websocket-response.ts @@ -1,5 +1,4 @@ -import type { BaseModel } from '~/models/types'; - +import type { BaseModel } from './base-types'; import type { ResponseHeader } from './response'; export const name = 'WebSocket Response'; diff --git a/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts b/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts index 3002b9b19e..2fe5e8b6e9 100644 --- a/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '~/models/types'; +import type { BaseModel } from './base-types'; export const name = 'Workspace Meta'; export const type = 'WorkspaceMeta'; @@ -18,6 +18,7 @@ export interface BaseWorkspaceMeta { hasUncommittedChanges: boolean; hasUnpushedChanges: boolean; gitFilePath: string | null; + gitFileLastSyncTime: number | null; } export type WorkspaceMeta = BaseWorkspaceMeta & BaseModel; @@ -33,6 +34,7 @@ export function init(): BaseWorkspaceMeta { activeUnitTestSuiteId: null, gitRepositoryId: null, gitFilePath: null, + gitFileLastSyncTime: null, parentId: null, pushSnapshotOnInitialize: false, hasUncommittedChanges: false, diff --git a/packages/insomnia/src/insomnia-data/src/models/workspace.ts b/packages/insomnia/src/insomnia-data/src/models/workspace.ts index f52b8954bc..361901b99d 100644 --- a/packages/insomnia/src/insomnia-data/src/models/workspace.ts +++ b/packages/insomnia/src/insomnia-data/src/models/workspace.ts @@ -1,5 +1,6 @@ import { strings } from '~/common/strings'; -import type { BaseModel } from '~/models/types'; + +import type { BaseModel } from './base-types'; export const name = 'Workspace'; export const type = 'Workspace'; diff --git a/packages/insomnia/src/insomnia-data/src/services/index.ts b/packages/insomnia/src/insomnia-data/src/services/index.ts index fd3c3f8a1b..d4befbdb37 100644 --- a/packages/insomnia/src/insomnia-data/src/services/index.ts +++ b/packages/insomnia/src/insomnia-data/src/services/index.ts @@ -1,5 +1,8 @@ -import { type Services } from '../../node-src/types'; +// Keep the Services type tied to the node implementation without creating a runtime import cycle. +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +type Services = ServicesNodeImpl; export type { Services }; let servicesImplementation: Services | null = null; diff --git a/packages/insomnia/src/konnect/__tests__/api.test.ts b/packages/insomnia/src/konnect/__tests__/api.test.ts index 08f7eff11a..b587977aff 100644 --- a/packages/insomnia/src/konnect/__tests__/api.test.ts +++ b/packages/insomnia/src/konnect/__tests__/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { extractRegionFromEndpoint, fetchAllControlPlanes, fetchAllServices, fetchRoutesForService, validatePat } from '../api'; +import { fetchAllControlPlanes, fetchAllServices, fetchRoutesForService, validatePat } from '../api'; vi.mock('../../common/constants', () => ({ getKonnectApiBaseURL: () => 'https://global.api.konghq.com', @@ -56,42 +56,6 @@ afterEach(() => { vi.clearAllMocks(); }); -// ─── extractRegionFromEndpoint ─────────────────────────────────────────────── - -describe('extractRegionFromEndpoint', () => { - it('extracts "us" from a US control plane endpoint', () => { - expect(extractRegionFromEndpoint('https://abc123.us.cp0.konghq.com')).toBe('us'); - }); - - it('extracts "eu" from an EU control plane endpoint', () => { - expect(extractRegionFromEndpoint('https://xyz789.eu.cp0.konghq.com')).toBe('eu'); - }); - - it('extracts "au" from an AU control plane endpoint', () => { - expect(extractRegionFromEndpoint('https://def456.au.cp0.konghq.com')).toBe('au'); - }); - - it('extracts "me" from a ME control plane endpoint', () => { - expect(extractRegionFromEndpoint('https://def456.me.cp0.konghq.com')).toBe('me'); - }); - - it('extracts "in" from an IN control plane endpoint', () => { - expect(extractRegionFromEndpoint('https://def456.in.cp0.konghq.com')).toBe('in'); - }); - - it('defaults to "us" for a malformed URL', () => { - expect(extractRegionFromEndpoint('not-a-url')).toBe('us'); - }); - - it('defaults to "us" for an unexpected hostname format (no cp0 segment)', () => { - expect(extractRegionFromEndpoint('https://api.konghq.com')).toBe('us'); - }); - - it('defaults to "us" for an empty string', () => { - expect(extractRegionFromEndpoint('')).toBe('us'); - }); -}); - // ─── validatePat ───────────────────────────────────────────────────────────── describe('validatePat', () => { @@ -152,9 +116,9 @@ describe('validatePat', () => { describe('fetchAllControlPlanes', () => { it('yields a single page when total <= PAGE_SIZE', async () => { - const cps = [{ id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; + const page1Data = [{ id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: cps, meta: { page: { total: 1, size: 100, number: 1 } } }), + jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } }), )); const pages: any[][] = []; @@ -163,7 +127,7 @@ describe('fetchAllControlPlanes', () => { } expect(pages).toHaveLength(1); - expect(pages[0]).toEqual(cps); + expect(pages[0]).toEqual(page1Data.map(cp => ({ ...cp, proxy_urls: null }))); }); it('yields multiple pages when total > PAGE_SIZE', async () => { @@ -184,7 +148,7 @@ describe('fetchAllControlPlanes', () => { expect(pages).toHaveLength(2); expect(pages[0]).toHaveLength(100); - expect(pages[1]).toEqual(page2Data); + expect(pages[1]).toEqual(page2Data.map(cp => ({ ...cp, proxy_urls: null }))); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0][0]).toContain('page[number]=1'); expect(fetchMock.mock.calls[1][0]).toContain('page[number]=2'); @@ -197,6 +161,24 @@ describe('fetchAllControlPlanes', () => { await expect(gen.next()).rejects.toThrow('Konnect API error 500 fetching control planes'); }); + it('normalizes omitted proxy_urls to null', async () => { + // The fetch boundary owns the `T | null` type contract: every nullable + // field must be defined, even when the upstream payload omits it. Without + // this, downstream code reading `controlPlane.proxy_urls` would see + // `undefined` despite the type saying otherwise. + const rawCp = { id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + jsonResponse({ data: [rawCp], meta: { page: { total: 1, size: 100, number: 1 } } }), + )); + + const pages: any[][] = []; + for await (const page of fetchAllControlPlanes('faketoken')) { + pages.push(page); + } + + expect(pages[0][0]).toEqual({ ...rawCp, proxy_urls: null }); + }); + it('yields empty data when total is 0', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( jsonResponse({ data: [], meta: { page: { total: 0, size: 100, number: 1 } } }), @@ -254,6 +236,19 @@ describe('fetchAllServices', () => { expect(fetchMock.mock.calls[0][0]).toMatch(/^https:\/\/eu\.api\.konghq\.com/); }); + it('normalizes omitted nullable fields (name, path, tags) to null', async () => { + // The fetch boundary owns the `T | null` type contract: every nullable + // field must be defined, even when the upstream payload omits it. If a + // future change drops this normalization, downstream consumers reading + // `service.name` would see `undefined` despite the type saying otherwise. + const rawSvc = { id: 'svc-1', protocol: 'http', host: 'h', port: 80, enabled: true }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: [rawSvc], offset: null }))); + + const result = await fetchAllServices('faketoken', 'cp-1', 'us'); + + expect(result[0]).toEqual({ ...rawSvc, name: null, path: null, tags: null }); + }); + it('throws on non-ok response', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 502 }))); @@ -288,6 +283,30 @@ describe('fetchRoutesForService', () => { expect(fetchMock.mock.calls[0][0]).toContain('/services/svc-42/routes'); }); + + it('normalizes omitted nullable fields to null so sanitizeRoute can rely on them', async () => { + // The fetch boundary owns the `T | null` type contract: every nullable + // field must be defined, even when the upstream payload omits it. + // sanitizeRoute uses strict `!== null` checks on name/expression, so if + // this normalization regresses we'd hit `stripTemplateSyntax(undefined)` + // at runtime — caught here instead of in production. + const rawRoute = { id: 'r-1', protocols: ['http'] }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: [rawRoute], offset: null }))); + + const result = await fetchRoutesForService('faketoken', 'cp-1', 'svc-1', 'us'); + + expect(result[0]).toEqual({ + ...rawRoute, + name: null, + methods: null, + paths: null, + hosts: null, + headers: null, + snis: null, + expression: null, + service: null, + }); + }); }); // ─── Retry logic (fetchWithRetry, tested through exported functions) ───────── @@ -437,7 +456,7 @@ describe('retry on 429', () => { await iterPromise; expect(pages).toHaveLength(1); - expect(pages[0]).toEqual(page1Data); + expect(pages[0]).toEqual(page1Data.map(cp => ({ ...cp, proxy_urls: null }))); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/insomnia/src/konnect/__tests__/expression-parser.test.ts b/packages/insomnia/src/konnect/__tests__/expression-parser.test.ts new file mode 100644 index 0000000000..63af101c12 --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/expression-parser.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { applyExpressionFields, extractFieldsFromExpression } from '../expression-parser'; + +describe('extractFieldsFromExpression', () => { + it('single method', () => { + const result = extractFieldsFromExpression('http.method == "GET"'); + expect(result.methods).toEqual(['GET']); + expect(result.paths).toBeNull(); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('single path (exact)', () => { + const result = extractFieldsFromExpression('http.path == "/users"'); + expect(result.methods).toBeNull(); + expect(result.paths).toEqual(['/users']); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('single path (prefix)', () => { + const result = extractFieldsFromExpression('http.path ^= "/api"'); + expect(result.methods).toBeNull(); + expect(result.paths).toEqual(['/api']); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('single host', () => { + const result = extractFieldsFromExpression('http.host == "api.example.com"'); + expect(result.methods).toBeNull(); + expect(result.paths).toBeNull(); + expect(result.hosts).toEqual(['api.example.com']); + expect(result.headers).toBeNull(); + }); + + it('single header', () => { + const result = extractFieldsFromExpression('http.headers.x_api_version == "2"'); + expect(result.methods).toBeNull(); + expect(result.paths).toBeNull(); + expect(result.hosts).toBeNull(); + expect(result.headers).toEqual({ 'x-api-version': ['2'] }); + }); + + it('AND combination: method + path', () => { + const result = extractFieldsFromExpression('http.method == "GET" && http.path == "/foo"'); + expect(result.methods).toEqual(['GET']); + expect(result.paths).toEqual(['/foo']); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('full combination: method + path + host + header ANDed', () => { + const result = extractFieldsFromExpression( + 'http.method == "POST" && http.path == "/submit" && http.host == "api.example.com" && http.headers.x_tenant == "acme"', + ); + expect(result.methods).toEqual(['POST']); + expect(result.paths).toEqual(['/submit']); + expect(result.hosts).toEqual(['api.example.com']); + expect(result.headers).toEqual({ 'x-tenant': ['acme'] }); + }); + + it('OR methods', () => { + const result = extractFieldsFromExpression('http.method == "GET" || http.method == "POST"'); + expect(result.methods).toEqual(['GET', 'POST']); + expect(result.paths).toBeNull(); + }); + + it('OR paths', () => { + const result = extractFieldsFromExpression('http.path == "/v1" || http.path == "/v2"'); + expect(result.methods).toBeNull(); + expect(result.paths).toEqual(['/v1', '/v2']); + }); + + it('mixed AND/OR', () => { + const result = extractFieldsFromExpression( + '(http.method == "GET" || http.method == "POST") && http.path == "/api"', + ); + expect(result.methods).toEqual(['GET', 'POST']); + expect(result.paths).toEqual(['/api']); + }); + + it('unparseable — all null', () => { + const result = extractFieldsFromExpression('net.src.ip in 10.0.0.0/8'); + expect(result.methods).toBeNull(); + expect(result.paths).toBeNull(); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('empty string — all null', () => { + const result = extractFieldsFromExpression(''); + expect(result.methods).toBeNull(); + expect(result.paths).toBeNull(); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('negation ignored — methods null', () => { + const result = extractFieldsFromExpression('http.method != "DELETE"'); + expect(result.methods).toBeNull(); + }); + + it('regex path ignored — paths null', () => { + const result = extractFieldsFromExpression('http.path ~ r#"^/users/\\d+$"#'); + expect(result.paths).toBeNull(); + }); + + it('partial extraction: method extracted, unparseable part ignored', () => { + const result = extractFieldsFromExpression('http.method == "GET" && net.src.ip in 10.0.0.0/8'); + expect(result.methods).toEqual(['GET']); + expect(result.paths).toBeNull(); + expect(result.hosts).toBeNull(); + expect(result.headers).toBeNull(); + }); + + it('header name normalization: underscores to hyphens, lowercased', () => { + const result = extractFieldsFromExpression('http.headers.X_Custom_Id == "123"'); + expect(result.headers).toEqual({ 'x-custom-id': ['123'] }); + }); +}); + +describe('applyExpressionFields', () => { + const baseRoute = { + id: 'r1', name: 'My Route', methods: null, paths: null, + protocols: ['http'], hosts: null, headers: null, snis: null, service: null, + }; + + it('no expression — passthrough', () => { + const result = applyExpressionFields({ ...baseRoute, expression: null }); + expect(result).toEqual({ syncable: true, route: { ...baseRoute, expression: null } }); + }); + + it('tls.sni in expression — skipped', () => { + const result = applyExpressionFields({ ...baseRoute, expression: 'tls.sni == "secure.example.com"' }); + expect(result.syncable).toBe(false); + if (!result.syncable) { + expect(result.reason).toMatch(/tls\.sni/); + } + }); + + it('tls.sni combined with other predicates — still skipped', () => { + const result = applyExpressionFields({ ...baseRoute, expression: 'tls.sni == "secure.example.com" && http.method == "GET"' }); + expect(result.syncable).toBe(false); + }); + + it('fully unparseable expression — skipped', () => { + const result = applyExpressionFields({ ...baseRoute, expression: 'net.src.ip in 10.0.0.0/8' }); + expect(result.syncable).toBe(false); + if (!result.syncable) { + expect(result.reason).toMatch(/no extractable fields/); + } + }); + + it('parseable expression — returns merged route', () => { + const result = applyExpressionFields({ ...baseRoute, expression: 'http.method == "GET" && http.path == "/foo"' }); + expect(result.syncable).toBe(true); + if (result.syncable) { + expect(result.route.methods).toEqual(['GET']); + expect(result.route.paths).toEqual(['/foo']); + } + }); + + it('partial expression — syncable with extracted fields only', () => { + const result = applyExpressionFields({ ...baseRoute, expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8' }); + expect(result.syncable).toBe(true); + if (result.syncable) { + expect(result.route.methods).toEqual(['GET']); + expect(result.route.paths).toBeNull(); + } + }); +}); diff --git a/packages/insomnia/src/konnect/__tests__/sync.test.ts b/packages/insomnia/src/konnect/__tests__/sync.test.ts index 38c368a155..376f66a490 100644 --- a/packages/insomnia/src/konnect/__tests__/sync.test.ts +++ b/packages/insomnia/src/konnect/__tests__/sync.test.ts @@ -8,9 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { initDatabase, models, services as insoservices } from '~/insomnia-data'; +import { resetV4Counter } from '../../__mocks__/uuid'; import { database as db } from '../../common/database'; import { mainDatabase } from '../../main/database.main'; -import { resetV4Counter } from '../../models/__mocks__/uuid'; import type { KonnectControlPlane, KonnectRoute, KonnectService } from '../api'; import { syncKonnect } from '../sync'; @@ -28,6 +28,7 @@ function makeCp(overrides: Partial = {}): KonnectControlPla cluster_type: 'CLUSTER_TYPE_HYBRID', control_plane_endpoint: 'https://abc123.us.cp0.konghq.com', }, + proxy_urls: null, ...overrides, }; } @@ -130,10 +131,10 @@ describe('Feature: HTTP Route Sync', () => { // 2 methods × 2 protocols = 4 requests expect(requests).toHaveLength(4); - const httpGet = requests.find((r: any) => r.method === 'GET' && r.konnectRouteKey.endsWith(':http')); - const httpsGet = requests.find((r: any) => r.method === 'GET' && r.konnectRouteKey.endsWith(':https')); - const httpPost = requests.find((r: any) => r.method === 'POST' && r.konnectRouteKey.endsWith(':http')); - const httpsPost = requests.find((r: any) => r.method === 'POST' && r.konnectRouteKey.endsWith(':https')); + const httpGet = requests.find(r => r.method === 'GET' && r.konnectRouteKey?.endsWith(':http')); + const httpsGet = requests.find(r => r.method === 'GET' && r.konnectRouteKey?.endsWith(':https')); + const httpPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':http')); + const httpsPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':https')); expect(httpGet).toMatchObject({ method: 'GET', url: 'http://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:http' }); expect(httpsGet).toMatchObject({ method: 'GET', url: 'https://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:https' }); @@ -195,11 +196,11 @@ describe('Feature: HTTP Route Sync', () => { const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); // 5 methods × 2 protocols = 10 expect(requests).toHaveLength(10); - const httpRequests = requests.filter((r: any) => r.konnectRouteKey.endsWith(':http')); - const httpsRequests = requests.filter((r: any) => r.konnectRouteKey.endsWith(':https')); + const httpRequests = requests.filter(r => r.konnectRouteKey?.endsWith(':http')); + const httpsRequests = requests.filter(r => r.konnectRouteKey?.endsWith(':https')); expect(httpRequests).toHaveLength(5); expect(httpsRequests).toHaveLength(5); - const methods = httpRequests.map((r: any) => r.method).sort(); + const methods = httpRequests.map(r => r.method).sort(); expect(methods).toEqual(['DELETE', 'GET', 'PATCH', 'POST', 'PUT']); for (const req of requests) { expect(req.name).toBe('/methods-null'); @@ -234,8 +235,8 @@ describe('Feature: HTTP Route Sync', () => { const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); // 1 method × 2 protocols = 2 expect(requests).toHaveLength(2); - const httpReq = requests.find((r: any) => r.url.startsWith('http://')); - const httpsReq = requests.find((r: any) => r.url.startsWith('https://')); + const httpReq = requests.find(r => r.url.startsWith('http://')); + const httpsReq = requests.find(r => r.url.startsWith('https://')); expect(httpReq).toMatchObject({ url: 'http://{{ _.proxy_host }}', name: 'Route route-1' }); expect(httpsReq).toMatchObject({ url: 'https://{{ _.proxy_host }}', name: 'Route route-1' }); for (const req of requests) { @@ -296,7 +297,7 @@ describe('Feature: HTTP Route Sync', () => { ); }); - it('Scenario: Regex path — tilde prefix stripped in URL and name', async () => { + it('Scenario: Regex path with shorthand class — falls back to /:path with path parameter', async () => { vi.stubGlobal('fetch', mockFetch( [makeCp()], [makeService()], @@ -307,9 +308,27 @@ describe('Feature: HTTP Route Sync', () => { const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(requests[0]).toMatchObject({ - url: 'http://{{ _.proxy_host }}/regex/\\d+', - name: '/regex/\\d+', + url: 'http://{{ _.proxy_host }}/:path', + name: '~/regex/\\d+', }); + expect(requests[0].pathParameters).toEqual([{ name: 'path', value: '' }]); + }); + + it('Scenario: Regex path with named capture group — parsed to colon param in URL and pathParameters', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests[0]).toMatchObject({ + url: 'http://{{ _.proxy_host }}/api/users/:userid', + name: '/api/users/:userid', + }); + expect(requests[0].pathParameters).toEqual([{ name: 'userid', value: '' }]); }); it('Scenario: strip_path and preserve_host — ignored (no effect on request URL)', async () => { @@ -640,6 +659,57 @@ describe('Feature: Re-sync', () => { const [updated] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(updated.method).toBe('GET'); }); + + it('Scenario: Re-sync preserves user-filled path param value when regex is unchanged', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + )); + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + // User fills in the path param value + const [created] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + await insoservices.request.update(created, { pathParameters: [{ name: 'userid', value: '42' }] }); + + // Re-sync — same regex, no change + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + )); + const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + expect(result.routes.updated).toBe(0); + const [unchanged] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(unchanged.pathParameters).toEqual([{ name: 'userid', value: '42' }]); + }); + + it('Scenario: Re-sync when regex capture group is renamed — old value dropped, new empty param created', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + )); + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + // User fills in the path param value + const [created] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + await insoservices.request.update(created, { pathParameters: [{ name: 'userid', value: '42' }] }); + + // Re-sync — capture group renamed from userId to accountId. + // The raw regex path is part of the route key, so a different capture group name + // produces a different key -> the old request is deleted and a new one is created. + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + )); + const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + expect(result.routes.created).toBe(1); + expect(result.routes.deleted).toBe(1); + const [updated] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(updated.url).toBe('http://{{ _.proxy_host }}/api/users/:accountid'); + // Old 'userid' value is gone; new 'accountid' param starts empty + expect(updated.pathParameters).toEqual([{ name: 'accountid', value: '' }]); + }); }); // ─── Feature: Idempotent Sync (Route Keying) ────────────────────────────────── @@ -654,7 +724,7 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); - const keys = requests.map((r: any) => r.konnectRouteKey); + const keys = requests.map(r => r.konnectRouteKey); expect(keys).toContain('route-uuid-1:GET:/api/v1/users:http'); expect(keys).toContain('route-uuid-1:POST:/api/v1/users:http'); }); @@ -669,7 +739,7 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(requests).toHaveLength(5); - const keys = requests.map((r: any) => r.konnectRouteKey).sort(); + const keys = requests.map(r => r.konnectRouteKey).sort(); expect(keys).toEqual([ 'route-uuid-2:DELETE:/api:http', 'route-uuid-2:GET:/api:http', @@ -754,11 +824,11 @@ describe('Feature: gRPC Route Sync', () => { const grpcRequests = konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } })); expect(grpcRequests).toHaveLength(2); - const keys = grpcRequests.map((r: any) => r.konnectRouteKey).sort(); + const keys = grpcRequests.map(r => r.konnectRouteKey).sort(); expect(keys).toContain('route-uuid-3:grpc:/addsvc.Add/Sum:grpc'); expect(keys).toContain('route-uuid-3:grpc:/addsvc.Add/Sum:grpcs'); - const grpcReq = grpcRequests.find((r: any) => r.konnectRouteKey.endsWith(':grpc')); - const grpcsReq = grpcRequests.find((r: any) => r.konnectRouteKey.endsWith(':grpcs')); + const grpcReq = grpcRequests.find(r => r.konnectRouteKey?.endsWith(':grpc')); + const grpcsReq = grpcRequests.find(r => r.konnectRouteKey?.endsWith(':grpcs')); expect(grpcReq!.url).toBe('grpc://{{ _.grpc_proxy_host }}'); expect(grpcsReq!.url).toBe('grpcs://{{ _.grpcs_proxy_host }}'); }); @@ -800,7 +870,7 @@ describe('Feature: gRPC Route Sync', () => { const grpcRequests = konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } })); expect(grpcRequests).toHaveLength(2); - const names = grpcRequests.map((r: any) => r.name).sort(); + const names = grpcRequests.map(r => r.name).sort(); expect(names).toEqual(['/hello.HelloService/LotsOfGreetings', '/hello.HelloService/LotsOfReplies']); }); @@ -891,11 +961,11 @@ describe('Feature: WebSocket Route Sync', () => { const wsRequests = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); expect(wsRequests).toHaveLength(2); - const keys = wsRequests.map((r: any) => r.konnectRouteKey).sort(); + const keys = wsRequests.map(r => r.konnectRouteKey).sort(); expect(keys).toContain('route-uuid-4:ws:/ws/mixed:ws'); expect(keys).toContain('route-uuid-4:ws:/ws/mixed:wss'); - const wsReq = wsRequests.find((r: any) => r.konnectRouteKey.endsWith(':ws')); - const wssReq = wsRequests.find((r: any) => r.konnectRouteKey.endsWith(':wss')); + const wsReq = wsRequests.find(r => r.konnectRouteKey?.endsWith(':ws')); + const wssReq = wsRequests.find(r => r.konnectRouteKey?.endsWith(':wss')); expect(wsReq!.url).toBe('ws://{{ _.proxy_host }}/ws/mixed'); expect(wssReq!.url).toBe('wss://{{ _.proxy_host }}/ws/mixed'); }); @@ -937,7 +1007,7 @@ describe('Feature: WebSocket Route Sync', () => { const wsRequests = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); expect(wsRequests).toHaveLength(2); - const urls = wsRequests.map((r: any) => r.url).sort(); + const urls = wsRequests.map(r => r.url).sort(); expect(urls).toEqual(['ws://{{ _.proxy_host }}/ws/multi-v1', 'ws://{{ _.proxy_host }}/ws/multi-v2']); }); @@ -1128,6 +1198,85 @@ describe('Feature: Environment Variable Mapping', () => { expect(proxyHost?.value).toBe('myproxy.example.com'); expect(apiKey?.value).toBe('secret-123'); }); + + it('Scenario: Sync auto-fills proxy vars from control plane proxy_urls', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp({ + proxy_urls: [ + { host: 'proxy.example.com', port: 8443, protocol: 'https' }, + { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, + { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + ], + })], + [], [], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const envWorkspace = (await db.find(models.workspace.type, { scope: 'environment' }))[0]; + const env = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); + const proxyHost = (env.kvPairData ?? []).find((kv: any) => kv.name === 'proxy_host'); + const grpcProxyHost = (env.kvPairData ?? []).find((kv: any) => kv.name === 'grpc_proxy_host'); + const grpcsProxyHost = (env.kvPairData ?? []).find((kv: any) => kv.name === 'grpcs_proxy_host'); + expect(proxyHost?.value).toBe('proxy.example.com:8443'); + expect(grpcProxyHost?.value).toBe('grpc.example.com:9090'); + expect(grpcsProxyHost?.value).toBe('grpcs.example.com:443'); + }); + + it('Scenario: Sync does not overwrite user-entered proxy values with proxy_urls', async () => { + // First sync without proxy_urls → empty vars + vi.stubGlobal('fetch', mockFetch([makeCp()], [], [])); + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + // User fills in proxy_host manually + const envWorkspace = (await db.find(models.workspace.type, { scope: 'environment' }))[0]; + const env = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); + const updatedKvPairs = (env.kvPairData ?? []).map((kv: any) => + kv.name === 'proxy_host' ? { ...kv, value: 'user-chosen.example.com' } : kv, + ); + await insoservices.environment.update(env, { kvPairData: updatedKvPairs }); + + // Re-sync with proxy_urls that would provide a different value + vi.stubGlobal('fetch', mockFetch( + [makeCp({ + proxy_urls: [ + { host: 'api-provided.example.com', port: 80, protocol: 'http' }, + ], + })], + [], [], + )); + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const updated = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); + const proxyHost = (updated.kvPairData ?? []).find((kv: any) => kv.name === 'proxy_host'); + expect(proxyHost?.value).toBe('user-chosen.example.com'); + }); + + it('Scenario: Re-sync fills empty proxy vars when proxy_urls become available', async () => { + // First sync without proxy_urls → empty vars + vi.stubGlobal('fetch', mockFetch([makeCp()], [], [])); + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const envWorkspace = (await db.find(models.workspace.type, { scope: 'environment' }))[0]; + const env = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); + const proxyHost = (env.kvPairData ?? []).find((kv: any) => kv.name === 'proxy_host'); + expect(proxyHost?.value).toBe(''); + + // Re-sync with proxy_urls now available + vi.stubGlobal('fetch', mockFetch( + [makeCp({ + proxy_urls: [ + { host: 'newly-available.example.com', port: 443, protocol: 'https' }, + ], + })], + [], [], + )); + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const updated = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); + const updatedProxyHost = (updated.kvPairData ?? []).find((kv: any) => kv.name === 'proxy_host'); + expect(updatedProxyHost?.value).toBe('newly-available.example.com'); + }); }); // ─── Feature: Control Plane (Project) Naming ──────────────────────────────── @@ -1228,12 +1377,12 @@ describe('Feature: Wildcard and Edge-Case Hosts', () => { // ─── Feature: Expression-Based Routes ────────────────────────────────────── describe('Feature: Expression-Based Routes', () => { - it('Scenario: Expression route — falls through as methods null', async () => { + it('Scenario: Simple method+path expression — creates 1 targeted request', async () => { vi.stubGlobal('fetch', mockFetch( [makeCp()], [makeService()], [makeRoute({ protocols: ['http'], - expression: 'http.path == "/foo" && http.method == "GET"', + expression: 'http.method == "GET" && http.path == "/foo"', paths: null, methods: null, name: 'Foo Route', @@ -1242,14 +1391,143 @@ describe('Feature: Expression-Based Routes', () => { await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ method: 'GET', name: '/foo' }); + expect(requests[0].url).toContain('/foo'); + expect(requests[0].name).toBe('/foo'); + }); + + it('Scenario: Path-only expression — defaults to all 5 methods', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'http.path == "/api/users"', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(requests).toHaveLength(5); for (const req of requests) { - expect(req.name).toBe('Foo Route'); + expect(req.url).toContain('/api/users'); } }); - it('Scenario: Expression route with stream protocol — skipped', async () => { + it('Scenario: Multiple methods via OR expression', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" || http.method == "POST"', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(2); + const methods = requests.map(r => r.method).sort(); + expect(methods).toEqual(['GET', 'POST']); + }); + + it('Scenario: Host expression — sets Host header on request', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'http.host == "api.example.com" && http.method == "GET"', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(1); + expect(requests[0].headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'api.example.com' }])); + }); + + it('Scenario: Header expression — sets extracted header on request', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'http.headers.x_tenant == "acme" && http.method == "GET" && http.path == "/api"', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(1); + expect(requests[0].headers).toEqual(expect.arrayContaining([{ name: 'x-tenant', value: 'acme' }])); + }); + + it('Scenario: Unparseable expression — skipped (no requests created)', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'net.src.ip in 10.0.0.0/8', + paths: null, + methods: null, + })], + )); + + const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); + expect(result.routes.skipped).toBe(1); + }); + + it('Scenario: Partial expression (method extractable, rest unparseable) — creates request', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe('GET'); + }); + + it('Scenario: Both protocols — creates requests for each', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http', 'https'], + expression: 'http.method == "GET" && http.path == "/foo"', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(2); + const protocols = requests.map(r => r.konnectRouteKey?.split(':').pop()).sort(); + expect(protocols).toEqual(['http', 'https']); + }); + + it('Scenario: Stream protocol — skipped', async () => { vi.stubGlobal('fetch', mockFetch( [makeCp()], [makeService()], [makeRoute({ @@ -1265,4 +1543,64 @@ describe('Feature: Expression-Based Routes', () => { expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); expect(result.routes.skipped).toBe(1); }); + + it('Scenario: Prefix path expression — creates requests at that path', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + expression: 'http.path ^= "/api/v1"', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(5); + for (const req of requests) { + expect(req.url).toContain('/api/v1'); + } + }); + + it('Scenario: Repeated predicates in OR expansion — deduplicates methods/paths/hosts', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['http'], + // Each branch repeats the same method and path — a common pattern when + // parenthesised OR expansions duplicate shared predicates. + expression: + '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com") || ' + + '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com")', + paths: null, + methods: null, + })], + )); + + await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + // After dedup: 1 method × 1 path × 1 protocol = 1 request (not 4) + const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ method: 'GET', url: 'http://{{ _.proxy_host }}/api' }); + }); + + it('Scenario: tls.sni expression — skipped', async () => { + vi.stubGlobal('fetch', mockFetch( + [makeCp()], [makeService()], + [makeRoute({ + protocols: ['https'], + expression: 'tls.sni == "secure.example.com" && http.method == "GET"', + paths: null, + methods: null, + })], + )); + + const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); + + expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); + expect(result.routes.skipped).toBe(1); + }); }); diff --git a/packages/insomnia/src/konnect/__tests__/transform.test.ts b/packages/insomnia/src/konnect/__tests__/transform.test.ts new file mode 100644 index 0000000000..c02188755e --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/transform.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveProxyVarDefaults, + extractRegionFromEndpoint, + generatePathPlaceholder, + konnectHeadersChanged, + mergeHeaders, + mergePathParameters, + pathParametersChanged, + sanitizeRoute, +} from '../transform'; + +// ─── extractRegionFromEndpoint ─────────────────────────────────────────────── + +describe('extractRegionFromEndpoint', () => { + it('extracts "us" from a US control plane endpoint', () => { + expect(extractRegionFromEndpoint('https://abc123.us.cp0.konghq.com')).toBe('us'); + }); + + it('extracts "eu" from an EU control plane endpoint', () => { + expect(extractRegionFromEndpoint('https://xyz789.eu.cp0.konghq.com')).toBe('eu'); + }); + + it('extracts "au" from an AU control plane endpoint', () => { + expect(extractRegionFromEndpoint('https://def456.au.cp0.konghq.com')).toBe('au'); + }); + + it('extracts "me" from a ME control plane endpoint', () => { + expect(extractRegionFromEndpoint('https://def456.me.cp0.konghq.com')).toBe('me'); + }); + + it('extracts "in" from an IN control plane endpoint', () => { + expect(extractRegionFromEndpoint('https://def456.in.cp0.konghq.com')).toBe('in'); + }); + + it('defaults to "us" for a malformed URL', () => { + expect(extractRegionFromEndpoint('not-a-url')).toBe('us'); + }); + + it('defaults to "us" for an unexpected hostname format (no cp0 segment)', () => { + expect(extractRegionFromEndpoint('https://api.konghq.com')).toBe('us'); + }); + + it('defaults to "us" for an empty string', () => { + expect(extractRegionFromEndpoint('')).toBe('us'); + }); +}); + +// ─── sanitizeRoute ─────────────────────────────────────────────────────────── + +describe('sanitizeRoute', () => { + const base = { + id: 'route-1', + protocols: ['http'], + snis: null, + service: null, + }; + + it('leaves a clean route unchanged', () => { + const route = { ...base, name: 'My Route', methods: ['GET'], paths: ['/api/v1'], hosts: ['example.com'], headers: { 'x-foo': ['bar'] }, expression: null }; + expect(sanitizeRoute(route)).toEqual(route); + }); + + it('strips {{ }} from name', () => { + expect(sanitizeRoute({ ...base, name: 'Route {{ env.SECRET }}', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe('Route '); + }); + + it('strips {{ }} from paths, keeping partial values', () => { + expect(sanitizeRoute({ ...base, name: null, methods: null, paths: ['/api/{{ env.SECRET }}/users'], hosts: null, headers: null, expression: null }).paths).toEqual(['/api//users']); + }); + + it('strips {{ }} from hosts, keeping partial values', () => { + expect(sanitizeRoute({ ...base, name: null, methods: null, paths: null, hosts: ['{{ env.SECRET }}.test.com'], headers: null, expression: null }).hosts).toEqual(['.test.com']); + }); + + it('sets methods to null when all entries are fully stripped, so the default fallback applies', () => { + expect(sanitizeRoute({ ...base, name: null, methods: ['{{ env.SECRET }}'], paths: null, hosts: null, headers: null, expression: null }).methods).toBeNull(); + }); + + it('filters out fully-stripped entries but retains valid ones', () => { + expect(sanitizeRoute({ ...base, name: null, methods: ['{{ env.SECRET }}', 'GET'], paths: null, hosts: null, headers: null, expression: null }).methods).toEqual(['GET']); + }); + + it('drops header entries whose value becomes entirely empty after stripping', () => { + expect(sanitizeRoute({ ...base, name: null, methods: null, paths: null, hosts: null, headers: { 'x-leak': ['{{ env.SECRET }}'] }, expression: null }).headers).toEqual({}); + }); + + it('drops header entries whose name becomes entirely empty after stripping', () => { + expect(sanitizeRoute({ ...base, name: null, methods: null, paths: null, hosts: null, headers: { '{{ env.SECRET }}': ['val'] }, expression: null }).headers).toEqual({}); + }); + + it('strips {% %} tag syntax', () => { + expect(sanitizeRoute({ ...base, name: '{% set x = secret %}Name', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe('Name'); + }); + + it('strips a {% %} tag nested inside {{ }}, preventing injection via delimiter interleaving', () => { + expect(sanitizeRoute({ ...base, name: 'before {{% %}} after', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe('before after'); + expect(sanitizeRoute({ ...base, name: '{{% %}% TEST %}', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe(''); + }); + + it('strips a {{ }} tag nested inside {% %}, preventing injection via delimiter interleaving', () => { + expect(sanitizeRoute({ ...base, name: '{%{{ env.SECRET }}%}', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe(''); + }); + + it('leaves unpaired delimiters intact (not valid Nunjucks, nothing to render)', () => { + expect(sanitizeRoute({ ...base, name: 'hello {{ world', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe('hello {{ world'); + expect(sanitizeRoute({ ...base, name: 'hello {% world', methods: null, paths: null, hosts: null, headers: null, expression: null }).name).toBe('hello {% world'); + }); + + it('strips {{ }} from expression', () => { + expect(sanitizeRoute({ ...base, name: null, methods: null, paths: null, hosts: null, headers: null, expression: 'http.path == "{{ env.SECRET }}"' }).expression).toBe('http.path == ""'); + }); + + it('handles null fields without throwing', () => { + const route = { ...base, name: null, methods: null, paths: null, hosts: null, headers: null, expression: null }; + expect(sanitizeRoute(route)).toEqual(route); + }); +}); + +// ─── deriveProxyVarDefaults ────────────────────────────────────────────────── + +describe('deriveProxyVarDefaults', () => { + it('returns empty object when proxy_urls is null', () => { + expect(deriveProxyVarDefaults(null)).toEqual({}); + }); + + it('returns empty object when proxy_urls is an empty array', () => { + expect(deriveProxyVarDefaults([])).toEqual({}); + }); + + it('extracts proxy_host from an http entry — omits standard port 80', () => { + const result = deriveProxyVarDefaults([ + { host: 'proxy.example.com', port: 80, protocol: 'http' }, + ]); + expect(result).toEqual({ proxy_host: 'proxy.example.com' }); + }); + + it('extracts proxy_host from an http entry — includes non-standard port', () => { + const result = deriveProxyVarDefaults([ + { host: 'proxy.example.com', port: 8080, protocol: 'http' }, + ]); + expect(result).toEqual({ proxy_host: 'proxy.example.com:8080' }); + }); + + it('extracts proxy_host from an https entry — omits standard port 443', () => { + const result = deriveProxyVarDefaults([ + { host: 'secure.example.com', port: 443, protocol: 'https' }, + ]); + expect(result).toEqual({ proxy_host: 'secure.example.com' }); + }); + + it('extracts proxy_host from an https entry — includes non-standard port', () => { + const result = deriveProxyVarDefaults([ + { host: 'secure.example.com', port: 8443, protocol: 'https' }, + ]); + expect(result).toEqual({ proxy_host: 'secure.example.com:8443' }); + }); + + it('extracts proxy_host from ws/wss entries — omits standard ports', () => { + expect(deriveProxyVarDefaults([ + { host: 'ws.example.com', port: 80, protocol: 'ws' }, + ])).toEqual({ proxy_host: 'ws.example.com' }); + + expect(deriveProxyVarDefaults([ + { host: 'wss.example.com', port: 443, protocol: 'wss' }, + ])).toEqual({ proxy_host: 'wss.example.com' }); + }); + + it('extracts proxy_host from ws/wss entries — includes non-standard ports', () => { + expect(deriveProxyVarDefaults([ + { host: 'ws.example.com', port: 8080, protocol: 'ws' }, + ])).toEqual({ proxy_host: 'ws.example.com:8080' }); + }); + + it('extracts grpc_proxy_host as host:port from a grpc entry', () => { + const result = deriveProxyVarDefaults([ + { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, + ]); + expect(result).toEqual({ grpc_proxy_host: 'grpc.example.com:9090' }); + }); + + it('extracts grpcs_proxy_host as host:port from a grpcs entry', () => { + const result = deriveProxyVarDefaults([ + { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + ]); + expect(result).toEqual({ grpcs_proxy_host: 'grpcs.example.com:443' }); + }); + + it('fills all three vars from a mixed proxy_urls array', () => { + const result = deriveProxyVarDefaults([ + { host: 'proxy.example.com', port: 443, protocol: 'https' }, + { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, + { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + ]); + expect(result).toEqual({ + proxy_host: 'proxy.example.com', + grpc_proxy_host: 'grpc.example.com:9090', + grpcs_proxy_host: 'grpcs.example.com:443', + }); + }); + + it('uses the first matching entry per protocol family', () => { + const result = deriveProxyVarDefaults([ + { host: 'first.example.com', port: 80, protocol: 'http' }, + { host: 'second.example.com', port: 443, protocol: 'https' }, + ]); + expect(result).toEqual({ proxy_host: 'first.example.com' }); + }); + + it('skips entries with empty host', () => { + const result = deriveProxyVarDefaults([ + { host: '', port: 80, protocol: 'http' }, + { host: 'fallback.example.com', port: 80, protocol: 'http' }, + ]); + expect(result).toEqual({ proxy_host: 'fallback.example.com' }); + }); + + it('handles case-insensitive protocol matching', () => { + const result = deriveProxyVarDefaults([ + { host: 'proxy.example.com', port: 80, protocol: 'HTTP' }, + { host: 'grpc.example.com', port: 9090, protocol: 'GRPC' }, + ]); + expect(result).toEqual({ + proxy_host: 'proxy.example.com', + grpc_proxy_host: 'grpc.example.com:9090', + }); + }); +}); + +// ─── generatePathPlaceholder ───────────────────────────────────────────────── + +describe('generatePathPlaceholder', () => { + it('named capture group — name lowercased, becomes colon param', () => { + expect(generatePathPlaceholder('/api/users/(?[0-9]+)')).toEqual({ + path: '/api/users/:userid', + pathParameters: [{ name: 'userid', value: '' }], + }); + }); + + it('multiple named capture groups — each lowercased', () => { + expect(generatePathPlaceholder('/api/(?[a-z]+)/(?[0-9]+)')).toEqual({ + path: '/api/:resource/:itemid', + pathParameters: [{ name: 'resource', value: '' }, { name: 'itemid', value: '' }], + }); + }); + + it('unnamed capture group — becomes :param_1', () => { + expect(generatePathPlaceholder('/api/items/([0-9]+)')).toEqual({ + path: '/api/items/:param_1', + pathParameters: [{ name: 'param_1', value: '' }], + }); + }); + + it('multiple unnamed groups — each gets an incrementing counter', () => { + expect(generatePathPlaceholder('/api/([a-z]+)/([0-9]+)')).toEqual({ + path: '/api/:param_1/:param_2', + pathParameters: [{ name: 'param_1', value: '' }, { name: 'param_2', value: '' }], + }); + }); + + it('stray character class — uses shared param_N counter', () => { + expect(generatePathPlaceholder('/api/[a-z]+')).toEqual({ + path: '/api/:param_1', + pathParameters: [{ name: 'param_1', value: '' }], + }); + }); + + it('unnamed group then stray class — counter is shared', () => { + expect(generatePathPlaceholder('/api/([0-9]+)/[a-z]+')).toEqual({ + path: '/api/:param_1/:param_2', + pathParameters: [{ name: 'param_1', value: '' }, { name: 'param_2', value: '' }], + }); + }); + + it('leading and trailing anchors stripped', () => { + expect(generatePathPlaceholder('^/api/v1$')).toEqual({ path: '/api/v1', pathParameters: [] }); + }); + + it('escaped slash and dot un-escaped', () => { + expect(generatePathPlaceholder('/api\\/v1\\/users\\.json')).toEqual({ path: '/api/v1/users.json', pathParameters: [] }); + }); + + it('optional trailing slash normalised', () => { + expect(generatePathPlaceholder('/api/users/?')).toEqual({ path: '/api/users/', pathParameters: [] }); + }); + + it('backslash shorthand (\\d+) — falls back to /:path with one path parameter', () => { + expect(generatePathPlaceholder('/regex/\\d+')).toEqual({ + path: '/:path', + pathParameters: [{ name: 'path', value: '' }], + }); + }); + + it('nested parens — dangling ) left after greedy match triggers fallback to /:path', () => { + expect(generatePathPlaceholder('/api/(foo(bar))')).toEqual({ + path: '/:path', + pathParameters: [{ name: 'path', value: '' }], + }); + }); + + it('fallbackMode="keep" — returns original regex string with no path parameters', () => { + expect(generatePathPlaceholder('/regex/\\d+', 'keep')).toEqual({ path: '/regex/\\d+', pathParameters: [] }); + }); + + it('plain path with no regex characters — returned unchanged with no parameters', () => { + expect(generatePathPlaceholder('/api/v1/users')).toEqual({ path: '/api/v1/users', pathParameters: [] }); + }); +}); + +// ─── mergeHeaders ──────────────────────────────────────────────────────────── + +describe('mergeHeaders', () => { + it('returns konnect headers when existing is empty', () => { + expect(mergeHeaders([], [{ name: 'host', value: 'api.example.com' }], [])).toEqual([ + { name: 'host', value: 'api.example.com' }, + ]); + }); + + it('preserves user headers not managed by konnect', () => { + const result = mergeHeaders( + [{ name: 'host', value: 'old.example.com' }, { name: 'x-custom', value: 'yes' }], + [{ name: 'host', value: 'new.example.com' }], + ['host'], + ); + expect(result).toEqual([ + { name: 'host', value: 'new.example.com' }, + { name: 'x-custom', value: 'yes' }, + ]); + }); + + it('removes a previously managed header that is no longer incoming', () => { + const result = mergeHeaders( + [{ name: 'host', value: 'old.example.com' }, { name: 'x-custom', value: 'yes' }], + [], + ['host'], + ); + expect(result).toEqual([{ name: 'x-custom', value: 'yes' }]); + }); +}); + +// ─── mergePathParameters ───────────────────────────────────────────────────── + +describe('mergePathParameters', () => { + it('preserves user-filled values for params that still exist', () => { + const result = mergePathParameters( + [{ name: 'id', value: '42' }], + [{ name: 'id', value: '' }], + ); + expect(result).toEqual([{ name: 'id', value: '42' }]); + }); + + it('drops params that are no longer in the incoming list', () => { + const result = mergePathParameters( + [{ name: 'old', value: 'x' }], + [{ name: 'new', value: '' }], + ); + expect(result).toEqual([{ name: 'new', value: '' }]); + }); + + it('new params get empty value', () => { + const result = mergePathParameters([], [{ name: 'id', value: '' }]); + expect(result).toEqual([{ name: 'id', value: '' }]); + }); +}); + +// ─── konnectHeadersChanged ─────────────────────────────────────────────────── + +describe('konnectHeadersChanged', () => { + it('returns false when incoming and existing managed headers are identical', () => { + expect(konnectHeadersChanged( + [{ name: 'host', value: 'api.example.com' }], + [{ name: 'host', value: 'api.example.com' }], + ['host'], + )).toBe(false); + }); + + it('returns true when a managed header value changes', () => { + expect(konnectHeadersChanged( + [{ name: 'host', value: 'old.example.com' }], + [{ name: 'host', value: 'new.example.com' }], + ['host'], + )).toBe(true); + }); + + it('returns true when a managed header is removed (incoming empty, prevManaged non-empty)', () => { + expect(konnectHeadersChanged( + [{ name: 'host', value: 'api.example.com' }], + [], + ['host'], + )).toBe(true); + }); + + it('returns false when incoming is empty and there were no previously managed headers', () => { + expect(konnectHeadersChanged( + [{ name: 'x-custom', value: 'yes' }], + [], + [], + )).toBe(false); + }); + + it('returns true when a new managed header is added', () => { + expect(konnectHeadersChanged( + [], + [{ name: 'host', value: 'api.example.com' }], + [], + )).toBe(true); + }); +}); + +// ─── pathParametersChanged ─────────────────────────────────────────────────── + +describe('pathParametersChanged', () => { + it('returns false when both are empty', () => { + expect(pathParametersChanged([], [])).toBe(false); + }); + + it('returns false when names match (values ignored)', () => { + expect(pathParametersChanged( + [{ name: 'id', value: '42' }], + [{ name: 'id', value: '' }], + )).toBe(false); + }); + + it('returns true when a param is added', () => { + expect(pathParametersChanged( + [], + [{ name: 'id', value: '' }], + )).toBe(true); + }); + + it('returns true when a param is removed', () => { + expect(pathParametersChanged( + [{ name: 'id', value: '42' }], + [], + )).toBe(true); + }); + + it('returns true when a param is renamed', () => { + expect(pathParametersChanged( + [{ name: 'userid', value: '42' }], + [{ name: 'accountid', value: '' }], + )).toBe(true); + }); +}); diff --git a/packages/insomnia/src/konnect/api.ts b/packages/insomnia/src/konnect/api.ts index 5b366803f8..b17a234541 100644 --- a/packages/insomnia/src/konnect/api.ts +++ b/packages/insomnia/src/konnect/api.ts @@ -10,6 +10,12 @@ const MAX_RETRY_ATTEMPTS = 5; const BASE_DELAY_MS = 1000; const MAX_DELAY_MS = 30_000; +export interface KonnectProxyUrl { + host: string; + port: number; + protocol: string; +} + export interface KonnectControlPlane { id: string; name: string; @@ -18,6 +24,7 @@ export interface KonnectControlPlane { cluster_type: string; control_plane_endpoint: string; }; + proxy_urls: KonnectProxyUrl[] | null; } export interface KonnectService { @@ -44,6 +51,33 @@ export interface KonnectRoute { service: { id: string } | null; } +// Boundary normalizers — coerce any missing nullable field to `null` so the +// declared `T | null` types are honest. Defending against `undefined` once +// here lets every downstream consumer use strict `=== null` checks and skip +// the `?? null` / `arr == null` defensive plumbing that otherwise leaks +// through sanitizeRoute, sync.ts, expression-parser, etc. +function normalizeControlPlane(cp: KonnectControlPlane): KonnectControlPlane { + return { ...cp, proxy_urls: cp.proxy_urls ?? null }; +} + +function normalizeService(s: KonnectService): KonnectService { + return { ...s, name: s.name ?? null, path: s.path ?? null, tags: s.tags ?? null }; +} + +function normalizeRoute(r: KonnectRoute): KonnectRoute { + return { + ...r, + name: r.name ?? null, + methods: r.methods ?? null, + paths: r.paths ?? null, + hosts: r.hosts ?? null, + headers: r.headers ?? null, + snis: r.snis ?? null, + expression: r.expression ?? null, + service: r.service ?? null, + }; +} + async function fetchWithRetry(url: string, pat: string, signal?: AbortSignal): Promise { let attempt = 0; while (true) { @@ -70,34 +104,6 @@ async function fetchWithRetry(url: string, pat: string, signal?: AbortSignal): P } } -export function extractRegionFromEndpoint(endpoint: string): string { - // e.g. "https://abc123.us.cp0.konghq.com" → "us" - try { - const hostname = new URL(endpoint).hostname; - const parts = hostname.split('.'); - // Pattern: ..cp0.konghq.com - if (parts.length >= 4 && parts[parts.length - 2] === 'konghq' && parts[parts.length - 1] === 'com') { - if (parts[parts.length - 3] === 'cp0') { - return parts[parts.length - 4]; - } - console.warn(`[konnect] Unexpected endpoint hostname format, defaulting region to "us": ${hostname}`); - } - } catch { - console.warn(`[konnect] Malformed control_plane_endpoint, defaulting region to "us": ${endpoint}`); - } - return 'us'; -} - -/** - * Names of the proxy environment variables Konnect sync manages. - * All are created as empty strings on first sync — the user must fill them in manually. - * - * - `proxy_host`: hostname only (no port), used in http/https/ws/wss URLs. - * - `grpc_proxy_host`: host:port, used in grpc:// URLs. - * - `grpcs_proxy_host`: host:port, used in grpcs:// URLs. - */ -export const KONNECT_PROXY_VAR_NAMES = ['proxy_host', 'grpc_proxy_host', 'grpcs_proxy_host'] as const; - export interface PatValidationResult { valid: boolean; error?: string; @@ -142,7 +148,7 @@ export async function* fetchAllControlPlanes( const total: number = body?.meta?.page?.total ?? 0; totalPages = Math.ceil(total / PAGE_SIZE) || 1; - yield body.data as KonnectControlPlane[]; + yield (body.data as KonnectControlPlane[]).map(normalizeControlPlane); page++; } while (page <= totalPages); } @@ -178,12 +184,13 @@ export async function fetchAllServices( region: string, signal?: AbortSignal, ): Promise { - return fetchAllOffsetPaginated( + const services = await fetchAllOffsetPaginated( `${regionalApiBase(region)}/v2/control-planes/${cpId}/core-entities/services`, pat, `fetching services for CP ${cpId}`, signal, ); + return services.map(normalizeService); } export async function fetchRoutesForService( @@ -193,12 +200,13 @@ export async function fetchRoutesForService( region: string, signal?: AbortSignal, ): Promise { - return fetchAllOffsetPaginated( + const routes = await fetchAllOffsetPaginated( `${regionalApiBase(region)}/v2/control-planes/${cpId}/core-entities/services/${serviceId}/routes`, pat, `fetching routes for service ${serviceId}`, signal, ); + return routes.map(normalizeRoute); } function regionalApiBase(region: string): string { diff --git a/packages/insomnia/src/konnect/expression-parser.ts b/packages/insomnia/src/konnect/expression-parser.ts new file mode 100644 index 0000000000..8d1048942b --- /dev/null +++ b/packages/insomnia/src/konnect/expression-parser.ts @@ -0,0 +1,103 @@ +import type { KonnectRoute } from './api'; + +export interface ExtractedRouteFields { + methods: string[] | null; + paths: string[] | null; + hosts: string[] | null; + headers: Record | null; +} + +export type ApplyExpressionResult = + | { syncable: true; route: KonnectRoute } + | { syncable: false; routeName: string; reason: string }; + +/** + * Extracts traditional route fields from a Kong expressions router DSL string. + * + * Handles flat AND/OR combinations of simple equality comparisons: + * http.method == "GET" + * http.path == "/foo" + * http.path ^= "/api" (prefix match — treated as exact path for URL construction) + * http.host == "api.example.com" + * http.headers. == "" + * + * `tls.sni` presence is detected separately by `applyExpressionFields` — routes that + * match on SNI are skipped, since Insomnia cannot set a TLS SNI override. + * + * Unsupported predicates (!=, ~, in, any(), net.*, etc.) are silently ignored; + * their corresponding fields remain null so the caller can apply defaults. + * + * Known limitation: cross-field AND-within-OR expressions are over-approximated. + * e.g. `(http.method == "GET" && http.path == "/v1") || (http.method == "POST" && http.path == "/v2")` + * yields methods: ["GET","POST"], paths: ["/v1","/v2"] → 4 requests instead of 2. + * In practice it seems more likely that this would be two separate routes. + */ +export function extractFieldsFromExpression(expression: string): ExtractedRouteFields { + const methodMatches = [...new Set([...expression.matchAll(/http\.method\s*==\s*"([A-Z]+)"/g)].map(m => m[1]))]; + const pathExact = [...expression.matchAll(/http\.path\s*==\s*"([^"]+)"/g)].map(m => m[1]); + const pathPrefix = [...expression.matchAll(/http\.path\s*\^=\s*"([^"]+)"/g)].map(m => m[1]); + const hostMatches = [...new Set([...expression.matchAll(/http\.host\s*==\s*"([^"]+)"/g)].map(m => m[1]))]; + const headerMatches = [...expression.matchAll(/http\.headers\.(\w+)\s*==\s*"([^"]+)"/g)]; + + const allPaths = [...new Set([...pathExact, ...pathPrefix])]; + + let headers: Record | null = null; + if (headerMatches.length > 0) { + headers = {}; + for (const match of headerMatches) { + const name = match[1].replace(/_/g, '-').toLowerCase(); + if (!headers[name]) { + headers[name] = []; + } + headers[name].push(match[2]); + } + } + + return { + methods: methodMatches.length > 0 ? methodMatches : null, + paths: allPaths.length > 0 ? allPaths : null, + hosts: hostMatches.length > 0 ? hostMatches : null, + headers, + }; +} + +/** + * If the route has an expression, extracts fields from it and returns the merged route. + * Returns `syncable: false` when: + * - The expression contains `tls.sni` — Insomnia cannot set a TLS SNI override. + * - The expression yields no usable fields — creating fallback requests would be misleading. + */ +export function applyExpressionFields(route: KonnectRoute): ApplyExpressionResult { + if (!route.expression) { + return { syncable: true, route }; + } + + if (/\btls\.sni\b/.test(route.expression)) { + return { + syncable: false, + routeName: route.name ?? `Route ${route.id}`, + reason: 'Expression route uses tls.sni matching — unsupported in Insomnia', + }; + } + + const extracted = extractFieldsFromExpression(route.expression); + + if (!extracted.methods && !extracted.paths && !extracted.hosts && !extracted.headers) { + return { + syncable: false, + routeName: route.name ?? `Route ${route.id}`, + reason: 'Expression route — no extractable fields (method/path/host/header)', + }; + } + + return { + syncable: true, + route: { + ...route, + methods: extracted.methods, + paths: extracted.paths, + hosts: extracted.hosts, + headers: extracted.headers, + }, + }; +} diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index ec9c64848f..0ee60e50e1 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -3,15 +3,27 @@ import { EnvironmentKvPairDataType, models, services as insoservices } from '~/i import { database as db } from '../common/database'; import { - extractRegionFromEndpoint, fetchAllControlPlanes, fetchAllServices, fetchRoutesForService, - KONNECT_PROXY_VAR_NAMES, type KonnectControlPlane, type KonnectRoute, type KonnectService, } from './api'; +import { applyExpressionFields } from './expression-parser'; +import { + buildRequestName, + deriveProxyVarDefaults, + extractRegionFromEndpoint, + KONNECT_PROXY_VAR_NAMES, + konnectHeadersChanged, + mergeHeaders, + mergePathParameters, + pathParametersChanged, + resolvePath, + routeDisplayName, + sanitizeRoute, +} from './transform'; interface SyncCounts { total: number; @@ -67,42 +79,6 @@ function mergeCounts(target: SyncCounts, source: SyncCounts): void { target.skipped += source.skipped; } -/** Strips the Kong regex `~` prefix from a path, or returns '' for null. */ -function resolvePath(rawPath: string | null): string { - if (rawPath === null) { return ''; } - return rawPath.startsWith('~') ? rawPath.slice(1) : rawPath; -} - -function routeDisplayName(route: { name: string | null; id: string }): string { - return route.name ?? `Route ${route.id}`; -} - -function buildRequestName( - route: { name: string | null; paths: string[] | null; id: string }, -): string { - const rawPath = route.paths?.[0]; - return rawPath !== undefined ? resolvePath(rawPath) : routeDisplayName(route); -} - -/** - * Merges Konnect-managed headers into an existing header array. - * Konnect header names are stored lowercase at the API boundary, so all comparisons - * here are direct string equality. Previously Konnect-managed headers that are no - * longer incoming are removed using the persisted `prevManagedNames` set — on first - * sync this will be empty so no existing headers are incorrectly stripped. - * User-added headers outside that set are always preserved. - */ -function mergeHeaders( - existing: { name: string; value: string }[], - konnect: { name: string; value: string }[], - prevManagedNames: string[], -): { name: string; value: string }[] { - const incomingNames = new Set(konnect.map(h => h.name)); - const prevManaged = new Set(prevManagedNames); - const userHeaders = existing.filter(h => !incomingNames.has(h.name) && !prevManaged.has(h.name)); - return [...konnect, ...userHeaders]; -} - /** Finds or creates a RequestGroup folder. For route-level folders, omit `name` match. */ async function upsertFolder(parentId: string, name: string, konnectRouteId: string): Promise { const existing = (await db.find(models.requestGroup.type, { parentId, konnectRouteId, name }))[0]; @@ -123,33 +99,6 @@ async function upsertRouteFolder(parentId: string, name: string, konnectRouteId: const L4_PROTOCOLS = new Set(['tcp', 'tls', 'udp', 'tls_passthrough']); -/** - * Returns true if the Konnect-managed portion of the existing headers differs from the incoming ones. - * Konnect header names are stored lowercase at the API boundary, so all comparisons are direct - * string equality. `prevManagedNames` is used to detect the case where all Konnect headers were - * removed from the route — incoming is empty but there are still managed headers to clean up. - */ -function konnectHeadersChanged( - existing: { name: string; value: string }[], - incoming: { name: string; value: string }[], - prevManagedNames: string[], -): boolean { - const prevManaged = new Set(prevManagedNames); - if (incoming.length === 0) { - return existing.some(h => prevManaged.has(h.name)); - } - const incomingByName = new Map(incoming.map(h => [h.name, h.value])); - let matched = 0; - for (const h of existing) { - const expected = incomingByName.get(h.name); - if (expected !== undefined) { - if (h.value !== expected) { return true; } - matched++; - } - } - return matched !== incoming.length; -} - interface ExistingRequestMaps { http: Map; ws: Map; @@ -200,7 +149,7 @@ async function syncGrpcRoute( const routeFolderId = await upsertRouteFolder(workspaceId, routeDisplayName(route), route.id); for (const rawPath of paths) { - const protoMethodName = resolvePath(rawPath); + const protoMethodName = resolvePath(rawPath).path; const baseName = protoMethodName || routeDisplayName(route); for (const protocol of grpcProtocols) { @@ -250,7 +199,7 @@ async function syncWsRoute( const routeFolderId = await upsertRouteFolder(workspaceId, routeDisplayName(route), route.id); for (const rawPath of paths) { - const path = resolvePath(rawPath); + const { path, pathParameters } = resolvePath(rawPath); const baseName = buildRequestName({ ...route, paths: rawPath !== null ? [rawPath] : null }); for (const protocol of wsProtocols) { @@ -272,12 +221,13 @@ async function syncWsRoute( const konnectManagedHeaderNames = headers.map(h => h.name); if (existing) { const merged = mergeHeaders(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []); - if (existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? [])) { - await insoservices.webSocketRequest.update(existing, { url, name, headers: merged, konnectManagedHeaderNames }); + const mergedPathParams = mergePathParameters(existing.pathParameters ?? [], pathParameters); + if (existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) || pathParametersChanged(existing.pathParameters ?? [], pathParameters)) { + await insoservices.webSocketRequest.update(existing, { url, name, headers: merged, pathParameters: mergedPathParams, konnectManagedHeaderNames }); routeCounts.updated++; } } else { - await insoservices.webSocketRequest.create({ parentId, url, name, headers, konnectRouteKey: key, konnectManagedHeaderNames }); + await insoservices.webSocketRequest.create({ parentId, url, name, headers, pathParameters, konnectRouteKey: key, konnectManagedHeaderNames }); routeCounts.created++; } } @@ -301,7 +251,7 @@ async function syncHttpRoute( const routeFolderId = await upsertRouteFolder(workspaceId, routeDisplayName(route), route.id); for (const routePath of paths) { - const resolvedPath = resolvePath(routePath); + const { path: resolvedPath, pathParameters } = resolvePath(routePath); const pathSegment = routePath ?? ''; const baseName = buildRequestName({ ...route, paths: routePath !== null ? [routePath] : null }); @@ -324,12 +274,13 @@ async function syncHttpRoute( const konnectManagedHeaderNames = headers.map(h => h.name); if (existing) { const merged = mergeHeaders(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []); - if (existing.method !== method || existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? [])) { - await insoservices.request.update(existing, { method, url, name, headers: merged, konnectManagedHeaderNames }); + const mergedPathParams = mergePathParameters(existing.pathParameters ?? [], pathParameters); + if (existing.method !== method || existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) || pathParametersChanged(existing.pathParameters ?? [], pathParameters)) { + await insoservices.request.update(existing, { method, url, name, headers: merged, pathParameters: mergedPathParams, konnectManagedHeaderNames }); routeCounts.updated++; } } else { - await insoservices.request.create({ parentId, method, url, name, headers, konnectRouteKey: key, konnectManagedHeaderNames }); + await insoservices.request.create({ parentId, method, url, name, headers, pathParameters, konnectRouteKey: key, konnectManagedHeaderNames }); routeCounts.created++; } } @@ -420,7 +371,7 @@ async function syncServiceWorkspace( } await insoservices.cookieJar.getOrCreateForParentId(workspace._id); - const incomingRoutes = await fetchRoutesForService(pat, controlPlane.id, service.id, region, signal); + const incomingRoutes = (await fetchRoutesForService(pat, controlPlane.id, service.id, region, signal)).map(sanitizeRoute); const existingData = await loadExistingRequestData(workspace._id); const incomingKeys = new Set(); const incomingRouteIds = new Set(); @@ -428,35 +379,46 @@ async function syncServiceWorkspace( for (const route of incomingRoutes) { signal?.throwIfAborted(); incomingRouteIds.add(route.id); - const isL4 = route.protocols.every(p => L4_PROTOCOLS.has(p)); - const isGrpc = route.protocols.some(p => p === 'grpc' || p === 'grpcs'); - const isWs = route.protocols.some(p => p === 'ws' || p === 'wss'); - const routeName = routeDisplayName(route); + const expressionResult = applyExpressionFields(route); + if (!expressionResult.syncable) { + counts.routes.skipped++; + skippedRoutes.push({ routeName: expressionResult.routeName, reason: expressionResult.reason, serviceName }); + continue; + } + const effectiveRoute = expressionResult.route; + + const isL4 = effectiveRoute.protocols.every(p => L4_PROTOCOLS.has(p)); + const isGrpc = effectiveRoute.protocols.some(p => p === 'grpc' || p === 'grpcs'); + const isWs = effectiveRoute.protocols.some(p => p === 'ws' || p === 'wss'); + + const routeName = routeDisplayName(effectiveRoute); if (isL4) { counts.routes.skipped++; - skippedRoutes.push({ routeName, reason: `Unsupported protocol: ${route.protocols.join(', ')}`, serviceName }); + skippedRoutes.push({ routeName, reason: `Unsupported protocol: ${effectiveRoute.protocols.join(', ')}`, serviceName }); continue; } // Routes matched by SNI cannot be represented — Insomnia derives SNI implicitly // from the URL hostname and has no SNI override. - if ((route.snis?.length ?? 0) > 0) { + // Note: expression-router tls.sni is caught earlier in applyExpressionFields; + // this check covers the traditional router's snis field. + if ((effectiveRoute.snis?.length ?? 0) > 0) { counts.routes.skipped++; skippedRoutes.push({ routeName, reason: 'Route uses SNI matching — unsupported in Insomnia', serviceName }); continue; } if (isGrpc) { - await syncGrpcRoute(route, workspace._id, existingData.maps.grpc, counts.routes, incomingKeys); + await syncGrpcRoute(effectiveRoute, workspace._id, existingData.maps.grpc, counts.routes, incomingKeys); } else { // Host header only applies to HTTP/WS — gRPC uses :authority which Insomnia derives from the URL const headers = [ - ...(route.hosts?.[0] ? [{ name: 'host', value: route.hosts[0] }] : []), - ...Object.entries(route.headers ?? {}).map(([name, values]) => ({ name: name.toLowerCase(), value: values[0] })), + ...(effectiveRoute.hosts?.[0] ? [{ name: 'host', value: effectiveRoute.hosts[0] }] : []), + ...Object.entries(effectiveRoute.headers ?? {}).map(([name, values]) => ({ name: name.toLowerCase(), value: values[0] })), ]; - await (isWs ? syncWsRoute(route, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys) : syncHttpRoute(route, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys)); + await (isWs ? syncWsRoute(effectiveRoute, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys) : syncHttpRoute(effectiveRoute, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys)); } } @@ -479,14 +441,27 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project: }); const projectEnv = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); - const existingNames = new Set((projectEnv.kvPairData ?? []).map(kv => kv.name)); + const existingKvPairs = projectEnv.kvPairData ?? []; + const existingByName = new Map(existingKvPairs.map(kv => [kv.name, kv])); + const proxyDefaults = deriveProxyVarDefaults(controlPlane.proxy_urls); const newKvPairs = [...KONNECT_PROXY_VAR_NAMES] - .filter(name => !existingNames.has(name)) - .map(name => ({ id: `env_${name}`, name, value: '', type: EnvironmentKvPairDataType.STRING, enabled: true })); + .filter(name => !existingByName.has(name)) + .map(name => ({ id: `env_${name}`, name, value: proxyDefaults[name] ?? '', type: EnvironmentKvPairDataType.STRING, enabled: true })); - if (newKvPairs.length > 0) { + // For existing vars that are still empty, fill in from proxy_urls if available + const updatedExisting = existingKvPairs.map(kv => { + if (kv.value === '' && (KONNECT_PROXY_VAR_NAMES as readonly string[]).includes(kv.name)) { + const defaultValue = proxyDefaults[kv.name as (typeof KONNECT_PROXY_VAR_NAMES)[number]]; + if (defaultValue) { + return { ...kv, value: defaultValue }; + } + } + return kv; + }); + + if (newKvPairs.length > 0 || updatedExisting.some((kv, i) => kv !== existingKvPairs[i])) { await insoservices.environment.update(projectEnv, { - kvPairData: [...(projectEnv.kvPairData ?? []), ...newKvPairs], + kvPairData: [...updatedExisting, ...newKvPairs], }); } diff --git a/packages/insomnia/src/konnect/transform.ts b/packages/insomnia/src/konnect/transform.ts new file mode 100644 index 0000000000..a32991ffb6 --- /dev/null +++ b/packages/insomnia/src/konnect/transform.ts @@ -0,0 +1,313 @@ +import type { KonnectProxyUrl, KonnectRoute } from './api'; + +// ─── Template injection sanitisation ───────────────────────────────────────── + +/** + * Strips Nunjucks template syntax (`{{ }}`, `{% %}`) from a string + * sourced from external API data, preventing template injection when the value + * is later rendered by Insomnia's Nunjucks engine. + */ +function stripTemplateSyntax(value: string): string { + let prev = ''; + let result = value; + while (result !== prev) { + prev = result; + result = result + .replace(/\{\{[\s\S]*?\}\}/g, '') + .replace(/\{%[\s\S]*?%\}/g, ''); + } + return result; +} + +/** Strips template syntax from each item, filters empties, and returns null if nothing remains. */ +function sanitizeStringArray(arr: string[] | null): string[] | null { + if (arr === null) { return null; } + const result = arr.map(stripTemplateSyntax).filter(s => s.trim() !== ''); + return result.length > 0 ? result : null; +} + +/** + * Returns a copy of the route with Nunjucks template syntax stripped from all + * string fields that flow into rendered request content. Array fields that + * become entirely empty after stripping are set to null so existing fallbacks + * (e.g. default HTTP methods) apply correctly. + */ +export function sanitizeRoute(route: KonnectRoute): KonnectRoute { + return { + ...route, + name: route.name !== null ? stripTemplateSyntax(route.name) : null, + methods: sanitizeStringArray(route.methods), + paths: sanitizeStringArray(route.paths), + hosts: sanitizeStringArray(route.hosts), + headers: route.headers + ? Object.fromEntries( + Object.entries(route.headers) + .map(([k, vs]): [string, string[]] => [stripTemplateSyntax(k), sanitizeStringArray(vs) ?? []]) + .filter(([k, vs]) => k.trim() !== '' && vs.length > 0), + ) + : null, + expression: route.expression !== null ? stripTemplateSyntax(route.expression) : null, + }; +} + +// ─── Region extraction ──────────────────────────────────────────────────────── + +/** + * Derives the Konnect region string from a control plane endpoint URL. + * e.g. "https://abc123.us.cp0.konghq.com" → "us" + * Falls back to "us" for unrecognised or malformed values. + */ +export function extractRegionFromEndpoint(endpoint: string): string { + try { + const hostname = new URL(endpoint).hostname; + const parts = hostname.split('.'); + // Pattern: ..cp0.konghq.com + if (parts.length >= 4 && parts[parts.length - 2] === 'konghq' && parts[parts.length - 1] === 'com') { + if (parts[parts.length - 3] === 'cp0') { + return parts[parts.length - 4]; + } + console.warn(`[konnect] Unexpected endpoint hostname format, defaulting region to "us": ${hostname}`); + } + } catch { + console.warn(`[konnect] Malformed control_plane_endpoint, defaulting region to "us": ${endpoint}`); + } + return 'us'; +} + +// ─── Proxy environment variables ───────────────────────────────────────────── + +/** + * Names of the proxy environment variables Konnect sync manages. + * On first sync, values are auto-filled from the control plane's `proxy_urls` + * when available; otherwise created as empty strings for manual entry. + * + * - `proxy_host`: host (with port when non-standard), used in http/https/ws/wss URLs. + * - `grpc_proxy_host`: host:port, used in grpc:// URLs. + * - `grpcs_proxy_host`: host:port, used in grpcs:// URLs. + */ +export const KONNECT_PROXY_VAR_NAMES = ['proxy_host', 'grpc_proxy_host', 'grpcs_proxy_host'] as const; + +const HTTP_LIKE_PROTOCOLS = new Set(['http', 'https', 'ws', 'wss']); +const GRPC_PROTOCOL = 'grpc'; +const GRPCS_PROTOCOL = 'grpcs'; + +/** Default ports per protocol — used to suppress redundant port numbers in the output. */ +const DEFAULT_PORTS: Record = { http: 80, ws: 80, https: 443, wss: 443 }; + +/** Returns `host` for standard ports, `host:port` for non-standard ones. */ +function formatHttpLikeHost(host: string, port: number, protocol: string): string { + const defaultPort = DEFAULT_PORTS[protocol]; + return defaultPort !== undefined && port === defaultPort ? host : `${host}:${port}`; +} + +/** + * Derives default values for the proxy environment variables from a control + * plane's `proxy_urls` array. Returns a partial map of var-name → value; + * omitted keys mean no matching entry was found. + * + * - `proxy_host` ← first http/https/ws/wss entry → host[:port] (port omitted if standard) + * - `grpc_proxy_host` ← first grpc entry → host:port + * - `grpcs_proxy_host` ← first grpcs entry → host:port + */ +export function deriveProxyVarDefaults( + proxyUrls: KonnectProxyUrl[] | null | undefined, +): Partial> { + const defaults: Partial> = {}; + if (!proxyUrls?.length) { + return defaults; + } + + for (const entry of proxyUrls) { + if (!entry.host) { + continue; + } + const proto = entry.protocol.toLowerCase(); + if (!defaults.proxy_host && HTTP_LIKE_PROTOCOLS.has(proto)) { + defaults.proxy_host = formatHttpLikeHost(entry.host, entry.port, proto); + } else if (!defaults.grpc_proxy_host && proto === GRPC_PROTOCOL) { + defaults.grpc_proxy_host = `${entry.host}:${entry.port}`; + } else if (!defaults.grpcs_proxy_host && proto === GRPCS_PROTOCOL) { + defaults.grpcs_proxy_host = `${entry.host}:${entry.port}`; + } + } + + return defaults; +} + +// ─── Path handling ──────────────────────────────────────────────────────────── + +export interface ResolvedPath { + /** URL path with colon-style path parameters, e.g. `/api/users/:userid`. */ + path: string; + /** Insomnia path parameters to store on the request (values pre-filled as empty). */ + pathParameters: { name: string; value: string }[]; +} + +/** + * Converts a Kong regex path string (tilde prefix already stripped) into: + * - a URL path using Insomnia's colon syntax (`:paramname`), and + * - a `pathParameters` array the user fills in via the Path Parameters tab. + * + * Named capture groups → `:name` (lowercased). + * Unnamed groups and stray character classes → `:param_1`, `:param_2`, … (shared counter). + * If the regex is too complex to parse cleanly, falls back to `/:path` (replace) or the raw + * regex string with no path parameters (keep). + */ +export function generatePathPlaceholder( + regexString: string, + fallbackMode: 'keep' | 'replace' = 'replace', +): ResolvedPath { + const paramNames: string[] = []; + + // Strip starting and ending anchors + let path = regexString.replace(/^\^|\$$/g, ''); + + // Un-escape standard path characters + path = path.replace(/\\\//g, '/'); + path = path.replace(/\\\./g, '.'); + path = path.replace(/\/\?$/, '/'); // Optional trailing slash + + // Passes must run in this order: + // 1. Named groups — pattern `(?...)` starts with `(?<`, so it's consumed before pass 2. + // 2. Unnamed groups — matches remaining `(...)` after named groups are gone. + // 3. Stray character classes — matches `[...]` that weren't inside a group. + // Reordering would cause pass 2 to match the inner `(` of a named group before pass 1 can handle it. + + // Pass 1 — Named groups: (?\d+) → :userid + path = path.replace(/\(\?<([a-zA-Z0-9_]+)>[^)]+\)/g, (_, groupName: string) => { + const name = groupName.toLowerCase(); + paramNames.push(name); + return `:${name}`; + }); + + // Passes 2 & 3 share a single param_N counter so the user sees one contiguous sequence + // (:param_1, :param_2, …) rather than two separate ones. + let paramCounter = 1; + // Pass 2 — Unnamed groups: ([0-9]+) → :param_N + path = path.replace(/\([^)]+\)/g, () => { + const name = `param_${paramCounter++}`; + paramNames.push(name); + return `:${name}`; + }); + // Pass 3 — Stray character classes: [a-z]+ → :param_N + path = path.replace(/\[[^\]]+\][+*?]?/g, () => { + const name = `param_${paramCounter++}`; + paramNames.push(name); + return `:${name}`; + }); + + // Validation: Check for leftover regex syntax + const hasLeftoverRegex = /[()[\]*+?\\]/.test(path); + if (hasLeftoverRegex) { + if (fallbackMode === 'keep') { return { path: regexString, pathParameters: [] }; } + return { path: '/:path', pathParameters: [{ name: 'path', value: '' }] }; + } + + // Ensure it starts with a slash + if (!path.startsWith('/')) { + path = '/' + path; + } + + return { + path, + pathParameters: paramNames.map(name => ({ name, value: '' })), + }; +} + +/** + * Resolves a Kong route path for use in an Insomnia URL. + * - null → `{ path: '', pathParameters: [] }` + * - plain path → path unchanged, no pathParameters + * - regex path (Kong `~` prefix) → parsed via generatePathPlaceholder + */ +export function resolvePath(rawPath: string | null): ResolvedPath { + if (rawPath === null) { return { path: '', pathParameters: [] }; } + if (rawPath.startsWith('~')) { return generatePathPlaceholder(rawPath.slice(1)); } + return { path: rawPath, pathParameters: [] }; +} + +export function routeDisplayName(route: { name: string | null; id: string }): string { + return route.name ?? `Route ${route.id}`; +} + +export function buildRequestName( + route: { name: string | null; paths: string[] | null; id: string }, +): string { + const rawPath = route.paths?.[0]; + if (rawPath === undefined) { return routeDisplayName(route); } + const resolved = resolvePath(rawPath).path; + // If the regex was too complex to parse (fell back to '/:path'), use the raw + // Kong path (including the '~' prefix) — it's more informative than '/:path'. + if (resolved === '/:path') { return rawPath; } + return resolved || routeDisplayName(route); +} + +// ─── Header / path-parameter merging ───────────────────────────────────────── + +/** + * Merges Konnect-managed headers into an existing header array. + * Previously Konnect-managed headers that are no longer incoming are removed + * using the persisted `prevManagedNames` set. User-added headers outside that + * set are always preserved. + */ +export function mergeHeaders( + existing: { name: string; value: string }[], + konnect: { name: string; value: string }[], + prevManagedNames: string[], +): { name: string; value: string }[] { + const incomingNames = new Set(konnect.map(h => h.name)); + const prevManaged = new Set(prevManagedNames); + const userHeaders = existing.filter(h => !incomingNames.has(h.name) && !prevManaged.has(h.name)); + return [...konnect, ...userHeaders]; +} + +/** + * Merges Konnect-derived path parameters into the existing set. + * User-filled values are preserved for any param name that still appears; + * renamed or removed params are dropped; new params get an empty value. + */ +export function mergePathParameters( + existing: { name: string; value: string }[], + incoming: { name: string; value: string }[], +): { name: string; value: string }[] { + const existingByName = new Map(existing.map(p => [p.name, p.value])); + return incoming.map(p => ({ name: p.name, value: existingByName.get(p.name) ?? '' })); +} + +/** + * Returns true if the incoming path parameters differ from existing ones + * (by name or count). User-filled values are not considered — only structure. + */ +export function pathParametersChanged( + existing: { name: string; value: string }[], + incoming: { name: string; value: string }[], +): boolean { + if (existing.length !== incoming.length) { return true; } + return existing.some((p, i) => p.name !== incoming[i].name); +} + +/** + * Returns true if the Konnect-managed portion of the existing headers differs + * from the incoming ones. Uses `prevManagedNames` to detect the case where all + * Konnect headers were removed from the route. + */ +export function konnectHeadersChanged( + existing: { name: string; value: string }[], + incoming: { name: string; value: string }[], + prevManagedNames: string[], +): boolean { + const prevManaged = new Set(prevManagedNames); + if (incoming.length === 0) { + return existing.some(h => prevManaged.has(h.name)); + } + const incomingByName = new Map(incoming.map(h => [h.name, h.value])); + let matched = 0; + for (const h of existing) { + const expected = incomingByName.get(h.name); + if (expected !== undefined) { + if (h.value !== expected) { return true; } + matched++; + } + } + return matched !== incoming.length; +} diff --git a/packages/insomnia/src/main/__tests__/sync-initialization.test.ts b/packages/insomnia/src/main/__tests__/sync-initialization.test.ts new file mode 100644 index 0000000000..4aecefa039 --- /dev/null +++ b/packages/insomnia/src/main/__tests__/sync-initialization.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; +import { models, services } from '~/insomnia-data'; +import { getMainVCS } from '~/main/cloud-sync/vcs'; +import { + initializeLocalBackendProjectAndMarkForSync, + pushSnapshotOnInitialize, +} from '~/sync/vcs/initialize-backend-project'; + +import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '../cloud-sync/initialization'; + +vi.mock('~/common/organization-storage-rules', () => ({ + fetchAndCacheOrganizationStorageRule: vi.fn(), +})); + +vi.mock('~/insomnia-data', () => ({ + services: { + workspace: { + getById: vi.fn(), + }, + project: { + getById: vi.fn(), + }, + userSession: { + getOrCreate: vi.fn(), + }, + workspaceMeta: { + getOrCreateByParentId: vi.fn(), + }, + environment: { + getOrCreateForParentId: vi.fn(), + }, + cookieJar: { + getOrCreateForParentId: vi.fn(), + }, + }, + models: { + project: { + isRemoteProject: vi.fn(), + }, + }, +})); + +vi.mock('~/main/cloud-sync/vcs', () => ({ + getMainVCS: vi.fn(), +})); + +vi.mock('~/sync/vcs/initialize-backend-project', () => ({ + initializeLocalBackendProjectAndMarkForSync: vi.fn(), + pushSnapshotOnInitialize: vi.fn(), +})); + +describe('sync-initialization', () => { + const mockVcs = { id: 'mock-vcs' } as any; + const workspace = { + _id: 'wrk_123', + parentId: 'proj_123', + name: 'My Workspace', + } as any; + const project = { + _id: 'proj_123', + parentId: 'org_123', + remoteId: 'remote_proj_123', + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(services.workspace.getById).mockResolvedValue(workspace); + vi.mocked(services.project.getById).mockResolvedValue(project); + vi.mocked(services.userSession.getOrCreate).mockResolvedValue({ id: 'sess_123' } as any); + vi.mocked(services.workspaceMeta.getOrCreateByParentId).mockResolvedValue({ gitRepositoryId: null } as any); + vi.mocked(services.environment.getOrCreateForParentId).mockResolvedValue({} as any); + vi.mocked(services.cookieJar.getOrCreateForParentId).mockResolvedValue({} as any); + vi.mocked(models.project.isRemoteProject).mockReturnValue(true); + vi.mocked(fetchAndCacheOrganizationStorageRule).mockResolvedValue({ + enableCloudSync: true, + } as any); + vi.mocked(getMainVCS).mockReturnValue(mockVcs); + vi.mocked(initializeLocalBackendProjectAndMarkForSync).mockResolvedValue(); + vi.mocked(pushSnapshotOnInitialize).mockResolvedValue(); + }); + + it('returns early when initializing a workspace backend project without a session', async () => { + vi.mocked(services.userSession.getOrCreate).mockResolvedValue({ id: null } as any); + + await initializeWorkspaceBackendProject({ workspaceId: workspace._id }); + + expect(services.workspaceMeta.getOrCreateByParentId).not.toHaveBeenCalled(); + expect(getMainVCS).not.toHaveBeenCalled(); + expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); + }); + + it('skips workspace backend initialization when the workspace already has git metadata', async () => { + vi.mocked(services.workspaceMeta.getOrCreateByParentId).mockResolvedValue({ gitRepositoryId: 'git_123' } as any); + + await initializeWorkspaceBackendProject({ workspaceId: workspace._id }); + + expect(getMainVCS).not.toHaveBeenCalled(); + expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); + }); + + it('skips syncing a new workspace when the project is not remote', async () => { + vi.mocked(models.project.isRemoteProject).mockReturnValue(false); + + await syncNewWorkspaceIfNeeded({ workspaceId: workspace._id }); + + expect(fetchAndCacheOrganizationStorageRule).not.toHaveBeenCalled(); + expect(getMainVCS).not.toHaveBeenCalled(); + expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); + expect(pushSnapshotOnInitialize).not.toHaveBeenCalled(); + }); + + it('skips syncing a new workspace when cloud sync is disabled', async () => { + vi.mocked(fetchAndCacheOrganizationStorageRule).mockResolvedValue({ + enableCloudSync: false, + } as any); + + await syncNewWorkspaceIfNeeded({ workspaceId: workspace._id }); + + expect(services.environment.getOrCreateForParentId).not.toHaveBeenCalled(); + expect(services.cookieJar.getOrCreateForParentId).not.toHaveBeenCalled(); + expect(getMainVCS).not.toHaveBeenCalled(); + expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); + expect(pushSnapshotOnInitialize).not.toHaveBeenCalled(); + }); + + it('initializes and pushes a new workspace for cloud sync-enabled remote projects', async () => { + await syncNewWorkspaceIfNeeded({ workspaceId: workspace._id }); + + expect(services.environment.getOrCreateForParentId).toHaveBeenCalledWith(workspace._id); + expect(services.cookieJar.getOrCreateForParentId).toHaveBeenCalledWith(workspace._id); + expect(services.workspaceMeta.getOrCreateByParentId).toHaveBeenCalledWith(workspace._id); + expect(getMainVCS).toHaveBeenCalled(); + expect(initializeLocalBackendProjectAndMarkForSync).toHaveBeenCalledWith({ + vcs: mockVcs, + workspace, + }); + expect(pushSnapshotOnInitialize).toHaveBeenCalledWith({ + vcs: mockVcs, + workspace, + project, + }); + }); + + it('logs and swallows sync initialization failures so callers can continue', async () => { + vi.mocked(initializeLocalBackendProjectAndMarkForSync).mockRejectedValue(new Error('boom')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(syncNewWorkspaceIfNeeded({ workspaceId: workspace._id })).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith( + `Failed to initialize sync to insomnia cloud for workspace ${workspace._id}. This will be retried when the workspace is opened on the app. boom`, + ); + }); +}); diff --git a/packages/insomnia/src/main/analytics.ts b/packages/insomnia/src/main/analytics.ts index baa25470af..b78061c767 100644 --- a/packages/insomnia/src/main/analytics.ts +++ b/packages/insomnia/src/main/analytics.ts @@ -13,7 +13,7 @@ import { getClientString, getProductName, getSegmentWriteKey, - PLAYWRIGHT, + PLAYWRIGHT_TEST, } from '../common/constants'; import { platform } from '../common/platform'; @@ -84,7 +84,7 @@ function hashString(input: string) { } export async function trackSegmentEvent(event: SegmentEvent, properties?: Record) { - if (PLAYWRIGHT) { + if (PLAYWRIGHT_TEST) { return; } const settings = await services.settings.getOrCreate(); @@ -138,7 +138,7 @@ export async function trackSegmentEvent(event: SegmentEvent, properties?: Record } export async function trackPageView(name: string) { - if (PLAYWRIGHT) { + if (PLAYWRIGHT_TEST) { return; } const settings = await services.settings.getOrCreate(); diff --git a/packages/insomnia/src/sync/vcs/__tests__/util.test.ts b/packages/insomnia/src/main/cloud-sync/core/__tests__/util.test.ts similarity index 94% rename from packages/insomnia/src/sync/vcs/__tests__/util.test.ts rename to packages/insomnia/src/main/cloud-sync/core/__tests__/util.test.ts index f090764a7b..b80db01ee3 100644 --- a/packages/insomnia/src/sync/vcs/__tests__/util.test.ts +++ b/packages/insomnia/src/main/cloud-sync/core/__tests__/util.test.ts @@ -1,9 +1,9 @@ import { createBuilder } from '@develohpanda/fluent-builder'; import { beforeEach, describe, expect, it } from 'vitest'; -import { baseModelSchema, workspaceModelSchema } from '../../../models/__schemas__/model-schemas'; -import { branchSchema, mergeConflictSchema, statusCandidateSchema } from '../../__schemas__/type-schemas'; -import type { StageEntry } from '../../types'; +import { baseModelSchema, workspaceModelSchema } from '../../../../sync/__schemas__/model-schemas'; +import { branchSchema, mergeConflictSchema, statusCandidateSchema } from '../../../../sync/__schemas__/type-schemas'; +import type { StageEntry } from '../../../../sync/types'; import { combinedMapKeys, compareBranches, @@ -13,7 +13,6 @@ import { getStagable, hash, hashDocument, - interceptAccessError, preMergeCheck, stateDelta, threeWayMerge, @@ -973,49 +972,3 @@ const newCandidate = (key: string, n: number) => .build(); const newBranch = (snapshots: string[]) => branchBuilder.snapshots(snapshots).build(); - -describe('interceptAccessError', () => { - it('intercepts an error', async () => { - // Arrange - - // Act - const action = async () => - (await interceptAccessError({ - action: 'action', - callback: () => { - throw new Error('DANGER! invalid access to the fifth dimensional nebulo 9.'); - }, - resourceName: 'resourceName', - resourceType: 'resourceType', - })) as Error; - - // Assert - const result = expect(action).rejects; - result.toBeInstanceOf(Error); - result.toThrowError( - 'You no longer have permission to action the "resourceName" resourceType. Contact your team administrator if you think this is an error.', - ); - }); - - it("does not intercept errors it doesn't care about", async () => { - // Arrange - const message = - 'Having been rejected by the planet smasher, Ziltoid seeks the council of the omnidimensional creator.'; - - // Act - const action = async () => - (await interceptAccessError({ - action: 'action', - callback: () => { - throw new Error(message); - }, - resourceName: 'resourceName', - resourceType: 'resourceType', - })) as Error; - - // Assert - const result = expect(action).rejects; - result.toBeInstanceOf(Error); - result.toThrowError(message); - }); -}); diff --git a/packages/insomnia/src/sync/vcs/__tests__/vcs.test.ts b/packages/insomnia/src/main/cloud-sync/core/__tests__/vcs.test.ts similarity index 93% rename from packages/insomnia/src/sync/vcs/__tests__/vcs.test.ts rename to packages/insomnia/src/main/cloud-sync/core/__tests__/vcs.test.ts index 0e842d61da..417f3ebeb5 100644 --- a/packages/insomnia/src/sync/vcs/__tests__/vcs.test.ts +++ b/packages/insomnia/src/main/cloud-sync/core/__tests__/vcs.test.ts @@ -1,12 +1,13 @@ import { createBuilder } from '@develohpanda/fluent-builder'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { baseModelSchema, workspaceModelSchema } from '../../../models/__schemas__/model-schemas'; -import { projectSchema } from '../../__schemas__/type-schemas'; -import MemoryDriver from '../../store/drivers/memory-driver'; -import type { BackendProject } from '../../types'; -import { describeChanges } from '../util'; -import { VCS } from '../vcs'; +import { baseModelSchema, workspaceModelSchema } from '../../../../sync/__schemas__/model-schemas'; +import { projectSchema } from '../../../../sync/__schemas__/type-schemas'; +import { shouldIgnoreKey } from '../../../../sync/ignore-keys'; +import { deterministicStringify } from '../../../../sync/lib/deterministic-stringify'; +import type { BackendProject } from '../../../../sync/types'; +import MemoryDriver from '../store/drivers/memory-driver'; +import { chunkArray, VCS } from '../vcs'; const baseModelBuilder = createBuilder(baseModelSchema); const workspaceModelBuilder = createBuilder(workspaceModelSchema); @@ -23,6 +24,45 @@ async function vcs(branch) { return v; } +function describeChanges(a, b): string[] { + const aT = Object.prototype.toString.call(a); + const bT = Object.prototype.toString.call(b); + + if (aT !== '[object Object]' || bT !== '[object Object]') { + return []; + } + + const changes: string[] = []; + const allKeys = Object.keys({ ...a, ...b }); + + for (const key of allKeys) { + if (shouldIgnoreKey(key, a)) { + continue; + } + + const aValue = a[key]; + const bValue = b[key]; + const aStr = deterministicStringify(aValue); + const bStr = deterministicStringify(bValue); + + if (aValue === undefined && bValue !== undefined) { + changes.push(`+${String(key)}`); + continue; + } + + if (aValue !== undefined && bValue === undefined) { + changes.push(`-${String(key)}`); + continue; + } + + if (aStr !== bStr) { + changes.push(key); + } + } + + return changes; +} + describe('VCS', () => { beforeEach(async () => { let ts = 1_000_000_000_000; @@ -987,3 +1027,31 @@ describe('VCS', () => { ); }); }); + +describe('chunkArray()', () => { + it('works with exact divisor', () => { + const chunks = chunkArray([1, 2, 3, 4, 5, 6], 3); + expect(chunks).toEqual([ + [1, 2, 3], + [4, 5, 6], + ]); + }); + + it('works with weird divisor', () => { + const chunks = chunkArray([1, 2, 3, 4, 5, 6], 4); + expect(chunks).toEqual([ + [1, 2, 3, 4], + [5, 6], + ]); + }); + + it('works with empty', () => { + const chunks = chunkArray([], 4); + expect(chunks).toEqual([]); + }); + + it('works with less than one chunk', () => { + const chunks = chunkArray([1, 2], 4); + expect(chunks).toEqual([[1, 2]]); + }); +}); diff --git a/packages/insomnia/src/sync/store/__tests__/index.test.ts b/packages/insomnia/src/main/cloud-sync/core/store/__tests__/index.test.ts similarity index 100% rename from packages/insomnia/src/sync/store/__tests__/index.test.ts rename to packages/insomnia/src/main/cloud-sync/core/store/__tests__/index.test.ts diff --git a/packages/insomnia/src/sync/store/drivers/base.ts b/packages/insomnia/src/main/cloud-sync/core/store/drivers/base.ts similarity index 100% rename from packages/insomnia/src/sync/store/drivers/base.ts rename to packages/insomnia/src/main/cloud-sync/core/store/drivers/base.ts diff --git a/packages/insomnia/src/sync/store/drivers/file-system-driver.ts b/packages/insomnia/src/main/cloud-sync/core/store/drivers/file-system-driver.ts similarity index 100% rename from packages/insomnia/src/sync/store/drivers/file-system-driver.ts rename to packages/insomnia/src/main/cloud-sync/core/store/drivers/file-system-driver.ts diff --git a/packages/insomnia/src/sync/store/drivers/graceful-rename.ts b/packages/insomnia/src/main/cloud-sync/core/store/drivers/graceful-rename.ts similarity index 97% rename from packages/insomnia/src/sync/store/drivers/graceful-rename.ts rename to packages/insomnia/src/main/cloud-sync/core/store/drivers/graceful-rename.ts index 1672962c52..d88d10d3bc 100644 --- a/packages/insomnia/src/sync/store/drivers/graceful-rename.ts +++ b/packages/insomnia/src/main/cloud-sync/core/store/drivers/graceful-rename.ts @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; -import { isWindows } from '../../../common/platform'; +import { isWindows } from '../../../../../common/platform'; // Based on node-graceful-fs and vs-code's take on renaming files in a way that is more resilient to Windows locking renames // https://github.com/microsoft/vscode/pull/188899/files#diff-2bf233effbb62ea789bb7c4739d222a43ccd97ed9f1219f75bb07e9dee91c1a7R529 // On Windows, A/V software can lock the directory, causing this diff --git a/packages/insomnia/src/sync/store/drivers/memory-driver.ts b/packages/insomnia/src/main/cloud-sync/core/store/drivers/memory-driver.ts similarity index 100% rename from packages/insomnia/src/sync/store/drivers/memory-driver.ts rename to packages/insomnia/src/main/cloud-sync/core/store/drivers/memory-driver.ts diff --git a/packages/insomnia/src/sync/store/hooks/__tests__/compress.test.ts b/packages/insomnia/src/main/cloud-sync/core/store/hooks/__tests__/compress.test.ts similarity index 100% rename from packages/insomnia/src/sync/store/hooks/__tests__/compress.test.ts rename to packages/insomnia/src/main/cloud-sync/core/store/hooks/__tests__/compress.test.ts diff --git a/packages/insomnia/src/sync/store/hooks/compress.ts b/packages/insomnia/src/main/cloud-sync/core/store/hooks/compress.ts similarity index 100% rename from packages/insomnia/src/sync/store/hooks/compress.ts rename to packages/insomnia/src/main/cloud-sync/core/store/hooks/compress.ts diff --git a/packages/insomnia/src/sync/store/index.ts b/packages/insomnia/src/main/cloud-sync/core/store/index.ts similarity index 100% rename from packages/insomnia/src/sync/store/index.ts rename to packages/insomnia/src/main/cloud-sync/core/store/index.ts diff --git a/packages/insomnia/src/sync/vcs/util.ts b/packages/insomnia/src/main/cloud-sync/core/util.ts similarity index 86% rename from packages/insomnia/src/sync/vcs/util.ts rename to packages/insomnia/src/main/cloud-sync/core/util.ts index 1bde75524d..608af39358 100644 --- a/packages/insomnia/src/sync/vcs/util.ts +++ b/packages/insomnia/src/main/cloud-sync/core/util.ts @@ -2,10 +2,10 @@ import crypto from 'node:crypto'; import clone from 'clone'; -import { strings } from '../../common/strings'; -import type { BaseModel } from '../../models'; -import { deleteKeys, resetKeys, shouldIgnoreKey } from '../ignore-keys'; -import { deterministicStringify } from '../lib/deterministic-stringify'; +import type { BaseModel } from '~/insomnia-data'; + +import { deleteKeys, resetKeys } from '../../../sync/ignore-keys'; +import { deterministicStringify } from '../../../sync/lib/deterministic-stringify'; import type { Branch, Compare, @@ -18,7 +18,7 @@ import type { StageEntry, StatusCandidate, StatusCandidateMap, -} from '../types'; +} from '../../../sync/types'; export function generateSnapshotStateMap(snapshot: Snapshot | null): SnapshotStateMap { if (!snapshot) { @@ -275,7 +275,7 @@ export function compareBranches(a: Branch | null, b: Branch | null): Compare { return result; } -export interface StateDelta { +interface StateDelta { add: SnapshotStateEntry[]; update: SnapshotStateEntry[]; remove: SnapshotStateEntry[]; @@ -495,66 +495,3 @@ export function updateStateWithConflictResolutions(state: SnapshotState, conflic return Object.keys(newStateMap).map(k => newStateMap[k]); } - -export function describeChanges(a: T, b: T): string[] { - const aT = Object.prototype.toString.call(a); - const bT = Object.prototype.toString.call(b); - - if (aT !== '[object Object]' || bT !== '[object Object]') { - return []; - } - - const changes: string[] = []; - const allKeys = Object.keys({ ...a, ...b }) as (keyof T)[]; - - for (const key of allKeys) { - if (shouldIgnoreKey(key as keyof T, a)) { - continue; - } - - const aValue = a[key]; - const bValue = b[key]; - const aStr = deterministicStringify(aValue); - const bStr = deterministicStringify(bValue); - - if (aValue === undefined && bValue !== undefined) { - changes.push(`+${String(key)}`); - continue; - } - - if (aValue !== undefined && bValue === undefined) { - changes.push(`-${String(key)}`); - continue; - } - - if (aStr !== bStr) { - // @ts-expect-error -- type unsoundness - changes.push(key); - } - } - - return changes; -} - -export const interceptAccessError = async ({ - callback, - action, - resourceName, - resourceType = strings.collection.singular.toLowerCase(), -}: { - callback: () => T | Promise; - action: string; - resourceName: string; - resourceType?: string; -}) => { - try { - return await callback(); - } catch (error: unknown) { - if (error instanceof Error && error.message.includes('invalid access')) { - throw new Error( - `You no longer have permission to ${action} the "${resourceName}" ${resourceType}. Contact your team administrator if you think this is an error.`, - ); - } - throw error; - } -}; diff --git a/packages/insomnia/src/sync/vcs/vcs.ts b/packages/insomnia/src/main/cloud-sync/core/vcs.ts similarity index 97% rename from packages/insomnia/src/sync/vcs/vcs.ts rename to packages/insomnia/src/main/cloud-sync/core/vcs.ts index 913d74cbac..a39c77e497 100644 --- a/packages/insomnia/src/sync/vcs/vcs.ts +++ b/packages/insomnia/src/main/cloud-sync/core/vcs.ts @@ -7,18 +7,16 @@ import path from 'node:path'; import clone from 'clone'; import { runVcsGraphQL } from 'insomnia-api'; -import { PLAYWRIGHT } from '~/common/constants'; +import { PLAYWRIGHT_TEST } from '~/common/constants'; +import type { BaseModel } from '~/insomnia-data'; -import * as crypt from '../../account/crypt'; -import * as session from '../../account/session'; -import type { Operation } from '../../common/database'; -import { generateId } from '../../common/misc'; -import type { BaseModel } from '../../models'; -import Store from '../store'; -import type { BaseDriver } from '../store/drivers/base'; -import compress from '../store/hooks/compress'; +import * as crypt from '../../../account/crypt'; +import * as session from '../../../account/session'; +import type { Operation } from '../../../common/database'; +import { generateId } from '../../../common/misc'; import type { BackendProject, + BackendProjectWithTeams, Branch, DocumentKey, Head, @@ -28,8 +26,10 @@ import type { Stage, StageEntry, StatusCandidate, -} from '../types'; -import type { BackendProjectWithTeams } from './normalize-backend-project-team'; +} from '../../../sync/types'; +import Store from './store'; +import type { BaseDriver } from './store/drivers/base'; +import compress from './store/hooks/compress'; import { compareBranches, generateCandidateMap, @@ -59,6 +59,31 @@ export function chunkArray(arr: T[], chunkSize: number) { return chunks; } +const generateAES256KeyInNode = async (): Promise => { + const subtle = crypto.webcrypto?.subtle; + + if (subtle) { + console.log('[crypt] Using Node WebCrypto AES Key Generation'); + const key = await subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ); + return subtle.exportKey('jwk', key); + } + + return { + kty: 'oct', + alg: 'A256GCM', + ext: true, + key_ops: ['encrypt', 'decrypt'], + k: crypto.randomBytes(32).toString('base64url'), + }; +}; + // Stage/Unstage // Staged items are about to be committed // Unstaged items have changed compared to staged or not and can be staged @@ -89,12 +114,6 @@ export class VCS { this._backendProject = null; } - newInstance(): VCS { - const newVCS: VCS = Object.assign({}, this) as any; - Object.setPrototypeOf(newVCS, VCS.prototype); - return newVCS; - } - async setBackendProject(backendProject: BackendProject) { this._backendProject = backendProject; console.debug(`[sync] Activated project ${backendProject.id}`); @@ -141,16 +160,6 @@ export class VCS { this._backendProject = null; } - async switchProject(rootDocumentId: string) { - const backendProject = await this._getBackendProjectByRootDocument(rootDocumentId); - - if (backendProject !== null) { - await this.setBackendProject(backendProject); - } else { - this._backendProject = null; - } - } - async switchAndCreateBackendProjectIfNotExist(rootDocumentId: string, name: string) { const project = await this._getOrCreateBackendProjectByRootDocument(rootDocumentId, name); await this.setBackendProject(project); @@ -1234,7 +1243,7 @@ export class VCS { }[], ) { // Generate symmetric key for ResourceGroup - const symmetricKey = await crypt.generateAES256Key(); + const symmetricKey = await generateAES256KeyInNode(); const symmetricKeyStr = JSON.stringify(symmetricKey); const teamKeys: { accountId: string; encSymmetricKey: string; autoLinked: boolean }[] = []; @@ -1303,7 +1312,7 @@ export class VCS { async _getBackendProjectSymmetricKey() { const { privateKey, symmetricKey } = await this._assertSession(); - if (PLAYWRIGHT) { + if (PLAYWRIGHT_TEST) { // use the session symmetric key in playwright tests return symmetricKey; } diff --git a/packages/insomnia/src/main/cloud-sync/create-vcs.ts b/packages/insomnia/src/main/cloud-sync/create-vcs.ts new file mode 100644 index 0000000000..f657f03950 --- /dev/null +++ b/packages/insomnia/src/main/cloud-sync/create-vcs.ts @@ -0,0 +1,18 @@ +import type { MergeConflict } from '../../sync/types'; +import FileSystemDriver from './core/store/drivers/file-system-driver'; +import { VCS } from './core/vcs'; + +export type ConflictHandler = ( + conflicts: MergeConflict[], + labels: { ours: string; theirs: string }, +) => Promise; + +export const createVCS = ({ + dataPath, + conflictHandler, +}: { + dataPath: string; + conflictHandler?: ConflictHandler; +}) => { + return new VCS(FileSystemDriver.create(dataPath), conflictHandler); +}; diff --git a/packages/insomnia/src/main/cloud-sync/initialization.ts b/packages/insomnia/src/main/cloud-sync/initialization.ts new file mode 100644 index 0000000000..436ae72ec5 --- /dev/null +++ b/packages/insomnia/src/main/cloud-sync/initialization.ts @@ -0,0 +1,71 @@ +import { fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; +import { models, services } from '~/insomnia-data'; +import { getMainVCS } from '~/main/cloud-sync/vcs'; +import { + initializeLocalBackendProjectAndMarkForSync, + pushSnapshotOnInitialize, +} from '~/sync/vcs/initialize-backend-project'; +import { invariant } from '~/utils/invariant'; + +export const initializeWorkspaceBackendProject = async ({ workspaceId }: { workspaceId: string }) => { + const workspace = await services.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + + const { id } = await services.userSession.getOrCreate(); + if (!id) { + return; + } + + const workspaceMeta = await services.workspaceMeta.getOrCreateByParentId(workspaceId); + if (workspaceMeta.gitRepositoryId) { + return; + } + + const vcs = getMainVCS(); + await initializeLocalBackendProjectAndMarkForSync({ + vcs, + workspace, + }); +}; + +export const syncNewWorkspaceIfNeeded = async ({ workspaceId }: { workspaceId: string }) => { + const workspace = await services.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + + const project = await services.project.getById(workspace.parentId); + invariant(project, 'Project not found'); + + const userSession = await services.userSession.getOrCreate(); + if (!userSession.id || !models.project.isRemoteProject(project)) { + return; + } + + const storageRules = await fetchAndCacheOrganizationStorageRule(project.parentId); + invariant(storageRules, 'Storage rules not found'); + + if (!storageRules.enableCloudSync) { + return; + } + + await services.environment.getOrCreateForParentId(workspace._id); + await services.cookieJar.getOrCreateForParentId(workspace._id); + await services.workspaceMeta.getOrCreateByParentId(workspace._id); + + try { + const vcs = getMainVCS(); + await initializeLocalBackendProjectAndMarkForSync({ + vcs, + workspace, + }); + await pushSnapshotOnInitialize({ + vcs, + workspace, + project, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + console.warn( + `Failed to initialize sync to insomnia cloud for workspace ${workspace._id}. This will be retried when the workspace is opened on the app. ${errorMessage}`, + ); + } +}; diff --git a/packages/insomnia/src/main/cloud-sync/ipc.ts b/packages/insomnia/src/main/cloud-sync/ipc.ts new file mode 100644 index 0000000000..2e2ab7979c --- /dev/null +++ b/packages/insomnia/src/main/cloud-sync/ipc.ts @@ -0,0 +1,103 @@ +import type { IpcRendererEvent } from 'electron'; + +import type { + BackendProject, + BackendProjectWithTeam, + Compare, + MergeConflict, + Snapshot, + Stage, + StageEntry, + Status, + StatusCandidate, +} from '~/sync/types'; + +import type { Operation } from '../../common/database'; +import { ipcMainHandle, ipcMainOn } from '../ipc/electron'; +import { + cancelPendingSyncConflict, + invokeMainVCS, + type PullRemoteBackendProjectOptions, + pullRemoteBackendProjectWithSingleton, + resolvePendingSyncConflict, +} from './vcs'; + +export interface SyncBridgeMethods { + archiveProject: () => Promise; + checkout: (candidates: StatusCandidate[], branchName: string) => Promise; + compareRemoteBranch: () => Promise; + fork: (newBranchName: string) => Promise; + getBranchNames: () => Promise; + getCurrentBranchName: () => Promise; + getHistory: (count?: number) => Promise; + getHistoryCount: () => Promise; + getRemoteBranchNames: () => Promise; + getVersion: () => Promise; + localBackendProjects: () => Promise; + merge: (candidates: StatusCandidate[], otherBranchName: string, snapshotMessage?: string) => Promise; + pull: (options: { + candidates: StatusCandidate[]; + teamId: string; + teamProjectId: string; + projectId: string; + }) => Promise; + push: (options: { teamId: string; teamProjectId: string }) => Promise; + remoteBackendProjects: (options: { teamId: string; teamProjectId: string }) => Promise; + removeBackendProjectsForRoot: (rootDocumentId: string) => Promise; + removeBranch: (branchName: string) => Promise; + removeRemoteBranch: (branchName: string) => Promise; + rollback: (snapshotId: string, candidates: StatusCandidate[]) => Promise; + rollbackToLatest: (candidates: StatusCandidate[]) => Promise; + stage: (stageEntries: StageEntry[]) => Promise; + status: (candidates: StatusCandidate[]) => Promise; + switchAndCreateBackendProjectIfNotExist: (rootDocumentId: string, name: string) => Promise; + takeSnapshot: (name: string) => Promise; + unstage: (stageEntries: StageEntry[]) => Promise; +} + +export interface SyncBridgeAPI extends SyncBridgeMethods { + getActiveBackendProject: () => Promise; + hasBackendProject: () => Promise; + pullRemoteBackendProject: (options: PullRemoteBackendProjectOptions) => Promise<{ + projectId: string; + workspaceId: string; + }>; + resolveConflict: (options: { handlerId: string; conflicts: MergeConflict[] }) => void; + cancelConflict: (options: { handlerId: string }) => void; + on: ( + channel: 'sync.merge-conflicts', + listener: ( + event: IpcRendererEvent, + options: { + handlerId: string; + conflicts: MergeConflict[]; + labels: { ours: string; theirs: string }; + }, + ) => void, + ) => () => void; +} + +export const registerSyncHandlers = () => { + ipcMainHandle('sync.invoke', (event, methodName: string, ...args: unknown[]) => { + return invokeMainVCS(event.sender, methodName, ...args); + }); + + ipcMainHandle('sync.pullRemoteBackendProject', (event, options: PullRemoteBackendProjectOptions) => { + return pullRemoteBackendProjectWithSingleton(event.sender, options); + }); + + ipcMainOn('sync.resolveConflict', (event, options: { handlerId: string; conflicts: MergeConflict[] }) => { + resolvePendingSyncConflict({ + handlerId: options.handlerId, + sender: event.sender, + conflicts: options.conflicts, + }); + }); + + ipcMainOn('sync.cancelConflict', (event, options: { handlerId: string }) => { + cancelPendingSyncConflict({ + handlerId: options.handlerId, + sender: event.sender, + }); + }); +}; diff --git a/packages/insomnia/src/sync/vcs/pull-backend-project.ts b/packages/insomnia/src/main/cloud-sync/pull-backend-project.ts similarity index 90% rename from packages/insomnia/src/sync/vcs/pull-backend-project.ts rename to packages/insomnia/src/main/cloud-sync/pull-backend-project.ts index ad052dab8b..895ffb276e 100644 --- a/packages/insomnia/src/sync/vcs/pull-backend-project.ts +++ b/packages/insomnia/src/main/cloud-sync/pull-backend-project.ts @@ -1,10 +1,10 @@ -import { type RemoteProject, type Workspace } from '~/insomnia-data'; +import type { RemoteProject, Workspace } from '~/insomnia-data'; import { database, models } from '~/insomnia-data'; +import type { VCS } from '~/main/cloud-sync/core/vcs'; +import { interceptAccessError } from '~/sync/access-error'; +import type { BackendProjectWithTeam } from '~/sync/types'; import { DEFAULT_BRANCH_NAME } from '../../common/constants'; -import type { BackendProjectWithTeam } from './normalize-backend-project-team'; -import { interceptAccessError } from './util'; -import type { VCS } from './vcs'; interface Options { vcs: VCS; diff --git a/packages/insomnia/src/main/cloud-sync/vcs.ts b/packages/insomnia/src/main/cloud-sync/vcs.ts new file mode 100644 index 0000000000..beab107ee1 --- /dev/null +++ b/packages/insomnia/src/main/cloud-sync/vcs.ts @@ -0,0 +1,157 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomUUID } from 'node:crypto'; + +import { app, type WebContents } from 'electron'; + +import type { RemoteProject } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; +import type { VCS } from '~/main/cloud-sync/core/vcs'; +import { createVCS } from '~/main/cloud-sync/create-vcs'; +import { pullBackendProject } from '~/main/cloud-sync/pull-backend-project'; +import type { BackendProjectWithTeam, MergeConflict } from '~/sync/types'; +import { UserAbortResolveMergeConflictError } from '~/sync/vcs/errors'; +import { invariant } from '~/utils/invariant'; + +interface SyncInvocationContext { + sender: WebContents; +} + +interface PendingConflictResolution { + senderId: number; + resolve: (conflicts: MergeConflict[]) => void; + reject: (error: Error) => void; +} + +export interface PullRemoteBackendProjectOptions { + organizationId: string; + backendProjectId: string; + remoteId: string; +} + +const syncInvocationContext = new AsyncLocalStorage(); +const pendingConflictResolutions = new Map(); + +let mainVCS: VCS | null = null; + +const requestConflictResolution = (conflicts: MergeConflict[], labels: { ours: string; theirs: string }) => { + const context = syncInvocationContext.getStore(); + invariant(context, 'Sync conflict resolution requires a renderer context'); + + const handlerId = randomUUID(); + context.sender.send('sync.merge-conflicts', { + handlerId, + conflicts, + labels, + }); + + return new Promise((resolve, reject) => { + pendingConflictResolutions.set(handlerId, { + senderId: context.sender.id, + resolve, + reject, + }); + }); +}; + +export const getMainVCS = () => { + if (mainVCS) { + return mainVCS; + } + + mainVCS = createVCS({ + dataPath: process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), + conflictHandler: requestConflictResolution, + }); + + return mainVCS; +}; + +export const runWithSyncRenderer = (sender: WebContents, callback: () => Promise | T) => { + return syncInvocationContext.run({ sender }, callback); +}; + +export const invokeMainVCS = async (sender: WebContents, methodName: string, ...args: unknown[]) => { + const vcs = getMainVCS(); + const method = vcs[methodName as keyof VCS]; + + if (typeof method !== 'function') { + throw new TypeError(`Unknown VCS method: ${methodName}`); + } + + return runWithSyncRenderer(sender, () => (method as (...args: unknown[]) => unknown).apply(vcs, args)); +}; + +export const resolvePendingSyncConflict = ({ + handlerId, + sender, + conflicts, +}: { + handlerId: string; + sender: WebContents; + conflicts: MergeConflict[]; +}) => { + const pendingConflictResolution = pendingConflictResolutions.get(handlerId); + invariant(pendingConflictResolution, `Unknown sync conflict request: ${handlerId}`); + invariant( + pendingConflictResolution.senderId === sender.id, + `Sync conflict request ${handlerId} was resolved by an unexpected renderer`, + ); + + pendingConflictResolutions.delete(handlerId); + pendingConflictResolution.resolve(conflicts); +}; + +export const cancelPendingSyncConflict = ({ handlerId, sender }: { handlerId: string; sender: WebContents }) => { + const pendingConflictResolution = pendingConflictResolutions.get(handlerId); + invariant(pendingConflictResolution, `Unknown sync conflict request: ${handlerId}`); + invariant( + pendingConflictResolution.senderId === sender.id, + `Sync conflict request ${handlerId} was cancelled by an unexpected renderer`, + ); + + pendingConflictResolutions.delete(handlerId); + pendingConflictResolution.reject(new UserAbortResolveMergeConflictError()); +}; + +export const pullRemoteBackendProjectWithSingleton = async ( + sender: WebContents, + { organizationId, backendProjectId, remoteId }: PullRemoteBackendProjectOptions, +) => { + return runWithSyncRenderer(sender, async () => { + // Use the singleton only for the remote listing (read-only network call). + // The actual pull uses an isolated VCS instance so the singleton's active + // backend project is never mutated, preventing cross-workspace interference + // with concurrent sync.invoke calls. + const vcs = getMainVCS(); + const remoteBackendProjects = await vcs.remoteBackendProjects({ + teamId: organizationId, + teamProjectId: remoteId, + }); + const backendProject = remoteBackendProjects.find(project => project.id === backendProjectId) as + | BackendProjectWithTeam + | undefined; + + invariant(backendProject, 'Backend project not found'); + + const project = await services.project.getByRemoteId(remoteId); + invariant(project?.remoteId, 'Project is not a remote project'); + + const pullVCS = createVCS({ + dataPath: process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), + conflictHandler: requestConflictResolution, + }); + + await pullVCS.removeBackendProjectsForRoot(backendProject.rootDocumentId); + const { workspaceId } = await pullBackendProject({ + vcs: pullVCS, + backendProject, + remoteProject: project as RemoteProject, + }); + invariant(typeof workspaceId === 'string', 'Workspace not found after pulling backend project'); + + return { + projectId: project._id, + workspaceId, + }; + }); +}; diff --git a/packages/insomnia/src/main/electron-storage.ts b/packages/insomnia/src/main/electron-storage.ts index 8decf17f92..68c919cdc6 100644 --- a/packages/insomnia/src/main/electron-storage.ts +++ b/packages/insomnia/src/main/electron-storage.ts @@ -1,6 +1,28 @@ import fs from 'node:fs'; import path from 'node:path'; +import { invariant } from '~/utils/invariant'; + +// Intentional singleton: initialized once per process via initElectronStorage and shared across the app. +let electronStorage: ElectronStorage | null = null; +export function initElectronStorage(dataPath: string) { + const electronStoragePath = path.join(dataPath, 'localStorage'); + const resolvedDataPath = path.resolve(dataPath); + const resolvedElectronStoragePath = path.resolve(electronStoragePath); + const relativePath = path.relative(resolvedDataPath, resolvedElectronStoragePath); + invariant(!relativePath.startsWith('..') && !path.isAbsolute(relativePath), `Invalid path`); + // Ensure that electronStorage is not yet initialized before creating a new instance. This prevents accidental re-initialization with a different path, which could lead to data loss. + invariant( + !electronStorage, + `ElectronStorage already initialized. Attempted re-init with: ${resolvedElectronStoragePath}`, + ); + electronStorage = new ElectronStorage(resolvedElectronStoragePath); +} +export function getElectronStorage(): ElectronStorage { + invariant(electronStorage, 'ElectronStorage has not been initialized.'); + return electronStorage; +} + class ElectronStorage { _buffer: Record = {}; _timeouts: Record = {}; @@ -15,24 +37,26 @@ class ElectronStorage { } setItem(key: string, obj?: T) { - clearTimeout(this._timeouts[key]); - this._buffer[key] = JSON.stringify(obj); - this._timeouts[key] = setTimeout(this._flush.bind(this), 100); + const storageKey = this._validateKey(key); + clearTimeout(this._timeouts[storageKey]); + this._buffer[storageKey] = JSON.stringify(obj); + this._timeouts[storageKey] = setTimeout(this._flush.bind(this), 100); } getItem(key: string, defaultObj?: T) { + const storageKey = this._validateKey(key); // Make sure things are flushed before we read this._flush(); let contents = JSON.stringify(defaultObj); - const path = this._getKeyPath(key); + const path = this._getKeyPath(storageKey); try { contents = String(fs.readFileSync(path)); } catch (error) { if (error.code === 'ENOENT') { - this.setItem(key, defaultObj); + this.setItem(storageKey, defaultObj); } } @@ -45,10 +69,11 @@ class ElectronStorage { } deleteItem(key: string) { - clearTimeout(this._timeouts[key]); - delete this._buffer[key]; + const storageKey = this._validateKey(key); + clearTimeout(this._timeouts[storageKey]); + delete this._buffer[storageKey]; - const path = this._getKeyPath(key); + const path = this._getKeyPath(storageKey); try { fs.unlinkSync(path); @@ -59,6 +84,14 @@ class ElectronStorage { } } + _validateKey(key: string) { + if (!key || key === '.' || key === '..' || key.includes('/') || key.includes('\\') || key.includes('\0')) { + throw new Error('Invalid electron storage key'); + } + + return key; + } + _flush() { const keys = Object.keys(this._buffer); diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 831e93f00d..19499f86c1 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -20,8 +20,16 @@ import { fromUrl } from 'hosted-git-info'; import { Errors, type PromiseFsClient } from 'isomorphic-git'; import YAML, { parse } from 'yaml'; -import type { GitRemoteProviderType, GitRepository, WorkspaceScope } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import type { + BaseModel, + GitProject, + GitRemoteProviderType, + GitRepository, + Workspace, + WorkspaceMeta, + WorkspaceScope, +} from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { GitVCSOperationErrors } from '~/sync/git/git-vcs-operation-errors'; import { gitRemoteProviderRegistry, @@ -29,14 +37,15 @@ import { type ProviderEmail, type ProviderRepository, } from '~/sync/git/providers'; +import type { FileIssue, FileIssueKind } from '~/sync/git/repo-file-watcher'; import { INSOMNIA_GITLAB_API_URL } from '../common/constants'; import { database } from '../common/database'; import { InsomniaFileSchema, InsomniaFileTypeValues } from '../common/import-v5-parser'; import { migrateToLatestYaml } from '../common/insomnia-schema-migrations'; import { insomniaSchemaTypeToScope } from '../common/insomnia-v5'; -import * as models from '../models'; import { fsClient } from '../sync/git/fs-client'; +import { CURRENT_MIGRATION_VERSION, migrateRepoStructureIfNeeded } from '../sync/git/git-repo-migration'; import GitVCS, { fetchRemoteBranches, GIT_CLONE_DIR, @@ -51,11 +60,11 @@ import GitVCS, { } from '../sync/git/git-vcs'; import { MemClient } from '../sync/git/mem-client'; import { NeDBClient } from '../sync/git/ne-db-client'; -import { GitProjectNeDBClient } from '../sync/git/project-ne-db-client'; import { projectRoutableFSClient } from '../sync/git/project-routable-fs-client'; +import { createElectronNotifier, RepoFileWatcherRegistry, type WatcherNotifier } from '../sync/git/repo-file-watcher'; import { routableFSClient } from '../sync/git/routable-fs-client'; import { shallowClone } from '../sync/git/shallow-clone'; -import type { MergeConflict } from '../sync/types'; +import type { AutoResolvedConflict, MergeConflict } from '../sync/types'; import { invariant } from '../utils/invariant'; import { SegmentEvent, trackSegmentEvent } from './analytics'; import { ipcMainHandle } from './ipc/electron'; @@ -63,6 +72,35 @@ import { ipcMainHandle } from './ipc/electron'; // Initialize Git Remote Providers on module load initializeGitRemoteProviders(); +/** + * Set of repo IDs for which conflict problems should be suppressed in the + * file-problems-changed IPC broadcast. Active while the user is resolving + * conflicts via SyncMergeModal so the generic "CLI conflict" blocking modal + * does not appear on top of the interactive resolver. + */ +const suppressedConflictRepos = new Set(); + +const _electronNotifier = createElectronNotifier(); +const conflictFilteringNotifier: WatcherNotifier = { + onDbSynced: () => _electronNotifier.onDbSynced(), + onProblemsChanged: payload => { + _electronNotifier.onProblemsChanged({ + ...payload, + conflictsSuppressed: suppressedConflictRepos.has(payload.repoId), + }); + }, +}; + +const repoFileWatcherRegistry = new RepoFileWatcherRegistry(conflictFilteringNotifier); + +function suppressConflictProblems(repoId: string): void { + suppressedConflictRepos.add(repoId); +} + +function clearConflictSuppression(repoId: string): void { + suppressedConflictRepos.delete(repoId); +} + type PushPull = 'push' | 'pull'; type VCSAction = | PushPull @@ -119,6 +157,20 @@ export function vcsSegmentEventProperties(type: 'git', action: VCSAction, error? return { type, action, error }; } +export interface WorkspaceFileIssue { + workspaceId: string; + gitRepositoryId: string; + relPath: string; + kind: FileIssueKind; + message: string; +} + +interface GetProjectGitFileIssuesOptions { + projectId: string; + workspaceId?: string; + gitRepositoryId?: string; +} + /** * Converts various Git URL formats to HTTPS URLs * Handles SSH URLs, Git URLs, and self-hosted Git servers @@ -166,16 +218,129 @@ async function getGitRepository({ projectId, workspaceId }: { projectId: string; invariant(projectId, 'Project ID is required'); const project = await services.project.getById(projectId); invariant(project, 'Project not found'); - invariant(project.gitRepositoryId, 'Project is not linked to a git repository'); - invariant( - project.gitRepositoryId && !models.project.isEmptyGitProject(project), - 'Project is not linked to a git repository', - ); - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + invariant(models.project.isConnectedGitProject(project), 'Project is not linked to a git repository'); + const repoId = models.project.getEffectiveRepoId(project); + invariant(repoId, 'Project is not linked to a git repository'); + const gitRepository = await services.gitRepository.getById(repoId); invariant(gitRepository, 'Git Repository not found'); return gitRepository; } +function toPosixRelPath(relPath: string) { + return relPath.split(path.sep).join(path.posix.sep); +} + +async function getProjectWorkspacesWithMeta(projectId: string) { + const workspaces = await services.workspace.findByParentId(projectId); + const metas = await Promise.all( + workspaces.map(async workspace => ({ + workspace, + meta: await services.workspaceMeta.getByParentId(workspace._id), + })), + ); + + return metas; +} + +export function mapWorkspaceFileIssues({ + issues, + repoId, + metas, + workspaceId, +}: { + issues: FileIssue[]; + repoId: string; + metas: { workspace: Workspace; meta: WorkspaceMeta | null | undefined }[]; + workspaceId?: string; +}) { + const relPathToWorkspaceId = new Map(); + + for (const { workspace, meta } of metas) { + if (workspaceId && workspace._id !== workspaceId) { + continue; + } + + if (!meta?.gitFilePath) { + continue; + } + + relPathToWorkspaceId.set(toPosixRelPath(meta.gitFilePath), workspace._id); + } + + return issues.flatMap(issue => { + const matchedWorkspaceId = relPathToWorkspaceId.get(toPosixRelPath(issue.relPath)); + if (!matchedWorkspaceId) { + return []; + } + + return [ + { + workspaceId: matchedWorkspaceId, + gitRepositoryId: repoId, + relPath: issue.relPath, + kind: issue.kind, + message: issue.message, + }, + ]; + }); +} + +export async function getProjectGitFileIssues({ + projectId, + workspaceId, + gitRepositoryId, +}: GetProjectGitFileIssuesOptions): Promise { + const project = await services.project.getById(projectId); + if (!project || !models.project.isConnectedGitProject(project)) { + return []; + } + + const effectiveRepoId = models.project.getEffectiveRepoId(project); + if (gitRepositoryId && gitRepositoryId !== effectiveRepoId) { + return []; + } + + return mapWorkspaceFileIssues({ + issues: repoFileWatcherRegistry.getProblems(effectiveRepoId!), + repoId: effectiveRepoId!, + metas: await getProjectWorkspacesWithMeta(projectId), + workspaceId, + }); +} + +export interface BranchRemoteInfo { + trackingRemote: string | null; + isOrigin: boolean; + remoteUrl: string | null; + remotes: { remote: string; url: string }[]; +} + +export const getBranchRemoteInfo = async ({ + projectId, + workspaceId, +}: { + projectId: string; + workspaceId?: string; +}): Promise => { + await getGitRepository({ projectId, workspaceId }); + const branchInfo = await GitVCS.getBranchRemoteInfo(); + const remotes = await GitVCS.listRemotes(); + return { ...branchInfo, remotes }; +}; + +async function assertBranchOnOrigin(context: string): Promise { + const { trackingRemote, isOrigin, remoteUrl } = await GitVCS.getBranchRemoteInfo(); + if (!isOrigin) { + const branch = await GitVCS.getCurrentBranch(); + throw new Error( + `Cannot ${context}: branch "${branch}" tracks remote "${trackingRemote}" (${remoteUrl}), ` + + `but Insomnia only manages the "origin" remote. ` + + `Use the git CLI to ${context} this branch, or run: ` + + `git branch --set-upstream-to=origin/${branch}`, + ); + } +} + /** * Creates a file system client for Git operations * Returns different clients based on whether we're working with a workspace or project @@ -221,17 +386,17 @@ async function getGitFSClient({ } // Project FS Client - // All app data is stored within a namespaced GIT_INSOMNIA_DIR directory at the root of the repository and is read/written from the local NeDB database - const neDbClient = GitProjectNeDBClient.createClient(projectId); - - // All git metadata in the GIT_INTERNAL_DIR directory is stored in a git/ directory on the filesystem + // All git metadata in the GIT_INTERNAL_DIR directory is stored in a .git/ directory on the filesystem const gitDataClient = fsClient(baseDir); - // All data outside the directories listed below will be stored in an 'other' directory. This is so we can support files that exist outside the ones the app is specifically in charge of. - const otherDataClient = fsClient(path.join(baseDir, 'other')); + // All files (YAML + non-YAML) are stored at the repository root so that + // native Git tools can operate directly on the repository directory. + // The RepoFileWatcher is solely responsible for syncing YAML ↔ NeDB. + const diskClient = fsClient(baseDir); - // The routable FS client directs isomorphic-git to read/write from the database or from the correct directory on the file system while performing git operations. - const routableFS = projectRoutableFSClient(otherDataClient, neDbClient, { + // The routable FS client routes prefix-matched paths (e.g. .git) to + // specialised FS clients; everything else goes to the disk client. + const routableFS = projectRoutableFSClient(diskClient, { [GIT_INTERNAL_DIR]: gitDataClient, }); @@ -332,6 +497,11 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: try { const gitRepository = await getGitRepository({ workspaceId, projectId }); + const baseDir = path.join( + process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), + `version-control/git/${gitRepository._id}`, + ); + const bufferId = await database.bufferChanges(); const fsClient = await getGitFSClient({ gitRepositoryId: gitRepository._id, projectId, workspaceId }); @@ -339,6 +509,8 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: let legacyInsomniaWorkspace; if (!workspaceId) { legacyInsomniaWorkspace = await containsLegacyInsomniaDir({ fsClient }); + // Ensure watcher is running (idempotent) + await repoFileWatcherRegistry.startWatcher(gitRepository._id, baseDir, projectId); } return { @@ -346,6 +518,10 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: branches: await GitVCS.listBranches(), gitRepository: gitRepository, legacyInsomniaWorkspace, + branchRemoteInfo: { + ...(await GitVCS.getBranchRemoteInfo()), + remotes: await GitVCS.listRemotes(), + }, }; } @@ -384,6 +560,13 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: await GitVCS.setAuthor(); await GitVCS.addRemote(uri); + // Start file watcher for project-scoped repos so external YAML edits + // (native git CLI, VS Code, etc.) flow back into the database. + // The watcher automatically imports all YAML files during creation. + if (!workspaceId) { + await repoFileWatcherRegistry.startWatcher(gitRepository._id, baseDir, projectId); + } + let legacyInsomniaWorkspace; if (!workspaceId) { legacyInsomniaWorkspace = await containsLegacyInsomniaDir({ fsClient }); @@ -396,6 +579,10 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId: branches: await GitVCS.listBranches(), gitRepository, legacyInsomniaWorkspace, + branchRemoteInfo: { + ...(await GitVCS.getBranchRemoteInfo()), + remotes: await GitVCS.listRemotes(), + }, }; } catch (e) { const errorMessage = e instanceof Error ? e.message : 'Error while fetching git repository.'; @@ -440,6 +627,7 @@ export const getGitBranches = async ({ export const gitFetchAction = async ({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) => { try { + await assertBranchOnOrigin('fetch'); const gitRepository = await getGitRepository({ projectId, workspaceId }); await GitVCS.fetch({ singleBranch: true, @@ -515,6 +703,8 @@ export const gitChangesLoader = async ({ }): Promise => { try { const gitRepository = await getGitRepository({ projectId, workspaceId }); + // Flush DB changes to disk before checking git status + await repoFileWatcherRegistry.flushNow(gitRepository._id); const branch = await GitVCS.getCurrentBranch(); const { changes, hasUncommittedChanges } = await getGitChanges(); @@ -552,6 +742,10 @@ export const canPushLoader = async ({ workspaceId?: string; }): Promise => { try { + const { isOrigin } = await GitVCS.getBranchRemoteInfo(); + if (!isOrigin) { + return { canPush: false }; + } let hasUnpushedChanges = false; const gitRepository = await getGitRepository({ workspaceId, projectId }); hasUnpushedChanges = await GitVCS.canPush(gitRepository.credentialsId); @@ -675,7 +869,7 @@ async function importLegacyInsomniaFolder({ fsClient, projectId }: { fsClient: P } // Parse the YAML file to get the document - const doc: models.BaseModel = YAML.parse(fileContents); + const doc: BaseModel = YAML.parse(fileContents); // Validate that the document ID matches the file path if (!legacyInsomniaFile.filePath.includes(doc._id)) { @@ -968,7 +1162,7 @@ export const cloneGitRepoAction = async ({ await services.project.update(project, { remoteId: null, - gitRepositoryId: gitRepository._id, + gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id), }); return project; @@ -977,7 +1171,7 @@ export const cloneGitRepoAction = async ({ const project = await services.project.create({ name: name || gitRepository.uri.split('/').pop() || 'New Git Project', parentId: organizationId, - gitRepositoryId: gitRepository._id, + gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id), }); return project; @@ -1020,6 +1214,13 @@ export const cloneGitRepoAction = async ({ await migrateLegacyInsomniaFolderToFile({ projectId: project._id }); } + // Start watcher — it automatically imports all YAML files during creation + const cloneBaseDir = path.join( + process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), + `version-control/git/${gitRepository._id}`, + ); + await repoFileWatcherRegistry.startWatcher(gitRepository._id, cloneBaseDir, project._id); + const updateRepository = await services.gitRepository.getById(gitRepository._id); invariant(updateRepository, 'Git Repository not found'); @@ -1276,7 +1477,8 @@ export const updateGitRepoAction = async ({ let gitRepository: GitRepository | undefined; if (gitRepositoryId && gitRepositoryId !== models.project.EMPTY_GIT_PROJECT_ID) { - gitRepository = await services.gitRepository.getById(gitRepositoryId); + const effectiveId = models.project.decodeRepoId(gitRepositoryId); + gitRepository = await services.gitRepository.getById(effectiveId); invariant(gitRepository, 'GitRepository not found'); } else { const newRepo: Partial = { @@ -1298,7 +1500,7 @@ export const updateGitRepoAction = async ({ const project = await services.project.getById(projectId); invariant(project, 'Project not found'); await services.project.update(project, { - gitRepositoryId: gitRepository._id, + gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id), }); } @@ -1363,6 +1565,10 @@ export const resetGitRepoAction = async ({ projectId, workspaceId }: { projectId } await services.gitRepository.remove(repo); + // Stop the file watcher for this repository (project-scoped flow only). + repoFileWatcherRegistry.stopWatcher(repo._id); + clearConflictSuppression(repo._id); + await database.flushChanges(flushId); return null; @@ -1383,6 +1589,8 @@ export const commitToGitRepoAction = async ({ }): Promise => { try { const gitRepository = await getGitRepository({ workspaceId, projectId }); + // Flush DB changes to disk before committing + await repoFileWatcherRegistry.flushNow(gitRepository._id); await GitVCS.setAuthor(); await GitVCS.commit(message); @@ -1427,7 +1635,9 @@ export const multipleCommitToGitRepoAction = async ({ files: string[]; }[]; }) => { - await getGitRepository({ projectId, workspaceId }); + const gitRepository = await getGitRepository({ projectId, workspaceId }); + // Flush DB changes to disk before committing + await repoFileWatcherRegistry.flushNow(gitRepository._id); await GitVCS.setAuthor(); for (const commit of commits) { @@ -1495,6 +1705,7 @@ export const commitAndPushToGitRepoAction = async ({ workspaceId?: string; message: string; }): Promise => { + await assertBranchOnOrigin('push'); const repo = await getGitRepository({ workspaceId, projectId }); // Validate credentials before committing to prevent orphaned local commits @@ -1507,6 +1718,8 @@ export const commitAndPushToGitRepoAction = async ({ } try { + // Flush DB changes to disk before committing + await repoFileWatcherRegistry.flushNow(repo._id); await GitVCS.setAuthor(); await GitVCS.commit(message); @@ -1672,6 +1885,7 @@ export const createNewGitBranchAction = async ({ export interface CheckoutGitBranchResult { errors?: string[]; success?: boolean; + warnings?: string[]; } export const checkoutGitBranchAction = async ({ @@ -1689,6 +1903,9 @@ export const checkoutGitBranchAction = async ({ const bufferId = await database.bufferChanges(); await GitVCS.checkout(branch); + // Import all YAML files from disk into the DB after checkout + await repoFileWatcherRegistry.importAllFiles(gitRepository._id); + const log = (await GitVCS.log({ depth: 1 })) || []; const author = log[0] ? log[0].commit.author : null; @@ -1713,6 +1930,18 @@ export const checkoutGitBranchAction = async ({ }); await database.flushChanges(bufferId); + + const branchRemoteInfo = await GitVCS.getBranchRemoteInfo(branch); + if (!branchRemoteInfo.isOrigin) { + return { + success: true, + warnings: [ + `Branch "${branch}" tracks remote "${branchRemoteInfo.trackingRemote}". ` + + `Push, pull, and fetch will not work from Insomnia. Use the git CLI to sync this branch.`, + ], + }; + } + return { success: true, }; @@ -1778,6 +2007,7 @@ export const mergeGitBranch = async ({ const bufferId = await database.bufferChanges(); try { + suppressConflictProblems(gitRepository._id); await GitVCS.merge({ theirsBranch, allowUncommittedChangesBeforeMerge, @@ -1785,6 +2015,12 @@ export const mergeGitBranch = async ({ // isomorphic-git does not update the working area after merge, we need to do it manually by checking out the current branch const currentBranch = await GitVCS.getCurrentBranch(); await GitVCS.checkout(currentBranch); + + // Import all YAML files from disk into the DB after merge + checkout + const gitRepoId = gitRepository._id; + await repoFileWatcherRegistry.importAllFiles(gitRepoId); + clearConflictSuppression(gitRepository._id); + trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName, @@ -1804,8 +2040,10 @@ export const mergeGitBranch = async ({ return {}; } catch (err) { if (err instanceof MergeConflictError) { + // Keep suppression active — user will resolve via SyncMergeModal. return err.data; } + clearConflictSuppression(gitRepository._id); let errorMessage = getErrorMessage(err); if (err instanceof Errors.HttpError) { @@ -1876,8 +2114,12 @@ export const pushToGitRemoteAction = async ({ workspaceId?: string; force?: boolean; }): Promise => { + await assertBranchOnOrigin('push'); const gitRepository = await getGitRepository({ projectId, workspaceId }); + // Flush DB changes to disk before pushing + await repoFileWatcherRegistry.flushNow(gitRepository._id); + // Check if there is anything to push let canPush = false; try { @@ -2018,14 +2260,23 @@ export async function fetchGitRemoteBranches({ } export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) { + let repoId: string | null = null; try { + await assertBranchOnOrigin('pull'); const gitRepository = await getGitRepository({ projectId, workspaceId }); + repoId = gitRepository._id; + suppressConflictProblems(repoId); invariant(gitRepository.credentialsId, 'Git Credentials ID is required'); const credentials = await services.gitCredentials.getById(gitRepository.credentialsId); invariant(credentials, 'Git Credentials not found'); const bufferId = await database.bufferChanges(); await GitVCS.pullWithConflictSupport(gitRepository.credentialsId); + + // Import all YAML files from disk into the DB after pull + await repoFileWatcherRegistry.importAllFiles(gitRepository._id); + clearConflictSuppression(repoId); + trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'pull'), providerName: credentials.provider, @@ -2048,9 +2299,13 @@ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: }; } catch (err: unknown) { if (err instanceof MergeConflictError) { + // Keep suppression active — user will resolve via SyncMergeModal. + // clearConflictSuppression is called by continueMerge or abortMergeAction. return err.data; } + if (repoId) clearConflictSuppression(repoId); + if ( err instanceof Errors.UserCanceledError || (err instanceof Errors.HttpError && (err.data.statusCode === 401 || err.data.statusCode === 403)) @@ -2092,12 +2347,14 @@ export const continueMerge = async ({ projectId, workspaceId, handledMergeConflicts, + autoResolvedConflicts, commitMessage, commitParent, }: { projectId: string; workspaceId?: string; handledMergeConflicts: MergeConflict[]; + autoResolvedConflicts?: AutoResolvedConflict[]; commitMessage: string; commitParent: string[]; }) => { @@ -2107,10 +2364,17 @@ export const continueMerge = async ({ await GitVCS.continueMerge({ handledMergeConflicts, + autoResolvedConflicts, commitMessage, commitParent, }); + // Import all YAML files from disk into the DB after merge resolution + await repoFileWatcherRegistry.importAllFiles(gitRepository._id); + // Files are clean now — lift the conflict suppression so any remaining + // issues (parse errors etc.) are reported normally. + clearConflictSuppression(gitRepository._id); + const log = (await GitVCS.log({ depth: 1 })) || []; const author = log[0] ? log[0].commit.author : null; @@ -2172,6 +2436,8 @@ export const discardChangesAction = async ({ await GitVCS.discardChanges(files); + await repoFileWatcherRegistry.importAllFiles(gitRepository._id); + await services.gitRepository.update(gitRepository, { cachedGitLastCommitTime: Date.now(), }); @@ -2188,7 +2454,9 @@ export const discardChangesAction = async ({ } }; -export const abortMergeAction = async () => { +export const abortMergeAction = async ({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) => { + const gitRepository = await getGitRepository({ projectId, workspaceId }); + clearConflictSuppression(gitRepository._id); return GitVCS.abortMerge(); }; @@ -2207,6 +2475,8 @@ export const gitStatusAction = async ({ }): Promise => { try { const gitRepository = await getGitRepository({ workspaceId, projectId }); + // Flush DB changes to disk before checking git status + await repoFileWatcherRegistry.flushNow(gitRepository._id); const { hasUncommittedChanges, changes } = await getGitChanges(); const localChanges = changes.staged.length + changes.unstaged.length; @@ -2613,6 +2883,96 @@ async function getCurrentBranchByRepositoryId({ }); } +export interface MigrationSummary { + logs: string[]; + failedProjects: { id: string; name: string }[]; + totalProjects: number; +} + +export async function runAllGitRepoMigrations(): Promise { + const logs: string[] = []; + const failedProjects: { id: string; name: string }[] = []; + + const allProjects = await services.project.all(); + const gitProjects = allProjects.filter((p): p is GitProject => models.project.isConnectedGitProject(p)); + + if (gitProjects.length === 0) return { logs, failedProjects, totalProjects: 0 }; + + // Batch-fetch all git repositories in one query instead of N individual lookups. + const repoIds = gitProjects.map(p => models.project.getEffectiveRepoId(p)).filter(Boolean) as string[]; + const gitRepositories = await database.find(models.gitRepository.type, { + _id: { $in: repoIds }, + }); + const repoById = new Map(gitRepositories.map(r => [r._id, r])); + + // Hoist — same value for every repo. + const baseDataPath = process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'); + + const ts = () => new Date().toISOString(); + const projectList = gitProjects.map(p => `"${p.name}"`).join(', '); + logs.push( + `${ts()} [INFO] Starting migration v${CURRENT_MIGRATION_VERSION} for ${gitProjects.length} repo(s): ${projectList}`, + ); + + let migratedCount = 0; + + await Promise.all( + gitProjects.map(async project => { + const gitRepository = repoById.get(models.project.getEffectiveRepoId(project)!); + if (!gitRepository) return; + + const repoId = gitRepository._id; + const logger = (level: 'info' | 'warn' | 'error', message: string) => { + logs.push(`${ts()} [${level.toUpperCase()}] ["${project.name}"] ${message}`); + }; + + const allowedBase = path.resolve(baseDataPath); + const baseDir = path.resolve(allowedBase, 'version-control', 'git', repoId); + if (!baseDir.startsWith(allowedBase + path.sep)) { + logger('warn', `Skipping repo with unsafe path — repoId may contain path traversal: ${repoId}`); + return; + } + + const success = await migrateRepoStructureIfNeeded(baseDir, project._id, repoId, logger); + if (!success) { + failedProjects.push({ id: project._id, name: project.name }); + } else { + migratedCount++; + } + }), + ); + + // In case we have any failed projects, convert them to local projects. + await Promise.all( + failedProjects.map(async ({ id, name }) => { + logs.push(`${ts()} [INFO] ["${name}"] Converting to local project`); + try { + const project = await services.project.getById(id); + if (!project || !models.project.isConnectedGitProject(project)) { + logs.push(`${ts()} [WARN] ["${name}"] Project not found or already local — skipping`); + return; + } + + const effectiveRepoId = models.project.getEffectiveRepoId(project as GitProject); + const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null; + if (gitRepository) { + await services.gitRepository.remove(gitRepository); + logs.push(`${ts()} [INFO] ["${name}"] Removed git repository ${effectiveRepoId}`); + } + + await services.project.update(project, { name, gitRepositoryId: null }); + logs.push(`${ts()} [INFO] ["${name}"] Successfully converted to local`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''; + logs.push(`${ts()} [ERROR] ["${name}"] Failed to convert to local: ${message}${stack}`); + } + }), + ); + + return { logs, failedProjects, totalProjects: migratedCount }; +} + export interface GitServiceAPI { loadGitRepository: typeof loadGitRepository; getGitBranches: typeof getGitBranches; @@ -2646,6 +3006,7 @@ export interface GitServiceAPI { fetchGitRemoteBranches: typeof fetchGitRemoteBranches; validateGitRepositoryCredentials: typeof validateGitRepositoryCredentials; validateGitCredentialById: typeof validateGitCredentialById; + getProjectGitFileIssues: typeof getProjectGitFileIssues; initSignInToGitProvider: typeof initSignInToGitProvider; completeSignInToGitProvider: typeof completeSignInToGitProvider; @@ -2654,6 +3015,8 @@ export interface GitServiceAPI { getGitProviderRepositories: typeof getGitProviderRepositories; getGitProviderEmails: typeof getGitProviderEmails; listGitProviders: typeof listGitProviders; + getBranchRemoteInfo: typeof getBranchRemoteInfo; + runAllGitRepoMigrations: typeof runAllGitRepoMigrations; } export const registerGitServiceAPI = () => { @@ -2666,12 +3029,13 @@ export const registerGitServiceAPI = () => { ); ipcMainHandle( 'git.validateGitRepositoryCredentials', - (_, options: Parameters[0]) => - validateGitRepositoryCredentials(options), + (_, options: Parameters[0]) => validateGitRepositoryCredentials(options), ); - ipcMainHandle( - 'git.validateGitCredentialById', - (_, options: Parameters[0]) => validateGitCredentialById(options), + ipcMainHandle('git.validateGitCredentialById', (_, options: Parameters[0]) => + validateGitCredentialById(options), + ); + ipcMainHandle('git.getProjectGitFileIssues', (_, options: Parameters[0]) => + getProjectGitFileIssues(options), ); ipcMainHandle('git.gitFetchAction', (_, options: Parameters[0]) => gitFetchAction(options)); ipcMainHandle('git.gitLogLoader', (_, options: Parameters[0]) => gitLogLoader(options)); @@ -2720,7 +3084,7 @@ export const registerGitServiceAPI = () => { ipcMainHandle('git.discardChanges', (_, options: Parameters[0]) => discardChangesAction(options), ); - ipcMainHandle('git.abortMerge', _ => abortMergeAction()); + ipcMainHandle('git.abortMerge', (_, options: Parameters[0]) => abortMergeAction(options)); ipcMainHandle('git.gitStatus', (_, options: Parameters[0]) => gitStatusAction(options)); ipcMainHandle('git.diff', () => diff()); ipcMainHandle('git.stageChanges', (_, options: Parameters[0]) => @@ -2756,4 +3120,8 @@ export const registerGitServiceAPI = () => { 'git.getCurrentBranchByRepositoryId', (_, options: Parameters[0]) => getCurrentBranchByRepositoryId(options), ); + ipcMainHandle('git.getBranchRemoteInfo', (_, options: Parameters[0]) => + getBranchRemoteInfo(options), + ); + ipcMainHandle('git.runAllGitRepoMigrations', () => runAllGitRepoMigrations()); }; diff --git a/packages/insomnia/src/sync/git/migrations.ts b/packages/insomnia/src/main/git/migrations.ts similarity index 94% rename from packages/insomnia/src/sync/git/migrations.ts rename to packages/insomnia/src/main/git/migrations.ts index 83a2019093..5d01e598ee 100644 --- a/packages/insomnia/src/sync/git/migrations.ts +++ b/packages/insomnia/src/main/git/migrations.ts @@ -24,27 +24,15 @@ * @see providers/ for provider implementations */ -import { database } from '~/common/database'; -import { type GitCredentials, type GitRepository, services } from '~/insomnia-data'; -import type ElectronStorage from '~/main/electron-storage'; -import { initElectronStorage } from '~/main/window-utils'; - -import * as models from '../../models'; +import type { GitCredentials, GitRepository } from '~/insomnia-data'; +import { database, models, services } from '~/insomnia-data'; +import { getElectronStorage } from '~/main/electron-storage'; const { isGitCredentialsOAuth } = models.gitRepository; const { isGitCredentialsV1 } = models.gitCredentials; const MIGRATION_KEY = 'GIT_CREDENTIALS_MIGRATION'; -let electronStorage: ElectronStorage | null = null; - -const getElectronStorage = () => { - if (!electronStorage) { - electronStorage = initElectronStorage(); - } - return electronStorage; -}; - const hasRunMigration = () => { const migrationStorage = getElectronStorage(); return migrationStorage.getItem(MIGRATION_KEY); diff --git a/packages/insomnia/src/main/importers/importers/__snapshots__/index.test.ts.snap b/packages/insomnia/src/main/importers/importers/__snapshots__/index.test.ts.snap index dfb6bf218c..531fcce82c 100644 --- a/packages/insomnia/src/main/importers/importers/__snapshots__/index.test.ts.snap +++ b/packages/insomnia/src/main/importers/importers/__snapshots__/index.test.ts.snap @@ -36,6 +36,10 @@ exports[`Fixtures > Import curl > complex-input.sh 1`] = ` "name": "another-header", "value": "foo", }, + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, ], "method": "POST", "name": "http://localhost:8000/api/v1/send", @@ -87,6 +91,10 @@ exports[`Fixtures > Import curl > dollar-sign-input.sh 1`] = ` "name": "Pragma", "value": "no-cache", }, + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, ], "method": "POST", "name": "https://test.dk", @@ -129,7 +137,12 @@ exports[`Fixtures > Import curl > form-input.sh 1`] = ` }, ], }, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "POST", "name": "https://insomnia.rest/signup", "parameters": [], @@ -224,7 +237,12 @@ exports[`Fixtures > Import curl > get-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "GET", "name": "http://somesite.com/getdata", "parameters": [ @@ -257,6 +275,10 @@ exports[`Fixtures > Import curl > header-colon-input.sh 1`] = ` "name": "X-Something", "value": "foo: bar:baz", }, + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, ], "method": "GET", "name": "https://insomnia.rest", @@ -297,6 +319,10 @@ exports[`Fixtures > Import curl > multi-data-input.sh 1`] = ` "name": "Content-Type", "value": "application/x-www-form-urlencoded", }, + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, ], "method": "POST", "name": "https://insomnia.rest", @@ -320,7 +346,12 @@ exports[`Fixtures > Import curl > multi-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "GET", "name": "https://insomnia.rest/1/2/3", "parameters": [], @@ -332,7 +363,12 @@ exports[`Fixtures > Import curl > multi-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "GET", "name": "https://insomnia.rest/foo/bar", "parameters": [], @@ -349,6 +385,10 @@ exports[`Fixtures > Import curl > multi-input.sh 1`] = ` "name": "Cookie", "value": "foo=bar", }, + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, ], "method": "GET", "name": "https://insomnia.rest", @@ -361,7 +401,12 @@ exports[`Fixtures > Import curl > multi-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "GET", "name": "https://insomnia.rest", "parameters": [], @@ -384,7 +429,12 @@ exports[`Fixtures > Import curl > no-url-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "POST", "name": "cURL Import 1", "parameters": [], @@ -410,7 +460,12 @@ exports[`Fixtures > Import curl > question-mark-input.sh 1`] = ` "mimeType": "", "text": "{"query":{"match_all":{}}}", }, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "POST", "name": "http://192.168.1.1:9200/executions/_search", "parameters": [ @@ -439,7 +494,12 @@ exports[`Fixtures > Import curl > simple-url-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "GET", "name": "https://www.google.com", "parameters": [], @@ -462,7 +522,12 @@ exports[`Fixtures > Import curl > url-only-input.sh 1`] = ` "_type": "request", "authentication": {}, "body": {}, - "headers": [], + "headers": [ + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, + ], "method": "GET", "name": "https://insomnia.rest/foo/bar", "parameters": [], @@ -502,6 +567,10 @@ exports[`Fixtures > Import curl > urlencoded-input.sh 1`] = ` "name": "Content-Type", "value": "application/x-www-form-urlencoded", }, + { + "name": "User-Agent", + "value": "insomnia/TEST", + }, ], "method": "POST", "name": "https://insomnia.rest", diff --git a/packages/insomnia/src/main/importers/importers/curl.test.ts b/packages/insomnia/src/main/importers/importers/curl.test.ts index ab2392cf44..87c15fe3d2 100644 --- a/packages/insomnia/src/main/importers/importers/curl.test.ts +++ b/packages/insomnia/src/main/importers/importers/curl.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { services } from '~/insomnia-data'; import { convert } from './curl'; describe('curl', () => { + afterEach(async () => { + await services.settings.patch({ disableAppVersionUserAgent: false }); + }); + const testCases = [ // --data flags with urlencoded content type { @@ -192,22 +198,42 @@ describe('curl', () => { { name: 'should handle -H with space after colon', curl: "curl https://example.com -H 'X-Host: example.com'", - expected: { headers: [{ name: 'X-Host', value: 'example.com' }] }, + expected: { + headers: [ + { name: 'X-Host', value: 'example.com' }, + { name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }, + ], + }, }, { name: 'should handle -H with no space after colon', curl: "curl https://example.com -H 'X-Host:example.com'", - expected: { headers: [{ name: 'X-Host', value: 'example.com' }] }, + expected: { + headers: [ + { name: 'X-Host', value: 'example.com' }, + { name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }, + ], + }, }, { name: 'should handle -H for Content-Type', curl: "curl https://example.com -H 'Content-Type:application/x-www-form-urlencoded'", - expected: { headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }] }, + expected: { + headers: [ + { name: 'Content-Type', value: 'application/x-www-form-urlencoded' }, + { name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }, + ], + }, }, { name: 'should handle -H with leading spaces before flag', curl: "curl https://example.com -H 'Content-Type:application/x-www-form-urlencoded'", - expected: { headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }] }, + expected: { + headers: [ + { name: 'Content-Type', value: 'application/x-www-form-urlencoded' }, + { name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }, + ], + }, }, // auth { @@ -225,7 +251,7 @@ describe('curl', () => { curl: `curl http://httpbin.org/get -H 'Authorization: Bearer mytoken123'`, expected: { authentication: { type: 'bearer', token: 'mytoken123' }, - headers: [], + headers: [{ name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }], }, }, { @@ -233,13 +259,50 @@ describe('curl', () => { curl: `curl http://httpbin.org/get -H 'x-foo: x-bar' -H 'Authorization: Bearer mytoken123' `, expected: { authentication: { type: 'bearer', token: 'mytoken123' }, - headers: [{ name: 'x-foo', value: 'x-bar' }], + headers: [ + { name: 'x-foo', value: 'x-bar' }, + { name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }, + ], + }, + }, + // User-Agent injection + { + name: 'should inject default User-Agent when none is provided', + curl: 'curl https://example.com', + expected: { + headers: [{ name: 'User-Agent', value: expect.stringMatching(/^insomnia\//) }], + }, + }, + { + name: 'should not override an explicit User-Agent header', + curl: "curl https://example.com -H 'User-Agent: my-agent/1.0'", + expected: { + headers: [{ name: 'User-Agent', value: 'my-agent/1.0' }], + }, + }, + { + name: 'should not override a lowercased user-agent header', + curl: "curl https://example.com -H 'user-agent: my-agent/1.0'", + expected: { + headers: [{ name: 'user-agent', value: 'my-agent/1.0' }], }, }, ]; - it.each(testCases)('$name', ({ curl, expected }) => { - const result = convert(curl); + it.each(testCases)('$name', async ({ curl, expected }) => { + const result = await convert(curl); expect(result).toMatchObject([expected]); }); + + it('should skip default User-Agent injection when disableAppVersionUserAgent is true', async () => { + await services.settings.patch({ disableAppVersionUserAgent: true }); + const result = await convert('curl https://example.com'); + expect(result).toMatchObject([{ headers: [] }]); + }); + + it('should preserve an explicit User-Agent even when disableAppVersionUserAgent is true', async () => { + await services.settings.patch({ disableAppVersionUserAgent: true }); + const result = await convert("curl https://example.com -H 'User-Agent: my-agent/1.0'"); + expect(result).toMatchObject([{ headers: [{ name: 'User-Agent', value: 'my-agent/1.0' }] }]); + }); }); diff --git a/packages/insomnia/src/main/importers/importers/curl.ts b/packages/insomnia/src/main/importers/importers/curl.ts index 91b57f170f..2481b96193 100644 --- a/packages/insomnia/src/main/importers/importers/curl.ts +++ b/packages/insomnia/src/main/importers/importers/curl.ts @@ -2,8 +2,9 @@ import { URL } from 'node:url'; import { type ControlOperator, parse, type ParseEntry } from 'shell-quote'; -import type { RequestAuthentication } from '~/insomnia-data'; +import { type RequestAuthentication,services } from '~/insomnia-data'; +import { getAppVersion } from '../../../common/constants'; import { type Converter, type ImportRequest, type Parameter } from '../entities'; export const id = 'curl'; @@ -392,7 +393,7 @@ const getPairValue = (parisByName: PairsByName, defa return defaultValue; }; -export const convert: Converter = rawData => { +export const convert: Converter = async rawData => { requestCount = 1; if (!rawData.match(/^\s*curl /)) { @@ -456,5 +457,17 @@ export const convert: Converter = rawData => { .map(importCommand) .map(buildRequestObject); + const { disableAppVersionUserAgent } = await services.settings.get(); + if (!disableAppVersionUserAgent) { + const defaultUserAgent = `insomnia/${getAppVersion()}`; + for (const req of requests) { + const headers = req.headers ?? []; + if (!headers.some(header => header.name.toLowerCase() === 'user-agent')) { + headers.push({ name: 'User-Agent', value: defaultUserAgent }); + req.headers = headers; + } + } + } + return requests; }; diff --git a/packages/insomnia/src/main/importers/importers/index.test.ts b/packages/insomnia/src/main/importers/importers/index.test.ts index 7d2bee2372..879e8b1e67 100644 --- a/packages/insomnia/src/main/importers/importers/index.test.ts +++ b/packages/insomnia/src/main/importers/importers/index.test.ts @@ -3,8 +3,14 @@ import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type * as constants from '../../../common/constants'; import { convert } from '../convert'; +vi.mock('../../../common/constants', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, getAppVersion: () => 'TEST' }; +}); + const fixturesPath = path.join(__dirname, './fixtures'); const fixtures = fs.readdirSync(fixturesPath); describe('Fixtures', () => { diff --git a/packages/insomnia/src/main/importers/importers/openapi-3.ts b/packages/insomnia/src/main/importers/importers/openapi-3.ts index 4aa1722b37..5474a1c581 100644 --- a/packages/insomnia/src/main/importers/importers/openapi-3.ts +++ b/packages/insomnia/src/main/importers/importers/openapi-3.ts @@ -5,6 +5,7 @@ import SwaggerParser from '@apidevtools/swagger-parser'; import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types'; import YAML from 'yaml'; +import { pathWithParamsAsPathParameters } from '../../../common/path-with-params'; import type { Converter, ImportRequest } from '../entities'; import { unthrowableParseJson } from '../utils'; @@ -253,7 +254,7 @@ const importFolderItem = * * I.e. "/foo/{bar}" => "/foo/:bar" */ -export const pathWithParamsAsPathParameters = (path?: string) => path?.replace(VARIABLE_SEARCH_VALUE, ':$1') ?? ''; +export { pathWithParamsAsPathParameters }; /** * Return Insomnia request diff --git a/packages/insomnia/src/main/install-plugin.ts b/packages/insomnia/src/main/install-plugin.ts index a956f4bf61..f9f5d53732 100644 --- a/packages/insomnia/src/main/install-plugin.ts +++ b/packages/insomnia/src/main/install-plugin.ts @@ -15,12 +15,14 @@ import { validatePluginName } from '../utils/plugin'; // Promisified version of execFile to use async/await export const execFilePromise = promisify(execFile); -// Allowed tarball hostnames for security +// Default allowed tarball hostnames for security // This is a security measure to prevent downloading from untrusted sources // and to ensure that the tarball is from a known source. // The list can be expanded as needed, but should be kept minimal for security. // Currently, only npmjs.org and GitHub Packages are allowed. -const allowedTarballHostnames = ['registry.npmjs.org', 'npm.pkg.github.com']; +const defaultAllowedTarballHostnames = ['registry.npmjs.org', 'npm.pkg.github.com']; + +const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'; interface InsomniaPlugin { // Insomnia attribute from package.json @@ -102,6 +104,7 @@ export default async function installPlugin(pluginName: string, allowScopedPacka try { // After fetching info, check the info.dist.tarball. This prevents downloading from weird hosts. const tarballUrl = new URL(info.dist.tarball); + const allowedTarballHostnames = await getAllowedTarballHostnames(); if (!allowedTarballHostnames.includes(tarballUrl.hostname)) { throw new Error(`Tarball must come from an allowed host. Got: ${tarballUrl.hostname}`); } @@ -211,7 +214,8 @@ export async function getPluginInfo(lookupName: string, allowScopedPackageNames console.log('[plugins] Fetching module info from npm'); - const stdout = await runYarnCommand(['info', lookupName, '--json', '--registry', 'https://registry.npmjs.org/']); + const registryUrl = await getRegistryUrl(); + const stdout = await runYarnCommand(['info', lookupName, '--json', '--registry', registryUrl]); let yarnOutput; try { @@ -262,6 +266,7 @@ export async function installPluginToTmpDir(lookupName: string, allowScopedPacka console.log(`[plugins] Installing plugin into temp dir: ${tmpDir}`); + const registryUrl = await getRegistryUrl(); await runYarnCommand( [ 'add', @@ -275,7 +280,7 @@ export async function installPluginToTmpDir(lookupName: string, allowScopedPacka '--no-progress', '--ignore-workspace-root-check', '--registry', - 'https://registry.npmjs.org/', + registryUrl, ], tmpDir, ); @@ -465,6 +470,49 @@ export function buildProxyEnv(settings: any): Record { return proxyEnv; } +/** + * Returns the npm registry URL from settings, falling back to the default. + */ +export async function getRegistryUrl(): Promise { + const settings = await services.settings.get(); + const customRegistry = safeTrim(settings.npmRegistryUrl); + if (customRegistry) { + // Validate it's a proper URL + try { + const parsed = new URL(customRegistry); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + console.warn(`[plugins] npmRegistryUrl must be http/https, got "${parsed.protocol}", using default`); + return DEFAULT_NPM_REGISTRY; + } + } catch { + console.warn(`[plugins] Invalid npmRegistryUrl "${customRegistry}", using default`); + return DEFAULT_NPM_REGISTRY; + } + // Ensure trailing slash for consistency + return customRegistry.endsWith('/') ? customRegistry : customRegistry + '/'; + } + return DEFAULT_NPM_REGISTRY; +} + +/** + * Returns the list of allowed tarball hostnames, including the custom registry hostname if configured. + */ +export async function getAllowedTarballHostnames(): Promise { + const settings = await services.settings.get(); + const customRegistry = safeTrim(settings.npmRegistryUrl); + if (customRegistry) { + try { + const registryHostname = new URL(customRegistry).hostname; + if (!defaultAllowedTarballHostnames.includes(registryHostname)) { + return [...defaultAllowedTarballHostnames, registryHostname]; + } + } catch { + // Invalid URL, just use defaults + } + } + return defaultAllowedTarballHostnames; +} + /** * Validates that a given string is a well-formed URL. */ diff --git a/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts b/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts index 1646068ab5..8b09a1645a 100644 --- a/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts +++ b/packages/insomnia/src/main/ipc/__tests__/grpc.test.ts @@ -5,10 +5,38 @@ import * as grpcReflection from 'grpc-reflection-js'; import protobuf from 'protobufjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadMethodsFromReflection } from '../grpc'; +import { services } from '~/insomnia-data'; + +import { loadMethodsFromReflection, writeProtoFileById } from '../grpc'; vi.mock('grpc-reflection-js'); vi.mock('@connectrpc/connect-node'); +vi.mock('../../../network/grpc/write-proto-file'); +vi.mock('@grpc/proto-loader', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, load: vi.fn().mockResolvedValue({}) }; +}); + +describe('writeProtoFileById', () => { + it('resolves proto file from services and delegates to writeProtoFile', async () => { + const { writeProtoFile } = await import('../../../network/grpc/write-proto-file'); + const { load } = await import('@grpc/proto-loader'); + const w = await services.workspace.create(); + const pf = await services.protoFile.create({ parentId: w._id, protoText: 'text' }); + const expected = { filePath: 'foo.proto', dirs: ['/tmp/insomnia-grpc'] }; + vi.mocked(writeProtoFile).mockResolvedValue(expected); + + const result = await writeProtoFileById(pf._id); + + expect(writeProtoFile).toHaveBeenCalledWith(expect.objectContaining({ _id: pf._id })); + expect(load).toHaveBeenCalledWith('foo.proto', expect.objectContaining({ includeDirs: ['/tmp/insomnia-grpc'] })); + expect(result).toEqual(expected); + }); + + it('throws when the proto file is not found', async () => { + await expect(writeProtoFileById('nonexistent-id')).rejects.toThrow('Proto file nonexistent-id not found'); + }); +}); describe('loadMethodsFromReflection', () => { describe('one service reflection', () => { diff --git a/packages/insomnia/src/main/ipc/electron-storage.ts b/packages/insomnia/src/main/ipc/electron-storage.ts new file mode 100644 index 0000000000..29014fde8f --- /dev/null +++ b/packages/insomnia/src/main/ipc/electron-storage.ts @@ -0,0 +1,20 @@ +import { getElectronStorage } from '../electron-storage'; +import { ipcMainHandle } from './electron'; + +export interface electronStorageBridgeAPI { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +} + +export function registerElectronStorageHandlers() { + ipcMainHandle('electronStorage.getItem', (_, key: string) => { + const storage = getElectronStorage(); + const value = storage.getItem(key); + return value ?? null; + }); + + ipcMainHandle('electronStorage.setItem', (_, key: string, value: string) => { + const storage = getElectronStorage(); + storage.setItem(key, value); + }); +} diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 91ca332f3f..e3c8344e29 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -36,6 +36,8 @@ export type HandleChannels = | 'extractJsonFileFromPostmanDataDumpArchive' | 'generateCommitsFromDiff' | 'generateMockRouteDataFromSpec' + | 'getAuthHeader' + | 'getOAuth2Token' | 'getExecution' | 'getLocalStorageDataFromFileOrigin' | 'git.abortMerge' @@ -51,6 +53,7 @@ export type HandleChannels = | 'git.diffFileLoader' | 'git.discardChanges' | 'git.fetchGitRemoteBranches' + | 'git.getProjectGitFileIssues' | 'git.validateGitRepositoryCredentials' | 'git.validateGitCredentialById' | 'git.getGitBranches' @@ -67,7 +70,9 @@ export type HandleChannels = | 'git.pullFromGitRemote' | 'git.pushToGitRemote' | 'git.resetGitRepo' + | 'git.runAllGitRepoMigrations' | 'git.getCurrentBranchByRepositoryId' + | 'git.getBranchRemoteInfo' | 'git.stageChanges' | 'git.unstageChanges' | 'git.updateGitRepo' @@ -78,6 +83,8 @@ export type HandleChannels = | 'git.getGitProviderEmails' | 'grpc.loadMethods' | 'grpc.loadMethodsFromReflection' + | 'grpc.writeProtoFile' + | 'initializeWorkspaceBackendProject' | 'insecureReadFile' | 'insecureReadFileWithEncoding' | 'installPlugin' @@ -119,6 +126,8 @@ export type HandleChannels = | 'readDir' | 'readOrCreateDataDir' | 'restoreBackup' + | 'electronStorage.getItem' + | 'electronStorage.setItem' | 'secretStorage.decryptString' | 'secretStorage.deleteSecret' | 'secretStorage.encryptString' @@ -129,13 +138,17 @@ export type HandleChannels = | 'showSaveDialog' | 'socketIO.event.findMany' | 'socketIO.event.send' + | 'syncNewWorkspaceIfNeeded' + | 'sync.invoke' + | 'sync.pullRemoteBackendProject' | 'socketIO.open' | 'socketIO.readyState' | 'webSocket.event.findMany' | 'webSocket.event.send' | 'webSocket.open' | 'webSocket.readyState' - | 'writeFile'; + | 'writeFile' + | 'writeResponseBodyToFile'; export const ipcMainHandle = ( channel: HandleChannels, @@ -186,6 +199,8 @@ export type MainOnChannels = | 'mcp.closeAll' | 'mcp.client.responseElicitationRequest' | 'mcp.client.responseSamplingRequest' + | 'sync.cancelConflict' + | 'sync.resolveConflict' | 'mcp.sendMCPRequest' | 'writeText'; @@ -205,12 +220,15 @@ export type RendererOnChannels = | 'shell:open' | 'show-notification' | 'show-toast' + | 'sync.merge-conflicts' | 'toggle-preferences-shortcuts' | 'toggle-preferences' | 'toggle-sidebar' | 'show-oauth-authorization-modal' | 'hide-oauth-authorization-modal' - | 'mcp-auth-confirmation'; + | 'mcp-auth-confirmation' + | 'git.db-synced' + | 'git.file-problems-changed'; export const ipcMainOn = ( channel: MainOnChannels, diff --git a/packages/insomnia/src/main/ipc/grpc.ts b/packages/insomnia/src/main/ipc/grpc.ts index 7f134907d9..c554a481f0 100644 --- a/packages/insomnia/src/main/ipc/grpc.ts +++ b/packages/insomnia/src/main/ipc/grpc.ts @@ -61,16 +61,7 @@ export interface gRPCBridgeAPI { loadMethods: typeof loadMethods; loadMethodsFromReflection: typeof loadMethodsFromReflection; closeAll: typeof closeAll; -} - -export function registergRPCHandlers() { - ipcMainOn('grpc.start', start); - ipcMainOn('grpc.sendMessage', sendMessage); - ipcMainOn('grpc.commit', (_, requestId) => commit(requestId)); - ipcMainOn('grpc.cancel', (_, requestId) => cancel(requestId)); - ipcMainOn('grpc.closeAll', closeAll); - ipcMainHandle('grpc.loadMethods', (_, requestId) => loadMethods(requestId)); - ipcMainHandle('grpc.loadMethodsFromReflection', (_, requestId) => loadMethodsFromReflection(requestId)); + writeProtoFile: (protoFileId: string) => Promise<{ filePath: string; dirs: string[] }>; } const grpcOptions = { @@ -80,6 +71,29 @@ const grpcOptions = { defaults: true, oneofs: true, }; + +export const writeProtoFileById = async (protoFileId: string): Promise<{ filePath: string; dirs: string[] }> => { + const protoFile = await services.protoFile.getById(protoFileId); + invariant(protoFile, `Proto file ${protoFileId} not found`); + const result = await writeProtoFile(protoFile); + await protoLoader.load(result.filePath, { + ...grpcOptions, + includeDirs: result.dirs, + }); + return result; +}; + +export function registergRPCHandlers() { + ipcMainOn('grpc.start', start); + ipcMainOn('grpc.sendMessage', sendMessage); + ipcMainOn('grpc.commit', (_, requestId) => commit(requestId)); + ipcMainOn('grpc.cancel', (_, requestId) => cancel(requestId)); + ipcMainOn('grpc.closeAll', closeAll); + ipcMainHandle('grpc.loadMethods', (_, requestId) => loadMethods(requestId)); + ipcMainHandle('grpc.loadMethodsFromReflection', (_, requestId) => loadMethodsFromReflection(requestId)); + ipcMainHandle('grpc.writeProtoFile', (_, protoFileId: string) => writeProtoFileById(protoFileId)); +} + const loadMethodsFromFilePath = async (filePath: string, includeDirs: string[]): Promise => { const definition = await protoLoader.load(filePath, { ...grpcOptions, diff --git a/packages/insomnia/src/main/ipc/invoke.ts b/packages/insomnia/src/main/ipc/invoke.ts new file mode 100644 index 0000000000..183305ea6b --- /dev/null +++ b/packages/insomnia/src/main/ipc/invoke.ts @@ -0,0 +1,26 @@ +import { ipcRenderer } from 'electron'; + +const normalizeIpcError = (error: unknown) => { + if (!(error instanceof Error)) { + return new Error(String(error)); + } + + const cleanedMessage = error.message.replace(/^Error invoking remote method '[^']+': Error:\s*/, ''); + + if (cleanedMessage === error.message) { + return error; + } + + const normalized = new Error(cleanedMessage); + normalized.name = error.name; + normalized.stack = error.stack; + return normalized; +}; + +export const invokeWithNormalizedError = async (channel: string, ...args: unknown[]) => { + try { + return (await ipcRenderer.invoke(channel, ...args)) as T; + } catch (error) { + throw normalizeIpcError(error); + } +}; diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 3831194b0c..e32d45ae6e 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -1,6 +1,8 @@ import fs, { mkdirSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import zlib from 'node:zlib'; import type { ISpectralDiagnostic } from '@stoplight/spectral-core'; import chardet from 'chardet'; @@ -17,11 +19,15 @@ import type { UtilityProcess } from 'electron/main'; import iconv from 'iconv-lite'; import { AI_PLUGIN_NAME } from '~/common/constants'; -import { type Services, services } from '~/insomnia-data'; +import { cannotAccessPathError } from '~/common/misc'; +import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; +import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization'; +import type { SyncBridgeAPI } from '~/main/cloud-sync/ipc'; import { convert } from '~/main/importers/convert'; import { getCurrentConfig, type LLMConfigServiceAPI } from '~/main/llm-config-service'; import { multipartBufferToArray, type Part } from '~/main/multipart-buffer-to-array'; -import { insecureReadFile, insecureReadFileWithEncoding, secureReadFile } from '~/main/secure-read-file'; +import { insecureReadFile, insecureReadFileWithEncoding, isPathAllowed, secureReadFile } from '~/main/secure-read-file'; import type { GenerateCommitsFromDiffFunction, GenerateMcpSamplingResponseFunction, @@ -30,7 +36,7 @@ import type { } from '~/plugins/types'; import type { HiddenBrowserWindowBridgeAPI } from '../../entry.hidden-window'; -import type { PluginTemplateTag } from '../../templating/types'; +import type { PluginTemplateTag, RenderedRequest } from '../../templating/types'; import type { SegmentEvent } from '../analytics'; import { setCurrentOrganizationId, trackPageView, trackSegmentEvent } from '../analytics'; import { @@ -43,8 +49,10 @@ import { backup, restoreBackup } from '../backup'; import type { GitServiceAPI } from '../git-service'; import installPlugin from '../install-plugin'; import type { CurlBridgeAPI } from '../network/curl'; +import { getAuthHeader as getAuthHeaderInMain } from '../network/get-auth-header'; import { cancelCurlRequest, curlRequest } from '../network/libcurl-promise'; import type { McpBridgeAPI } from '../network/mcp'; +import { getOAuth2Token as getOAuth2TokenInMain } from '../network/o-auth-2/get-token'; import { addExecutionStep, completeExecutionStep, @@ -56,6 +64,7 @@ import { import type { SocketIOBridgeAPI } from '../network/socket-io'; import type { WebSocketBridgeAPI } from '../network/websocket'; import { ipcMainHandle, ipcMainOn, type RendererOnChannels } from './electron'; +import type { electronStorageBridgeAPI } from './electron-storage'; import extractPostmanDataDumpHandler from './extract-postman-data-dump'; import type { gRPCBridgeAPI } from './grpc'; import type { secretStorageBridgeAPI } from './secret-storage'; @@ -87,6 +96,43 @@ const readDir = async (_: unknown, options: { path: string }) => { } }; +const writeResponseBodyToFile = async ( + _: unknown, + options: { sourcePath: string; destinationPath: string; bodyCompression?: 'zip' | null }, +) => { + // Validate sourcePath is within the expected responses directory to prevent a + // compromised renderer from using this handler to read arbitrary files on disk. + const userdataDirectory = process.env.INSOMNIA_DATA_PATH || app.getPath('userData'); + const allowedResponsesDir = path.join(userdataDirectory, 'responses'); + const resolvedSource = path.resolve(options.sourcePath); + if (!resolvedSource.startsWith(allowedResponsesDir + path.sep) || !resolvedSource.endsWith('.response')) { + throw new Error( + 'writeResponseBodyToFile: sourcePath is outside the allowed responses directory or does not end in .response', + ); + } + + try { + const dir = path.dirname(options.destinationPath); + await fs.promises.mkdir(dir, { recursive: true }); + + await (options.bodyCompression === 'zip' + ? pipeline( + fs.createReadStream(options.sourcePath), + zlib.createGunzip(), + fs.createWriteStream(options.destinationPath), + ) + : fs.promises.copyFile(options.sourcePath, options.destinationPath)); + + return options.destinationPath; + } catch (err) { + if (err instanceof Error) { + throw err; + } + + throw new Error(String(err)); + } +}; + export interface RendererToMainBridgeAPI { loginStateChange: () => void; openInBrowser: (url: string) => void; @@ -102,9 +148,21 @@ export interface RendererToMainBridgeAPI { cancelAuthorizationInDefaultBrowser: typeof cancelAuthorizationInDefaultBrowser; setMenuBarVisibility: (visible: boolean) => void; installPlugin: typeof installPlugin; + initializeWorkspaceBackendProject: typeof initializeWorkspaceBackendProject; parseImport: typeof convert; multipartBufferToArray: (options: { bodyBuffer: Buffer; contentType: string }) => Promise; writeFile: (options: { path: string; content: string | Buffer }) => Promise; + writeResponseBodyToFile: (options: { + sourcePath: string; + destinationPath: string; + bodyCompression?: 'zip' | null; + }) => Promise; + getAuthHeader: (renderedRequest: RenderedRequest, url: string) => Promise; + getOAuth2Token: ( + requestId: string, + authentication: AuthTypeOAuth2, + forceRefresh?: boolean, + ) => Promise; secureReadFile: (options: { path: string }) => Promise; insecureReadFile: (options: { path: string }) => Promise; insecureReadFileWithEncoding: (options: { @@ -126,6 +184,8 @@ export interface RendererToMainBridgeAPI { git: GitServiceAPI; llm: LLMConfigServiceAPI; secretStorage: secretStorageBridgeAPI; + electronStorage: electronStorageBridgeAPI; + sync: SyncBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; setCurrentOrganizationId: (organizationId: string | undefined) => void; @@ -176,6 +236,7 @@ export interface RendererToMainBridgeAPI { | { response: Awaited>; error: undefined } | { response: undefined; error: string } >; + syncNewWorkspaceIfNeeded: typeof syncNewWorkspaceIfNeeded; } export function registerMainHandlers() { @@ -206,7 +267,13 @@ export function registerMainHandlers() { if (typeof fn !== 'function') { throw new TypeError(`Unknown service method: ${serviceName}.${methodName}`); } - return (fn as (...args: unknown[]) => unknown).call(service, ...args); + const result = await (fn as (...args: unknown[]) => unknown).call(service, ...args); + // Tag Buffer results before contextBridge serializes them as plain Uint8Array, + // so the preload can distinguish them from intentional Uint8Array returns. + if (Buffer.isBuffer(result)) { + return { __type: 'Buffer', data: Array.from(result as Buffer) }; + } + return result; }); ipcMainHandle('multipartBufferToArray', async (_, options) => { return multipartBufferToArray(options); @@ -242,6 +309,12 @@ export function registerMainHandlers() { ipcMainHandle('parseImport', async (_, ...args: Parameters) => { return convert(...args); }); + ipcMainHandle( + 'initializeWorkspaceBackendProject', + async (_, options: Parameters[0]) => { + return initializeWorkspaceBackendProject(options); + }, + ); ipcMainHandle('writeFile', async (_, options: { path: string; content: string | Buffer }) => { try { const dir = path.dirname(options.path); @@ -252,7 +325,13 @@ export function registerMainHandlers() { throw new Error(err); } }); - + ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile); + ipcMainHandle('getAuthHeader', (_, renderedRequest: RenderedRequest, url: string) => { + return getAuthHeaderInMain(renderedRequest, url); + }); + ipcMainHandle('getOAuth2Token', (_, requestId: string, authentication: AuthTypeOAuth2, forceRefresh?: boolean) => { + return getOAuth2TokenInMain(requestId, authentication, forceRefresh); + }); ipcMainHandle('lintSpec', async (_, options: { documentContent: string; rulesetPath: string }) => { const { documentContent, rulesetPath } = options; return new Promise((resolve, reject) => { @@ -358,6 +437,9 @@ export function registerMainHandlers() { }); ipcMainHandle('extractJsonFileFromPostmanDataDumpArchive', extractPostmanDataDumpHandler); + ipcMainHandle('syncNewWorkspaceIfNeeded', async (_, options: Parameters[0]) => { + return syncNewWorkspaceIfNeeded(options); + }); ipcMainHandle('getLocalStorageDataFromFileOrigin', async () => { const tmpDir = app.getPath('userData'); @@ -414,6 +496,15 @@ export function registerMainHandlers() { useDynamicMockResponses: boolean, mockServerAdditionalFiles: string[], ) => { + const settings = await services.settings.getOrCreate(); + + for (const filePath of mockServerAdditionalFiles) { + const { isAllowed, securedPath } = isPathAllowed(filePath, settings.dataFolders); + if (!isAllowed) { + return { error: cannotAccessPathError(securedPath), routes: [] }; + } + } + return new Promise((resolve, reject) => { const process = utilityProcess.fork(path.join(__dirname, 'main/mock-generation-process.mjs')); diff --git a/packages/insomnia/src/main/ipc/secret-storage.ts b/packages/insomnia/src/main/ipc/secret-storage.ts index cef2363244..a234c983bc 100644 --- a/packages/insomnia/src/main/ipc/secret-storage.ts +++ b/packages/insomnia/src/main/ipc/secret-storage.ts @@ -1,7 +1,6 @@ import { safeStorage } from 'electron'; -import type ElectronStorage from '../electron-storage'; -import { initElectronStorage } from '../window-utils'; +import { getElectronStorage } from '../electron-storage'; import { ipcMainHandle } from './electron'; export interface secretStorageBridgeAPI { @@ -20,15 +19,6 @@ export function registerSecretStorageHandlers() { ipcMainHandle('secretStorage.decryptString', (_, raw) => decryptString(raw)); } -let electronStorage: ElectronStorage | null = null; - -const getElectronStorage = () => { - if (!electronStorage) { - electronStorage = initElectronStorage(); - } - return electronStorage; -}; - const setSecret = async (key: string, secret: string) => { try { const secretStorage = getElectronStorage(); diff --git a/packages/insomnia/src/main/mcp/common.ts b/packages/insomnia/src/main/mcp/common.ts index 5ff3514c01..dc4c3ba229 100644 --- a/packages/insomnia/src/main/mcp/common.ts +++ b/packages/insomnia/src/main/mcp/common.ts @@ -27,7 +27,7 @@ import { unsupportedMethodPrefix, } from '~/common/mcp-utils'; import { generateId } from '~/common/misc'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import type { CommonMcpOptions, McpClient, @@ -40,7 +40,6 @@ import type { OpenMcpClientConnectionOptions, } from '~/main/mcp/types'; import { insecureReadFile } from '~/main/secure-read-file'; -import * as models from '~/models'; import { invariant } from '~/utils/invariant'; interface ConnectingState { diff --git a/packages/insomnia/src/main/mcp/oauth-client-provider.ts b/packages/insomnia/src/main/mcp/oauth-client-provider.ts index bf64777850..d8461d3d99 100644 --- a/packages/insomnia/src/main/mcp/oauth-client-provider.ts +++ b/packages/insomnia/src/main/mcp/oauth-client-provider.ts @@ -12,7 +12,7 @@ import type { RequestAuthentication } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { authorizeUserInDefaultBrowser } from '~/main/authorize-user-in-default-browser'; import type { ConnectionContext } from '~/main/mcp/common'; -import { encryptOAuthUrl } from '~/network/o-auth-2/utils'; +import { encryptOAuthUrl } from '~/main/network/o-auth-2/get-token'; import { invariant } from '~/utils/invariant'; export class MCPAuthError extends Error { diff --git a/packages/insomnia/src/main/mcp/transport-stdio.ts b/packages/insomnia/src/main/mcp/transport-stdio.ts index 9510924a36..89af3444b2 100644 --- a/packages/insomnia/src/main/mcp/transport-stdio.ts +++ b/packages/insomnia/src/main/mcp/transport-stdio.ts @@ -3,10 +3,10 @@ import { InitializeRequestSchema, type JSONRPCRequest } from '@modelcontextproto import { shellPath } from 'shell-path'; import { parse } from 'shell-quote'; -import { type McpResponse, services } from '~/insomnia-data'; +import type { McpResponse } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { type ConnectionContext, writeTimeline } from '~/main/mcp/common'; import type { OpenMcpStdioClientConnectionOptions } from '~/main/mcp/types'; -import * as models from '~/models'; export const createStdioTransport = async ( context: ConnectionContext, diff --git a/packages/insomnia/src/main/mcp/transport-streamable-http.ts b/packages/insomnia/src/main/mcp/transport-streamable-http.ts index f7b6e82c8e..7a8e17ccce 100644 --- a/packages/insomnia/src/main/mcp/transport-streamable-http.ts +++ b/packages/insomnia/src/main/mcp/transport-streamable-http.ts @@ -9,12 +9,11 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { BrowserWindow } from 'electron'; import type { Dispatcher } from 'undici'; -import type { RequestHeader } from '~/insomnia-data'; -import { type McpResponse, services } from '~/insomnia-data'; +import type { McpResponse, RequestHeader } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { type ConnectionContext, getFetchDispatcher, writeEventLogAndNotify, writeTimeline } from '~/main/mcp/common'; import { MCPAuthError, type McpOAuthClientProvider } from '~/main/mcp/oauth-client-provider'; import type { McpAuthEventWithoutBase, OpenMcpHTTPClientConnectionOptions } from '~/main/mcp/types'; -import * as models from '~/models'; // Extend undici RequestInit to include dispatcher, it's in node.js fetch but not in dom fetch. interface NodeRequestInit extends RequestInit { diff --git a/packages/insomnia/src/main/mcp/types.ts b/packages/insomnia/src/main/mcp/types.ts index 26375eb9ac..6f9691557e 100644 --- a/packages/insomnia/src/main/mcp/types.ts +++ b/packages/insomnia/src/main/mcp/types.ts @@ -4,8 +4,7 @@ import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/cl import type { ClientRequest, JSONRPCResponse, Notification } from '@modelcontextprotocol/sdk/types.js'; import type z from 'zod'; -import type { RequestAuthentication, RequestHeader } from '~/insomnia-data'; -import type * as models from '~/models'; +import type { models, RequestAuthentication, RequestHeader } from '~/insomnia-data'; // Refer the SDK: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/shared/protocol.ts#L504 // The Client type has missing transport property diff --git a/packages/insomnia/src/main/network/curl.ts b/packages/insomnia/src/main/network/curl.ts index 6cf7410ce8..69ec5ec944 100644 --- a/packages/insomnia/src/main/network/curl.ts +++ b/packages/insomnia/src/main/network/curl.ts @@ -10,15 +10,14 @@ import { REALTIME_EVENTS_CHANNELS } from '~/common/constants'; import type { CookieJar, RequestAuthentication, RequestHeader, Response } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { insecureReadFile } from '~/main/secure-read-file'; -import { readCurlResponse } from '~/models/helpers/response-operations'; import { describeByteSize, generateId, getSetCookieHeaders } from '../../common/misc'; import { filterClientCertificates } from '../../network/certificate'; +import { parseHeaderStrings } from '../../network/parse-header-strings'; import { addSetCookiesToToughCookieJar } from '../../network/set-cookie-util'; import { invariant } from '../../utils/invariant'; import { ipcMainHandle, ipcMainOn } from '../ipc/electron'; import { createConfiguredCurlInstance } from './libcurl-promise'; -import { parseHeaderStrings } from './parse-header-strings'; export interface CurlConnection extends Curl { _id: string; @@ -435,7 +434,9 @@ export const registerCurlHandlers = () => { ipcMainOn('curl.closeAll', closeAllCurlConnections); ipcMainHandle('curl.readyState', (_, options: Parameters[0]) => getCurlReadyState(options)); ipcMainHandle('curl.event.findMany', (_, options: Parameters[0]) => findMany(options)); - ipcMainHandle('readCurlResponse', (_, options: Parameters[0]) => readCurlResponse(options)); + ipcMainHandle('readCurlResponse', (_, options: Parameters[0]) => + services.helpers.readCurlResponse(options), + ); }; electron.app.on('window-all-closed', closeAllCurlConnections); diff --git a/packages/insomnia/src/main/network/get-auth-header.ts b/packages/insomnia/src/main/network/get-auth-header.ts new file mode 100644 index 0000000000..7e5152045d --- /dev/null +++ b/packages/insomnia/src/main/network/get-auth-header.ts @@ -0,0 +1,156 @@ +import * as Hawk from 'hawk'; + +import type { AuthTypeOAuth2, RequestAuthentication, RequestHeader } from '~/insomnia-data'; +import type { RenderedRequest } from '~/templating/types'; + +import { COOKIE, HEADER } from '../../network/api-key/constants'; +import { getBasicAuthHeader } from '../../network/basic-auth/get-header'; +import { getBearerAuthHeader } from '../../network/bearer-auth/get-header'; +import getOAuth1Token from './o-auth-1/get-token'; +import { getOAuth2Token } from './o-auth-2/get-token'; + +const buildBearerHeader = (accessToken: string, prefix?: string): RequestHeader | undefined => { + if (!accessToken) { + return; + } + + return { + name: 'Authorization', + value: prefix === 'NO_PREFIX' ? accessToken : `${prefix || 'Bearer'} ${accessToken}`, + }; +}; + +export async function getAuthHeader(renderedRequest: RenderedRequest, url: string): Promise { + const { method, body } = renderedRequest; + const authentication = renderedRequest.authentication as RequestAuthentication; + + const requestId = renderedRequest._id; + + if (authentication.disabled) { + return; + } + + if (authentication.type === 'apikey' && authentication.addTo === HEADER) { + const { key, value } = authentication; + + if (!key || !value) { + return; + } + + return { + name: key, + value, + }; + } + + if (authentication.type === 'apikey' && authentication.addTo === COOKIE) { + const { key, value } = authentication; + if (!key || !value) { + return undefined; + } + return { + name: 'Cookie', + value: `${key}=${value}`, + }; + } + + if (authentication.type === 'basic') { + const { username, password, useISO88591 } = authentication; + const encoding = useISO88591 ? 'latin1' : 'utf8'; + return getBasicAuthHeader(username, password, encoding); + } + + if (authentication.type === 'bearer' && authentication.token) { + const { token, prefix } = authentication; + return getBearerAuthHeader(token, prefix); + } + + if (authentication.type === 'oauth2') { + try { + // HACK: GraphQL requests use a child request to fetch the schema with an + // ID of "{{request_id}}.graphql". Here we are removing the .graphql suffix and + // pretending we are fetching a token for the original request. This makes sure + // the same tokens are used for schema fetching. See issue #835 on GitHub. + const tokenId = requestId.match(/\.graphql$/) ? requestId.replace(/\.graphql$/, '') : requestId; + const oAuth2Token = await getOAuth2Token(tokenId, authentication as AuthTypeOAuth2); + + if (oAuth2Token) { + return buildBearerHeader(oAuth2Token.accessToken, authentication.tokenPrefix); + } + + return; + } catch (err) { + console.log('[oauth2] Failed to get token', err); + return; + } + } + + if (authentication.type === 'oauth1') { + const oAuth1Token = await getOAuth1Token(url, method, authentication, body); + + if (oAuth1Token) { + return { + name: 'Authorization', + value: oAuth1Token.Authorization, + }; + } + + return; + } + + if (authentication.type === 'hawk') { + const headerOptions = { + credentials: { + id: authentication.id, + key: authentication.key, + algorithm: authentication.algorithm, + }, + ext: authentication.ext, + }; + + if (!authentication.validatePayload) { + return { + name: 'Authorization', + value: Hawk.client.header(url, method, headerOptions).header, + }; + } + return { + name: 'Authorization', + value: Hawk.client.header(url, method, { + ...headerOptions, + payload: renderedRequest.body.text, + contentType: renderedRequest.body.mimeType || undefined, + }).header, + }; + } + + if (authentication.type === 'asap') { + let parsedAdditionalClaims; + try { + parsedAdditionalClaims = JSON.parse(authentication.additionalClaims || '{}'); + } catch (err) { + throw new Error(`Unable to parse additional-claims: ${err}`); + } + + if (parsedAdditionalClaims && typeof parsedAdditionalClaims !== 'object') { + throw new Error(`additional-claims must be an object received: '${typeof parsedAdditionalClaims}' instead`); + } + + const generator = (await import('httplease-asap')).createAuthHeaderGenerator({ + privateKey: authentication.privateKey, + issuer: authentication.issuer, + keyId: authentication.keyId, + audience: authentication.audience, + subject: authentication.subject, + additionalClaims: parsedAdditionalClaims, + tokenExpiryMs: 10 * 60 * 1000, + tokenMaxAgeMs: 9 * 60 * 1000, + }); + return { + name: 'Authorization', + value: generator(), + }; + } + + return; +} diff --git a/packages/insomnia/src/main/network/libcurl-promise.ts b/packages/insomnia/src/main/network/libcurl-promise.ts index c69daa150d..622b01e796 100644 --- a/packages/insomnia/src/main/network/libcurl-promise.ts +++ b/packages/insomnia/src/main/network/libcurl-promise.ts @@ -26,9 +26,9 @@ import type { ClientCertificate, RequestHeader, ResponseHeader } from '~/insomni import { version } from '../../../package.json'; import { type AuthTypes, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED } from '../../common/constants'; import { cannotAccessPathError, describeByteSize, hasAuthHeader } from '../../common/misc'; +import { parseHeaderStrings } from '../../network/parse-header-strings'; import { insecureReadFile, isPathAllowed } from '../secure-read-file'; import { buildMultipart } from './multipart'; -import { parseHeaderStrings } from './parse-header-strings'; export interface CurlRequestOptions { requestId: string; // for cancellation req: RequestUsedHere; @@ -179,7 +179,7 @@ export const curlRequest = (options: CurlRequestOptions) => } // NOTE: temporary workaround for testing mockbin api - if (process.env.PLAYWRIGHT) { + if (process.env.PLAYWRIGHT_TEST) { req.headers = [...req.headers, { name: 'X-Mockbin-Test', value: 'true' }]; } diff --git a/packages/insomnia/src/main/network/mcp.ts b/packages/insomnia/src/main/network/mcp.ts index 433de90099..28378a696d 100644 --- a/packages/insomnia/src/main/network/mcp.ts +++ b/packages/insomnia/src/main/network/mcp.ts @@ -20,7 +20,7 @@ import electron from 'electron'; import { getAppVersion, getProductName, REALTIME_EVENTS_CHANNELS } from '~/common/constants'; import { getMcpMethodFromMessage, METHOD_NOTIFICATION_CANCELLED } from '~/common/mcp-utils'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { SegmentEvent, trackSegmentEvent } from '~/main/analytics'; import { callTool, @@ -69,7 +69,6 @@ import type { OpenMcpClientConnectionOptions, OpenMcpHTTPClientConnectionOptions, } from '~/main/mcp/types'; -import * as models from '~/models'; import { invariant } from '~/utils/invariant'; import { ipcMainHandle, ipcMainOn } from '../ipc/electron'; diff --git a/packages/insomnia/src/main/network/multipart.ts b/packages/insomnia/src/main/network/multipart.ts index f3d7c6c373..c30f2724d2 100644 --- a/packages/insomnia/src/main/network/multipart.ts +++ b/packages/insomnia/src/main/network/multipart.ts @@ -10,7 +10,7 @@ import { lookup } from 'mime-types'; import type { RequestBodyParameter } from '~/insomnia-data'; -export const DEFAULT_BOUNDARY = 'X-INSOMNIA-BOUNDARY'; +import { DEFAULT_BOUNDARY } from '../../network/multipart-constants'; interface Multipart { boundary: typeof DEFAULT_BOUNDARY; diff --git a/packages/insomnia/src/network/o-auth-1/get-token.ts b/packages/insomnia/src/main/network/o-auth-1/get-token.ts similarity index 88% rename from packages/insomnia/src/network/o-auth-1/get-token.ts rename to packages/insomnia/src/main/network/o-auth-1/get-token.ts index a5983f8f1e..e3014bdc20 100644 --- a/packages/insomnia/src/network/o-auth-1/get-token.ts +++ b/packages/insomnia/src/main/network/o-auth-1/get-token.ts @@ -1,21 +1,17 @@ -/** - * Get an OAuth1Token object and also handle storing/saving/refreshing - * @returns {Promise.} - */ import crypto from 'node:crypto'; import OAuth1 from 'oauth-1.0a'; import type { RequestAuthentication, RequestBody } from '~/insomnia-data'; -import { CONTENT_TYPE_FORM_URLENCODED } from '../../common/constants'; -import type { OAuth1SignatureMethod } from './constants'; import { + CONTENT_TYPE_FORM_URLENCODED, + type OAuth1SignatureMethod, SIGNATURE_METHOD_HMAC_SHA1, SIGNATURE_METHOD_HMAC_SHA256, SIGNATURE_METHOD_PLAINTEXT, SIGNATURE_METHOD_RSA_SHA1, -} from './constants'; +} from '../../../common/constants'; function hashFunction(signatureMethod: OAuth1SignatureMethod) { if (signatureMethod === SIGNATURE_METHOD_HMAC_SHA1) { @@ -65,9 +61,7 @@ export default async function getToken( url: url, method: method, includeBodyHash: false, - data: { - // These are conditionally filled in below - }, + data: {}, }; if (authentication.callback) { @@ -114,7 +108,6 @@ export default async function getToken( secret: authentication.privateKey || '', }; - // We override getSigningKey for RSA-SHA1 because we don't want ddo/oauth-1.0a to percentEncode the token oauth.getSigningKey = function (tokenSecret) { return tokenSecret || ''; }; diff --git a/packages/insomnia/src/network/o-auth-2/get-token.ts b/packages/insomnia/src/main/network/o-auth-2/get-token.ts similarity index 78% rename from packages/insomnia/src/network/o-auth-2/get-token.ts rename to packages/insomnia/src/main/network/o-auth-2/get-token.ts index 63b51b2554..c040244a2f 100644 --- a/packages/insomnia/src/network/o-auth-2/get-token.ts +++ b/packages/insomnia/src/main/network/o-auth-2/get-token.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import querystring from 'node:querystring'; +import { BrowserWindow } from 'electron'; import { v4 as uuidv4 } from 'uuid'; import type { @@ -14,17 +15,15 @@ import type { Response, } from '~/insomnia-data'; import { database as db, models, services } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; -import { encryptOAuthUrl } from '~/network/o-auth-2/utils'; +import { authorizeUserInDefaultBrowser } from '~/main/authorize-user-in-default-browser'; +import { authorizeUserInWindow } from '~/main/authorize-user-in-window'; +import { getElectronStorage as getSharedElectronStorage } from '~/main/electron-storage'; -import { version } from '../../../package.json'; -import { getOauthRedirectUrl } from '../../common/constants'; -import { escapeRegex } from '../../common/misc'; -import uiEventBus, { OAUTH2_AUTHORIZATION_STATUS_CHANGE } from '../../ui/event-bus'; -import { invariant } from '../../utils/invariant'; -import { setDefaultProtocol } from '../../utils/url/protocol'; -import { getAuthObjectOrNull, isAuthEnabled } from '../authentication'; -import { getBasicAuthHeader } from '../basic-auth/get-header'; +import { version } from '../../../../package.json'; +import { getOauthRedirectUrl, getOauthRelayUrl, OAUTH_WINDOW_SESSION_ID_KEY } from '../../../common/constants'; +import { type DefaultBrowserRedirectParam, escapeRegex } from '../../../common/misc'; +import { getAuthObjectOrNull, isAuthEnabled } from '../../../network/authentication'; +import { getBasicAuthHeader } from '../../../network/basic-auth/get-header'; import { fetchMcpRequestData, fetchRequestData, @@ -33,36 +32,126 @@ import { sendCurlAndWriteTimeline, tryToInterpolateRequest, tryToTransformRequestWithPlugins, -} from '../network'; -import { type AuthKeys, GRANT_TYPE_AUTHORIZATION_CODE, PKCE_CHALLENGE_S256 } from './constants'; +} from '../../../network/network'; +import { invariant } from '../../../utils/invariant'; +import { setDefaultProtocol } from '../../../utils/url/protocol'; const { isRequestGroup, isRequestGroupId } = models.requestGroup; -const LOCALSTORAGE_KEY_SESSION_ID = 'insomnia::current-oauth-session-id'; + +export const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code'; +export const GRANT_TYPE_IMPLICIT = 'implicit'; +export const GRANT_TYPE_PASSWORD = 'password'; +export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; +export const GRANT_TYPE_REFRESH = 'refresh_token'; +export const GRANT_TYPE_MCP_AUTH_FLOW = 'mcp_auth_flow'; +export type AuthKeys = + | 'access_token' + | 'id_token' + | 'client_id' + | 'client_secret' + | 'audience' + | 'resource' + | 'code_challenge' + | 'code_challenge_method' + | 'code_verifier' + | 'code' + | 'nonce' + | 'error' + | 'error_description' + | 'error_uri' + | 'expires_in' + | 'grant_type' + | 'password' + | 'redirect_uri' + | 'refresh_token' + | 'response_type' + | 'scope' + | 'state' + | 'token_type' + | 'username' + | 'xError' + | 'xResponseId'; +export const PKCE_CHALLENGE_S256 = 'S256'; +export const PKCE_CHALLENGE_PLAIN = 'plain'; + +export type OAuth2AuthorizationStatusType = 'none' | 'getting_code' | 'getting_token'; + +const showOAuthAuthorizationModal = (authCodeUrlStr: string) => { + BrowserWindow.getAllWindows().forEach(window => { + window.webContents.send('show-oauth-authorization-modal', authCodeUrlStr); + }); +}; + +const hideOAuthAuthorizationModal = () => { + BrowserWindow.getAllWindows().forEach(window => { + window.webContents.send('hide-oauth-authorization-modal'); + }); +}; +const getElectronStorage = () => { + return getSharedElectronStorage(); +}; export function initNewOAuthSession() { - // the value of this variable needs to start with 'persist:' - // otherwise sessions won't be persisted over application-restarts const authWindowSessionId = `persist:oauth2_${uuidv4()}`; - window.localStorage.setItem(LOCALSTORAGE_KEY_SESSION_ID, authWindowSessionId); + const storage = getElectronStorage(); + storage.setItem(OAUTH_WINDOW_SESSION_ID_KEY, authWindowSessionId); return authWindowSessionId; } export function getOAuthSession(): string { - const token = window.localStorage.getItem(LOCALSTORAGE_KEY_SESSION_ID); + const storage = getElectronStorage(); + const token = storage.getItem(OAUTH_WINDOW_SESSION_ID_KEY); return token || initNewOAuthSession(); } -// NOTE -// 1. return valid access token from insomnia db -// 2. send refresh token in order to save and return valid access token -// 3. run a given grant type and save and return valid access token +export const encryptOAuthUrl = (authCodeUrlStr: string) => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 3072, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const relayUrl = `${getOauthRelayUrl()}?authCodeUrl=${encodeURIComponent(authCodeUrlStr)}&publicKey=${encodeURIComponent(publicKey)}`; + + const decryptOAuthResult = (result: DefaultBrowserRedirectParam): string => { + if ('redirectUrl' in result) { + return result.redirectUrl; + } + + const { encryptedRedirectUrl, encryptedKey, iv } = result; + const aesKey = crypto.privateDecrypt( + { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from(encryptedKey, 'base64'), + ); + const encryptedBuf = Buffer.from(encryptedRedirectUrl, 'base64'); + const authTag = encryptedBuf.slice(-16); + const ciphertext = encryptedBuf.slice(0, -16); + // nosemgrep: javascript.node-crypto.security.gcm-no-tag-length.gcm-no-tag-length + const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(iv, 'base64'), { + authTagLength: 16, + }); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); + return decrypted; + }; + + return { + relayUrl, + decryptOAuthResult, + }; +}; + export const getOAuth2Token = async ( requestId: string, authentication: AuthTypeOAuth2, forceRefresh = false, ): Promise => { try { - // If it's MCP Auth Flow, should leave it to be handled by the MCP auth provider if (authentication.grantType === 'mcp_auth_flow') { return undefined; } @@ -99,7 +188,7 @@ export const getOAuth2Token = async ( ] : []), ].forEach(p => p.value && implicitUrl.searchParams.append(p.name, p.value)); - const redirectedTo = await window.main.authorizeUserInWindow({ + const redirectedTo = await authorizeUserInWindow({ url: implicitUrl.toString(), urlSuccessRegex: /(access_token=|id_token=)/, urlFailureRegex: /(error=)/, @@ -130,7 +219,6 @@ export const getOAuth2Token = async ( if (authentication.grantType === 'authorization_code') { invariant(authentication.authorizationUrl, 'Invalid authorization URL'); - // default to S256 if usePkce is true and pkceMethod is not defined const pkceMethod = authentication.usePkce && !authentication.pkceMethod ? PKCE_CHALLENGE_S256 : authentication.pkceMethod; const codeVerifier = authentication.usePkce ? encodePKCE(crypto.randomBytes(32)) : ''; @@ -162,20 +250,15 @@ export const getOAuth2Token = async ( const authCodeUrlStr = authCodeUrl.toString(); const { relayUrl, decryptOAuthResult } = encryptOAuthUrl(authCodeUrlStr); - uiEventBus.emit(OAUTH2_AUTHORIZATION_STATUS_CHANGE, { - status: 'getting_code', - authCodeUrlStr: relayUrl, - }); - // If the user has selected to use the default browser, we will open the - // authorization URL in the default browser and wait for the user to - // authorize the application. - const result = await window.main.authorizeUserInDefaultBrowser({ + showOAuthAuthorizationModal(relayUrl); + const result = await authorizeUserInDefaultBrowser({ url: relayUrl, }); + hideOAuthAuthorizationModal(); redirectedTo = decryptOAuthResult(result); } else { - redirectedTo = await window.main.authorizeUserInWindow({ + redirectedTo = await authorizeUserInWindow({ url: authCodeUrl.toString(), urlSuccessRegex: authentication.redirectUrl ? new RegExp(`${escapeRegex(authentication.redirectUrl)}.*([?&]code=)`, 'i') @@ -231,37 +314,20 @@ export const getOAuth2Token = async ( headers.push(getBasicAuthHeader(authentication.clientId, authentication.clientSecret)); } - if (authentication.useDefaultBrowser) { - uiEventBus.emit(OAUTH2_AUTHORIZATION_STATUS_CHANGE, { - status: 'getting_token', - }); - } - const response = await sendAccessTokenRequest(requestId, authentication, params, headers); const old = await services.oAuth2Token.getOrCreateByParentId(closestAuthId); - if (authentication.useDefaultBrowser) { - uiEventBus.emit(OAUTH2_AUTHORIZATION_STATUS_CHANGE, { - status: 'none', - }); - } - return services.oAuth2Token.update( old, transformNewAccessTokenToOauthModel(await oauthResponseToAccessToken(authentication.accessTokenUrl, response)), ); } catch (err) { if (authentication.useDefaultBrowser) { - uiEventBus.emit(OAUTH2_AUTHORIZATION_STATUS_CHANGE, { - status: 'none', - }); + hideOAuthAuthorizationModal(); } throw err; } }; -// 1. get token from db and return if valid -// 2. if expired, and no refresh token return null -// 3. run refresh token query and return new token or null if it fails async function getExistingAccessTokenAndRefreshIfExpired( requestId: string, @@ -293,8 +359,6 @@ async function getExistingAccessTokenAndRefreshIfExpired( return { oAuth2Token: token, closestAuthId }; } - // token is expired - if (!token.refreshToken) { return { oAuth2Token: undefined, closestAuthId }; } @@ -317,12 +381,9 @@ async function getExistingAccessTokenAndRefreshIfExpired( const response = await sendAccessTokenRequest(requestId, authentication, params, headers); const statusCode = response.statusCode || 0; - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); if (statusCode === 401) { - // If the refresh token was rejected due an unauthorized request, we will - // return a null access_token to trigger an authentication request to fetch - // brand new refresh and access tokens. const old = await services.oAuth2Token.getOrCreateByParentId(closestAuthId); services.oAuth2Token.update(old, transformNewAccessTokenToOauthModel({ access_token: null })); return { oAuth2Token: undefined, closestAuthId }; @@ -332,9 +393,6 @@ async function getExistingAccessTokenAndRefreshIfExpired( if (!isSuccessful) { if (hasBodyAndIsError) { const body = tryToParse(bodyBuffer.toString()); - // If the refresh token was rejected due an oauth2 invalid_grant error, we will - // return a null access_token to trigger an authentication request to fetch - // brand new refresh and access tokens. if (body?.error === 'invalid_grant') { console.log(`[oauth2] Refresh token rejected due to invalid_grant error: ${body.error_description}`); const old = await services.oAuth2Token.getOrCreateByParentId(closestAuthId); @@ -365,7 +423,7 @@ async function getExistingAccessTokenAndRefreshIfExpired( } export const oauthResponseToAccessToken = async (accessTokenUrl: string, response: Response) => { - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); if (!bodyBuffer) { return { xResponseId: response._id, @@ -391,7 +449,6 @@ const transformNewAccessTokenToOauthModel = ( ): Partial => { const expiry = accessToken.expires_in ? +accessToken.expires_in : 0; return { - // Calculate expiry date expiresAt: accessToken.expires_in ? Date.now() + expiry * 1000 : null, refreshToken: accessToken.refresh_token || undefined, accessToken: accessToken.access_token || undefined, @@ -399,14 +456,11 @@ const transformNewAccessTokenToOauthModel = ( error: accessToken.error || undefined, errorDescription: accessToken.error_description || undefined, errorUri: accessToken.error_uri || undefined, - // Special Case for response timeline viewing xResponseId: accessToken.xResponseId || null, - // Special Case for empty body or http error code custom messages xError: accessToken.xError || null, }; }; -// This can be sent from a folder const sendAccessTokenRequest = async ( requestOrGroupId: string, authentication: AuthTypeOAuth2, @@ -415,7 +469,6 @@ const sendAccessTokenRequest = async ( ) => { invariant(authentication.accessTokenUrl, 'Missing access token URL'); console.log(`[network] Sending with settings req=${requestOrGroupId}`); - // @TODO unpack oauth into regular timeline and remove oauth timeline dialog const initializedData = isRequestGroupId(requestOrGroupId) ? await fetchRequestGroupData(requestOrGroupId) : models.mcpRequest.isMcpRequestId(requestOrGroupId) @@ -436,7 +489,6 @@ const sendAccessTokenRequest = async ( } const newRequest: Request = { ...models.request.init(), - // Do not inherit authentication from parent request or group since this is a special request authentication: { type: 'none', disabled: false, @@ -470,26 +522,17 @@ const sendAccessTokenRequest = async ( return await services.response.create(responsePatch); }; + export const encodePKCE = (buffer: Buffer) => { - return ( - buffer - .toString('base64') - // The characters + / = are reserved for PKCE as per the RFC, - // so we replace them with unreserved characters - // Docs: https://tools.ietf.org/html/rfc7636#section-4.2 - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') - ); + return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); }; + const tryToParse = (body: string): Record | null => { try { return JSON.parse(body); } catch {} try { - // NOTE: parse does not return a JS Object, so - // we cannot use hasOwnProperty on it return querystring.parse(body); } catch {} return null; diff --git a/packages/insomnia/src/main/network/websocket.ts b/packages/insomnia/src/main/network/websocket.ts index f38859d29f..1e3e8aee75 100644 --- a/packages/insomnia/src/main/network/websocket.ts +++ b/packages/insomnia/src/main/network/websocket.ts @@ -20,11 +20,10 @@ import type { RequestHeader, WebSocketResponse, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { jarFromCookies } from '../../common/cookies'; import { generateId, getSetCookieHeaders } from '../../common/misc'; -import * as models from '../../models'; import { COOKIE, HEADER, QUERY_PARAMS } from '../../network/api-key/constants'; import { getBasicAuthHeader } from '../../network/basic-auth/get-header'; import { getBearerAuthHeader } from '../../network/bearer-auth/get-header'; diff --git a/packages/insomnia/src/main/sentry.ts b/packages/insomnia/src/main/sentry.ts index bd45d33253..c5964e332e 100644 --- a/packages/insomnia/src/main/sentry.ts +++ b/packages/insomnia/src/main/sentry.ts @@ -1,11 +1,10 @@ import * as Sentry from '@sentry/electron/main'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import * as session from '../account/session'; import { type ChangeBufferEvent, database as db } from '../common/database'; import { SENTRY_OPTIONS } from '../common/sentry'; -import * as models from '../models/index'; let enabled = false; diff --git a/packages/insomnia/src/main/templating-worker-database.ts b/packages/insomnia/src/main/templating-worker-database.ts index 9a4d37d8b4..ca9666299c 100644 --- a/packages/insomnia/src/main/templating-worker-database.ts +++ b/packages/insomnia/src/main/templating-worker-database.ts @@ -7,14 +7,19 @@ import iconv from 'iconv-lite'; import { v4 as uuidv4 } from 'uuid'; import { jarFromCookies } from '~/common/cookies'; -import type { CloudProviderCredential, Request as DBRequest, RequestGroup, Response, Workspace } from '~/insomnia-data'; +import type { + AllTypes, + CloudProviderCredential, + Request as DBRequest, + RequestGroup, + Response, + Workspace, +} from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import { getBodyBuffer, readCurlResponse } from '~/models/helpers/response-operations'; import { getAppBundlePlugins, RESPONSE_CODE_REASONS } from '../common/constants'; import { isDevelopment } from '../common/constants'; import { database as db } from '../common/database'; -import type * as models from '../models'; import { fetchRequestData, sendCurlAndWriteTimeline, tryToInterpolateRequest } from '../network/network'; import { getPluginCommonContext, type Plugin, type TemplateTag } from '../plugins'; import type { PluginTemplateTag, PluginTemplateTagContext, PluginToMainAPIPaths } from '../templating/types'; @@ -89,7 +94,7 @@ const pluginToMainAPI: Record Promise< 'request.getById': async (body: { id: string }) => { return await services.request.getById(body.id); }, - 'request.getAncestors': async (body: { request: DBRequest | RequestGroup | Workspace; types: models.AllTypes[] }) => { + 'request.getAncestors': async (body: { request: DBRequest | RequestGroup | Workspace; types: AllTypes[] }) => { return await db.withAncestors(body.request, body.types); }, 'workspace.getById': async (body: { id: string }) => { @@ -110,7 +115,7 @@ const pluginToMainAPI: Record Promise< return await services.response.getLatestForRequestId(body.requestId, body.environmentId); }, 'response.getBodyBuffer': async (body: { response: Response; readFailureValue: string }) => { - return await getBodyBuffer(body.response, body.readFailureValue); + return await services.helpers.getResponseBodyBuffer(body.response, body.readFailureValue); }, 'pluginData.hasItem': async (body: { pluginName: string; key: string }) => { const doc = await services.pluginData.getByKey(body.pluginName, body.key); @@ -217,7 +222,7 @@ const pluginToMainAPI: Record Promise< if (!lastRedirect) { throw new Error('Error in response: the lastRedirect is not defined'); } - const bodyResult = await readCurlResponse({ + const bodyResult = await services.helpers.readCurlResponse({ bodyPath: responseBodyPath, bodyCompression: patch.bodyCompression, }); diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index cd10034f93..14f199503c 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -20,7 +20,7 @@ import { getAppBuildDate, getAppVersion, getProductName, isDevelopment, MNEMONIC import { docsBase } from '../common/documentation'; import { isLinux, isMac } from '../common/platform'; import { invariant } from '../utils/invariant'; -import ElectronStorage from './electron-storage'; +import { getElectronStorage } from './electron-storage'; import { ipcMainOn } from './ipc/electron'; import { getLogDirectory } from './log'; @@ -28,11 +28,8 @@ const DEFAULT_WIDTH = 1280; const DEFAULT_HEIGHT = 720; const MINIMUM_WIDTH = 500; const MINIMUM_HEIGHT = 400; - const browserWindows = new Map<'Insomnia' | 'HiddenBrowserWindow', ElectronBrowserWindow>(); -let electronStorage: ElectronStorage | null = null; let hiddenWindowIsBusy = false; - interface Bounds { height?: number; width?: number; @@ -40,9 +37,6 @@ interface Bounds { y?: number; } -export function init() { - initElectronStorage(); -} const stopAndWaitForHiddenBrowserWindow = async (runningHiddenBrowserWindow: BrowserWindow) => { return await new Promise(resolve => { // overwrite the closed handler @@ -194,7 +188,7 @@ export function createWindow(): ElectronBrowserWindow { backgroundColor: '#2C2C2C', fullscreen: fullscreen, fullscreenable: true, - title: getProductName(), + title: `${getProductName()} ${getAppVersion()}`, width: width || DEFAULT_WIDTH, height: height || DEFAULT_HEIGHT, minHeight: MINIMUM_HEIGHT, @@ -720,6 +714,7 @@ function saveBounds() { } const fullscreen = browserWindow?.isFullScreen(); + const electronStorage = getElectronStorage(); // Only save the size if we're not in fullscreen if (!fullscreen) { @@ -737,6 +732,7 @@ function getBounds() { let maximize = false; try { + const electronStorage = getElectronStorage(); bounds = electronStorage?.getItem('bounds', {}); fullscreen = electronStorage?.getItem('fullscreen', false); maximize = electronStorage?.getItem('maximize', false); @@ -758,6 +754,7 @@ const ZOOM_MIN = 0.05; const getZoomFactor = () => { try { + const electronStorage = getElectronStorage(); return electronStorage?.getItem('zoomFactor', ZOOM_DEFAULT); } catch (error) { // This should never happen, but if it does...! @@ -779,17 +776,10 @@ export const setZoom = (transformer: (current: number) => number) => () => { const actual = Math.min(Math.max(ZOOM_MIN, desired), ZOOM_MAX); browserWindow.webContents.setZoomLevel(actual); + const electronStorage = getElectronStorage(); electronStorage?.setItem('zoomFactor', actual); }; -export function initElectronStorage() { - const electronStoragePath = path.join(process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), 'localStorage'); - if (!electronStorage) { - electronStorage = new ElectronStorage(electronStoragePath); - } - return electronStorage; -} - export function createWindowsAndReturnMain() { const mainWindow = browserWindows.get('Insomnia') ?? createWindow(); if (!browserWindows.get('HiddenBrowserWindow')) { diff --git a/packages/insomnia/src/models/helpers/__mocks__/settings.ts b/packages/insomnia/src/models/helpers/__mocks__/settings.ts deleted file mode 100644 index 9c9d72d22e..0000000000 --- a/packages/insomnia/src/models/helpers/__mocks__/settings.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { vi } from 'vitest'; - -import * as settingsOriginal from '../settings'; - -const actual = vi.requireActual('../settings') as typeof settingsOriginal; - -actual.getConfigSettings = vi.fn(); - -module.exports = actual; diff --git a/packages/insomnia/src/models/helpers/project.ts b/packages/insomnia/src/models/helpers/project.ts deleted file mode 100644 index f2d6cb978d..0000000000 --- a/packages/insomnia/src/models/helpers/project.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createTeamProject, isApiError } from 'insomnia-api'; - -import type { Project, Workspace } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; - -import { database } from '../../common/database'; -import { - initializeLocalBackendProjectAndMarkForSync, - pushSnapshotOnInitialize, -} from '../../sync/vcs/initialize-backend-project'; -import type { VCS } from '../../sync/vcs/vcs'; -import { invariant } from '../../utils/invariant'; - -export const sortProjects = (projects: T[]) => [ - ...projects.filter(p => models.project.isDefaultOrganizationProject(p)).sort((a, b) => a.name.localeCompare(b.name)), - ...projects.filter(p => !models.project.isDefaultOrganizationProject(p)).sort((a, b) => a.name.localeCompare(b.name)), -]; - -export async function updateLocalProjectToRemote({ - project, - vcs, - sessionId, - organizationId, -}: { - project: Project; - vcs: VCS; - sessionId: string; - organizationId: string; -}) { - try { - const newCloudProject = await createTeamProject({ - sessionId, - organizationId, - name: project.name, - }); - const updatedProject = await services.project.update(project, { - name: newCloudProject.name, - remoteId: newCloudProject.id, - }); - - // For each workspace in the local project - const projectWorkspaces = await database.find('Workspace', { - parentId: updatedProject._id, - }); - - for (const workspace of projectWorkspaces) { - const workspaceMeta = await services.workspaceMeta.getOrCreateByParentId(workspace._id); - - // Initialize Sync on the workspace if it's not using Git sync - try { - if (!workspaceMeta.gitRepositoryId) { - invariant(vcs, 'VCS must be initialized'); - - await initializeLocalBackendProjectAndMarkForSync({ vcs, workspace }); - await pushSnapshotOnInitialize({ vcs, workspace, project: updatedProject }); - } - } catch (e) { - console.warn( - 'Failed to initialize sync on workspace. This will be retried when the workspace is opened on the app.', - e, - ); - // TODO: here we should show the try again dialog - } - } - } catch (error: unknown) { - if (isApiError(error)) { - let errorMessage = 'An unexpected error occurred while connecting the project. Please try again.'; - if (error.name === 'FORBIDDEN' || error.name === 'NEEDS_TO_UPGRADE') { - errorMessage = error.message; - } - return { - error: errorMessage, - }; - } - return { - error: error instanceof Error ? error.message : String(error), - }; - } - - return { - error: null, - }; -} diff --git a/packages/insomnia/src/models/index.ts b/packages/insomnia/src/models/index.ts deleted file mode 100644 index df988a1dd5..0000000000 --- a/packages/insomnia/src/models/index.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { models } from '~/insomnia-data'; -import type { AllTypes, BaseModel } from '~/models/types'; - -export type { AllTypes, BaseModel }; -// Reference to each model -export const apiSpec = models.apiSpec; -export const clientCertificate = models.clientCertificate; -export const caCertificate = models.caCertificate; -export const cookieJar = models.cookieJar; -export const environment = models.environment; -export const gitCredentials = models.gitCredentials; -export const gitRepository = models.gitRepository; -export const oAuth2Token = models.oAuth2Token; -export const pluginData = models.pluginData; -export const mockServer = models.mockServer; -export const mockRoute = models.mockRoute; -export const request = models.request; -export const requestGroup = models.requestGroup; -export const requestGroupMeta = models.requestGroupMeta; -export const requestMeta = models.requestMeta; -export const requestVersion = models.requestVersion; -export const runnerTestResult = models.runnerTestResult; -export const response = models.response; -export const settings = models.settings; -export const project = models.project; -export const stats = models.stats; -export const unitTest = models.unitTest; -export const unitTestSuite = models.unitTestSuite; -export const unitTestResult = models.unitTestResult; -export const protoFile = models.protoFile; -export const protoDirectory = models.protoDirectory; -export const grpcRequest = models.grpcRequest; -export const grpcRequestMeta = models.grpcRequestMeta; -export const workspace = models.workspace; -export const workspaceMeta = models.workspaceMeta; -export const webSocketPayload = models.webSocketPayload; -export const webSocketRequest = models.webSocketRequest; -export const webSocketResponse = models.webSocketResponse; -export const webSocketRequestMeta = models.webSocketRequestMeta; -export const socketIORequest = models.socketIORequest; -export const socketIOPayload = models.socketIOPayload; -export const socketIORequestMeta = models.socketIORequestMeta; -export const socketIOResponse = models.socketIOResponse; -export * as organization from './organization'; -export const userSession = models.userSession; -export const cloudCredential = models.cloudCredential; -export const mcpRequest = models.mcpRequest; -export const mcpPayload = models.mcpPayload; -export const mcpResponse = models.mcpResponse; - -export const all = models.all; -export const types = models.types; -export const isValidType = (type: string): type is AllTypes => { - return types().includes(type as AllTypes); -}; -export function canSync(d: BaseModel) { - if (d.isPrivate) { - return false; - } - - const m = getModel(d.type); - - if (!m) { - return false; - } - - return m.canSync || false; -} - -export function getModel(type: string) { - return all().find(m => m.type === type) || null; -} - -export function mustGetModel(type: string) { - const model = getModel(type); - - if (!model) { - throw new Error(`The model type ${type} must exist but could not be found.`); - } - - return model; -} - -export function canDuplicate(type: string) { - const model = getModel(type); - return model ? model.canDuplicate : false; -} - -export function rewriteReferences(doc: T, idMapping: Map): T { - const model = getModel(doc.type); - if (!model) return doc; - return 'rewriteReferences' in model - ? (model.rewriteReferences as unknown as (doc: T, idMapping: Map) => T)(doc, idMapping) - : doc; -} - -// Use function instead of object to avoid issues with circular dependencies -export const getAllDescendantMap = (): Partial> => { - return { - [project.type]: [workspace.type], - [workspace.type]: [ - requestGroup.type, - request.type, - grpcRequest.type, - webSocketRequest.type, - socketIORequest.type, - cookieJar.type, - environment.type, - apiSpec.type, - mockServer.type, - unitTestSuite.type, - protoDirectory.type, - protoFile.type, - workspaceMeta.type, - runnerTestResult.type, - caCertificate.type, - clientCertificate.type, - mcpRequest.type, - ], - [requestGroup.type]: [ - requestGroup.type, - request.type, - grpcRequest.type, - webSocketRequest.type, - socketIORequest.type, - runnerTestResult.type, - requestGroupMeta.type, - oAuth2Token.type, - ], - [request.type]: [requestMeta.type, response.type, requestVersion.type, oAuth2Token.type], - [grpcRequest.type]: [grpcRequestMeta.type], - [webSocketRequest.type]: [webSocketPayload.type, webSocketResponse.type, requestMeta.type], - [socketIORequest.type]: [socketIOPayload.type, socketIOResponse.type, requestMeta.type], - [mcpRequest.type]: [mcpPayload.type, mcpResponse.type], - [mockServer.type]: [mockRoute.type], - [environment.type]: [environment.type], - [unitTestSuite.type]: [unitTest.type, unitTestResult.type], - [unitTest.type]: [unitTestResult.type], - [protoDirectory.type]: [protoDirectory.type, protoFile.type], - }; -}; - -let childToParentMap: Partial> | undefined; - -const getChildToParentMap = () => { - if (childToParentMap) { - return childToParentMap; - } - const childToParents: Partial> = {}; - for (const [parent, children] of Object.entries(getAllDescendantMap())) { - for (const child of children) { - if (!childToParents[child]) childToParents[child] = []; - childToParents[child].push(parent as AllTypes); - } - } - childToParentMap = childToParents; - return childToParents; -}; - -export const generateDescendantMap = (queryTypes: AllTypes[]): Partial> => { - const result: Partial> = {}; - - const visited = new Set(); - const collectAncestors = (child: AllTypes) => { - if (!child || visited.has(child)) { - return; - } - visited.add(child); - const parentMap = getChildToParentMap(); - const parents = parentMap[child]; - if (parents?.length) { - for (const p of parents) { - if (!result[p]) { - result[p] = []; - } - result[p].push(child); - collectAncestors(p); - } - } - }; - - for (const type of queryTypes) { - collectAncestors(type); - } - - return result; -}; diff --git a/packages/insomnia/src/network/__tests__/authentication.test.ts b/packages/insomnia/src/network/__tests__/authentication.test.ts index be2cb30d33..43d1439bbc 100644 --- a/packages/insomnia/src/network/__tests__/authentication.test.ts +++ b/packages/insomnia/src/network/__tests__/authentication.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { _buildBearerHeader, getAuthHeader, getAuthObjectOrNull, getAuthQueryParams } from '../authentication'; +import { getAuthHeader } from '../../main/network/get-auth-header'; +import { _buildBearerHeader, getAuthObjectOrNull } from '../authentication'; describe('OAuth 1.0', () => { it('Does OAuth 1.0', async () => { @@ -183,23 +184,6 @@ describe('API Key', () => { }); }); }); - - describe('getAuthQueryParams', () => { - it('Creates a query param with key as parameter name and value as parameter value, when addTo is "queryParams"', async () => { - const authentication = { - type: 'apikey', - key: 'x-api-key', - value: 'test', - addTo: 'queryParams', - }; - - const header = getAuthQueryParams(authentication, 'https://insomnia.rest/'); - expect(header).toEqual({ - name: 'x-api-key', - value: 'test', - }); - }); - }); }); describe('getAuthObjectOrNull', () => { diff --git a/packages/insomnia/src/network/__tests__/is-url-matched-in-no-proxy-rule.test.ts b/packages/insomnia/src/network/__tests__/is-url-matched-in-no-proxy-rule.test.ts deleted file mode 100644 index 5770cc97d6..0000000000 --- a/packages/insomnia/src/network/__tests__/is-url-matched-in-no-proxy-rule.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isUrlMatchedInNoProxyRule } from '../is-url-matched-in-no-proxy-rule'; - -describe('isUrlMatchedInNoProxyRule - noProxyRule hostname and wildcard matches', () => { - it('should handle poorly formatted url', () => { - const noProxyRule = 'localhost,127.0.0.1'; - const url = ''; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); - - it('should handle invalid url', () => { - const noProxyRule = 'localhost,127.0.0.1'; - const url = 'this is not a valid url and can not be parsed by the node url.parse library'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); - - it('should handle poorly formatted noProxyRule', () => { - const noProxyRule = null; - const url = 'https://git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); - - it('should handle basic filtering', () => { - const noProxyRule = 'localhost,git.acme.com,127.0.0.1,,,'; - const url = 'https://127.0.0.1/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should handle basic filtering with trailing dot', () => { - const noProxyRule = 'localhost.,git.acme.com,127.0.0.1'; - const url = 'https://localhost/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match an exact domain', () => { - const noProxyRule = 'localhost,git.acme.com,127.0.0.1'; - const url = 'https://git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match to a FQDN domain', () => { - const noProxyRule = 'localhost,git.acme.com,127.0.0.1'; - const url = 'https://hostname.git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match to a long FQDN domain', () => { - const noProxyRule = 'localhost,git.acme.com,127.0.0.1'; - const url = 'https://host.hostname.git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should not match partial domain', () => { - const noProxyRule = 'google.com'; - const url = 'https://oogle.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); - - it('should match domain starting with a dot', () => { - const noProxyRule = 'localhost,.acme.com,127.0.0.1'; - const url = 'https://git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match domain starting with a wildcard', () => { - const noProxyRule = 'localhost,*.acme.com,127.0.0.1'; - const url = 'https://git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match domain starting with a dot and a wildcard', () => { - const noProxyRule = '.*.acme.com'; - const url = 'https://git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should not match domain with interior wildcard', () => { - const noProxyRule = 'git.*.com'; - const url = 'https://git.acme.com/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); - - it('should match with no port', () => { - const noProxyRule = 'localhost'; - const url = 'https://localhost:8080/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match with port', () => { - const noProxyRule = 'localhost:8080'; - const url = 'https://localhost:8080/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should not match with wrong port', () => { - const noProxyRule = 'localhost:8081'; - const url = 'https://localhost:8080/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); - - it('should match with port and no hostname', () => { - const noProxyRule = ':8080'; - const url = 'https://localhost:8080/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match with inferred port 80 from hostname', () => { - const noProxyRule = 'localhost:80'; - const url = 'http://localhost/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should match with inferred port 443 from hostname', () => { - const noProxyRule = 'localhost:443'; - const url = 'https://localhost/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(true); - }); - - it('should not match with wrong inferred port from hostname', () => { - const noProxyRule = 'localhost:8081'; - const url = 'https://localhost/username/repo-name'; - expect(isUrlMatchedInNoProxyRule(url, noProxyRule)).toBe(false); - }); -}); diff --git a/packages/insomnia/src/network/__tests__/multipart.test.ts b/packages/insomnia/src/network/__tests__/multipart.test.ts index cd9fd22acf..daa125da4d 100644 --- a/packages/insomnia/src/network/__tests__/multipart.test.ts +++ b/packages/insomnia/src/network/__tests__/multipart.test.ts @@ -3,7 +3,8 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { buildMultipart, DEFAULT_BOUNDARY } from '../../main/network/multipart'; +import { buildMultipart } from '../../main/network/multipart'; +import { DEFAULT_BOUNDARY } from '../multipart-constants'; describe('buildMultipart()', () => { it('builds a simple request', async () => { diff --git a/packages/insomnia/src/network/__tests__/network.test.ts b/packages/insomnia/src/network/__tests__/network.test.ts index a3a9bf45a7..f3815d583b 100644 --- a/packages/insomnia/src/network/__tests__/network.test.ts +++ b/packages/insomnia/src/network/__tests__/network.test.ts @@ -4,23 +4,36 @@ import nodePath from 'node:path'; import { CurlHttpVersion, CurlNetrc } from '@getinsomnia/node-libcurl'; import { beforeEach, describe, expect, it } from 'vitest'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED } from '../../common/constants'; import { filterHeaders } from '../../common/misc'; import { getRenderedRequestAndContext } from '../../common/render'; import { HttpVersions } from '../../common/settings'; import { _parseHeaders, getHttpVersion } from '../../main/network/libcurl-promise'; -import { DEFAULT_BOUNDARY } from '../../main/network/multipart'; -import { _getAwsAuthHeaders } from '../../main/network/parse-header-strings'; -import * as models from '../../models'; -import { getBodyBuffer } from '../../models/helpers/response-operations'; +import { _getAwsAuthHeaders } from '../../network/parse-header-strings'; +import { DEFAULT_BOUNDARY } from '../multipart-constants'; import * as networkUtils from '../network'; -import { getSetCookiesFromResponseHeaders } from '../network'; +import { getAuthQueryParams, getSetCookiesFromResponseHeaders } from '../network'; const getRenderedRequest = async (args: Parameters[0]) => (await getRenderedRequestAndContext(args)).request; +describe('getAuthQueryParams', () => { + it('Creates a query param with key as parameter name and value as parameter value, when addTo is "queryParams"', async () => { + const authentication = { + type: 'apikey', + key: 'x-api-key', + value: 'test', + addTo: 'queryParams', + }; + const header = getAuthQueryParams(authentication); + expect(header).toEqual({ + name: 'x-api-key', + value: 'test', + }); + }); +}); describe('sendCurlAndWriteTimeline()', () => { beforeEach(async () => { await services.project.all(); @@ -101,7 +114,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -180,7 +193,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -284,7 +297,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -349,7 +362,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -433,7 +446,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -498,7 +511,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -542,7 +555,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -585,7 +598,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -629,7 +642,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -734,7 +747,7 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); const body = JSON.parse(String(bodyBuffer)); expect(body).toEqual({ meta: {}, @@ -793,7 +806,9 @@ describe('sendCurlAndWriteTimeline()', () => { '/tmp/res_id', 'res_id', ); - expect(JSON.parse(String(await getBodyBuffer(responseV1))).options.HTTP_VERSION).toBe('V1_0'); + expect(JSON.parse(String(await services.helpers.getResponseBodyBuffer(responseV1))).options.HTTP_VERSION).toBe( + 'V1_0', + ); expect(getHttpVersion(HttpVersions.V1_0).curlHttpVersion).toBe(CurlHttpVersion.V1_0); expect(getHttpVersion(HttpVersions.V1_1).curlHttpVersion).toBe(CurlHttpVersion.V1_1); expect(getHttpVersion(HttpVersions.V2PriorKnowledge).curlHttpVersion).toBe(CurlHttpVersion.V2PriorKnowledge); diff --git a/packages/insomnia/src/network/__tests__/parse-header-strings.test.ts b/packages/insomnia/src/network/__tests__/parse-header-strings.test.ts index cbb4b38b32..674c58b7cf 100644 --- a/packages/insomnia/src/network/__tests__/parse-header-strings.test.ts +++ b/packages/insomnia/src/network/__tests__/parse-header-strings.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { CONTENT_TYPE_FORM_DATA } from '../../common/constants'; -import { parseHeaderStrings } from '../../main/network/parse-header-strings'; +import { parseHeaderStrings } from '../parse-header-strings'; describe('parseHeaderStrings', () => { it('should default with empty inputs', () => { diff --git a/packages/insomnia/src/network/__tests__/url-matches-cert-host.test.ts b/packages/insomnia/src/network/__tests__/url-matches-cert-host.test.ts index 8273ec7c55..47377e128a 100644 --- a/packages/insomnia/src/network/__tests__/url-matches-cert-host.test.ts +++ b/packages/insomnia/src/network/__tests__/url-matches-cert-host.test.ts @@ -28,6 +28,12 @@ describe('urlMatchesCertHost', () => { expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); }); + it('should return true if the request URL uses an IPv4 host with an explicit port', () => { + const requestUrl = 'http://127.0.0.1:4010'; + const certificateHost = 'http://127.0.0.1:4010'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); + }); + it('should return false if the request URL and certificate host have different ports', () => { const requestUrl = 'https://www.example.org:1234/some/resources?query=1'; const certificateHost = 'https://www.example.org:123'; @@ -63,6 +69,12 @@ describe('urlMatchesCertHost', () => { const certificateHost = 'https://www.example.org'; expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); }); + + it('should return false if an IPv4 request URL and certificate host use different ports', () => { + const requestUrl = 'http://127.0.0.1:4010'; + const certificateHost = 'http://127.0.0.1:4011'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(false); + }); }); describe('when using wildcard certificate hosts', () => { @@ -96,6 +108,18 @@ describe('urlMatchesCertHost', () => { expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); }); + it('should return true if the request URL host matches a wildcard-prefixed certificate host without a dot separator', () => { + const requestUrl = 'https://my.example.org/some/resources?query=1'; + const certificateHost = '*example.org'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); + }); + + it('should return true if the request URL host exactly ends with the wildcard-prefixed certificate host', () => { + const requestUrl = 'https://example.org/some/resources?query=1'; + const certificateHost = '*example.org'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); + }); + it('should return false if the request URL host does not match the certificate host', () => { const requestUrl = 'https://my.example.com/some/resources?query=1'; const certificateHost = 'https://*.example.org'; @@ -151,5 +175,77 @@ describe('urlMatchesCertHost', () => { const certificateHost = 'https://example.org?'; expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(false); }); + + it('should return false if the request URL is invalid', () => { + expect(urlMatchesCertHost('example.com', 'not-a-valid-url')).toBe(false); + }); + }); + + describe('when using HTTP request URLs', () => { + it('should return true if the HTTP request URL matches the certificate host by hostname', () => { + const requestUrl = 'http://www.example.org/some/resources?query=1'; + const certificateHost = 'www.example.org'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); + }); + + it('should return true if the HTTP request URL with an explicit non-default port matches the certificate host', () => { + const requestUrl = 'http://www.example.org:8080/some/resources?query=1'; + const certificateHost = 'www.example.org:8080'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); + }); + + it('should return false if the HTTP request URL and the certificate host have different ports', () => { + const requestUrl = 'http://www.example.org:8080/some/resources?query=1'; + const certificateHost = 'www.example.org:9090'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(false); + }); + + it('should return true if the HTTP request URL host matches a wildcard certificate host', () => { + const requestUrl = 'http://my.example.org/some/resources?query=1'; + const certificateHost = '*.example.org'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(true); + }); + + it('should return false if the HTTP request URL host does not match the certificate host', () => { + const requestUrl = 'http://www.example.org/some/resources?query=1'; + const certificateHost = 'www.example.com'; + expect(urlMatchesCertHost(certificateHost, requestUrl)).toBe(false); + }); + }); + + describe('when using IPv4 addresses', () => { + it('should return true if the request URL and certificate host use the same IPv4 address', () => { + expect(urlMatchesCertHost('192.168.1.1', 'https://192.168.1.1/')).toBe(true); + }); + + it('should return true if the request URL and certificate host use the same IPv4 address and port', () => { + expect(urlMatchesCertHost('192.168.1.1:8443', 'https://192.168.1.1:8443/')).toBe(true); + }); + + it('should return false if the request URL and certificate host use different IPv4 addresses', () => { + expect(urlMatchesCertHost('192.168.1.1', 'https://192.168.1.2/')).toBe(false); + }); + + it('should return true if the certificate host uses a wildcard with an IPv4-like pattern', () => { + expect(urlMatchesCertHost('192.168.1.*', 'https://192.168.1.100/')).toBe(true); + }); + + it('should return false if the request URL has a different IPv4 subnet than the certificate wildcard', () => { + expect(urlMatchesCertHost('192.168.1.*', 'https://192.168.2.100/')).toBe(false); + }); + }); + + describe('when using IPv6 addresses', () => { + it('should return true if the request URL and certificate host both use the same IPv6 address', () => { + expect(urlMatchesCertHost('[::1]', 'https://[::1]/')).toBe(true); + }); + + it('should return true if the request URL and certificate host both use the same IPv6 address and port', () => { + expect(urlMatchesCertHost('[::1]:8443', 'https://[::1]:8443/')).toBe(true); + }); + + it('should return false if the request URL and certificate host use different IPv6 addresses', () => { + expect(urlMatchesCertHost('[::1]', 'https://[::2]/')).toBe(false); + }); }); }); diff --git a/packages/insomnia/src/network/authentication.ts b/packages/insomnia/src/network/authentication.ts index 2b61a2be75..55c163b359 100644 --- a/packages/insomnia/src/network/authentication.ts +++ b/packages/insomnia/src/network/authentication.ts @@ -1,162 +1,4 @@ -import * as Hawk from 'hawk'; - -import type { AuthTypeOAuth2, RequestAuthentication, RequestParameter } from '~/insomnia-data'; - -import type { RenderedRequest } from '../templating/types'; -import { COOKIE, HEADER, QUERY_PARAMS } from './api-key/constants'; -import { getBasicAuthHeader } from './basic-auth/get-header'; -import { getBearerAuthHeader } from './bearer-auth/get-header'; -import getOAuth1Token from './o-auth-1/get-token'; -import { getOAuth2Token } from './o-auth-2/get-token'; - -interface Header { - name: string; - value: string; -} - -export async function getAuthHeader(renderedRequest: RenderedRequest, url: string) { - const { method, body } = renderedRequest; - const authentication = renderedRequest.authentication as RequestAuthentication; - - const requestId = renderedRequest._id; - - if (authentication.disabled) { - return; - } - - if (authentication.type === 'apikey' && authentication.addTo === HEADER) { - const { key, value } = authentication; - return { - name: key, - value: value, - } as Header; - } - - if (authentication.type === 'apikey' && authentication.addTo === COOKIE) { - const { key, value } = authentication; - return { - name: 'Cookie', - value: `${key}=${value}`, - } as Header; - } - - if (authentication.type === 'basic') { - const { username, password, useISO88591 } = authentication; - const encoding = useISO88591 ? 'latin1' : 'utf8'; - return getBasicAuthHeader(username, password, encoding); - } - - if (authentication.type === 'bearer' && authentication.token) { - const { token, prefix } = authentication; - return getBearerAuthHeader(token, prefix); - } - - if (authentication.type === 'oauth2') { - // HACK: GraphQL requests use a child request to fetch the schema with an - // ID of "{{request_id}}.graphql". Here we are removing the .graphql suffix and - // pretending we are fetching a token for the original request. This makes sure - // the same tokens are used for schema fetching. See issue #835 on GitHub. - try { - const tokenId = requestId.match(/\.graphql$/) ? requestId.replace(/\.graphql$/, '') : requestId; - const oAuth2Token = await getOAuth2Token(tokenId, authentication as AuthTypeOAuth2); - - if (oAuth2Token) { - const token = oAuth2Token.accessToken; - return _buildBearerHeader(token, authentication.tokenPrefix); - } - return; - } catch (err) { - // TODO: Show this error in the UI - console.log('[oauth2] Failed to get token', err); - return; - } - } - - if (authentication.type === 'oauth1') { - const oAuth1Token = await getOAuth1Token(url, method, authentication, body); - - if (oAuth1Token) { - return { - name: 'Authorization', - value: oAuth1Token.Authorization, - }; - } - return; - } - - if (authentication.type === 'hawk') { - const { id, key, algorithm, ext, validatePayload } = authentication; - let headerOptions = { - credentials: { - id, - key, - algorithm, - }, - ext: ext, - }; - - if (validatePayload) { - const payloadValidationFields = { - payload: renderedRequest.body.text, - contentType: renderedRequest.body.mimeType, - }; - headerOptions = Object.assign({}, payloadValidationFields, headerOptions); - } - - const { header } = Hawk.client.header(url, method, headerOptions); - return { - name: 'Authorization', - value: header, - }; - } - - if (authentication.type === 'asap') { - const { issuer, subject, audience, keyId, additionalClaims, privateKey } = authentication; - - let parsedAdditionalClaims; - try { - parsedAdditionalClaims = JSON.parse(additionalClaims || '{}'); - } catch (err) { - throw new Error(`Unable to parse additional-claims: ${err}`); - } - - if (parsedAdditionalClaims && typeof parsedAdditionalClaims !== 'object') { - throw new Error(`additional-claims must be an object received: '${typeof parsedAdditionalClaims}' instead`); - } - const generator = (await import('httplease-asap')).createAuthHeaderGenerator({ - privateKey, - issuer, - keyId, - audience, - subject, - additionalClaims: parsedAdditionalClaims, - tokenExpiryMs: 10 * 60 * 1000, // Optional, max is 1 hour. This is how long the generated token stays valid. - tokenMaxAgeMs: 9 * 60 * 1000, // Optional, must be less than tokenExpiryMs. How long to cache the token. - }); - return { - name: 'Authorization', - value: generator(), - }; - } - - return; -} - -export function getAuthQueryParams(authentication: RequestAuthentication) { - if (authentication.disabled) { - return; - } - - if (authentication.type === 'apikey' && authentication.addTo === QUERY_PARAMS) { - const { key, value } = authentication; - return { - name: key, - value: value, - } as RequestParameter; - } - - return; -} +import type { RequestAuthentication } from '~/insomnia-data'; export const _buildBearerHeader = (accessToken: string, prefix?: string) => { if (!accessToken) { diff --git a/packages/insomnia/src/network/grpc/__tests__/write-proto-file.test.ts b/packages/insomnia/src/network/grpc/__tests__/write-proto-file.test.ts index 9a0964696d..d20f91e4f8 100644 --- a/packages/insomnia/src/network/grpc/__tests__/write-proto-file.test.ts +++ b/packages/insomnia/src/network/grpc/__tests__/write-proto-file.test.ts @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { services } from '~/insomnia-data'; -import * as models from '../../../models'; import { writeProtoFile } from '../write-proto-file'; describe('writeProtoFile', () => { diff --git a/packages/insomnia/src/network/grpc/proto-directory-loader.tsx b/packages/insomnia/src/network/grpc/proto-directory-loader.tsx deleted file mode 100644 index b8ab8be92d..0000000000 --- a/packages/insomnia/src/network/grpc/proto-directory-loader.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import type { ProtoDirectory } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; - -import { insecureReadFile } from '../../main/secure-read-file'; - -interface IngestResult { - createdDir?: ProtoDirectory | null; - createdIds: string[]; - error?: Error | null; -} - -export class ProtoDirectoryLoader { - createdIds: string[] = []; - rootDirPath: string; - workspaceId: string; - - constructor(rootDirPath: string, workspaceId: string) { - this.rootDirPath = rootDirPath; - this.workspaceId = workspaceId; - } - - async _parseDir(entryPath: string, parentId: string) { - const result = await this._ingest(entryPath, parentId); - return Boolean(result); - } - - async _parseFile(entryPath: string, parentId: string) { - const extension = path.extname(entryPath); - - // Ignore if not a .proto file - if (extension !== '.proto') { - return false; - } - - // allow to read the file as it is chosen by user - const protoText = await insecureReadFile(entryPath); - const name = path.basename(entryPath); - const { _id } = await services.protoFile.create({ - name, - parentId, - protoText, - }); - this.createdIds.push(_id); - return true; - } - - async _ingest(dirPath: string, parentId: string): Promise { - // Check exists - if (!fs.existsSync(dirPath)) { - return null; - } - - const newDirId = models.protoDirectory.createId(); - // Read contents - const entries = await fs.promises.readdir(dirPath, { - withFileTypes: true, - }); - // Loop and read all entries - let filesFound = false; - - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - const fullEntryPath = path.resolve(dirPath, entry.name); - const result = await (entry.isDirectory() - ? this._parseDir(fullEntryPath, newDirId) - : this._parseFile(fullEntryPath, newDirId)); - filesFound = filesFound || result; - } - - // Only create the directory if a .proto file is found in the tree - if (filesFound) { - const createdProtoDir = await services.protoDirectory.create({ - _id: newDirId, - name: path.basename(dirPath), - parentId, - }); - this.createdIds.push(createdProtoDir._id); - return createdProtoDir; - } - - return null; - } - - async load() { - try { - const createdDir = await this._ingest(this.rootDirPath, this.workspaceId); - return { - createdDir, - createdIds: this.createdIds, - error: null, - } as IngestResult; - } catch (error) { - return { - createdDir: null, - createdIds: this.createdIds, - error, - } as IngestResult; - } - } -} diff --git a/packages/insomnia/src/network/grpc/write-proto-file.ts b/packages/insomnia/src/network/grpc/write-proto-file.ts index d67e1ffbc0..517e042c31 100644 --- a/packages/insomnia/src/network/grpc/write-proto-file.ts +++ b/packages/insomnia/src/network/grpc/write-proto-file.ts @@ -2,11 +2,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { ProtoDirectory, ProtoFile, Workspace } from '~/insomnia-data'; +import type { BaseModel, ProtoDirectory, ProtoFile, Workspace } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { database as db } from '../../common/database'; -import type { BaseModel } from '../../models'; -import * as models from '../../models'; const { isProtoDirectory } = models.protoDirectory; const { isProtoFile } = models.protoFile; diff --git a/packages/insomnia/src/network/is-url-matched-in-no-proxy-rule.ts b/packages/insomnia/src/network/is-url-matched-in-no-proxy-rule.ts deleted file mode 100644 index b12cae57fe..0000000000 --- a/packages/insomnia/src/network/is-url-matched-in-no-proxy-rule.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { parse as urlParse } from 'node:url'; - -function formatHostname(rawHostname: string) { - // canonicalize the hostname, so that 'oogle.com' won't match 'google.com' - const hostname = rawHostname.replace(/^\.*/, '.').toLowerCase(); - return hostname.endsWith('.') ? hostname.slice(0, -1) : hostname; -} - -function parseNoProxyZone(zone: string) { - zone = zone.trim().toLowerCase(); - const zoneParts = zone.split(':', 2); - const zoneHost = formatHostname(zoneParts[0]); - const zonePort = zoneParts[1]; - const hasPort = zone.includes(':'); - - return { hostname: zoneHost, port: zonePort, hasPort: hasPort }; -} - -function matchesHostname(hostname: string, noProxyZoneHostname: string) { - const wildcardNeedle = noProxyZoneHostname.startsWith('.*.') ? noProxyZoneHostname.slice(2) : noProxyZoneHostname; - const isMatchedAt = hostname.indexOf(wildcardNeedle); - return isMatchedAt !== -1 && isMatchedAt === hostname.length - wildcardNeedle.length; -} - -export function isUrlMatchedInNoProxyRule(url: string | undefined, noProxyRule: any) { - if (!url || !noProxyRule || typeof noProxyRule !== 'string') { - return false; - } - const uri = urlParse(url); - if (!uri.hostname && !uri.port && !uri.protocol) { - return false; - } - const port = uri.port || (uri.protocol === 'https:' ? '443' : '80'); - // TODO: remove non-null assertion - - const hostname = formatHostname(uri.hostname!); - const noProxyList = noProxyRule.split(','); - - // iterate through the noProxyList until it finds a match. - return noProxyList.map(parseNoProxyZone).some(noProxyZone => { - if (!noProxyZone.hostname && noProxyZone.hasPort) { - return port === noProxyZone.port; - } - const hostnameMatched = matchesHostname(hostname, noProxyZone.hostname); - if (noProxyZone.hasPort) { - return port === noProxyZone.port && hostnameMatched; - } - return hostnameMatched; - }); -} diff --git a/packages/insomnia/src/network/multipart-constants.ts b/packages/insomnia/src/network/multipart-constants.ts new file mode 100644 index 0000000000..4672659be8 --- /dev/null +++ b/packages/insomnia/src/network/multipart-constants.ts @@ -0,0 +1 @@ +export const DEFAULT_BOUNDARY = 'X-INSOMNIA-BOUNDARY'; diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index 07584daaec..09de79a332 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -24,7 +24,7 @@ import type { WebSocketRequest, Workspace, } from '~/insomnia-data'; -import { EnvironmentType, services } from '~/insomnia-data'; +import { EnvironmentType, models, services } from '~/insomnia-data'; import { getKVPairFromData } from '~/utils/environment-utils'; import type { @@ -39,7 +39,6 @@ import { generateId, getContentTypeHeader, getLocationHeader, getSetCookieHeader import { getRenderedRequestAndContext } from '../common/render'; import { ascendingFirstIndexStringSort } from '../common/sorting'; import type { HeaderResult, ResponsePatch, ResponseTimelineEntry } from '../main/network/libcurl-promise'; -import * as models from '../models'; import * as pluginApp from '../plugins/context/app'; import * as pluginData from '../plugins/context/data'; import * as pluginNetwork from '../plugins/context/network'; @@ -53,7 +52,8 @@ import { maskOrDecryptVaultDataIfNecessary } from '../templating/utils'; import { invariant } from '../utils/invariant'; import { serializeNDJSON } from '../utils/ndjson'; import { buildQueryStringFromParams, joinUrlAndQueryString, smartEncodeUrl } from '../utils/url/querystring'; -import { getAuthHeader, getAuthObjectOrNull, getAuthQueryParams, isAuthEnabled } from './authentication'; +import { QUERY_PARAMS } from './api-key/constants'; +import { getAuthObjectOrNull, isAuthEnabled } from './authentication'; import { cancellableCurlRequest, cancellableRunScript } from './cancellation'; import { filterClientCertificates } from './certificate'; import { runScriptConcurrently, type TransformedExecuteScriptContext } from './concurrency'; @@ -877,7 +877,11 @@ export async function sendCurlAndWriteTimeline( if (!renderedRequest.settingSendCookies) { timeline.push({ value: 'Disable cookie sending due to user setting', name: 'Text', timestamp: Date.now() }); } - const authHeader = await getAuthHeader(renderedRequest, finalUrl); + const getRenderedRequestAuthHeader = + process.type === 'renderer' + ? (r: RenderedRequest, u: string) => window.main.getAuthHeader(r, u) + : (await import('../main/network/get-auth-header')).getAuthHeader; + const authHeader = await getRenderedRequestAuthHeader(renderedRequest, finalUrl); const requestOptions = { requestId, req: renderedRequest, @@ -976,6 +980,21 @@ export const responseTransform = async ( console.log(`[network] Response succeeded req=${patch.parentId} status=${response.statusCode || '?'}`); return await _applyResponsePluginHooks(response, renderedRequest, context); }; +export function getAuthQueryParams(authentication: RequestAuthentication) { + if (authentication.disabled) { + return; + } + + if (authentication.type === 'apikey' && authentication.addTo === QUERY_PARAMS) { + const { key, value } = authentication; + return { + name: key, + value: value, + } as RequestParameter; + } + + return; +} export const transformUrl = ( url: string, params: RequestParameter[], diff --git a/packages/insomnia/src/network/o-auth-1/constants.ts b/packages/insomnia/src/network/o-auth-1/constants.ts deleted file mode 100644 index f12ebe619f..0000000000 --- a/packages/insomnia/src/network/o-auth-1/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type OAuth1SignatureMethod = 'HMAC-SHA1' | 'RSA-SHA1' | 'HMAC-SHA256' | 'PLAINTEXT'; -export const SIGNATURE_METHOD_HMAC_SHA1: OAuth1SignatureMethod = 'HMAC-SHA1'; -export const SIGNATURE_METHOD_HMAC_SHA256: OAuth1SignatureMethod = 'HMAC-SHA256'; -export const SIGNATURE_METHOD_RSA_SHA1: OAuth1SignatureMethod = 'RSA-SHA1'; -export const SIGNATURE_METHOD_PLAINTEXT: OAuth1SignatureMethod = 'PLAINTEXT'; diff --git a/packages/insomnia/src/network/o-auth-2/constants.ts b/packages/insomnia/src/network/o-auth-2/constants.ts deleted file mode 100644 index f4e08df9ac..0000000000 --- a/packages/insomnia/src/network/o-auth-2/constants.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code'; -export const GRANT_TYPE_IMPLICIT = 'implicit'; -export const GRANT_TYPE_PASSWORD = 'password'; -export const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; -export const GRANT_TYPE_REFRESH = 'refresh_token'; -export const GRANT_TYPE_MCP_AUTH_FLOW = 'mcp_auth_flow'; -export type AuthKeys = - | 'access_token' - | 'id_token' - | 'client_id' - | 'client_secret' - | 'audience' - | 'resource' - | 'code_challenge' - | 'code_challenge_method' - | 'code_verifier' - | 'code' - | 'nonce' - | 'error' - | 'error_description' - | 'error_uri' - | 'expires_in' - | 'grant_type' - | 'password' - | 'redirect_uri' - | 'refresh_token' - | 'response_type' - | 'scope' - | 'state' - | 'token_type' - | 'username' - | 'xError' - | 'xResponseId'; -export const PKCE_CHALLENGE_S256 = 'S256'; -export const PKCE_CHALLENGE_PLAIN = 'plain'; - -export type OAuth2AuthorizationStatusType = 'none' | 'getting_code' | 'getting_token'; diff --git a/packages/insomnia/src/network/o-auth-2/utils.ts b/packages/insomnia/src/network/o-auth-2/utils.ts deleted file mode 100644 index 45f492bd7f..0000000000 --- a/packages/insomnia/src/network/o-auth-2/utils.ts +++ /dev/null @@ -1,46 +0,0 @@ -import crypto from 'node:crypto'; - -import { getOauthRelayUrl } from '~/common/constants'; -import type { DefaultBrowserRedirectParam } from '~/common/misc'; - -export const encryptOAuthUrl = (authCodeUrlStr: string) => { - const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 3072, - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, - }); - - const relayUrl = `${getOauthRelayUrl()}?authCodeUrl=${encodeURIComponent(authCodeUrlStr)}&publicKey=${encodeURIComponent(publicKey)}`; - - const decryptOAuthResult = (result: DefaultBrowserRedirectParam): string => { - if ('redirectUrl' in result) { - return result.redirectUrl; - } - - const { encryptedRedirectUrl, encryptedKey, iv } = result; - const aesKey = crypto.privateDecrypt( - { - key: privateKey, - padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: 'sha256', - }, - Buffer.from(encryptedKey, 'base64'), - ); - const encryptedBuf = Buffer.from(encryptedRedirectUrl, 'base64'); - const authTag = encryptedBuf.slice(-16); - const ciphertext = encryptedBuf.slice(0, -16); - // nosemgrep: javascript.node-crypto.security.gcm-no-tag-length.gcm-no-tag-length - const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(iv, 'base64'), { - authTagLength: 16, - }); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); - return decrypted; - }; - - return { - relayUrl, - decryptOAuthResult, - }; -}; diff --git a/packages/insomnia/src/main/network/parse-header-strings.ts b/packages/insomnia/src/network/parse-header-strings.ts similarity index 93% rename from packages/insomnia/src/main/network/parse-header-strings.ts rename to packages/insomnia/src/network/parse-header-strings.ts index d5de81e34f..cfab4b6891 100644 --- a/packages/insomnia/src/main/network/parse-header-strings.ts +++ b/packages/insomnia/src/network/parse-header-strings.ts @@ -1,11 +1,7 @@ -import { parse as urlParse } from 'node:url'; - import aws4 from 'aws4'; import clone from 'clone'; -import type { RequestAuthentication } from '~/insomnia-data'; - -import { CONTENT_TYPE_FORM_DATA } from '../../common/constants'; +import { CONTENT_TYPE_FORM_DATA } from '~/common/constants'; import { getContentTypeHeader, getHostHeader, @@ -13,8 +9,10 @@ import { hasAcceptHeader, hasAuthHeader, hasContentTypeHeader, -} from '../../common/misc'; -import { DEFAULT_BOUNDARY } from './multipart'; +} from '~/common/misc'; +import type { RequestAuthentication } from '~/insomnia-data'; + +import { DEFAULT_BOUNDARY } from './multipart-constants'; // Special header value that will prevent the header being sent const DISABLE_HEADER_VALUE = '__Di$aB13d__'; @@ -25,6 +23,7 @@ interface Input { requestBodyPath?: string; authHeader?: { name: string; value: string }; } + interface Req { headers: any; method: string; @@ -42,6 +41,7 @@ export const parseHeaderStrings = ({ req, finalUrl, requestBody, requestBodyPath { name: 'Transfer-Encoding', value: DISABLE_HEADER_VALUE }, ); } + const { authentication, method } = req; if (authentication && 'type' in authentication) { const isDigest = authentication.type === 'digest'; @@ -114,6 +114,7 @@ interface AWSOptions { contentTypeHeader?: string; body?: string; } + export function _getAwsAuthHeaders({ authentication, url, @@ -122,7 +123,7 @@ export function _getAwsAuthHeaders({ contentTypeHeader, body, }: AWSOptions): { name: string; value: any }[] { - const { path, host } = urlParse(url); + const parsedUrl = new URL(url); const onlyContentTypeHeader = contentTypeHeader ? { 'content-type': contentTypeHeader } : {}; const { service, region, accessKeyId, secretAccessKey, sessionToken } = authentication; const signature = aws4.sign( @@ -132,15 +133,17 @@ export function _getAwsAuthHeaders({ body, method, headers: onlyContentTypeHeader, - path: path || undefined, + path: `${parsedUrl.pathname}${parsedUrl.search}` || undefined, // AWS uses host header for signing so prioritize that if the user set it manually - host: hostHeader || host || undefined, + host: hostHeader || parsedUrl.host || undefined, }, { accessKeyId, secretAccessKey, sessionToken }, ); + if (!signature.headers) { return []; } + return Object.entries(signature.headers) .filter(([name]) => name !== 'content-type') // Don't add this because we already have it .map(([name, value]) => ({ name, value })); diff --git a/packages/insomnia/src/network/unit-test-feature.ts b/packages/insomnia/src/network/unit-test-feature.ts index 75ceafa2e0..7ba0000e03 100644 --- a/packages/insomnia/src/network/unit-test-feature.ts +++ b/packages/insomnia/src/network/unit-test-feature.ts @@ -1,5 +1,4 @@ import { services } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; import { parseGraphQLReqeustBody } from '../utils/graph-ql'; import { @@ -44,7 +43,7 @@ export function getSendRequestCallback() { (acc, { name, value }) => ({ ...acc, [name.toLowerCase() || '']: value || '' }), [], ); - const bodyBuffer = (await getBodyBuffer(res)) as Buffer; + const bodyBuffer = (await services.helpers.getResponseBodyBuffer(res)) as Buffer; const data = bodyBuffer ? bodyBuffer.toString('utf8') : undefined; return { status, statusMessage, data, headers, responseTime }; }; diff --git a/packages/insomnia/src/network/url-matches-cert-host.ts b/packages/insomnia/src/network/url-matches-cert-host.ts index b929a4e513..958d2c2603 100644 --- a/packages/insomnia/src/network/url-matches-cert-host.ts +++ b/packages/insomnia/src/network/url-matches-cert-host.ts @@ -1,5 +1,3 @@ -import { parse as urlParse, URL } from 'node:url'; - import { escapeRegex } from '../common/misc'; import { setDefaultProtocol } from '../utils/url/protocol'; @@ -7,7 +5,17 @@ const DEFAULT_PORT = 443; export function urlMatchesCertHost(certificateHost: string, requestUrl: string, needCheckPort = true) { const cHostWithProtocol = setDefaultProtocol(certificateHost, 'https:'); - const { hostname, port } = urlParse(requestUrl); + + let hostname = ''; + let port = ''; + try { + const parsedUrl = new URL(requestUrl); + hostname = parsedUrl.hostname; + port = parsedUrl.port; + } catch { + // If URL parsing fails, return false + return false; + } let certificateHostWithProtocol = new URL('https://example.com'); try { certificateHostWithProtocol = new URL(cHostWithProtocol); @@ -15,8 +23,13 @@ export function urlMatchesCertHost(certificateHost: string, requestUrl: string, // return false early if the certificate host is invalid return false; } - const { hostname: cHostname, port: cPort } = certificateHostWithProtocol; - // @ts-expect-error -- TSCONVERSION `parseInt(null)` returns `NaN` + let { hostname: cHostname, port: cPort } = certificateHostWithProtocol; + // This function is used in both main and renderer processes. In the renderer process, + // URL API encodes * in the hostname and port (e.g. *.example.com becomes %2A.example.com). + // Here we decode them back to * to make it consistent. + cHostname = decodeURIComponent(cHostname); + cPort = decodeURIComponent(cPort); + const assumedPort = Number.parseInt(port) || DEFAULT_PORT; const assumedCPort = Number.parseInt(cPort) || DEFAULT_PORT; const cHostnameRegex = escapeRegex(cHostname || '').replace(/\\\*/g, '.*'); diff --git a/packages/insomnia/src/plugins/context/__tests__/request.test.ts b/packages/insomnia/src/plugins/context/__tests__/request.test.ts index 53ac07741d..f014b09f8e 100644 --- a/packages/insomnia/src/plugins/context/__tests__/request.test.ts +++ b/packages/insomnia/src/plugins/context/__tests__/request.test.ts @@ -4,7 +4,6 @@ import { services } from '~/insomnia-data'; import { CONTENT_TYPE_FORM_URLENCODED } from '../../../common/constants'; import { database as db } from '../../../common/database'; -import * as models from '../../../models'; import * as plugin from '../request'; const CONTEXT = { user_key: 'my_user_key', diff --git a/packages/insomnia/src/plugins/context/network.ts b/packages/insomnia/src/plugins/context/network.ts index 0038c36792..280e16efe2 100644 --- a/packages/insomnia/src/plugins/context/network.ts +++ b/packages/insomnia/src/plugins/context/network.ts @@ -2,7 +2,6 @@ import { v4 as uuidv4 } from 'uuid'; import type { Request, ResponseHeader } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import { readCurlResponse } from '~/models/helpers/response-operations'; import { RESPONSE_CODE_REASONS } from '../../common/constants'; import { @@ -113,7 +112,7 @@ export function init(): { if (!lastRedirect) { throw new Error('Error in response: the lastRedirect is not defined'); } - const bodyResult = await readCurlResponse({ + const bodyResult = await services.helpers.readCurlResponse({ bodyPath: responseBodyPath, bodyCompression: patch.bodyCompression, }); diff --git a/packages/insomnia/src/plugins/context/response.ts b/packages/insomnia/src/plugins/context/response.ts index a9ade5e019..fb6589ce0f 100644 --- a/packages/insomnia/src/plugins/context/response.ts +++ b/packages/insomnia/src/plugins/context/response.ts @@ -1,8 +1,9 @@ import fs from 'node:fs'; +import type { Readable } from 'node:stream'; +import zlib from 'node:zlib'; -import type { ResponseHeader } from '~/insomnia-data'; -import { getBodyBuffer, getBodyStream } from '~/models/helpers/response-operations'; - +import type { Compression, ResponseHeader } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; interface MaybeResponse { parentId?: string; @@ -49,11 +50,31 @@ export function init(response?: MaybeResponse) { }, getBody() { - return getBodyBuffer(response); + return services.helpers.getResponseBodyBuffer(response); }, getBodyStream() { - return getBodyStream(response); + // To avoid break the plugin APIs, keep this API as synchronous and move the implementation here. + const getResponseBodyStream = ( + response?: { bodyPath?: string; bodyCompression?: Compression }, + readFailureValue?: string, + ): Readable | string | null => { + if (!response?.bodyPath) { + return null; + } + try { + fs.statSync(response?.bodyPath); + } catch (err) { + console.warn('Failed to read response body', err.message); + return readFailureValue === undefined ? null : readFailureValue; + } + if (response?.bodyCompression === 'zip') { + return fs.createReadStream(response?.bodyPath).pipe(zlib.createGunzip()); + } + return fs.createReadStream(response?.bodyPath); + }; + + return getResponseBodyStream(response); }, setBody(body: Buffer) { diff --git a/packages/insomnia/src/plugins/index.ts b/packages/insomnia/src/plugins/index.ts index 246fa25159..6ca0fd31ba 100644 --- a/packages/insomnia/src/plugins/index.ts +++ b/packages/insomnia/src/plugins/index.ts @@ -4,15 +4,13 @@ import path from 'node:path'; import electron from 'electron'; import type { GrpcRequest, Request, RequestGroup, SocketIORequest, WebSocketRequest, Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { fetchFromTemplateWorkerDatabase } from '~/templating/base-extension-worker'; import type { ParsedApiSpec } from '../common/api-specs'; import { getAppBundlePlugins, isDevelopment } from '../common/constants'; import { database as db } from '../common/database'; import type { PluginConfigMap } from '../common/settings'; -import * as models from '../models'; import * as pluginApp from '../plugins/context/app'; import * as pluginNetwork from '../plugins/context/network'; import * as pluginStore from '../plugins/context/store'; @@ -411,7 +409,7 @@ export function getPluginCommonContext({ }, response: { getLatestForRequestId: services.response.getLatestForRequestId, - getBodyBuffer, + getBodyBuffer: services.helpers.getResponseBodyBuffer, }, settings: { get: services.settings.get, diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index 67bfb36ecd..ea09034463 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -13,14 +13,17 @@ import { Outlet, Scripts, ScrollRestoration, + useFetchers, useNavigate, useParams, + useRevalidator, useRouteLoaderData, } from 'react-router'; +import { useLatest } from 'react-use'; import { EXTERNAL_VAULT_PLUGIN_NAME, isDevelopment } from '~/common/constants'; import type { Settings, UserSession } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { executePluginMainAction, reloadPlugins } from '~/plugins'; import { createPlugin } from '~/plugins/create'; import { setTheme } from '~/plugins/misc'; @@ -32,7 +35,7 @@ import { GIT_PROVIDER_COMPLETE_SIGN_IN_FETCHER_KEY, useGitProviderCompleteSignInFetcher, } from '~/routes/git-credentials.complete-sign-in'; -import { SegmentEvent } from '~/ui/analytics'; +import { PENDING_IMPORT_ATTRIBUTION_KEY, SegmentEvent, trackImportEvent } from '~/ui/analytics'; import { getLoginUrl } from '~/ui/auth-session-provider.client'; import { CopyButton } from '~/ui/components/base/copy-button'; import { Link } from '~/ui/components/base/link'; @@ -321,6 +324,19 @@ const Root = () => { }); const navigate = useNavigate(); + const { revalidate } = useRevalidator(); + const inflightFetchers = useFetchers(); + const ifInSubmission = inflightFetchers.some(f => f.formMethod === 'POST'); + const latestInSubmission = useLatest(ifInSubmission); + + useEffect(() => { + return window.main.on('git.db-synced', () => { + if (!latestInSubmission.current) { + revalidate(); + } + }); + }, [latestInSubmission, revalidate]); + useEffect(() => { return window.main.on('shell:open', async (_: IpcRendererEvent, url: string) => { // Get the url without params @@ -352,12 +368,28 @@ const Root = () => { } // Supports params: uri, curl, origin if (urlWithoutParams === 'insomnia://app/import') { - window.main.trackSegmentEvent({ - event: SegmentEvent.importStarted, - properties: { - source: 'import-url', - }, - }); + // Clean up the flag set during deep-link replay so it never leaks + // into later modal evaluations within the same session. + window.sessionStorage.removeItem('suppressWelcomeModals'); + + const importSource = params.source?.trim() || undefined; + const importSourceUrl = params.sourceUrl?.trim() || undefined; + const hasAttribution = !!(importSource || importSourceUrl); + if (hasAttribution) { + window.sessionStorage.setItem( + PENDING_IMPORT_ATTRIBUTION_KEY, + JSON.stringify({ importSource, importSourceUrl }), + ); + } + + const userSession = await services.userSession.getOrCreate(); + if (!userSession.id) { + window.sessionStorage.setItem('pendingDeepLinkAfterAuthorize', url); + window.localStorage.setItem('logoutMessage', 'Please log in to import this resource.'); + trackImportEvent(SegmentEvent.importLoginRequired); + return navigate(href('/auth/login')); + } + trackImportEvent(SegmentEvent.importStarted, { source: 'import-url' }); if (params.uri) { return setImportObject({ @@ -569,10 +601,10 @@ const Root = () => {
{errorDetailKeys.length > 0 ? errorDetailKeys.map(k => ( - - {k}: {restParams[k]} - - )) + + {k}: {restParams[k]} + + )) : 'Unknown error'}
), @@ -593,6 +625,21 @@ const Root = () => { redirectToDefaultBrowserSubmit, ]); + // Replay a deep link that was queued before login (e.g. insomnia://app/import + // clicked while signed out). We wait for organizationId so that the full + // redirect chain (org → project) has settled and the import modal can read + // route params. For users with no projects yet the "-- New Project --" + // default in the import dialog is the correct behaviour. + useEffect(() => { + const pendingDeepLink = window.sessionStorage.getItem('pendingDeepLinkAfterAuthorize'); + if (pendingDeepLink && organizationId && organizationId !== models.organization.SCRATCHPAD_ORGANIZATION_ID) { + window.sessionStorage.removeItem('pendingDeepLinkAfterAuthorize'); + window.sessionStorage.setItem('suppressWelcomeModals', 'true'); + trackImportEvent(SegmentEvent.importResumedAfterLogin); + window.main.openDeepLink(pendingDeepLink); + } + }, [organizationId]); + return ( <>
diff --git a/packages/insomnia/src/routes/auth.login.tsx b/packages/insomnia/src/routes/auth.login.tsx index e28bd55fb2..fbd5439ca9 100644 --- a/packages/insomnia/src/routes/auth.login.tsx +++ b/packages/insomnia/src/routes/auth.login.tsx @@ -3,7 +3,6 @@ import { Button } from 'react-aria-components'; import { href, redirect, useNavigate } from 'react-router'; import { models } from '~/insomnia-data'; -import { SCRATCHPAD_ORGANIZATION_ID } from '~/models/organization'; import { SegmentEvent } from '~/ui/analytics'; import { getLoginUrl } from '~/ui/auth-session-provider.client'; import { Icon } from '~/ui/components/icon'; @@ -158,7 +157,7 @@ const Component = () => { }); navigate( href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug', { - organizationId: SCRATCHPAD_ORGANIZATION_ID, + organizationId: models.organization.SCRATCHPAD_ORGANIZATION_ID, projectId: models.project.SCRATCHPAD_PROJECT_ID, workspaceId: models.workspace.SCRATCHPAD_WORKSPACE_ID, }), diff --git a/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts b/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts index 5230f9f742..64e524c2d7 100644 --- a/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts +++ b/packages/insomnia/src/routes/cloud-credentials.$cloudCredentialId.update.ts @@ -1,7 +1,8 @@ import { href } from 'react-router'; import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; -import { type CloudProviderCredential, services } from '~/insomnia-data'; +import type { CloudProviderCredential } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import { executePluginMainAction } from '~/plugins'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/cloud-credentials.create.tsx b/packages/insomnia/src/routes/cloud-credentials.create.tsx index 5c0ee16cda..54a15fcfae 100644 --- a/packages/insomnia/src/routes/cloud-credentials.create.tsx +++ b/packages/insomnia/src/routes/cloud-credentials.create.tsx @@ -1,7 +1,8 @@ import { href } from 'react-router'; import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; -import { type CloudProviderCredential, services } from '~/insomnia-data'; +import type { CloudProviderCredential } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import { executePluginMainAction } from '~/plugins'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/commands.tsx b/packages/insomnia/src/routes/commands.tsx index 0585586463..c0a3a0a6e3 100644 --- a/packages/insomnia/src/routes/commands.tsx +++ b/packages/insomnia/src/routes/commands.tsx @@ -1,6 +1,5 @@ import type { Organization } from 'insomnia-api'; -import { database } from '~/common/database'; import { fuzzyMatch } from '~/common/misc'; import type { Environment, @@ -11,14 +10,14 @@ import type { WebSocketRequest, Workspace, } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; -import { environment, grpcRequest, project, request, requestGroup, workspace } from '~/models'; -import { isScratchpadOrganizationId } from '~/models/organization'; +import { database, models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherLoadHook } from '~/utils/router'; import type { Route } from './+types/commands'; +const { environment, grpcRequest, project, request, requestGroup, workspace } = models; + export async function clientLoader(args: Route.ClientLoaderArgs) { const searchParams = new URL(args.request.url).searchParams; const organizationId = searchParams.get('organizationId'); @@ -43,7 +42,7 @@ export async function clientLoader(args: Route.ClientLoaderArgs) { const allOrganizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; - const allOrganizationsIds = isScratchpadOrganizationId(organizationId) + const allOrganizationsIds = models.organization.isScratchpadOrganizationId(organizationId) ? [organizationId] : allOrganizations.map(org => org.id); diff --git a/packages/insomnia/src/routes/git-credentials.$id.update.tsx b/packages/insomnia/src/routes/git-credentials.$id.update.tsx index 5a1aba6766..f6cbaf7832 100644 --- a/packages/insomnia/src/routes/git-credentials.$id.update.tsx +++ b/packages/insomnia/src/routes/git-credentials.$id.update.tsx @@ -1,6 +1,7 @@ import { href } from 'react-router'; -import { type GitCredentialsV2, models, services } from '~/insomnia-data'; +import type { GitCredentialsV2 } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/git-credentials.$id.update'; diff --git a/packages/insomnia/src/routes/git-credentials.create.tsx b/packages/insomnia/src/routes/git-credentials.create.tsx index 9bbb513095..7002087104 100644 --- a/packages/insomnia/src/routes/git-credentials.create.tsx +++ b/packages/insomnia/src/routes/git-credentials.create.tsx @@ -1,6 +1,7 @@ import { href } from 'react-router'; -import { type BaseGitCredentialsV2, services } from '~/insomnia-data'; +import type { BaseGitCredentialsV2 } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/git-credentials.create'; diff --git a/packages/insomnia/src/routes/git-migration.$.tsx b/packages/insomnia/src/routes/git-migration.$.tsx new file mode 100644 index 0000000000..1681ba1000 --- /dev/null +++ b/packages/insomnia/src/routes/git-migration.$.tsx @@ -0,0 +1,224 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; + +import { Button } from '~/basic-components/button'; +import { CopyButton } from '~/ui/components/base/copy-button'; +import { Link as ExternalLink } from '~/ui/components/base/link'; +import { InsomniaLogo } from '~/ui/components/insomnia-icon'; +import { TrailLinesContainer } from '~/ui/components/trail-lines-container'; +import git_migration from '~/ui/images/git-migration/git.png'; + +type MigrationStatus = 'default' | 'running' | 'completed' | 'partiallyCompleted' | 'error'; + +const MIN_DISPLAY_MS = 3000; + +const MigrationView = () => { + const [status, setStatus] = useState('default'); + const [migrationLogs, setMigrationLogs] = useState([]); + const [failedProjects, setFailedProjects] = useState<{ id: string; name: string }[]>([]); + const [migratedCount, setMigratedCount] = useState(0); + + const handleMigration = () => { + setStatus('running'); + const startTime = Date.now(); + window.main.git + .runAllGitRepoMigrations() + .then((result: { logs: string[]; failedProjects: { id: string; name: string }[]; totalProjects: number }) => { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed); + setTimeout(() => { + setMigrationLogs(result.logs); + setFailedProjects(result.failedProjects); + setMigratedCount(result.totalProjects); + setStatus(result.failedProjects.length > 0 ? 'partiallyCompleted' : 'completed'); + }, remaining); + }) + .catch((err: unknown) => { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed); + const errorMsg = err instanceof Error ? err.message : 'An unexpected error occurred.'; + setTimeout(() => { + setMigrationLogs(prev => [...prev, `[ERROR] ${errorMsg}`]); + setStatus('error'); + }, remaining); + }); + }; + + const isUpdateRunning = status === 'running'; + const isUpdateCompletedSuccessfully = status === 'completed'; + const isUpdateErrored = status === 'error'; + const isUpdateCompletedWithErrors = status === 'partiallyCompleted'; + + return ( +
+
+
+

+ {isUpdateCompletedSuccessfully && } + {isUpdateCompletedSuccessfully + ? 'Update Successful' + : isUpdateErrored + ? 'Something went wrong' + : isUpdateCompletedWithErrors + ? 'Update successful with some warnings' + : 'Required file system update'} +

+ + {isUpdateCompletedSuccessfully ? ( +

+ All {migratedCount} Insomnia git project{migratedCount !== 1 ? 's' : ''} on your local system have been + updated. You can now explore them, use git from your favourite CLI, or modify them from any other tool of + your choice. +

+ ) : isUpdateCompletedWithErrors ? ( + <> +

+ The following Git Sync projects were disconnected from remote as a result of the file system update: +

+
    + {failedProjects.map(p => ( +
  1. {p.name}
  2. + ))} +
+

+ These projects will need to be reconnected to the git remote server to continue with push, pull, and + fetch actions. +

+ + ) : isUpdateErrored ? ( + <> +

We hit an unexpected error while updating your file system. Please try again.

+

+ Having trouble and need to contact us, or back up to an old version? See our{' '} + + docs. + +

+ + ) : ( + <> +

+ In order to continue with this update, we need to adjust your local projects. This is required to enable + managing Insomnia changes using git on the CLI. +

+

+ Note: This change is backwards compatible, but we strongly recommend{' '} + + following these best practices + {' '} + when returning to an earlier version of Insomnia. +

+

+ {isUpdateRunning + ? 'Note: Your data is safe and the update only takes seconds.' + : 'Note: This update does NOT affect any ongoing work or pending changes, and only affects how your local Insomnia files are stored.'} +

+ + )} + +
+ {isUpdateCompletedSuccessfully ? ( + + Open Insomnia + + ) : isUpdateCompletedWithErrors ? ( +
+ 0 ? migrationLogs.join('\n') : 'No logs available.'} + title="Copy error logs to clipboard" + > + + Copy Error Logs + + + Open Insomnia + +
+ ) : isUpdateErrored ? ( +
+ 0 ? migrationLogs.join('\n') : 'No logs available.'} + title="Copy error logs to clipboard" + > + + Copy Error Logs + + +
+ ) : ( + + )} +
+
+
+
+ ); +}; + +const Component = () => { + const [showMigrationView, setShowMigrationView] = useState(false); + + return ( +
+ + {showMigrationView ? ( + + ) : ( +
+
+ +
+

What's new in v12.6.0

+
+

+ Manage Insomnia changes using git CLI actions +

+
+

+ Now you can use traditional git actions on your CLI to manage changes to your Git Sync projects. +

+
+ +
+
+
+
+ +
+
+
+
+ )} +
+
+ ); +}; + +export default Component; diff --git a/packages/insomnia/src/routes/git.all-connected-repos.tsx b/packages/insomnia/src/routes/git.all-connected-repos.tsx index 767d232ae0..2f9749865f 100644 --- a/packages/insomnia/src/routes/git.all-connected-repos.tsx +++ b/packages/insomnia/src/routes/git.all-connected-repos.tsx @@ -3,8 +3,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import type { Project } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { createFetcherLoadHook } from '~/utils/router'; export async function clientLoader() { @@ -22,18 +21,17 @@ export async function clientLoader() { const organizationMap = Object.fromEntries(organizations.map(o => [o.id, o])); - const allConnectedGitProjects = allProjects.filter( - project => models.project.isGitProject(project) && !models.project.isEmptyGitProject(project), - ); + const allConnectedGitProjects = allProjects.filter(project => models.project.isConnectedGitProject(project)); const gitRepoURIInfoMap: Record = {}; await Promise.all( - allConnectedGitProjects.map(async ({ gitRepositoryId, name, parentId }) => { + allConnectedGitProjects.map(async project => { + const gitRepositoryId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; if (gitRepositoryId) { const gitRepository = await services.gitRepository.getById(gitRepositoryId); if (gitRepository) { gitRepoURIInfoMap[gitRepository.uri] = { - organizationName: organizationMap[parentId]?.name || '', - projectName: name, + organizationName: organizationMap[project.parentId]?.name || '', + projectName: project.name, }; } } diff --git a/packages/insomnia/src/routes/import.resources.tsx b/packages/insomnia/src/routes/import.resources.tsx index cd9093162c..232ee26a27 100644 --- a/packages/insomnia/src/routes/import.resources.tsx +++ b/packages/insomnia/src/routes/import.resources.tsx @@ -9,14 +9,6 @@ import { } from '~/common/import'; import type { Workspace } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import * as requestOperations from '~/models/helpers/request-operations'; -import { - initializeLocalBackendProjectAndMarkForSync, - pushSnapshotOnInitialize, -} from '~/sync/vcs/initialize-backend-project'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; -import { fetchAndCacheOrganizationStorageRule } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -107,7 +99,8 @@ export async function clientAction({ request }: Route.ClientActionArgs) { // When navigating, we are interested in knowing if there was only one workspace and only one request const singleImportedWorkspace = Array.isArray(importedWorkspaces) && importedWorkspaces.length === 1 && importedWorkspaces[0]; - const requests = singleImportedWorkspace && (await requestOperations.findByParentId(singleImportedWorkspace._id)); + const requests = + singleImportedWorkspace && (await services.helpers.findRequestByParentId(singleImportedWorkspace._id)); const singleImportedRequest = Array.isArray(requests) && requests.length === 1 && requests.at(0); return { done: true, singleImportedWorkspace, singleImportedRequest }; } catch (error) { @@ -129,40 +122,6 @@ export const useImportResourcesFetcher = createFetcherSubmitHook( clientAction, ); -// The reason why we put this function here is because this function indirectly depends on some modules that can only run in a browser environment. -// If we put this function in import.ts which is depended by Inso CLI, Inso CLI will fail to build because it doesn't have access to the browser environment. -// So we put this function here and pass it to importResourcesToProject func to avoid the dependency issue. export async function syncNewWorkspaceIfNeeded(newWorkspace: Workspace) { - const project = await services.project.getById(newWorkspace.parentId); - invariant(project, 'Project not found'); - const userSession = await services.userSession.getOrCreate(); - - if (userSession.id && models.project.isRemoteProject(project)) { - const storageRules = await fetchAndCacheOrganizationStorageRule(project.parentId); - invariant(storageRules, 'Storage rules not found'); - - if (storageRules.enableCloudSync) { - // Create default env, cookie jar, and meta - await services.environment.getOrCreateForParentId(newWorkspace._id); - await services.cookieJar.getOrCreateForParentId(newWorkspace._id); - await services.workspaceMeta.getOrCreateByParentId(newWorkspace._id); - try { - const vcs = VCSInstance().newInstance(); - await initializeLocalBackendProjectAndMarkForSync({ - vcs, - workspace: newWorkspace, - }); - await pushSnapshotOnInitialize({ - vcs, - workspace: newWorkspace, - project, - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - console.warn( - `Failed to initialize sync to insomnia cloud for workspace ${newWorkspace._id}. This will be retried when the workspace is opened on the app. ${errorMessage}`, - ); - } - } - } + return window.main.syncNewWorkspaceIfNeeded({ workspaceId: newWorkspace._id }); } diff --git a/packages/insomnia/src/routes/import.scan.tsx b/packages/insomnia/src/routes/import.scan.tsx index bbc9d884c7..8d7dae62bf 100644 --- a/packages/insomnia/src/routes/import.scan.tsx +++ b/packages/insomnia/src/routes/import.scan.tsx @@ -9,7 +9,7 @@ import { scanResources, } from '~/common/import'; import type { ImportEntry } from '~/main/importers/entities'; -import { SegmentEvent } from '~/ui/analytics'; +import { SegmentEvent, trackImportEvent } from '~/ui/analytics'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -27,12 +27,7 @@ export const scanImportResources = async (data: { invariant(typeof source === 'string', 'Source is required.'); invariant(IMPORT_SOURCE_TYPES.includes(source), 'Unsupported import type'); - window.main.trackSegmentEvent({ - event: SegmentEvent.importScanned, - properties: { - source, - }, - }); + trackImportEvent(SegmentEvent.importScanned, { source }); const contentList: ImportEntry[] = []; diff --git a/packages/insomnia/src/routes/organization.$organizationId.insomnia-sync.pull-remote-file.tsx b/packages/insomnia/src/routes/organization.$organizationId.insomnia-sync.pull-remote-file.tsx index 6b9888d396..5c4096eeb4 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.insomnia-sync.pull-remote-file.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.insomnia-sync.pull-remote-file.tsx @@ -1,9 +1,6 @@ import { href, redirect } from 'react-router'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; -import { pullBackendProject } from '~/sync/vcs/pull-backend-project'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -20,38 +17,19 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const remoteId = formData.get('remoteId'); invariant(typeof remoteId === 'string', 'Remote Id is required'); - const vcs = VCSInstance(); - - const remoteBackendProjects = await vcs.remoteBackendProjects({ - teamId: organizationId, - teamProjectId: remoteId, - }); - - const backendProject = remoteBackendProjects.find(p => p.id === backendProjectId); - - invariant(backendProject, 'Backend project not found'); - - const project = await services.project.getByRemoteId(remoteId); - - invariant(project?.remoteId, 'Project is not a remote project'); - - // Clone old VCS so we don't mess anything up while working on other backend projects - const newVCS = vcs.newInstance(); - // Remove all backend projects for workspace first - await newVCS.removeBackendProjectsForRoot(backendProject.rootDocumentId); - - const { workspaceId } = await pullBackendProject({ - vcs: newVCS, - backendProject, - remoteProject: project, + const { projectId, workspaceId } = await window.main.sync.pullRemoteBackendProject({ + organizationId, + backendProjectId, + remoteId, }); + invariant(typeof workspaceId === 'string', 'Workspace not found after pulling remote collection'); const workspace = await services.workspace.getById(workspaceId); invariant(workspace, 'Workspace not found'); const activity = models.workspace.scopeToActivity(workspace?.scope); - return redirect(`/organization/${organizationId}/project/${project._id}/workspace/${workspaceId}/${activity}`); + return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${activity}`); } catch (e) { console.warn('Failed to pull remote collection', e); return { diff --git a/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx b/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx index 05ee767545..c0f9f61db3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.permissions.tsx @@ -1,8 +1,7 @@ import { type Billing, type FeatureList, getOrganizationFeatures, type Organization } from 'insomnia-api'; import { href, redirect, type ShouldRevalidateFunctionArgs } from 'react-router'; -import { services } from '~/insomnia-data'; -import { isScratchpadOrganizationId } from '~/models/organization'; +import { models, services } from '~/insomnia-data'; import { createFetcherLoadHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.permissions'; @@ -29,7 +28,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId } = params; const { id: sessionId, accountId } = await services.userSession.getOrCreate(); - if (isScratchpadOrganizationId(organizationId)) { + if (models.organization.isScratchpadOrganizationId(organizationId)) { return { featuresPromise: Promise.resolve(fallbackFeatures), billingPromise: Promise.resolve(fallbackBilling), diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 2bbff592f3..755cd93c8c 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -30,8 +30,7 @@ import { fuzzyMatchAll } from '~/common/misc'; import type { InsomniaFile } from '~/common/project'; import { sortMethodMap } from '~/common/sorting'; import type { GitRepository, Project, WorkspaceScope } from '~/insomnia-data'; -import * as models from '~/models'; -import { isOwnerOfOrganization, isPersonalOrganization, isScratchpadOrganizationId } from '~/models/organization'; +import { models } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useOrganizationLoaderData } from '~/routes/organization'; import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file'; @@ -53,6 +52,7 @@ import { OrganizationTabList } from '~/ui/components/tabs/tab-list'; import { TimeFromNow } from '~/ui/components/time-from-now'; import { showResourceNotFoundToast } from '~/ui/components/toast-notification'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; +import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; @@ -104,12 +104,18 @@ const Component = () => { const organizationData = useOrganizationLoaderData(); const { presence } = useInsomniaEventStreamContext(); + const { issuesByWorkspaceId } = useGitFileIssues(); const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); const createNewWorkspaceFetcher = useWorkspaceNewActionFetcher(); const { billing } = useOrganizationPermissions(); + const projectFileIssues = Object.values(issuesByWorkspaceId); + const hasProjectFileIssues = projectFileIssues.length > 0; + const projectFileIssuesMessage = + 'There are issues with one or more Insomnia files in this project. Use the git CLI and your local file system to resolve them and continue.'; + useEffect(() => { - if (!isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isScratchpadOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } @@ -132,8 +138,10 @@ const Component = () => { const [isUpdateProjectModalOpen, setIsUpdateProjectModalOpen] = useState(false); const organization = organizationData?.organizations.find(o => o.id === organizationId); const isUserOwner = - organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId }); - const isPersonalOrg = organization && isPersonalOrganization(organization); + organization && + userSession.accountId && + models.organization.isOwnerOfOrganization({ organization, accountId: userSession.accountId }); + const isPersonalOrg = organization && models.organization.isPersonalOrganization(organization); const tabNavigate = useTabNavigate(); @@ -165,6 +173,7 @@ const Component = () => { }); return { ...file, + fileIssue: file.workspace ? issuesByWorkspaceId[file.workspace._id] : undefined, loading: loadingBackendProjects.includes(file.remoteId) || (pullFileFetcher.formData?.get('backendProjectId') && @@ -341,6 +350,18 @@ const Component = () => {
) : null} + {hasProjectFileIssues ? ( +
+
+

+ + {projectFileIssuesMessage} +

+
+
+ ) : null} {isProjectInconsistent && (
@@ -605,6 +626,12 @@ const Component = () => { {item.hasUncommittedChanges ? 'Uncommitted changes' : 'Unpushed changes'}
)} + {item.fileIssue && ( +
+ + {item.fileIssue.kind === 'conflict' ? 'Merge in progress' : 'Invalid schema'} +
+ )}
); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx index 342c13a789..787d4cf1aa 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx @@ -3,7 +3,7 @@ import { href, redirect } from 'react-router'; import { database } from '~/common/database'; import { projectLock } from '~/common/project'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook, getInitialRouteForOrganization } from '~/utils/router'; @@ -32,8 +32,9 @@ export async function clientAction({ params }: Route.ClientActionArgs) { }); } - if (project.gitRepositoryId) { - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + if (models.project.isConnectedGitProject(project)) { + const effectiveRepoId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; + const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null; gitRepository && (await services.gitRepository.remove(gitRepository)); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx index 3dffc9aa0f..36836a9523 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx @@ -7,9 +7,7 @@ import { isNotNullOrUndefined } from '~/common/misc'; import type { InsomniaFile } from '~/common/project'; import { descendingNumberSort } from '~/common/sorting'; import type { ApiSpec, GitRepository, MockServer, Project, WorkspaceMeta } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import { sortProjects } from '~/models/helpers/project'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherLoadHook } from '~/utils/router'; @@ -122,7 +120,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { parentId: organizationId, })) || []; - const projects = sortProjects(organizationProjects); + const projects = models.project.sortProjects(organizationProjects); const files = await getAllLocalFiles({ projectId }); return { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index c24db54da6..cb10b7a739 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -15,12 +15,12 @@ import { getProjectsWithGitRepositories, } from '~/common/project'; import { models, services } from '~/insomnia-data'; -import { sortProjects } from '~/models/helpers/project'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { ScratchPadTutorialPanel } from '~/ui/components/panes/scratchpad-tutorial-pane'; import { ProjectNavigationSidebar } from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar'; import { SyncBar } from '~/ui/components/sidebar/sync-bar'; import uiEventBus, { TOGGLE_PROJECT_SIDEBAR } from '~/ui/event-bus'; +import { GitFileIssuesProvider, useProjectGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; @@ -88,7 +88,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { getAllLocalFiles({ projectId }), getProjectsWithGitRepositories({ organizationId }), ]); - const projects = sortProjects(organizationProjects); + const projects = models.project.sortProjects(organizationProjects); const remoteFilesPromise = getAllRemoteFiles({ projectId, organizationId }); const learningFeaturePromise = getInsomniaLearningFeature(fallbackLearningFeature); @@ -121,7 +121,6 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { export function useProjectLoaderData() { return useRouteLoaderData('routes/organization.$organizationId.project.$projectId'); } - const Component = ({ loaderData }: Route.ComponentProps) => { const { organizationId } = useParams() as { organizationId: string; @@ -160,6 +159,14 @@ const Component = ({ loaderData }: Route.ComponentProps) => { const { features } = useOrganizationPermissions(); const isScratchPad = models.project.isScratchpadProject(activeProject); + const gitRepositoryId = + activeProject && models.project.isConnectedGitProject(activeProject) + ? models.project.getEffectiveRepoId(activeProject) + : null; + const gitFileIssues = useProjectGitFileIssues({ + projectId: activeProject?._id, + gitRepositoryId, + }); return ( <> @@ -211,7 +218,9 @@ const Component = ({ loaderData }: Route.ComponentProps) => { hitAreaMargins={{ coarse: 15, fine: 15 }} /> - + + + diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index 77022bf44d..a3680cd0bd 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -4,8 +4,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import { projectLock } from '~/common/project'; import type { WorkspaceMeta } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { SegmentEvent } from '~/ui/analytics'; import { showToast } from '~/ui/components/toast-notification'; @@ -40,7 +39,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const project = await services.project.getById(projectId); invariant(project, 'Project not found'); - const gitRepository = project.gitRepositoryId ? await services.gitRepository.getById(project.gitRepositoryId) : null; + const effectiveRepoId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; + const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null; const user = await services.userSession.getOrCreate(); const sessionId = user.id; @@ -165,8 +165,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }, }); - if (project.gitRepositoryId) { - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + if (models.project.isConnectedGitProject(project)) { + const gitRepository = await services.gitRepository.getById(models.project.getEffectiveRepoId(project) || ''); gitRepository && (await services.gitRepository.remove(gitRepository)); } @@ -337,7 +337,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) // convert from git to local if (storageType === 'local' && project.gitRepositoryId) { - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + const effectiveId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; + const gitRepository = effectiveId ? await services.gitRepository.getById(effectiveId) : null; gitRepository && (await services.gitRepository.remove(gitRepository)); await services.project.update(project, { name, gitRepositoryId: null }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.new.tsx index 8208c9e936..74256398e9 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.new.tsx @@ -1,6 +1,7 @@ import { href } from 'react-router'; -import { type ClientCertificate, services } from '~/insomnia-data'; +import type { ClientCertificate } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.new'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.update.tsx index 2909810b68..6a5d172fa4 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.clientcert.update.tsx @@ -1,6 +1,7 @@ import { href } from 'react-router'; -import { type ClientCertificate, services } from '~/insomnia-data'; +import type { ClientCertificate } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.reorder.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.reorder.tsx index 40cc0b9c9c..3387cf55b1 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.reorder.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.reorder.tsx @@ -1,7 +1,6 @@ import { href } from 'react-router'; import { models, services } from '~/insomnia-data'; -import { getById, update } from '~/models/helpers/request-operations'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -10,7 +9,7 @@ import type { Route } from './+types/organization.$organizationId.project.$proje const { isRequestGroup, isRequestGroupId } = models.requestGroup; const getCollectionItem = async (id: string) => { - const item = await (isRequestGroupId(id) ? services.requestGroup.getById(id) : getById(id)); + const item = await (isRequestGroupId(id) ? services.requestGroup.getById(id) : services.helpers.getRequestById(id)); invariant(item, 'Item not found'); @@ -45,7 +44,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { const parentId = targetItem._id; await (isRequestGroup(item) ? services.requestGroup.update(item, { parentId, metaSortKey }) - : update(item, { parentId, metaSortKey })); + : services.helpers.updateRequest(item, { parentId, metaSortKey })); return null; } @@ -58,7 +57,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { await (isRequestGroup(item) ? services.requestGroup.update(item, { parentId, metaSortKey }) - : update(item, { parentId, metaSortKey })); + : services.helpers.updateRequest(item, { parentId, metaSortKey })); return null; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx index d45995e02a..e0d5c4372d 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.connect.tsx @@ -1,11 +1,14 @@ import { GRAPHQL_TRANSPORT_WS_PROTOCOL, MessageType } from 'graphql-ws'; import { href } from 'react-router'; -import type { ChangeBufferEvent } from '~/common/database'; -import type { CookieJar, McpTransportType, RequestAuthentication, RequestHeader } from '~/insomnia-data'; -import { models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; -import { getAuthHeader } from '~/network/authentication'; +import type { + ChangeBufferEvent, + CookieJar, + McpTransportType, + RequestAuthentication, + RequestHeader, +} from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import type { RenderedRequest } from '~/templating/types'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -30,7 +33,7 @@ export interface ConnectActionParams { export async function clientAction({ params, request }: Route.ClientActionArgs) { const { requestId, workspaceId } = params; - const req = await requestOperations.getById(requestId); + const req = await services.helpers.getRequestById(requestId); invariant(req, 'Request not found'); invariant(workspaceId, 'Workspace ID is required'); const rendered = (await request.json()) as ConnectActionParams; @@ -70,7 +73,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) } if (isEventStreamRequest(req)) { const renderedRequest = { ...req, ...rendered } as RenderedRequest; - const authHeader = await getAuthHeader(renderedRequest, rendered.url); + const authHeader = await window.main.getAuthHeader(renderedRequest, rendered.url); window.main.curl.open({ requestId, workspaceId, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.duplicate.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.duplicate.tsx index 94e8c534c6..5ed5c40e21 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.duplicate.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.duplicate.tsx @@ -1,7 +1,6 @@ import { href, redirect } from 'react-router'; import { services } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -11,7 +10,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) const { organizationId, projectId, workspaceId, requestId } = params; const { name, parentId } = await request.json(); - const req = await requestOperations.getById(requestId); + const req = await services.helpers.getRequestById(requestId); invariant(req, 'Request not found'); if (parentId) { @@ -19,7 +18,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) invariant(workspace, 'Workspace is required'); // TODO: if gRPC, we should also copy the protofile to the destination workspace - INS-267 // Move to top of sort order - const newRequest = await requestOperations.duplicate(req, { name, parentId, metaSortKey: -1e9 }); + const newRequest = await services.helpers.duplicateRequest(req, { name, parentId, metaSortKey: -1e9 }); invariant(newRequest, 'Failed to duplicate request'); services.stats.incrementCreatedRequests(); @@ -27,7 +26,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) return null; } - const newRequest = await requestOperations.duplicate(req, { name }); + const newRequest = await services.helpers.duplicateRequest(req, { name }); invariant(newRequest, 'Failed to duplicate request'); services.stats.incrementCreatedRequests(); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx index addecce489..d45194c9ff 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.grant-access.tsx @@ -2,7 +2,6 @@ import { href } from 'react-router'; import type { McpRequest } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -11,12 +10,12 @@ import type { Route } from './+types/organization.$organizationId.project.$proje export async function clientAction({ params, request }: Route.ClientActionArgs) { const { requestId, projectId } = params; - const req = (await requestOperations.getById(requestId)) as McpRequest; + const req = (await services.helpers.getRequestById(requestId)) as McpRequest; invariant(req, 'Request not found'); const { accessLevel } = await request.json(); if (accessLevel === 'request') { - await requestOperations.update(req, { mcpStdioAccess: true }); + await services.helpers.updateRequest(req, { mcpStdioAccess: true }); return; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx index a8e6bef5ff..042d2ebce6 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all.tsx @@ -1,8 +1,6 @@ import { href } from 'react-router'; import { services } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; -import { removeResponsesForRequest } from '~/models/helpers/response-operations'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -11,13 +9,13 @@ import type { Route } from './+types/organization.$organizationId.project.$proje export async function clientAction({ params }: Route.ClientActionArgs) { const { workspaceId, requestId } = params; - const req = await requestOperations.getById(requestId); + const req = await services.helpers.getRequestById(requestId); invariant(req, 'Request not found'); const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); invariant(workspaceMeta, 'Active workspace meta not found'); - await removeResponsesForRequest(requestId, workspaceMeta.activeEnvironmentId); + await services.helpers.removeResponsesForRequest(requestId, workspaceMeta.activeEnvironmentId); return null; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx index 775c96065a..bae0fa7431 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete.tsx @@ -1,9 +1,6 @@ import { href } from 'react-router'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import * as requestOperations from '~/models/helpers/request-operations'; -import { removeResponse } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -12,7 +9,7 @@ import type { Route } from './+types/organization.$organizationId.project.$proje export async function clientAction({ request, params }: Route.ClientActionArgs) { const { workspaceId, requestId } = params; - const req = await requestOperations.getById(requestId); + const req = await services.helpers.getRequestById(requestId); invariant(req, 'Request not found'); const { responseId } = await request.json(); @@ -38,7 +35,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const res = await responseModel.getById(responseId); invariant(res, 'Response not found'); - await removeResponse(res); + await services.helpers.removeResponse(res); const response = await responseModel.getLatestForRequestId(requestId, workspaceMeta.activeEnvironmentId); if (response?.requestVersionId) { await services.requestVersion.restore(response.requestVersionId); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx index 4dc3b14da9..11f676837c 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send.tsx @@ -1,6 +1,3 @@ -import { createWriteStream } from 'node:fs'; -import path from 'node:path'; - import contentDisposition from 'content-disposition'; import { extension as mimeExtension } from 'mime-types'; import { href, redirect } from 'react-router'; @@ -14,11 +11,9 @@ import type { RunnerResultPerRequestPerIteration, UserUploadEnvironment, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import type { ResponsePatch } from '~/main/network/libcurl-promise'; import type { TimingStep } from '~/main/network/request-timing'; -import * as models from '~/models'; -import { getBodyStream } from '~/models/helpers/response-operations'; import { defaultSendActionRuntime, fetchRequestData, @@ -30,7 +25,7 @@ import { tryToInterpolateRequest, tryToTransformRequestWithPlugins, } from '~/network/network'; -import { SegmentEvent } from '~/ui/analytics'; +import { type ImportAttribution, importAttributionKey, SegmentEvent } from '~/ui/analytics'; import { parseGraphQLReqeustBody } from '~/utils/graph-ql'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -71,7 +66,7 @@ export interface RunnerContextForRequest { responseId: string; } -const writeToDownloadPath = ( +const writeToDownloadPath = async ( downloadPathAndName: string, responsePatch: ResponsePatch, requestMeta: RequestMeta, @@ -79,27 +74,25 @@ const writeToDownloadPath = ( ) => { invariant(downloadPathAndName, 'filename should be set by now'); - const to = createWriteStream(downloadPathAndName); - const readStream = getBodyStream(responsePatch); - if (!readStream || typeof readStream === 'string') { - return null; - } - readStream.pipe(to); - - return new Promise(resolve => { - readStream.on('end', async () => { + try { + if (!responsePatch.bodyPath) { + responsePatch.error = `Failed to save to ${downloadPathAndName}: unable to read response body`; + } else { + await window.main.writeResponseBodyToFile({ + sourcePath: responsePatch.bodyPath, + destinationPath: downloadPathAndName, + bodyCompression: responsePatch.bodyCompression, + }); responsePatch.error = `Saved to ${downloadPathAndName}`; - const response = await services.response.create(responsePatch, maxHistoryResponses); - await services.requestMeta.update(requestMeta, { activeResponseId: response._id }); - resolve(null); - }); - readStream.on('error', async err => { - console.warn('Failed to download request after sending', responsePatch.bodyPath, err); - const response = await services.response.create(responsePatch, maxHistoryResponses); - await services.requestMeta.update(requestMeta, { activeResponseId: response._id }); - resolve(null); - }); - }); + } + } catch (err) { + responsePatch.error = `Failed to save to ${downloadPathAndName}`; + console.warn('Failed to download request after sending', responsePatch.bodyPath, err); + } + + const response = await services.response.create(responsePatch, maxHistoryResponses); + await services.requestMeta.update(requestMeta, { activeResponseId: response._id }); + return null; }; // Can fail with errors from: @@ -319,8 +312,8 @@ export const sendActionImplementation = async (options: { const name = header ? contentDisposition.parse(header.value).parameters.filename : `${requestData.request.name.replace(/\s/g, '-').toLowerCase()}.${(responsePatch.contentType && mimeExtension(responsePatch.contentType)) || 'unknown'}`; - writeToDownloadPath( - path.join(requestMeta.downloadPath, name), + await writeToDownloadPath( + window.path.join(requestMeta.downloadPath, name), responsePatch, requestMeta, requestData.settings.maxHistoryResponses, @@ -338,7 +331,7 @@ export const sendActionImplementation = async (options: { return { nextRequestIdOrName: postMutatedContext.execution?.nextRequestIdOrName }; } window.localStorage.setItem('insomnia.sendAndDownloadLocation', filePath); - writeToDownloadPath(filePath, responsePatch, requestMeta, requestData.settings.maxHistoryResponses); + await writeToDownloadPath(filePath, responsePatch, requestMeta, requestData.settings.maxHistoryResponses); return { nextRequestIdOrName: postMutatedContext.execution?.nextRequestIdOrName }; }; @@ -380,6 +373,23 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) count_tests: response.requestTestResults?.length || 0, }, }); + + const attributionStorageKey = importAttributionKey(requestId); + const jsonImportAttribution = window.localStorage.getItem(attributionStorageKey); + if (jsonImportAttribution) { + try { + const importAttribution = JSON.parse(jsonImportAttribution) as ImportAttribution; + window.main.trackSegmentEvent({ + event: SegmentEvent.importedRequestFirstSend, + properties: { + ...importAttribution, + protocol: activeRequest.type, + }, + }); + } finally { + window.localStorage.removeItem(attributionStorageKey); + } + } } } } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx index 167ae32e86..61c3f08e0e 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.tsx @@ -2,6 +2,7 @@ import { href, Outlet, redirect, useRouteLoaderData } from 'react-router'; import { database } from '~/common/database'; import type { + BaseModel, GrpcRequest, GrpcRequestMeta, McpPayload, @@ -19,11 +20,7 @@ import type { WebSocketRequest, WebSocketResponse, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import type { BaseModel } from '~/models'; -import * as models from '~/models'; -import * as requestOperations from '~/models/helpers/request-operations'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { showResourceNotFoundToast } from '~/ui/components/toast-notification'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; @@ -93,7 +90,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { throw redirect(href('/organization/:organizationId/project/:projectId', { organizationId, projectId })); } - const activeRequest = await requestOperations.getById(requestId); + const activeRequest = await services.helpers.getRequestById(requestId); if (!activeRequest) { showResourceNotFoundToast(`Request not found: ${requestId}`); if (activeWorkspace.scope === 'mcp') { @@ -175,7 +172,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const isOversizedResponse = length > 5 * 1024 * 1024; // 5MB // Oversized repsonses are handled in the response-viewer.tsx for now if (!isOversizedResponse) { - const buffer = await getBodyBuffer(activeResponse); + const buffer = await services.helpers.getResponseBodyBuffer(activeResponse); activeResponse.bodyBuffer = typeof buffer === 'string' ? Buffer.from(buffer) : buffer; } } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-meta.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-meta.tsx index adf6dc5672..e3178beb4f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-meta.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-meta.tsx @@ -1,8 +1,7 @@ import { href } from 'react-router'; import type { GrpcRequestMeta, RequestMeta, SocketIORequestMeta, WebSocketRequestMeta } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx index 097ff760c6..b29c463753 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload.tsx @@ -1,8 +1,7 @@ import { href } from 'react-router'; import type { McpPayload, SocketIOPayload } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { createFetcherSubmitHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update-payload'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx index 0b4cb95c35..ce8aec6de8 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx @@ -1,8 +1,7 @@ import { href } from 'react-router'; import type { WebSocketRequest } from '~/insomnia-data'; -import { models } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; +import { models, services } from '~/insomnia-data'; import { SegmentEvent } from '~/ui/analytics'; import { updateMimeType } from '~/ui/components/dropdowns/content-type-dropdown'; import { invariant } from '~/utils/invariant'; @@ -15,7 +14,7 @@ const { getPathParametersFromUrl, isRequest } = models.request; export async function clientAction({ params, request }: Route.ClientActionArgs) { const { requestId } = params; - const req = await requestOperations.getById(requestId); + const req = await services.helpers.getRequestById(requestId); invariant(req, 'Request not found'); const patch = await request.json(); @@ -39,11 +38,11 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) // TODO: if gRPC, we should also copy the protofile to the destination workspace - INS-267 const isMimeTypeChanged = isRequest(req) && patch.body && patch.body.mimeType !== req.body.mimeType; if (isMimeTypeChanged) { - await requestOperations.update(req, { ...patch, ...updateMimeType(req, patch.body?.mimeType) }); + await services.helpers.updateRequest(req, { ...patch, ...updateMimeType(req, patch.body?.mimeType) }); return null; } - await requestOperations.update(req, patch); + await services.helpers.updateRequest(req, patch); if (req.name !== patch.name) { window.main.trackSegmentEvent({ diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.delete.tsx index e7440da919..5b2bf98884 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.delete.tsx @@ -1,7 +1,6 @@ import { href, redirect } from 'react-router'; import { services } from '~/insomnia-data'; -import * as requestOperations from '~/models/helpers/request-operations'; import { SegmentEvent } from '~/ui/analytics'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -13,10 +12,10 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) const formData = await request.formData(); const id = formData.get('id') as string; - const req = await requestOperations.getById(id); + const req = await services.helpers.getRequestById(id); invariant(req, 'Request not found'); services.stats.incrementDeletedRequests(); - await requestOperations.remove(req); + await services.helpers.removeRequest(req); const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); invariant(workspaceMeta, 'Workspace meta not found'); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx index e245b7607e..19e1952811 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx @@ -23,11 +23,9 @@ import { v4 as uuidv4 } from 'uuid'; import { JSON_ORDER_PREFIX, JSON_ORDER_SEPARATOR } from '~/common/constants'; import type { RunnerResultPerRequest, RunnerTestResult, UserUploadEnvironment } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import type { ResponseTimelineEntry } from '~/main/network/libcurl-promise'; import type { TimingStep } from '~/main/network/request-timing'; -import * as models from '~/models'; -import { getTimeline } from '~/models/helpers/response-operations'; import { cancelRequestById } from '~/network/cancellation'; import { defaultSendActionRuntime } from '~/network/network'; import { useRootLoaderData } from '~/root'; @@ -73,7 +71,7 @@ async function aggregateAllTimelines(errorMsg: string | null, testResult: Runner const resp = await services.response.getById(respInfo.responseId); if (resp) { - const timeline = getTimeline(resp, true) as unknown as ResponseTimelineEntry[]; + const timeline = (await services.helpers.getResponseTimeline(resp, true)) as unknown as ResponseTimelineEntry[]; timelines = [ ...timelines, { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx index 768b9a67f3..441ca7a769 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx @@ -40,10 +40,10 @@ import { import * as reactUse from 'react-use'; import { DEFAULT_SIDEBAR_SIZE, getProductName, SORT_ORDERS, type SortOrder, sortOrderName } from '~/common/constants'; -import { type ChangeBufferEvent } from '~/common/database'; import { generateId } from '~/common/misc'; import type { PlatformKeyCombinations } from '~/common/settings'; import type { + ChangeBufferEvent, Environment, GrpcRequest, Project, @@ -53,10 +53,8 @@ import type { WebSocketRequest, Workspace, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import type { GrpcMethodInfo } from '~/main/ipc/grpc'; -import * as models from '~/models'; -import { isScratchpadOrganizationId } from '~/models/organization'; import { useRootLoaderData } from '~/root'; import { type Child, @@ -1166,7 +1164,9 @@ const Debug = () => { {/* Hide tabs when it's on the tutorial panel */} {!panel && } - {!panel && !isScratchpadOrganizationId(organizationId) && } + {!panel && !models.organization.isScratchpadOrganizationId(organizationId) && ( + + )} { @@ -29,7 +27,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }) .filter(isNotNullOrUndefined); - await vcs.stage(itemsToStage); + await window.main.sync.stage(itemsToStage); return null; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx index a07015cd60..fbe7b1af67 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx @@ -1,7 +1,6 @@ import { href } from 'react-router'; import { services } from '~/insomnia-data'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { getSyncItems, remoteBackendProjectsCache, remoteBranchesCache, remoteCompareCache } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherLoadHook, createFetcherSubmitHook } from '~/utils/router'; @@ -14,22 +13,21 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const project = await services.project.getById(projectId); invariant(project, 'Project not found'); invariant(project.remoteId, 'Project is not remote'); - const vcs = VCSInstance(); const { syncItems } = await getSyncItems({ workspaceId }); - const localBranches = (await vcs.getBranchNames()).sort(); - const currentBranch = await vcs.getCurrentBranchName(); - const history = (await vcs.getHistory()).sort((a, b) => (b.created > a.created ? 1 : -1)); - const historyCount = await vcs.getHistoryCount(); - const status = await vcs.status(syncItems); + const localBranches = (await window.main.sync.getBranchNames()).sort(); + const currentBranch = await window.main.sync.getCurrentBranchName(); + const history = (await window.main.sync.getHistory()).sort((a, b) => (b.created > a.created ? 1 : -1)); + const historyCount = await window.main.sync.getHistoryCount(); + const status = await window.main.sync.status(syncItems); let remoteBranches: string[] = []; let compare = { ahead: 0, behind: 0 }; try { - remoteBranches = (remoteBranchesCache[workspaceId] || (await vcs.getRemoteBranchNames())).sort(); - compare = remoteCompareCache[workspaceId] || (await vcs.compareRemoteBranch()); + remoteBranches = (remoteBranchesCache[workspaceId] || (await window.main.sync.getRemoteBranchNames())).sort(); + compare = remoteCompareCache[workspaceId] || (await window.main.sync.compareRemoteBranch()); const remoteBackendProjects = remoteBackendProjectsCache[project.remoteId] || - (await vcs.remoteBackendProjects({ + (await window.main.sync.remoteBackendProjects({ teamId: project.parentId, teamProjectId: project.remoteId, })); @@ -75,10 +73,9 @@ export async function clientAction({ params }: Route.ClientActionArgs) { invariant(project.remoteId, 'Project is not remote'); try { - const vcs = VCSInstance(); - const remoteBranches = (await vcs.getRemoteBranchNames()).sort(); - const compare = await vcs.compareRemoteBranch(); - const remoteBackendProjects = await vcs.remoteBackendProjects({ + const remoteBranches = (await window.main.sync.getRemoteBranchNames()).sort(); + const compare = await window.main.sync.compareRemoteBranch(); + const remoteBackendProjects = await window.main.sync.remoteBackendProjects({ teamId: project.parentId, teamProjectId: project.remoteId, }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.tsx index 9e1c8af97b..46fc520e9b 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.tsx @@ -1,10 +1,7 @@ import { href } from 'react-router'; -import { database } from '~/common/database'; import type { Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; +import { database, models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherLoadHook } from '~/utils/router'; @@ -25,11 +22,11 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { backendProjectsToPull: [], }; } - const vcs = VCSInstance(); - - const allPulledBackendProjectsForRemoteId = (await vcs.localBackendProjects()).filter(p => p.id === remoteId); + const allPulledBackendProjectsForRemoteId = (await window.main.sync.localBackendProjects()).filter( + p => p.id === remoteId, + ); // Remote backend projects are fetched from the backend since they are not stored locally - const allFetchedRemoteBackendProjectsForRemoteId = await vcs.remoteBackendProjects({ + const allFetchedRemoteBackendProjectsForRemoteId = await window.main.sync.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId, }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx index fbc95d2ce8..1784a3bf72 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx @@ -1,7 +1,6 @@ import { href } from 'react-router'; import { isNotNullOrUndefined } from '~/common/misc'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { getSyncItems } from '~/ui/sync-utils'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -15,8 +14,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const keys = data.keys; invariant(Array.isArray(keys), 'Keys are required'); const { syncItems } = await getSyncItems({ workspaceId }); - const vcs = VCSInstance(); - const status = await vcs.status(syncItems); + const status = await window.main.sync.status(syncItems); // Staging needs to happen since it creates blobs for the files const itemsToUnstage = keys .map(key => { @@ -29,7 +27,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }) .filter(isNotNullOrUndefined); - await vcs.unstage(itemsToUnstage); + await window.main.sync.unstage(itemsToUnstage); return null; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx index 44d16e2cdc..313e46277a 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx @@ -18,9 +18,7 @@ import { import { database as db } from '~/common/database'; import { getResponseCookiesFromHeaders } from '~/common/har'; import type { MockRoute, MockServer, Request, RequestHeader, Response } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useRequestNewMockSendActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new-mock-send'; import { useMockRouteUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.update'; @@ -67,7 +65,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const isOversizedResponse = length > 5 * 1024 * 1024; // 5MB // Oversized responses are handled in the response-viewer.tsx for now if (!isOversizedResponse) { - const buffer = await getBodyBuffer(activeResponse); + const buffer = await services.helpers.getResponseBodyBuffer(activeResponse); activeResponse.bodyBuffer = typeof buffer === 'string' ? Buffer.from(buffer) : buffer; } } @@ -183,6 +181,13 @@ export const MockRouteRoute = () => { return ''; } console.log('[mock] Error: invalid response from remote', { res, mockbinUrl }); + if (res && typeof res === 'object') { + const errorRes = res as { error?: string; message?: string }; + const parts = [errorRes.error, errorRes.message].filter(Boolean); + if (parts.length > 0) { + return parts.join('\n'); + } + } return 'Unexpected response, see console for details'; } catch (e) { if (isApiError(e)) { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx index 49b500636e..4624c36a1f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx @@ -2,8 +2,7 @@ import type { IRuleResult } from '@stoplight/spectral-core'; import { href, redirect } from 'react-router'; import { importResourcesToWorkspace, scanResources } from '~/common/import'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { SegmentEvent } from '~/ui/analytics'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -27,12 +26,12 @@ export async function clientAction({ params }: Route.ClientActionArgs) { const isLintError = (result: IRuleResult) => result.severity === 0; - const gitRepositoryId = models.project.isGitProject(project) - ? project.gitRepositoryId + const gitRepositoryId = models.project.isConnectedGitProject(project) + ? models.project.getEffectiveRepoId(project) : workspaceMeta?.gitRepositoryId; const rulesetPath = gitRepositoryId - ? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/other/.spectral.yaml`) + ? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/.spectral.yaml`) : ''; const { diagnostics, error } = await window.main.lintSpec({ documentContent: apiSpec.contents, rulesetPath }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 5cee9e5b85..eff5b366a1 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -28,9 +28,7 @@ import YAML from 'yaml'; import { parseApiSpec } from '~/common/api-specs'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; import { debounce } from '~/common/misc'; -import { services } from '~/insomnia-data'; -import * as models from '~/models/index'; -import { isScratchpadOrganizationId } from '~/models/organization'; +import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useSpecGenerateRequestCollectionActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection'; @@ -83,13 +81,13 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); - const gitRepositoryId = models.project.isGitProject(project) - ? project.gitRepositoryId + const gitRepositoryId = models.project.isConnectedGitProject(project) + ? models.project.getEffectiveRepoId(project) : workspaceMeta?.gitRepositoryId; // we don't run the lint here because it is expensive and slows first render too much // TODO: add this in once we run this loader outside the renderer const rulesetPath = gitRepositoryId - ? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/other/.spectral.yaml`) + ? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/.spectral.yaml`) : ''; let parsedSpec: OpenAPIV3.Document | undefined; @@ -168,7 +166,7 @@ const Component = ({ params }: Route.ComponentProps) => { const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); useEffect(() => { - if (!isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isScratchpadOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.run-all-tests.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.run-all-tests.tsx index c4e73fc713..95464ad447 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.run-all-tests.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.run-all-tests.tsx @@ -3,8 +3,7 @@ import { href, redirect } from 'react-router'; import { database } from '~/common/database'; import type { UnitTest } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { getSendRequestCallback } from '~/network/unit-test-feature'; import { SegmentEvent } from '~/ui/analytics'; import { invariant } from '~/utils/invariant'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test-result.$testResultId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test-result.$testResultId.tsx index 2f63992198..685fb6cf44 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test-result.$testResultId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test-result.$testResultId.tsx @@ -3,7 +3,7 @@ import { useRouteLoaderData } from 'react-router'; import { database } from '~/common/database'; import type { UnitTestResult } from '~/insomnia-data'; -import * as models from '~/models'; +import { models } from '~/insomnia-data'; import { Icon } from '~/ui/components/icon'; import { invariant } from '~/utils/invariant'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.delete.tsx index d9a7ad8714..4f8fd9326f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.delete.tsx @@ -2,8 +2,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import type { UnitTest } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { SegmentEvent } from '~/ui/analytics'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.run.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.run.tsx index fe995ed563..1c86d3d7f4 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.run.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.run.tsx @@ -3,8 +3,7 @@ import { href, redirect } from 'react-router'; import { database } from '~/common/database'; import type { UnitTest } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { getSendRequestCallback } from '~/network/unit-test-feature'; import { SegmentEvent } from '~/ui/analytics'; import { invariant } from '~/utils/invariant'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.update.tsx index 946393700a..b3f38ca959 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.update.tsx @@ -2,8 +2,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import type { UnitTest } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.tsx index 5485907951..977f7d4156 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.tsx @@ -18,8 +18,7 @@ import { useParams, useRouteLoaderData } from 'react-router'; import { database } from '~/common/database'; import { documentationLinks } from '~/common/documentation'; import type { Request, UnitTest, UnitTestSuite } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { useRunAllTestsActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.run-all-tests'; import { useTestDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.delete'; import { useTestRunActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test.$testId.run'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.update.tsx index cd1fd49713..74d9e37a5f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.update.tsx @@ -2,8 +2,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; import type { UnitTestSuite } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx index c925de9fad..8461ca4c8c 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx @@ -20,7 +20,7 @@ import { NavLink, Route as RouteComponent, Routes, useFetchers, useLoaderData, u import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; import { database } from '~/common/database'; import type { UnitTestSuite } from '~/insomnia-data'; -import * as models from '~/models'; +import { models } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useTestSuiteDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.delete'; import { useRunAllTestsActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.run-all-tests'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.toggle-expand-all.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.toggle-expand-all.tsx index 34d5373a92..17df002f10 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.toggle-expand-all.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.toggle-expand-all.tsx @@ -1,8 +1,7 @@ import { href } from 'react-router'; import { database } from '~/common/database'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx index 4359150fc2..0cfc287f45 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx @@ -1,5 +1,7 @@ -import { href, Outlet, redirect, useRouteLoaderData } from 'react-router'; +import { href, Outlet, redirect, useNavigate, useParams, useRouteLoaderData } from 'react-router'; +import { Button } from '~/basic-components/button'; +import { Modal } from '~/basic-components/modal'; import type { SortOrder } from '~/common/constants'; import { database } from '~/common/database'; import { sortMethodMap } from '~/common/sorting'; @@ -25,12 +27,11 @@ import type { Workspace, WorkspaceMeta, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import { sortProjects } from '~/models/helpers/project'; +import { models, services } from '~/insomnia-data'; import { pushSnapshotOnInitialize } from '~/sync/vcs/initialize-backend-project'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; +import { Icon } from '~/ui/components/icon'; import { showResourceNotFoundToast } from '~/ui/components/toast-notification'; +import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { createFetcherLoadHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId'; @@ -71,6 +72,18 @@ export interface Child { ancestors?: string[]; } +const workspaceFileIssueModalText = { + 'conflict': { + modalTitle: 'Cannot read file: Merge in progress', + summary: 'Complete the merge in your CLI tool to unlock this page.', + }, + 'parse-error': { + modalTitle: 'Cannot read file: Invalid schema', + summary: + 'Recent changes introduced schema errors in the Insomnia file for this page. Resolve the file using the CLI to unlock this page.', + }, +} as const; + export async function clientLoader({ params, request }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; @@ -88,8 +101,8 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const activeWorkspaceMeta = await services.workspaceMeta.getOrCreateByParentId(workspaceId); - const gitRepositoryId = models.project.isGitProject(activeProject) - ? activeProject.gitRepositoryId + const gitRepositoryId = models.project.isConnectedGitProject(activeProject) + ? models.project.getEffectiveRepoId(activeProject) : activeWorkspaceMeta.gitRepositoryId; const gitRepository = await services.gitRepository.getById(gitRepositoryId || ''); @@ -144,7 +157,7 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) parentId: organizationId, })) || []; - const projects = sortProjects(organizationProjects); + const projects = models.project.sortProjects(organizationProjects); const searchParams = new URL(request.url).searchParams; const sortOrder = searchParams.get('sortOrder') as SortOrder; @@ -272,12 +285,11 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) let vcsVersion = null; if (isLoggedInIsCloudProjectAndIsNotGitRepo) { try { - const vcs = VCSInstance(); - await vcs.switchAndCreateBackendProjectIfNotExist(workspaceId, activeWorkspace.name); + await window.main.sync.switchAndCreateBackendProjectIfNotExist(workspaceId, activeWorkspace.name); if (activeWorkspaceMeta.pushSnapshotOnInitialize) { - await pushSnapshotOnInitialize({ vcs, workspace: activeWorkspace, project: activeProject }); + await pushSnapshotOnInitialize({ vcs: window.main.sync, workspace: activeWorkspace, project: activeProject }); } - vcsVersion = await vcs.getVersion(); + vcsVersion = await window.main.sync.getVersion(); } catch (err) { console.warn('Failed to initialize VCS', err); } @@ -381,9 +393,56 @@ export const revalidateWorkspaceActiveRequestByFolder = async (requestGroup: Req }; const Component = () => { + const navigate = useNavigate(); + const { organizationId, projectId, workspaceId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; + const { issuesByWorkspaceId, conflictsSuppressed } = useGitFileIssues(); + const currentIssue = issuesByWorkspaceId[workspaceId]; + + const handleBackToList = () => { + navigate( + href('/organization/:organizationId/project/:projectId', { + organizationId, + projectId, + }), + ); + }; + + const modalText = currentIssue ? workspaceFileIssueModalText[currentIssue.kind] : null; + const isIssueModalOpen = Boolean( + currentIssue && modalText && !(currentIssue.kind === 'conflict' && conflictsSuppressed), + ); + return (
+ + {modalText ? ( +
+ +
+

{modalText.modalTitle}

+

{modalText.summary}

+ {currentIssue.relPath && ( +
    +
  • + {currentIssue.relPath} +
  • +
+ )} +
+ +
+ ) : null} +
); }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx index fd5d1287af..43930041ad 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx @@ -1,9 +1,7 @@ import { href, redirect } from 'react-router'; import type { Project, Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; +import { models, services } from '~/insomnia-data'; import { SegmentEvent } from '~/ui/analytics'; import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { invariant } from '~/utils/invariant'; @@ -17,10 +15,11 @@ async function deleteCloudSyncWorkspace(workspace: Workspace, project: Project, if (models.project.isRemoteProject(project) && !isGitSync) { try { - const vcs = VCSInstance(); - await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); + await window.main.sync.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); // For cloud sync workspaces, delete only local file or also delete remote copy - await (localOnly ? vcs.removeBackendProjectsForRoot(workspace._id) : vcs.archiveProject()); + await (localOnly + ? window.main.sync.removeBackendProjectsForRoot(workspace._id) + : window.main.sync.archiveProject()); // Emit cloud sync file change event when cloud sync workspace is deleted to refresh the remote projects list cache uiEventBus.emit(CLOUD_SYNC_FILE_CHANGE); } catch (err) { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx index 9dae85aab4..ca05847e91 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx @@ -1,18 +1,12 @@ -import fs from 'node:fs'; -import path from 'node:path'; - import { upsertMockbin } from 'insomnia-api'; import { href, redirect } from 'react-router'; import { getAppVersion, getMockServiceURL, METHOD_GET } from '~/common/constants'; import { database } from '~/common/database'; import type { MockRoute, MockServer, WorkspaceScope } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import type { MockRouteData } from '~/plugins/types'; import { safeToUseInsomniaFileNameWithExt } from '~/sync/git/insomnia-filename'; -import { initializeLocalBackendProjectAndMarkForSync } from '~/sync/vcs/initialize-backend-project'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { SegmentEvent } from '~/ui/analytics'; import { showToast } from '~/ui/components/toast-notification'; import { invariant } from '~/utils/invariant'; @@ -110,7 +104,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const safeToUseFileNameWithExtension = safeToUseInsomniaFileNameWithExt(fileName); await services.workspaceMeta.update(workspaceMeta, { - gitFilePath: path.join(workspaceData.folderPath || '', safeToUseFileNameWithExtension), + gitFilePath: window.path.join(workspaceData.folderPath || '', safeToUseFileNameWithExtension), }); } @@ -169,10 +163,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) !models.project.isGitProject(project) && !models.project.isLocalProject(project) ) { - const vcs = VCSInstance(); - await initializeLocalBackendProjectAndMarkForSync({ - vcs, - workspace, + await window.main.initializeWorkspaceBackendProject({ + workspaceId: workspace._id, }); } @@ -318,7 +310,16 @@ async function createMockServer( if (workspaceData.apiSpecContents) { openapiSpec = workspaceData.apiSpecContents; } else if (workspaceData.mockServerSpecSource === 'file') { - openapiSpec = fs.readFileSync(workspaceData.mockServerOASFilePath!, 'utf8'); + const { content, error } = await window.main.insecureReadFileWithEncoding({ + path: workspaceData.mockServerOASFilePath!, + encoding: 'utf8', + }); + + if (error) { + throw new Error(String(error)); + } + + openapiSpec = content; } else if (workspaceData.mockServerSpecSource === 'url') { specUrl = workspaceData.mockServerSpecURL!; } else if (workspaceData.mockServerSpecSource === 'text') { @@ -353,10 +354,8 @@ async function createMockServer( const { id } = await services.userSession.getOrCreate(); if (id && !workspaceMeta.gitRepositoryId) { - const vcs = VCSInstance(); - await initializeLocalBackendProjectAndMarkForSync({ - vcs, - workspace, + await window.main.initializeWorkspaceBackendProject({ + workspaceId: workspace._id, }); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index 4f77bb0b31..b0c6c13779 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -5,9 +5,7 @@ import { href, redirect, useParams } from 'react-router'; import { logout } from '~/account/session'; import { getProjectsWithGitRepositories } from '~/common/project'; import type { GitRepository, Project } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import { sortProjects } from '~/models/helpers/project'; -import { isScratchpadOrganizationId } from '~/models/organization'; +import { models, services } from '~/insomnia-data'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { NoProjectView } from '~/ui/components/panes/no-project-view'; @@ -33,7 +31,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } const organizationProjects = await getProjectsWithGitRepositories({ organizationId }); - const projects = sortProjects(organizationProjects); + const projects = models.project.sortProjects(organizationProjects); // If there are projects in the organization and no project is selected, redirect to the first project if (projects.length > 0) { return redirect(`/organization/${organizationId}/project/${projects[0]._id}`); @@ -54,7 +52,7 @@ const Component = () => { const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); useEffect(() => { - if (!isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isScratchpadOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx index 466c458eff..730a3951f7 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx @@ -5,8 +5,7 @@ import { database } from '~/common/database'; import { isNotNullOrUndefined } from '~/common/misc'; import { projectLock } from '~/common/project'; import type { Project } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import * as models from '~/models'; +import { models, services } from '~/insomnia-data'; import { SegmentEvent } from '~/ui/analytics'; import { showToast } from '~/ui/components/toast-notification'; import { invariant } from '~/utils/invariant'; diff --git a/packages/insomnia/src/routes/organization._index.tsx b/packages/insomnia/src/routes/organization._index.tsx index 92c8e823da..bd9e4563aa 100644 --- a/packages/insomnia/src/routes/organization._index.tsx +++ b/packages/insomnia/src/routes/organization._index.tsx @@ -2,8 +2,7 @@ import type { Organization } from 'insomnia-api'; import { href, redirect } from 'react-router'; import * as session from '~/account/session'; -import { services } from '~/insomnia-data'; -import { findPersonalOrganization } from '~/models/organization'; +import { models, services } from '~/insomnia-data'; import { migrateProjectsUnderOrganization, syncOrganizations } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; @@ -17,7 +16,7 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; invariant(organizations.length, 'Failed to fetch organizations. Check your network connection and try again.'); - const personalOrganization = findPersonalOrganization(organizations, accountId); + const personalOrganization = models.organization.findPersonalOrganization(organizations, accountId); invariant( personalOrganization, 'Failed to find personal organization your account appears to be in an invalid state. Please contact support if this is a recurring issue.', diff --git a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx index 6dd75b65c5..45290acded 100644 --- a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx +++ b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx @@ -1,10 +1,8 @@ import type { Organization } from 'insomnia-api'; import { href, redirect } from 'react-router'; -import { database } from '~/common/database'; import type { Project } from '~/insomnia-data'; -import { models, services } from '~/insomnia-data'; -import { findPersonalOrganization } from '~/models/organization'; +import { database, models, services } from '~/insomnia-data'; import { migrateProjectsUnderOrganization, syncOrganizations, syncProjects } from '~/ui/organization-utils'; import { invariant } from '~/utils/invariant'; import { AsyncTask, createFetcherSubmitHook } from '~/utils/router'; @@ -37,7 +35,7 @@ export async function clientAction({ request }: Route.ClientActionArgs) { if (asyncTaskList.includes(AsyncTask.MigrateProjects)) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; invariant(organizations, 'Failed to fetch organizations.'); - const personalOrganization = findPersonalOrganization(organizations, accountId); + const personalOrganization = models.organization.findPersonalOrganization(organizations, accountId); invariant(personalOrganization, 'personalOrganization is required'); invariant(personalOrganization.id, 'personalOrganizationId is required'); invariant(sessionId, 'sessionId is required'); diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index 7d83d4bc2a..c8481eadaa 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -1,4 +1,4 @@ -import { type Billing, type CurrentPlan, type FeatureList, type Organization, type UserProfile } from 'insomnia-api'; +import { type Billing, type CurrentPlan, type FeatureList, type Organization, type User } from 'insomnia-api'; import React, { Fragment, useCallback, useEffect, useState } from 'react'; import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components'; import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router'; @@ -6,7 +6,6 @@ import * as reactUse from 'react-use'; import type { Settings } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; -import { SCRATCHPAD_ORGANIZATION_ID } from '~/models/organization'; import { useRootLoaderData } from '~/root'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects'; @@ -36,7 +35,7 @@ import type { Route } from './+types/organization'; export interface OrganizationLoaderData { organizations: Organization[]; - user?: UserProfile; + user?: User; currentPlan?: CurrentPlan; } @@ -44,7 +43,7 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { const { id, accountId } = await services.userSession.getOrCreate(); if (id) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; - const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as UserProfile; + const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as User; const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan; return { organizations: sortOrganizations(accountId, organizations), @@ -206,7 +205,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || []; const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || []; const hasUntrackedData = untrackedProjects.length > 0 || untrackedWorkspaces.length > 0; - const isScratchPad = organizationId === SCRATCHPAD_ORGANIZATION_ID; + const isScratchPad = organizationId === models.organization.SCRATCHPAD_ORGANIZATION_ID; useCloseConnection({ organizationId, diff --git a/packages/insomnia/src/routes/untracked-projects.tsx b/packages/insomnia/src/routes/untracked-projects.tsx index 813a8cfe72..c11093623f 100644 --- a/packages/insomnia/src/routes/untracked-projects.tsx +++ b/packages/insomnia/src/routes/untracked-projects.tsx @@ -1,9 +1,7 @@ import type { Organization } from 'insomnia-api'; -import { database } from '~/common/database'; import type { Project, Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import { SCRATCHPAD_ORGANIZATION_ID } from '~/models/organization'; +import { database, models, services } from '~/insomnia-data'; import { createFetcherLoadHook } from '~/utils/router'; import type { Route } from './+types/untracked-projects'; @@ -16,7 +14,7 @@ export interface UntrackedProjectsLoaderData { export async function clientLoader(_args: Route.ClientLoaderArgs) { const { accountId } = await services.userSession.getOrCreate(); const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[]; - const listOfOrganizationIds = [...organizations.map(o => o.id), SCRATCHPAD_ORGANIZATION_ID]; + const listOfOrganizationIds = [...organizations.map(o => o.id), models.organization.SCRATCHPAD_ORGANIZATION_ID]; const projects = await database.find('Project', { parentId: { $nin: listOfOrganizationIds }, diff --git a/packages/insomnia/src/script-executor.ts b/packages/insomnia/src/script-executor.ts index 8d0503ee41..a85cd8fc48 100644 --- a/packages/insomnia/src/script-executor.ts +++ b/packages/insomnia/src/script-executor.ts @@ -11,7 +11,7 @@ import { mergeSettings, type RequestContext, } from '../../insomnia-scripting-environment/src/objects'; -import { requireInterceptor } from './require-interceptor'; +import { requireInterceptor } from './scripting/require-interceptor'; import { invariant } from './utils/invariant'; export const runScript = async ({ diff --git a/packages/insomnia/src/scripting/__tests__/require-interceptor.test.ts b/packages/insomnia/src/scripting/__tests__/require-interceptor.test.ts new file mode 100644 index 0000000000..6043d28ecf --- /dev/null +++ b/packages/insomnia/src/scripting/__tests__/require-interceptor.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { requireInterceptor } from '../require-interceptor'; + +const allows = (moduleName: string) => + expect(() => requireInterceptor(moduleName)).not.toThrow(); + +const blocks = (moduleName: string) => + expect(() => requireInterceptor(moduleName)).toThrow(); + +describe('requireInterceptor', () => { + describe('blocked modules', () => { + it('blocks child_process', () => blocks('child_process')); + it('blocks fs', () => blocks('fs')); + it('blocks os', () => blocks('os')); + it('blocks net', () => blocks('net')); + it('blocks http', () => blocks('http')); + it('blocks https', () => blocks('https')); + it('blocks crypto', () => blocks('crypto')); + it('blocks vm', () => blocks('vm')); + it('blocks worker_threads', () => blocks('worker_threads')); + it('blocks unknown module', () => blocks('some-unknown-module')); + }); + + describe('node built-ins', () => { + it('allows path', () => allows('path')); + it('allows assert', () => allows('assert')); + it('allows url', () => allows('url')); + it('allows punycode', () => allows('punycode')); + it('allows querystring', () => allows('querystring')); + it('allows string_decoder', () => allows('string_decoder')); + it('allows stream', () => allows('stream')); + it('allows events', () => allows('events')); + }); + + describe('timers', () => { + it('allows timers', () => allows('timers')); + + // it('strips setImmediate from timers', () => { + // const timers = requireInterceptor('timers'); + // expect(timers.setImmediate).toBeUndefined(); + // }); + + it('strips queueMicrotask from timers', () => { + const timers = requireInterceptor('timers'); + expect(timers.queueMicrotask).toBeUndefined(); + }); + + it('preserves setTimeout in timers', () => { + const timers = requireInterceptor('timers'); + expect(timers.setTimeout).toBeDefined(); + }); + + it('preserves setInterval in timers', () => { + const timers = requireInterceptor('timers'); + expect(timers.setInterval).toBeDefined(); + }); + }); + + describe('buffer', () => { + it('allows buffer', () => allows('buffer')); + + it('blocks Buffer.allocUnsafe', () => { + const { Buffer: SafeBuffer } = requireInterceptor('buffer'); + expect(() => SafeBuffer.allocUnsafe(8)).toThrow('Buffer.allocUnsafe is not available in sandbox scripts'); + }); + + it('blocks Buffer.allocUnsafeSlow', () => { + const { Buffer: SafeBuffer } = requireInterceptor('buffer'); + expect(() => SafeBuffer.allocUnsafeSlow(8)).toThrow('Buffer.allocUnsafeSlow is not available in sandbox scripts'); + }); + + it('allows Buffer.alloc', () => { + const { Buffer: SafeBuffer } = requireInterceptor('buffer'); + expect(() => SafeBuffer.alloc(8)).not.toThrow(); + }); + + it('allows Buffer.from', () => { + const { Buffer: SafeBuffer } = requireInterceptor('buffer'); + expect(() => SafeBuffer.from('hello')).not.toThrow(); + }); + }); + + describe('util', () => { + it('allows util', () => allows('util')); + + it('blocks util.inherits', () => { + const util = requireInterceptor('util'); + expect(() => util.inherits()).toThrow('util.inherits is not available in sandbox scripts'); + }); + + it('blocks util.debuglog', () => { + const util = requireInterceptor('util'); + expect(() => util.debuglog()).toThrow('util.debuglog is not available in sandbox scripts'); + }); + + it('allows util.format', () => { + const util = requireInterceptor('util'); + expect(() => util.format('%s', 'hello')).not.toThrow(); + }); + + it('allows util.inspect', () => { + const util = requireInterceptor('util'); + expect(() => util.inspect({})).not.toThrow(); + }); + }); + + describe('external modules', () => { + it('allows ajv', () => allows('ajv')); + it('allows chai', () => allows('chai')); + it('allows cheerio', () => allows('cheerio')); + it('allows crypto-js', () => allows('crypto-js')); + it('allows csv-parse/lib/sync', () => allows('csv-parse/lib/sync')); + it('allows lodash', () => allows('lodash')); + it('allows moment', () => allows('moment')); + it('allows tv4', () => allows('tv4')); + it('allows uuid', () => allows('uuid')); + it('allows xml2js', () => allows('xml2js')); + }); + + describe('base64 helpers', () => { + it('allows atob', () => allows('atob')); + it('allows btoa', () => allows('btoa')); + + it('atob returns a function', () => { + expect(typeof requireInterceptor('atob')).toBe('function'); + }); + + it('btoa returns a function', () => { + expect(typeof requireInterceptor('btoa')).toBe('function'); + }); + }); + + describe('collection modules', () => { + it('allows insomnia-collection', () => allows('insomnia-collection')); + it('allows postman-collection', () => allows('postman-collection')); + + it('insomnia-collection and postman-collection return the same module', () => { + expect(requireInterceptor('insomnia-collection')).toBe(requireInterceptor('postman-collection')); + }); + }); +}); diff --git a/packages/insomnia/src/scripting/__tests__/sandbox.test.ts b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts new file mode 100644 index 0000000000..dd72129cde --- /dev/null +++ b/packages/insomnia/src/scripting/__tests__/sandbox.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest'; + +import { checkSandboxViolations } from '../sandbox'; +import { blockedPropertyRules, blockedRootRules } from '../script-security-policy'; + +const ALL_BLOCKED_PROPERTIES = new Set(blockedPropertyRules.map(r => r.name)); +const ALL_BLOCKED_ROOTS = new Set(blockedRootRules.map(r => r.name)); + +const check = (script: string, props = ALL_BLOCKED_PROPERTIES, roots = ALL_BLOCKED_ROOTS) => + () => checkSandboxViolations(script, props, roots); + +const blocked = (script: string) => expect(check(script)).toThrow(); +const allowed = (script: string) => expect(check(script)).not.toThrow(); + +const withoutProperty = (name: string) => + new Set([...ALL_BLOCKED_PROPERTIES].filter(p => p !== name)); + +const withoutRoot = (name: string) => + new Set([...ALL_BLOCKED_ROOTS].filter(r => r !== name)); + +// --------------------------------------------------------------------------- +// Blocked properties — one canonical script per rule covering both dot and +// bracket notation where applicable. The unblocking section below mirrors +// each rule to confirm the disable path works too. +// --------------------------------------------------------------------------- + +describe('checkSandboxViolations', () => { + + describe('blocked properties — dot notation', () => { + it('blocks prototype', () => blocked('Promise.prototype.then')); + it('blocks mainModule', () => blocked('proc.mainModule')); + it('blocks constructor', () => blocked('obj.constructor')); + it('blocks __proto__', () => blocked('obj.__proto__')); + it('blocks prepareStackTrace', () => blocked('Error.prepareStackTrace')); + it('blocks captureStackTrace', () => blocked('Error.captureStackTrace')); + it('blocks getPrototypeOf', () => blocked('Object.getPrototypeOf(target)')); + it('blocks setPrototypeOf', () => blocked('Object.setPrototypeOf(obj, null)')); + it('blocks getFunction', () => blocked('frame.getFunction()')); + it('blocks getThis', () => blocked('frame.getThis()')); + it('blocks __defineGetter__', () => blocked('obj.__defineGetter__("foo", fn)')); + it('blocks __defineSetter__', () => blocked('obj.__defineSetter__("foo", fn)')); + it('blocks __lookupGetter__', () => blocked('obj.__lookupGetter__("foo")')); + it('blocks __lookupSetter__', () => blocked('obj.__lookupSetter__("foo")')); + it('blocks defineProperty', () => blocked('Object.defineProperty(obj, "key", desc)')); + it('blocks defineProperties', () => blocked('Object.defineProperties(obj, descs)')); + it('blocks getOwnPropertyDescriptor', () => blocked('Object.getOwnPropertyDescriptor(obj, "key")')); + it('blocks getOwnPropertyDescriptors', () => blocked('Object.getOwnPropertyDescriptors(obj)')); + }); + + describe('blocked properties — bracket notation', () => { + it('blocks constructor', () => blocked('obj["constructor"]')); + it('blocks __proto__', () => blocked('obj["__proto__"]')); + it('blocks prototype', () => blocked('Promise["prototype"]')); + it('blocks prepareStackTrace', () => blocked('Error["prepareStackTrace"]')); + it('blocks captureStackTrace', () => blocked('Error["captureStackTrace"]')); + it('blocks defineProperty', () => blocked('Object["defineProperty"](obj, "key", desc)')); + }); + + // --------------------------------------------------------------------------- + // Blocked roots + // --------------------------------------------------------------------------- + + describe('blocked roots — direct member access', () => { + it('blocks this', () => blocked('this.x')); + it('blocks globalThis', () => blocked('globalThis.require')); + it('blocks global', () => blocked('global.require')); + it('blocks window', () => blocked('window.process')); + it('blocks self', () => blocked('self.process')); + it('blocks frames', () => blocked('frames[0]')); + it('blocks process', () => blocked('process.env')); + it('blocks module', () => blocked('module.exports')); + it('blocks exports', () => blocked('exports.foo')); + it('blocks Buffer', () => blocked('Buffer.from("data")')); + it('blocks arguments', () => blocked('arguments[0]')); + }); + + describe('blocked roots — direct call', () => { + it('blocks constructor called directly', () => + blocked('constructor("return process")()')); + }); + + describe('blocked roots — bracket notation', () => { + it('blocks globalThis["require"]', () => blocked('globalThis["require"]()')); + it('blocks window["process"]', () => blocked('window["process"]')); + it('blocks self["require"]', () => blocked('self["require"]')); + it('blocks process["env"]', () => blocked('process["env"]')); + }); + + // --------------------------------------------------------------------------- + // Alias chains and destructuring + // --------------------------------------------------------------------------- + + describe('this — alias chains and destructuring', () => { + it('blocks this.process.mainModule.require via member', () => + blocked(`this.process.mainModule.require('child_process')`)); + + it('blocks this["process"]', () => + blocked(`this['process']`)); + + it('blocks dynamic key on this', () => + blocked(`const k = 'process'; this[k]`)); + + it('blocks const alias: const t = this; t.process', () => + blocked(`const t = this; t.process.mainModule.require('child_process')`)); + + it('blocks assignment alias: let t; t = this; t.process', () => + blocked(`let t; t = this; t.process.mainModule.require('child_process')`)); + + it('blocks destructuring from this', () => + blocked(`const { process } = this`)); + + it('blocks destructuring assignment from this', () => + blocked(`({ process } = this)`)); + }); + + describe('globalThis — alias chains and destructuring', () => { + it('blocks const alias: const g = globalThis; g.require', () => + blocked(`const g = globalThis; g.require('child_process')`)); + + it('blocks destructuring from globalThis', () => + blocked(`const { require } = globalThis`)); + + it('blocks destructuring assignment from globalThis', () => + blocked(`({ require } = globalThis)`)); + }); + + // --------------------------------------------------------------------------- + // Prototype chain mutation + // --------------------------------------------------------------------------- + + describe('prototype chain mutation', () => { + it('blocks Promise.prototype.then mutation', () => + blocked(`Promise.prototype.then = function(fn) { fn.call(globalThis); }`)); + + it('blocks Promise.prototype.catch mutation', () => + blocked(`Promise.prototype.catch = function() {}`)); + + it('blocks Array.prototype.map mutation', () => + blocked(`Array.prototype.map = function() {}`)); + + it('blocks Function.prototype.call mutation', () => + blocked(`Function.prototype.call = function() {}`)); + + it('blocks reading Promise.prototype', () => + blocked(`const proto = Promise.prototype`)); + + it('blocks bracket notation on Promise.prototype', () => + blocked(`Promise['prototype']`)); + }); + + // --------------------------------------------------------------------------- + // Dynamic import + // --------------------------------------------------------------------------- + + describe('import', () => { + it('blocks dynamic import()', () => + blocked(`import('child_process')`)); + + it('blocks dynamic import() with variable', () => + blocked(`const m = 'child_process'; import(m)`)); + + it('blocks static import declaration', () => + blocked(`import fs from 'fs'`)); + + it('blocks static import with named exports', () => + blocked(`import { readFile } from 'fs'`)); + }); + + // --------------------------------------------------------------------------- + // Symbol.species + // --------------------------------------------------------------------------- + + describe('Symbol.species', () => { + it('blocks Symbol.species', () => + blocked(`Symbol.species`)); + }); + + // --------------------------------------------------------------------------- + // Unblocking — disabling a rule must allow previously blocked scripts + // --------------------------------------------------------------------------- + + describe('unblocking — disabling a blocked property rule allows the script', () => { + const cases: [name: string, script: string][] = [ + ['prototype', 'Promise.prototype.then'], + ['mainModule', 'proc.mainModule'], + ['constructor', 'obj.constructor'], + ['__proto__', 'obj.__proto__'], + ['prepareStackTrace', 'Error.prepareStackTrace'], + ['captureStackTrace', 'Error.captureStackTrace'], + ['getPrototypeOf', 'Object.getPrototypeOf(target)'], + ['setPrototypeOf', 'Object.setPrototypeOf(obj, null)'], + ['getFunction', 'frame.getFunction()'], + ['getThis', 'frame.getThis()'], + ['__defineGetter__', 'obj.__defineGetter__("foo", fn)'], + ['__defineSetter__', 'obj.__defineSetter__("foo", fn)'], + ['__lookupGetter__', 'obj.__lookupGetter__("foo")'], + ['__lookupSetter__', 'obj.__lookupSetter__("foo")'], + ['defineProperty', 'Object.defineProperty(obj, "key", desc)'], + ['defineProperties', 'Object.defineProperties(obj, descs)'], + ['getOwnPropertyDescriptor', 'Object.getOwnPropertyDescriptor(obj, "key")'], + ['getOwnPropertyDescriptors','Object.getOwnPropertyDescriptors(obj)'], + ]; + + for (const [name, script] of cases) { + it(`disabling '${name}' allows: ${script}`, () => + expect(check(script, withoutProperty(name))).not.toThrow()); + } + }); + + describe('unblocking — disabling a blocked root rule allows the script', () => { + const cases: [name: string, script: string][] = [ + ['this', 'this.x'], + ['globalThis', 'globalThis.require'], + ['global', 'global.require'], + ['window', 'window.process'], + ['self', 'self.process'], + ['frames', 'frames[0]'], + ['process', 'process.env'], + ['module', 'module.exports'], + ['exports', 'exports.foo'], + ['Buffer', 'Buffer.from("data")'], + ['constructor', 'constructor("return process")()'], + ['arguments', 'arguments[0]'], + ]; + + for (const [name, script] of cases) { + it(`disabling '${name}' allows: ${script}`, () => + expect(check(script, ALL_BLOCKED_PROPERTIES, withoutRoot(name))).not.toThrow()); + } + + it('disabling this also allows const aliases of this', () => + expect(check('const t = this; t.x', ALL_BLOCKED_PROPERTIES, withoutRoot('this'))).not.toThrow()); + + it('disabling globalThis also allows const aliases of globalThis', () => + expect(check('const g = globalThis; g.require', ALL_BLOCKED_PROPERTIES, withoutRoot('globalThis'))).not.toThrow()); + }); + + // --------------------------------------------------------------------------- + // Allowed scripts + // --------------------------------------------------------------------------- + + describe('allowed scripts', () => { + it('allows normal variable declarations', () => + allowed(`const x = 1 + 2`)); + + it('allows require() calls', () => + allowed(`require('lodash')`)); + + it('allows insomnia API usage', () => + allowed(`insomnia.environment.set('key', 'val')`)); + + it('allows async/await', () => + allowed(`const res = await insomnia.sendRequest('https://example.com')`)); + + it('allows pm.test()', () => + allowed(`pm.test('status is 200', () => { pm.expect(pm.response.code).to.equal(200); })`)); + + it('allows lodash usage', () => + allowed(`const val = _.get(obj, 'foo.bar')`)); + + it('allows console.log', () => + allowed(`console.log('hello')`)); + + it('allows class with prototype-like property name in string', () => + allowed(`const key = 'prototype'; obj[key]`)); + }); +}); diff --git a/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts b/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts new file mode 100644 index 0000000000..0dd4245389 --- /dev/null +++ b/packages/insomnia/src/scripting/__tests__/script-security-policy.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; + +import { requireInterceptor } from '../require-interceptor'; +import { defaultSecurityPolicy } from '../sandbox'; +import { interceptorRules, maskRules } from '../script-security-policy'; + +// Build the mask map once — shared across all tests. +const { names, values } = defaultSecurityPolicy.buildMaskScope(); +const maskMap = new Map(names.map((name, i) => [name, values[i]])); + +describe('ScriptSecurityPolicy.buildMaskScope()', () => { + describe('coverage — every rule with a maskName is present', () => { + it('includes all interceptor rule mask names', () => { + for (const rule of interceptorRules) { + if (rule.maskName) { + expect(names, `missing mask for interceptor rule "${rule.name}"`).toContain(rule.maskName); + } + } + }); + + it('includes all mask rule names', () => { + for (const rule of maskRules) { + if (rule.maskName) { + expect(names, `missing mask for mask rule "${rule.name}"`).toContain(rule.maskName); + } + } + }); + }); + + describe('mask rules — blocked globals resolve to undefined', () => { + const undefinedMasks = [ + 'globalThis', + 'global', + 'Function', + 'process', + 'setImmediate', + 'queueMicrotask', + 'Proxy', + 'Reflect', + 'WebAssembly', + ]; + + for (const name of undefinedMasks) { + it(`${name} → undefined`, () => { + expect(maskMap.has(name)).toBe(true); + expect(maskMap.get(name)).toBeUndefined(); + }); + } + }); + + describe('require interceptor', () => { + it('masks require with requireInterceptor', () => { + expect(maskMap.get('require')).toBe(requireInterceptor); + }); + }); + + describe('window allowlist', () => { + // In Vitest's Node environment window is undefined, so the rule returns undefined. + it('masks window to undefined in Node environment', () => { + expect(maskMap.has('window')).toBe(true); + expect(maskMap.get('window')).toBeUndefined(); + }); + }); + + describe('eval interceptor', () => { + const evalFn = maskMap.get('eval') as (script: string) => unknown; + + it('is a function', () => { + expect(typeof evalFn).toBe('function'); + }); + + it('throws on null input', () => { + expect(() => (evalFn as any)(null)).toThrow(); + }); + + it('throws on non-string input', () => { + expect(() => (evalFn as any)(42)).toThrow(); + }); + + describe('blocks AST violations smuggled through eval', () => { + it('blocks dynamic import()', () => { + expect(() => evalFn('import("child_process")')).toThrow(); + }); + + it('blocks globalThis access', () => { + expect(() => evalFn('globalThis.process')).toThrow(); + }); + + it('blocks constructor access', () => { + expect(() => evalFn('obj.constructor')).toThrow(); + }); + + it('blocks __proto__ access', () => { + expect(() => evalFn('obj.__proto__')).toThrow(); + }); + + it('blocks prototype access', () => { + expect(() => evalFn('Promise.prototype')).toThrow(); + }); + + it('blocks setPrototypeOf access', () => { + expect(() => evalFn('Object.setPrototypeOf(obj, null)')).toThrow(); + }); + + it('blocks captureStackTrace access', () => { + expect(() => evalFn('Error.captureStackTrace(obj)')).toThrow(); + }); + + it('blocks defineProperty access', () => { + expect(() => evalFn('Object.defineProperty(obj, "key", {})')).toThrow(); + }); + }); + + describe('allows safe eval', () => { + it('evaluates arithmetic', () => { + expect(evalFn('1 + 1')).toBe(2); + }); + + it('evaluates string expressions', () => { + expect(evalFn('"hello"')).toBe('hello'); + }); + }); + }); +}); + +describe('ScriptSecurityPolicy builders', () => { + describe('withoutRule()', () => { + it('removes a rule by name', () => { + const policy = defaultSecurityPolicy.withoutRule('process'); + const { names } = policy.buildMaskScope(); + expect(names).not.toContain('process'); + }); + + it('leaves other rules intact', () => { + const policy = defaultSecurityPolicy.withoutRule('process'); + const { names } = policy.buildMaskScope(); + expect(names).toContain('globalThis'); + }); + + it('is a no-op for an unknown rule name', () => { + const before = defaultSecurityPolicy.buildMaskScope().names.length; + const after = defaultSecurityPolicy.withoutRule('nonexistent').buildMaskScope().names.length; + expect(after).toBe(before); + }); + + it('can remove each mask rule individually', () => { + for (const rule of maskRules) { + const policy = defaultSecurityPolicy.withoutRule(rule.name); + const { names } = policy.buildMaskScope(); + if (rule.maskName) { + expect(names, `rule '${rule.name}' was not removed`).not.toContain(rule.maskName); + } + } + }); + + it('can remove each interceptor rule individually', () => { + for (const rule of interceptorRules) { + const policy = defaultSecurityPolicy.withoutRule(rule.name); + const { names } = policy.buildMaskScope(); + if (rule.maskName) { + expect(names, `rule '${rule.name}' was not removed`).not.toContain(rule.maskName); + } + } + }); + }); + + describe('withRule()', () => { + it('appends a new mask rule', () => { + const policy = defaultSecurityPolicy.withRule({ + name: 'custom-mask', + description: 'test rule', + maskName: 'customGlobal', + maskValue: undefined, + }); + const { names } = policy.buildMaskScope(); + expect(names).toContain('customGlobal'); + }); + + it('does not mutate the original policy', () => { + defaultSecurityPolicy.withRule({ + name: 'custom-mask', + description: 'test rule', + maskName: 'customGlobal', + maskValue: undefined, + }); + const { names } = defaultSecurityPolicy.buildMaskScope(); + expect(names).not.toContain('customGlobal'); + }); + }); +}); diff --git a/packages/insomnia/src/require-interceptor.ts b/packages/insomnia/src/scripting/require-interceptor.ts similarity index 53% rename from packages/insomnia/src/require-interceptor.ts rename to packages/insomnia/src/scripting/require-interceptor.ts index 44cd75c1ae..76e1029396 100644 --- a/packages/insomnia/src/require-interceptor.ts +++ b/packages/insomnia/src/scripting/require-interceptor.ts @@ -9,7 +9,7 @@ import tv4 from 'tv4'; import * as uuid from 'uuid'; import xml2js from 'xml2js'; -import { Collection as CollectionModule } from '../../insomnia-scripting-environment/src/objects'; +import { Collection as CollectionModule } from '../../../insomnia-scripting-environment/src/objects'; const externalModules = new Map([ ['ajv', ajv], @@ -24,20 +24,46 @@ const externalModules = new Map([ ['xml2js', xml2js], ]); +// wraps `target` with a Proxy restricting access to dangerious methods within the accepted modules. +const blockMethods = (target: object, blocked: string[], label: string): object => + new Proxy(target, { + get(t, prop) { + if (typeof prop === 'string' && blocked.includes(prop)) { + throw new Error(`${label}.${prop} is not available in sandbox scripts`); + } + const value = (t as any)[prop]; + return typeof value === 'function' ? value.bind(t) : value; + }, + }); + export const requireInterceptor = (moduleName: string): any => { - if ( + if (moduleName === 'timers') { + // Block setImmediate + return blockMethods(require('node:timers'), ['setImmediate'], 'timers'); + } else if (moduleName === 'buffer') { + // Block unsafe allocation methods to prevent heap memory disclosure. + // Buffer.allocUnsafe(n) / Buffer.allocUnsafeSlow(n) return a buffer backed by uninitialized memory. + const bufferModule = require('node:buffer'); + return { + ...bufferModule, + Buffer: blockMethods(bufferModule.Buffer, ['allocUnsafe', 'allocUnsafeSlow'], 'Buffer'), + }; + } else if (moduleName === 'util') { + // Block escape utils like util.inherits and util.debuglog + // util.inherits(ctor, superCtor) — directly manipulates the prototype chain ( + // util.debuglog(section) — conditionally writes to stderr based on the NODE_DEBUG environment variable + return blockMethods(require('node:util'), ['inherits', 'debuglog'], 'util'); + + } else if ( [ // node.js modules 'path', 'assert', - 'buffer', - 'util', 'url', 'punycode', 'querystring', 'string_decoder', 'stream', - 'timers', 'events', // follows should be npm modules // but they are moved to here to avoid introducing additional dependencies diff --git a/packages/insomnia/src/scripting/run-script.ts b/packages/insomnia/src/scripting/run-script.ts new file mode 100644 index 0000000000..c2dbbc4bc0 --- /dev/null +++ b/packages/insomnia/src/scripting/run-script.ts @@ -0,0 +1,154 @@ +import * as _ from 'es-toolkit/compat'; + +import { + InsomniaObject, + mergeClientCertificates, + mergeCookieJar, + mergeRequests, + mergeSettings, + type RequestContext, +} from '../../../insomnia-scripting-environment/src/objects'; +import { defaultSecurityPolicy, prepareSandbox, ScriptSecurityPolicy } from './sandbox'; + +export const runScript = async ({ + script, + context, + securityPolicy = defaultSecurityPolicy, +}: { + script: string; + context: RequestContext; + securityPolicy?: ScriptSecurityPolicy; +}): Promise => { + const activePolicy = context.settings.scriptSandboxEnabled !== false + ? securityPolicy + : new ScriptSecurityPolicy([]); + + const { + executionContext, + scriptConsole, + maskNames, + maskValues, + bridgeOps, + } = await prepareSandbox(script, context, activePolicy); + + const AsyncFunction = (async () => {}).constructor; + const scriptParams = [ + 'insomnia', // insomnia scripting API object + 'console', // log console + '_', // lodash library + 'setTimeout', // proxied setTimeout tracked by the async task monitor + '__waitForAllTestsDone__', // Drains pm.test() assertions before the script exits + '__bridgeReset__', // Clears the async task list and re-enables monitoring + '__bridgeStop__', // Stops recording new promises into the task list + '__bridgeSettle__', // Awaits all tracked promises before returning + ...maskNames, // Masked globals from the security policy (e.g. eval → undefined) + ]; + const strictMode = context.settings.scriptStrictModeEnabled !== false; + const scriptBody = [ + `__bridgeReset__();`, // Start with a clean async task slate for this script run + `await (async function() {`, // IIFE gives the user script its own lexical scope + ...(strictMode ? [` 'use strict';`] : []), // Strict mode: this === undefined, prevents silent errors + ` const $ = insomnia;`, // Postman-compat alias for the insomnia scripting object + ` ${script}`, // User script body + `})();`, + `await __waitForAllTestsDone__();`, // Wait for all pm.test() callbacks to resolve + `__bridgeStop__();`, // Stop tracking new promises (user script is done) + `await __bridgeSettle__();`, // Drain any fire-and-forget promises the script created + `return insomnia;`, // Return the (possibly mutated) insomnia context + ].join('\n'); + + // const scriptBody = [ + // `const $ = insomnia;`, + // `__bridgeReset__();`, + // `try {`, + // ` ${script}`, + // ` await __waitForAllTestsDone__();`, + // `} finally {`, + // ` __bridgeStop__();`, + // ` await __bridgeSettle__();`, + // `}`, + // `return insomnia;`, + // ].join('\n'); + + const executeScript = AsyncFunction(...scriptParams, scriptBody); + + const mutatedInsomniaObject = await executeScript( + executionContext, + scriptConsole, + _, + proxiedSetTimeout, + bridgeOps.waitForAllTestsDone, + bridgeOps.resetAsyncTasks, + bridgeOps.stopMonitorAsyncTasks, + bridgeOps.asyncTasksAllSettled, + ...maskValues, + ); + + if (mutatedInsomniaObject == null || !(mutatedInsomniaObject instanceof InsomniaObject)) { + throw new Error('insomnia object is invalid or script returns earlier than expected.'); + } + + const mutatedContextObject = mutatedInsomniaObject.toObject(); + const updatedRequest = mergeRequests(context.request, mutatedContextObject.request); + const updatedSettings = mergeSettings(context.settings, mutatedContextObject.request); + const updatedCertificates = mergeClientCertificates( + mutatedContextObject.clientCertificates, + mutatedContextObject.request, + ); + const updatedCookieJar = mergeCookieJar(context.cookieJar, mutatedContextObject.cookieJar); + + return { + ...context, + environment: { + id: context.environment.id, + name: context.environment.name, + data: mutatedContextObject.environment, + }, + baseEnvironment: { + id: context.baseEnvironment.id, + name: context.baseEnvironment.name, + data: mutatedContextObject.baseEnvironment, + }, + iterationData: context.iterationData + ? { + name: context.iterationData.name, + data: mutatedContextObject.iterationData, + } + : undefined, + transientVariables: { + name: context.transientVariables?.name || 'transientVariables', + data: mutatedContextObject.variables, + }, + request: updatedRequest, + execution: mutatedContextObject.execution, + settings: updatedSettings, + clientCertificates: updatedCertificates, + cookieJar: updatedCookieJar, + globals: context.globals && { + id: context.globals.id, + name: context.globals.name, + data: mutatedContextObject.globals, + }, + baseGlobals: context.baseGlobals && { + id: context.baseGlobals.id, + name: context.baseGlobals.name, + data: mutatedContextObject.baseGlobals, + }, + requestTestResults: mutatedContextObject.requestTestResults, + logs: scriptConsole.dumpLogsAsArray(), + parentFolders: mutatedContextObject.parentFolders, + }; +}; + +function proxiedSetTimeout(callback: () => void, ms?: number | undefined) { + let resolveHdl: (value: unknown) => void; + + new Promise(resolve => { + resolveHdl = resolve; + }); + + return setTimeout(() => { + callback(); + resolveHdl(null); + }, ms); +} diff --git a/packages/insomnia/src/scripting/sandbox.ts b/packages/insomnia/src/scripting/sandbox.ts new file mode 100644 index 0000000000..b9883cf62e --- /dev/null +++ b/packages/insomnia/src/scripting/sandbox.ts @@ -0,0 +1,357 @@ +import * as acorn from 'acorn'; +import * as walk from "acorn-walk"; + +import { + getNewConsole, + initInsomniaObject, + type RequestContext, + waitForAllTestsDone, +} from '../../../insomnia-scripting-environment/src/objects'; +import { blockedPropertyRules, blockedRootRules, interceptorRules, maskRules, type ThreatRule } from './script-security-policy'; + +// Frozen, pre-bound references to the bridge lifecycle methods +export interface BridgeOps { + resetAsyncTasks: () => void; + stopMonitorAsyncTasks: () => void; + asyncTasksAllSettled: () => Promise; + waitForAllTestsDone: () => Promise; +} + +export interface SandboxContext { + executionContext: Awaited>; + scriptConsole: ReturnType; + maskNames: string[]; + maskValues: unknown[]; + bridgeOps: BridgeOps; +} + +// Derive the default blocked sets from the canonical rule lists in script-security-policy. +const SANDBOX_BLOCKED_PROPERTIES = new Set(blockedPropertyRules.map(r => r.name)); +const SANDBOX_BLOCKED_ROOTS = new Set(blockedRootRules.map(r => r.name)); + +// These interceptor rules always apply — they cannot be disabled via settings and run even when +// the sandbox is turned off, because they gate access to critical host APIs (require, window, eval). +const ALWAYS_ON_INTERCEPTORS = new Set(['require', 'window', 'eval']); + +// Walks a MemberExpression down to its root Identifier. +function getMemberRoot(node: any): string | null { + if (node.type === 'Identifier') return node.name; + if (node.type === 'MemberExpression') return getMemberRoot(node.object); + return null; +} + + // Returns MemberExpression property name. +function getMemberPropertyName(node: acorn.MemberExpression): string | null { + if (!node.computed && node.property.type === 'Identifier') { + return (node.property as acorn.Identifier).name; + } + if (node.computed && node.property.type === 'Literal') { + const val = (node.property as acorn.Literal).value; + return typeof val === 'string' ? val : null; + } + return null; +} + +function deepFreeze(obj: T): T { + const propNames = Object.getOwnPropertyNames(obj); + for (const name of propNames) { + const value = (obj as any)[name]; + if (value && typeof value === "object") { + deepFreeze(value); + } + } + return Object.freeze(obj); +} + +// parses `script` and checks for sandbox policy violations. +// Pass custom sets to apply per-rule overrides from user settings. +export function checkSandboxViolations( + script: string, + blockedProperties: Set = SANDBOX_BLOCKED_PROPERTIES, + blockedRoots: Set = SANDBOX_BLOCKED_ROOTS, +): void { + let tree: any; + for (const sourceType of ['module', 'script'] as const) { + try { + tree = acorn.parse(script, { ecmaVersion: 2022, sourceType }); + break; + } catch { + // try next sourceType + } + } + // We should evenutally drop non-valid JavaScript. + if (!tree) { + // throw new Error(); + return; + } + + // Maps each blocked name to its root origin so error messages can explain alias chains. + // e.g. `const s = this; s.x` → blocked.get('s') === 'this' + const blocked = new Map(); + for (const root of blockedRoots) { + blocked.set(root, root); + } + + // Helper: render a name with its origin chain if aliased (e.g. "'s' → 'this'"). + const label = (name: string) => { + const origin = blocked.get(name); + return origin && origin !== name ? `'${name}' → '${origin}'` : `'${name}'`; + }; + + // Helper: returns the specific rule name to tell the user to disable. + // For aliases the root origin is the actual named rule; for direct roots it's the name itself. + const ruleHint = (name: string) => { + const origin = blocked.get(name) ?? name; + return `'${origin}'`; + }; + + walk.simple(tree, { + // const/let/var g = globalThis OR const s = this + VariableDeclarator(node: acorn.VariableDeclarator) { + if (node.id.type !== 'Identifier') return; + const id = node.id as acorn.Identifier; + if (node.init?.type === 'Identifier') { + const initName = (node.init as acorn.Identifier).name; + const origin = blocked.get(initName); + if (origin !== undefined) { + blocked.set(id.name, origin); + } + } + // `this` is a ThisExpression, not an Identifier — handle separately. + // Only track aliases if the 'this' rule is active in the current policy. + if (node.init?.type === 'ThisExpression' && blockedRoots.has('this')) { + blocked.set(id.name, 'this'); + } + }, + // g = globalThis (bare assignment) OR s = this + AssignmentExpression(node: acorn.AssignmentExpression) { + if (node.left.type !== 'Identifier') return; + const id = node.left as acorn.Identifier; + if (node.right.type === 'Identifier') { + const rightName = (node.right as acorn.Identifier).name; + const origin = blocked.get(rightName); + if (origin !== undefined) { + blocked.set(id.name, origin); + } + } + if (node.right.type === 'ThisExpression' && blockedRoots.has('this')) { + blocked.set(id.name, 'this'); + } + }, + }); + + // check for violations using the fully expanded blocked map. + walk.simple(tree, { + MemberExpression(node: acorn.MemberExpression) { + if (node.object.type === 'ThisExpression' && blockedRoots.has('this')) { + throw new Error( + `The script was blocked because it used 'this'.\n` + + `If this is intended, disable it via Settings → Scripting → Blocked roots.`, + ); + } + + // Covers dot and computed bracket notation via root chain traversal. + const root = getMemberRoot(node.object); + if (root && blocked.has(root)) { + throw new Error( + `The script was blocked because it used ${label(root)}.\n` + + `If this is intended, disable ${ruleHint(root)} via Settings → Scripting → Blocked roots.`, + ); + } + + // obj.constructor, obj['__proto__'], obj.getPrototypeOf, etc. + const prop = getMemberPropertyName(node); + if (prop && blockedProperties.has(prop)) { + throw new Error( + `The script was blocked because it used the property '${prop}'.\n` + + `If this is intended, disable '${prop}' via Settings → Scripting → Blocked properties.`, + ); + } + + // Symbol.species: Promise[Symbol.species] / Array[Symbol.species]. + if ( + node.object.type === 'Identifier' && + (node.object as acorn.Identifier).name === 'Symbol' && + prop === 'species' + ) { + throw new Error( + `The script was blocked because it used Symbol.species.\n` + + `If this is intended, disable 'species' via Settings → Scripting → Blocked properties.`, + ); + } + }, + VariableDeclarator(node: acorn.VariableDeclarator) { + if (node.id.type !== 'ObjectPattern') return; + // Destructuring declaration: const { require } = globalThis + if ( + node.init?.type === 'Identifier' && + blocked.has((node.init as acorn.Identifier).name) + ) { + const initName = (node.init as acorn.Identifier).name; + throw new Error( + `The script was blocked because it destructured from ${label(initName)}.\n` + + `If this is intended, disable ${ruleHint(initName)} via Settings → Scripting → Blocked roots.`, + ); + } + // Destructuring from this: const { process } = this + if (node.init?.type === 'ThisExpression' && blockedRoots.has('this')) { + throw new Error( + `The script was blocked because it destructured from 'this'.\n` + + `If this is intended, disable it via Settings → Scripting → Blocked roots.`, + ); + } + }, + AssignmentExpression(node: acorn.AssignmentExpression) { + // Destructuring assignment: ({ require } = globalThis) + if ( + node.left.type === 'ObjectPattern' && + node.right.type === 'Identifier' && + blocked.has((node.right as acorn.Identifier).name) + ) { + const rightName = (node.right as acorn.Identifier).name; + throw new Error( + `The script was blocked because it destructured from ${label(rightName)}.\n` + + `If this is intended, disable ${ruleHint(rightName)} via Settings → Scripting → Blocked roots.`, + ); + } + // Destructuring assignment from this: ({ process } = this) + if (node.left.type === 'ObjectPattern' && node.right.type === 'ThisExpression' && blockedRoots.has('this')) { + throw new Error( + `The script was blocked because it destructured from 'this'.\n` + + `If this is intended, disable it via Settings → Scripting → Blocked roots.`, + ); + } + }, + // Static import declaration: import fs from 'fs' + ImportDeclaration(_node: acorn.ImportDeclaration) { + throw new Error( + `The script was blocked because it used a static import declaration.\n` + + `If this is intended, disable 'eval-intercept' via Settings → Scripting → Enable script sandbox.`, + ); + }, + // Dynamic import(): import('node:child_process') + ImportExpression(_node: acorn.Node) { + throw new Error( + `The script was blocked because it used a dynamic import().\n` + + `If this is intended, disable 'eval-intercept' via Settings → Scripting → Enable script sandbox.`, + ); + }, + // Direct call of a blocked identifier: constructor('return process')() + // Not caught by MemberExpression since there is no property access involved. + CallExpression(node: acorn.CallExpression) { + if ( + node.callee.type === 'Identifier' && + blocked.has((node.callee as acorn.Identifier).name) + ) { + const calleeName = (node.callee as acorn.Identifier).name; + throw new Error( + `The script was blocked because it called ${label(calleeName)}.\n` + + `If this is intended, disable ${ruleHint(calleeName)} via Settings → Scripting → Blocked roots.`, + ); + } + }, + }); +} + +// Builds and applies the runtime security policy for user-supplied scripts. +// Extend with `.withRule()` or reduce with `.withoutRule()`. +export class ScriptSecurityPolicy { + constructor(private readonly rules: ThreatRule[]) {} + + // returns a policy with `rule` appended (immutable). + withRule(rule: ThreatRule): ScriptSecurityPolicy { + return new ScriptSecurityPolicy([...this.rules, rule]); + } + + // returns a policy with the named rule removed (immutable). + withoutRule(name: string): ScriptSecurityPolicy { + return new ScriptSecurityPolicy(this.rules.filter(r => r.name !== name)); + } + + // returns parallel `names` / `values` arrays for all rules that carry a runtime mask. + // Pass `violationCheck` to forward the caller's filtered checker (e.g. to eval-intercept). + buildMaskScope(violationCheck: (script: string) => void = checkSandboxViolations): { names: string[]; values: unknown[] } { + const names: string[] = []; + const values: unknown[] = []; + for (const rule of this.rules) { + if (rule.maskName !== undefined) { + names.push(rule.maskName); + values.push( + rule.buildMaskValue !== undefined + ? rule.buildMaskValue(violationCheck) + : rule.maskValue, + ); + } + } + return { names, values }; + } +} + +// Default policy (runtime interceptors and masks). +export const defaultSecurityPolicy = new ScriptSecurityPolicy([ + ...interceptorRules, + ...maskRules, +]); + +// runs all pre-execution security checks and initialises the script environment. +// 1. AST blockes globals, dangerous properties, aliasing, destructuring, dynamic import, and symbol.species. +// 2. mask scope returns the parallel names/values arrays +export async function prepareSandbox( + script: string, + context: RequestContext, + securityPolicy: ScriptSecurityPolicy = defaultSecurityPolicy, +): Promise { + const scriptConsole = getNewConsole(); + + let sandboxContext = context; + let maskNames: string[] = []; + let maskValues: unknown[] = []; + + if (context.settings.scriptSandboxEnabled !== false) { + const disabledProps = new Set(context.settings.disabledBlockedProperties); + const disabledRoots = new Set(context.settings.disabledBlockedRoots); + const activeProperties = new Set([...SANDBOX_BLOCKED_PROPERTIES].filter(p => !disabledProps.has(p))); + const activeRoots = new Set([...SANDBOX_BLOCKED_ROOTS].filter(r => !disabledRoots.has(r))); + + // Bind the filtered checker so eval-intercept uses the same active policy. + const activeSandboxCheck = (s: string) => checkSandboxViolations(s, activeProperties, activeRoots); + + try { + activeSandboxCheck(script); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + (error as NodeJS.ErrnoException).code = 'SECURITY_POLICY_VIOLATION'; + throw error; + } + + // prevents mutate via insomnia._settings. + sandboxContext = { ...context, settings: deepFreeze({ ...context.settings }) }; + // Always-on interceptors cannot be disabled via settings — filter them out before applying user overrides. + const disabledRules = (context.settings.disabledSecurityRules ?? []).filter( + name => !ALWAYS_ON_INTERCEPTORS.has(name), + ); + const activePolicy = disabledRules.reduce( + (policy, ruleName) => policy.withoutRule(ruleName), + securityPolicy, + ); + ({ names: maskNames, values: maskValues } = activePolicy.buildMaskScope(activeSandboxCheck)); + } else { + console.warn('[sandbox] script sandbox is disabled — running script without security checks'); + // Even with the sandbox off, always apply the require/window/eval interceptors. + const alwaysOnPolicy = new ScriptSecurityPolicy( + interceptorRules.filter(r => ALWAYS_ON_INTERCEPTORS.has(r.name)), + ); + ({ names: maskNames, values: maskValues } = alwaysOnPolicy.buildMaskScope(checkSandboxViolations)); + } + + const executionContext = await initInsomniaObject(sandboxContext, scriptConsole.log); + + const bridgeOps: BridgeOps = { + resetAsyncTasks: Object.freeze(window.bridge.resetAsyncTasks.bind(window.bridge)), + stopMonitorAsyncTasks: Object.freeze(window.bridge.stopMonitorAsyncTasks.bind(window.bridge)), + asyncTasksAllSettled: Object.freeze(window.bridge.asyncTasksAllSettled.bind(window.bridge)), + waitForAllTestsDone: Object.freeze(waitForAllTestsDone), + }; + + return { executionContext, scriptConsole, maskNames, maskValues, bridgeOps }; +} diff --git a/packages/insomnia/src/scripting/script-security-policy.ts b/packages/insomnia/src/scripting/script-security-policy.ts new file mode 100644 index 0000000000..5e56689170 --- /dev/null +++ b/packages/insomnia/src/scripting/script-security-policy.ts @@ -0,0 +1,162 @@ +import { invariant } from '../utils/invariant'; +import { requireInterceptor } from './require-interceptor'; + +export interface ASTRule { + name: string; // the identifier / property name being blocked. + description: string; +} + +export const blockedPropertyRules: ASTRule[] = [ + { name: 'prototype', description: 'Prototype mutation — direct assignment (e.g. Promise.prototype.then = ...) can corrupt built-ins for all code in the sandbox.' }, + { name: 'mainModule', description: 'Prevents accessing the reference property to the top-level module object.' }, + { name: 'constructor', description: 'Prevents accessing .constructor on any object.' }, + { name: '__proto__', description: 'Prototype mutation — direct prototype chain manipulation; can reassign an object\'s prototype to a host object.' }, + { name: 'prepareStackTrace', description: 'Stack inspection escape — V8 stack trace hook (CVE-2023-29017, CVE-2023-30547); a crafted Error can run arbitrary code during stringify.' }, + { name: 'captureStackTrace', description: 'Stack inspection — V8 method that captures the current call stack onto an object, exposing stack frame host objects.' }, + { name: 'getPrototypeOf', description: 'Prototype chain traversal — can reach the .constructor of a host object and reconstruct Function.' }, + { name: 'setPrototypeOf', description: 'Prototype mutation — directly replaces an object\'s prototype, enabling prototype chain manipulation at runtime.' }, + { name: 'getFunction', description: 'Stack inspection — V8 CallSite method that leaks unsanitised host objects from the call stack.' }, + { name: 'getThis', description: 'Stack inspection — V8 CallSite method that leaks the unsanitised receiver of each stack frame.' }, + { name: '__defineGetter__', description: 'Accessor helper — deprecated method that bypasses the normal property descriptor flow.' }, + { name: '__defineSetter__', description: 'Accessor helper — deprecated method that bypasses the normal property descriptor flow.' }, + { name: '__lookupGetter__', description: 'Accessor helper — deprecated method that can be used to inspect hidden property descriptors.' }, + { name: '__lookupSetter__', description: 'Accessor helper — deprecated method that can be used to inspect hidden property descriptors.' }, + { name: 'defineProperty', description: 'Property descriptor manipulation — installs arbitrary getters, setters, or non-configurable properties on any object including built-ins.' }, + { name: 'defineProperties', description: 'Property descriptor manipulation — same as defineProperty but for multiple properties at once.' }, + { name: 'getOwnPropertyDescriptor', description: 'Property descriptor inspection — returns the full descriptor including any getter/setter functions, which may be host objects.' }, + { name: 'getOwnPropertyDescriptors', description: 'Property descriptor inspection — returns all property descriptors at once; same risk as getOwnPropertyDescriptor.' }, +]; + +export const blockedRootRules: ASTRule[] = [ + { name: 'this', description: 'Global object access — in the outer AsyncFunction scope (non-strict) \'this\' is the host global object, with the same reach as globalThis.' }, + { name: 'globalThis', description: 'Global object access — primary global object alias that exposes every host API that parameter masking is meant to hide.' }, + { name: 'global', description: 'Global object access — Node.js alias for globalThis; dynamic access (e.g. global["req"+"uire"]) bypasses string-literal detection.' }, + { name: 'window', description: 'Global object access — browser global alias; inside Electron it also reaches Node.js APIs via window.bridge and similar.' }, + { name: 'self', description: 'Global object access — Web Worker / browser alias for globalThis; available in some Electron renderer contexts.' }, + { name: 'frames', description: 'Global object access — browser alias for the window.frames collection; can be used to navigate to an unsandboxed global.' }, + { name: 'process', description: 'Node.js internals access — exposes mainModule, env, and other Node.js internals not part of the supported scripting API.' }, + { name: 'module', description: 'Module system bypass — Node.js module wrapper object; .require and .children expose the full module graph.' }, + { name: 'exports', description: 'Module system bypass — Node.js module exports object; mutating it affects the live module cache.' }, + { name: 'Buffer', description: 'Unsafe memory access — the Buffer global provides allocUnsafe(), which reads uninitialised memory.' }, + { name: 'constructor', description: 'Function constructor escape — in AsyncFunction scope this IS AsyncFunction; a direct call constructs a new function in the real global scope.' }, + { name: 'arguments', description: 'Caller inspection — can leak the caller\'s frame in generator or sloppy-mode contexts, exposing host objects.' }, +]; + +export interface ThreatRule { + name: string; // unique rule id. + description: string; // message detailing the block reason. + maskName?: string; // identifier to mask in the script's function scope + maskValue?: unknown; // value bound to `maskName`. (normally `undefined` or a interceptor function). + buildMaskValue?: (violationCheck: (script: string) => void) => unknown; // Factory called at buildMaskScope() time. Receives checkSandboxViolations so interceptors can perform full static analysis on dynamic input (e.g. eval strings). +} + +// mask interceptor binding rules. +export const interceptorRules: ThreatRule[] = [ + { + name: 'require', + description: 'Replaces the require() function with an interceptor to prevent access to modules outside an explicit allowlist.', + maskName: 'require', + maskValue: requireInterceptor, + }, + { + name: 'window', + description: 'Replaces the window object with a restricted proxy to prevent access to host APIs beyond the three bridge methods the script executor requires.', + maskName: 'window', + buildMaskValue: _violationCheck => { + if (typeof window === 'undefined') { + return; + } + const allowedBridgeMethods = new Set([ + 'resetAsyncTasks', + 'stopMonitorAsyncTasks', + 'asyncTasksAllSettled', + ]); + const bridgeProxy = new Proxy(window.bridge, { + get(target, prop: string | symbol) { + if (allowedBridgeMethods.has(prop)) { + return Reflect.get(target, prop); + } + return; + }, + }); + return new Proxy(window, { + get(_target, prop: string | symbol) { + if (prop === 'bridge') { + return bridgeProxy; + } + return; + }, + }); + }, + }, + { + name: 'eval', + description: 'Replaces the eval() function with an interceptor to prevent execution of scripts containing sandbox violations.', + maskName: 'eval', + buildMaskValue: violationCheck => (script: string) => { + invariant(script && typeof script === 'string', 'eval is called with invalid or empty value'); + violationCheck(script); + + + return (0, eval)(script); + }, + }, +]; + +// Runtime masks — bindings replaced with undefined to make them unreachable. +export const maskRules: ThreatRule[] = [ + { + name: 'globalThis', + description: 'Prevents access to the globalThis object to prevent exposure of process, require, and other host APIs that parameter masking is meant to hide.', + maskName: 'globalThis', + maskValue: undefined, + }, + { + name: 'global', + description: 'Prevents access to the global parameter (Node.js alias for globalThis) to prevent dynamic access to host APIs (e.g. global["req"+"uire"]).', + maskName: 'global', + maskValue: undefined, + }, + { + name: 'Function', + description: 'Prevents access to the Function constructor to prevent creation of new functions in the real global scope, escaping parameter-level masking (e.g. Function("return process")()).', + maskName: 'Function', + maskValue: undefined, + }, + { + name: 'process', + description: 'Prevents access to the process object to prevent exposure of mainModule, env, and other Node.js internals not part of the supported scripting API.', + maskName: 'process', + maskValue: undefined, + }, + { + name: 'setImmediate', + description: 'Prevents access to the setImmediate function to prevent its use as an untracked async scheduling side-channel.', + maskName: 'setImmediate', + maskValue: undefined, + }, + { + name: 'queueMicrotask', + maskName: 'queueMicrotask', + description: 'Prevents access to the queueMicrotask function to prevent scheduling work outside the async/await flow tracked by the executor, which would make clean shutdown harder.', + maskValue: undefined, + }, + { + name: 'Proxy', + description: 'Prevents access to the Proxy constructor to prevent apply/construct traps from receiving unwrapped host objects, which enables prototype chain traversal to real host globals (CVE-2023-32314).', + maskName: 'Proxy', + maskValue: undefined, + }, + { + name: 'Reflect', + description: 'Prevents access to the Reflect object to prevent Reflect.apply() and Reflect.construct() from invoking functions with an explicit this value, bypassing the strict-mode this===undefined invariant.', + maskName: 'Reflect', + maskValue: undefined, + }, + { + name: 'WebAssembly', + description: 'Prevents access to the WebAssembly API to prevent loading and executing arbitrary native bytecode, which would bypass JS-level sandboxing entirely.', + maskName: 'WebAssembly', + maskValue: undefined, + }, +]; diff --git a/packages/insomnia/src/models/__schemas__/model-schemas.ts b/packages/insomnia/src/sync/__schemas__/model-schemas.ts similarity index 85% rename from packages/insomnia/src/models/__schemas__/model-schemas.ts rename to packages/insomnia/src/sync/__schemas__/model-schemas.ts index 7543b92989..45b0004e59 100644 --- a/packages/insomnia/src/models/__schemas__/model-schemas.ts +++ b/packages/insomnia/src/sync/__schemas__/model-schemas.ts @@ -1,10 +1,10 @@ import type { Schema } from '@develohpanda/fluent-builder'; import clone from 'clone'; -import type { Environment, GrpcRequest, Request, RequestGroup, Workspace } from '~/insomnia-data'; -import { EnvironmentKvPairDataType, EnvironmentType } from '~/insomnia-data'; +import type { AllTypes, BaseModel, Environment, GrpcRequest, Request, RequestGroup, Workspace } from '~/insomnia-data'; +import { EnvironmentKvPairDataType, EnvironmentType, models } from '~/insomnia-data'; -import { type AllTypes, type BaseModel, environment, grpcRequest, request, requestGroup, workspace } from '..'; +const { environment, grpcRequest, request, requestGroup, workspace } = models; // move into fluent-builder const toSchema = (obj: T): Schema => { diff --git a/packages/insomnia/src/sync/__schemas__/type-schemas.ts b/packages/insomnia/src/sync/__schemas__/type-schemas.ts index 35a58f2233..257cbb1251 100644 --- a/packages/insomnia/src/sync/__schemas__/type-schemas.ts +++ b/packages/insomnia/src/sync/__schemas__/type-schemas.ts @@ -1,8 +1,15 @@ import { createBuilder, type Schema } from '@develohpanda/fluent-builder'; -import { baseModelSchema } from '../../models/__schemas__/model-schemas'; -import type { BackendProject, Branch, MergeConflict, SnapshotStateEntry, StatusCandidate, Team } from '../types'; -import type { BackendProjectWithTeam } from '../vcs/normalize-backend-project-team'; +import { baseModelSchema } from '../../sync/__schemas__/model-schemas'; +import type { + BackendProject, + BackendProjectWithTeam, + Branch, + MergeConflict, + SnapshotStateEntry, + StatusCandidate, + Team, +} from '../types'; export const projectSchema: Schema = { id: () => 'id', diff --git a/packages/insomnia/src/sync/__tests__/ignore-keys.test.ts b/packages/insomnia/src/sync/__tests__/ignore-keys.test.ts index ce604a3943..1ba9be6732 100644 --- a/packages/insomnia/src/sync/__tests__/ignore-keys.test.ts +++ b/packages/insomnia/src/sync/__tests__/ignore-keys.test.ts @@ -1,7 +1,7 @@ import { createBuilder } from '@develohpanda/fluent-builder'; import { describe, expect, it } from 'vitest'; -import { baseModelSchema, workspaceModelSchema } from '../../models/__schemas__/model-schemas'; +import { baseModelSchema, workspaceModelSchema } from '../__schemas__/model-schemas'; import { deleteKeys, resetKeys, shouldIgnoreKey } from '../ignore-keys'; const baseModelBuilder = createBuilder(baseModelSchema); diff --git a/packages/insomnia/src/sync/access-error.test.ts b/packages/insomnia/src/sync/access-error.test.ts new file mode 100644 index 0000000000..95ee32097a --- /dev/null +++ b/packages/insomnia/src/sync/access-error.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { interceptAccessError } from './access-error'; + +describe('interceptAccessError', () => { + it('intercepts an error', async () => { + const action = async () => + (await interceptAccessError({ + action: 'action', + callback: () => { + throw new Error('DANGER! invalid access to the fifth dimensional nebulo 9.'); + }, + resourceName: 'resourceName', + resourceType: 'resourceType', + })) as Error; + + await expect(action).rejects.toBeInstanceOf(Error); + await expect(action).rejects.toThrowError( + 'You no longer have permission to action the "resourceName" resourceType. Contact your team administrator if you think this is an error.', + ); + }); + + it("does not intercept errors it doesn't care about", async () => { + const message = + 'Having been rejected by the planet smasher, Ziltoid seeks the council of the omnidimensional creator.'; + + const action = async () => + (await interceptAccessError({ + action: 'action', + callback: () => { + throw new Error(message); + }, + resourceName: 'resourceName', + resourceType: 'resourceType', + })) as Error; + + await expect(action).rejects.toBeInstanceOf(Error); + await expect(action).rejects.toThrowError(message); + }); +}); diff --git a/packages/insomnia/src/sync/access-error.ts b/packages/insomnia/src/sync/access-error.ts new file mode 100644 index 0000000000..2a34eafb9f --- /dev/null +++ b/packages/insomnia/src/sync/access-error.ts @@ -0,0 +1,24 @@ +import { strings } from '../common/strings'; + +export const interceptAccessError = async ({ + callback, + action, + resourceName, + resourceType = strings.collection.singular.toLowerCase(), +}: { + callback: () => T | Promise; + action: string; + resourceName: string; + resourceType?: string; +}) => { + try { + return await callback(); + } catch (error: unknown) { + if (error instanceof Error && error.message.includes('invalid access')) { + throw new Error( + `You no longer have permission to ${action} the "${resourceName}" ${resourceType}. Contact your team administrator if you think this is an error.`, + ); + } + throw error; + } +}; diff --git a/packages/insomnia/src/sync/git/__tests__/git-repo-migration.test.ts b/packages/insomnia/src/sync/git/__tests__/git-repo-migration.test.ts new file mode 100644 index 0000000000..077c99be79 --- /dev/null +++ b/packages/insomnia/src/sync/git/__tests__/git-repo-migration.test.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { services } from '~/insomnia-data'; + +import { database as db } from '../../../common/database'; +import { CURRENT_MIGRATION_VERSION, migrateRepoStructureIfNeeded } from '../git-repo-migration'; + +vi.mock('../../../common/insomnia-v5', () => ({ + getInsomniaV5DataExport: vi.fn().mockResolvedValue(''), +})); + +const mkDir = (dirPath: string) => fs.promises.mkdir(dirPath, { recursive: true }); +const fileExists = (filePath: string) => + fs.promises + .access(filePath) + .then(() => true) + .catch(() => false); +const dirExists = (dirPath: string) => + fs.promises + .stat(dirPath) + .then(s => s.isDirectory()) + .catch(() => false); + +type LogEntry = string; +const makeLogger = () => { + const logs: LogEntry[] = []; + const logger = (level: 'info' | 'warn' | 'error', message: string) => + logs.push(`[${level.toUpperCase()}] ${message}`); + return { logs, logger }; +}; + +describe('migrateRepoStructureIfNeeded', () => { + let baseDir: string; + + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + baseDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-git-migration-')); + }); + + afterEach(async () => { + await fs.promises.rm(baseDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('returns true immediately when already migrated and no old directories exist', async () => { + await services.gitRepository.create({ _id: 'git_repo_a', repoMigrationVersion: CURRENT_MIGRATION_VERSION }); + const { logs, logger } = makeLogger(); + + const result = await migrateRepoStructureIfNeeded(baseDir, 'proj_a', 'git_repo_a', logger); + + expect(result).toBe(true); + expect(logs).toHaveLength(0); + }); + + it('re-runs migration when old git/ directory exists even if version stamp is current', async () => { + await services.gitRepository.create({ _id: 'git_repo_b', repoMigrationVersion: CURRENT_MIGRATION_VERSION }); + await mkDir(path.join(baseDir, 'git')); + await fs.promises.writeFile(path.join(baseDir, 'git', 'config'), '[core]\n\trepositoryformatversion = 0'); + const { logger } = makeLogger(); + + const result = await migrateRepoStructureIfNeeded(baseDir, 'proj_b', 'git_repo_b', logger); + + expect(result).toBe(true); + expect(await dirExists(path.join(baseDir, '.git'))).toBe(true); + expect(await dirExists(path.join(baseDir, 'git'))).toBe(false); + }); + + it('renames git/ to .git/ and preserves contents', async () => { + await services.gitRepository.create({ _id: 'git_repo_c' }); + await mkDir(path.join(baseDir, 'git')); + await fs.promises.writeFile(path.join(baseDir, 'git', 'config'), '[core]\n\trepositoryformatversion = 0'); + const { logger } = makeLogger(); + + const result = await migrateRepoStructureIfNeeded(baseDir, 'proj_c', 'git_repo_c', logger); + + expect(result).toBe(true); + expect(await dirExists(path.join(baseDir, '.git'))).toBe(true); + expect(await fileExists(path.join(baseDir, '.git', 'config'))).toBe(true); + expect(await dirExists(path.join(baseDir, 'git'))).toBe(false); + }); + + it('moves other/ contents to repo root', async () => { + await services.gitRepository.create({ _id: 'git_repo_d' }); + await mkDir(path.join(baseDir, 'other')); + await fs.promises.writeFile(path.join(baseDir, 'other', 'README.md'), '# Hello'); + const { logger } = makeLogger(); + + const result = await migrateRepoStructureIfNeeded(baseDir, 'proj_d', 'git_repo_d', logger); + + expect(result).toBe(true); + expect(await fileExists(path.join(baseDir, 'README.md'))).toBe(true); + expect(await dirExists(path.join(baseDir, 'other'))).toBe(false); + }); + + it('writes workspace YAML to disk', async () => { + const { getInsomniaV5DataExport } = await import('../../../common/insomnia-v5'); + vi.mocked(getInsomniaV5DataExport).mockResolvedValueOnce('name: My Workspace\n'); + + await services.gitRepository.create({ _id: 'git_repo_e' }); + await services.project.create({ _id: 'proj_e', name: 'Test Project' }); + await services.workspace.create({ _id: 'wrk_e', name: 'My Workspace', parentId: 'proj_e', scope: 'collection' }); + const { logger } = makeLogger(); + + await migrateRepoStructureIfNeeded(baseDir, 'proj_e', 'git_repo_e', logger); + + const yamlPath = path.join(baseDir, 'insomnia.wrk_e.yaml'); + expect(await fileExists(yamlPath)).toBe(true); + const content = await fs.promises.readFile(yamlPath, 'utf8'); + expect(content).toBe('name: My Workspace\n'); + }); + + it('does not include the repo ID in any log message', async () => { + await services.gitRepository.create({ _id: 'git_repo_f' }); + await mkDir(path.join(baseDir, 'git')); + await fs.promises.writeFile(path.join(baseDir, 'git', 'config'), '[core]'); + const { logs, logger } = makeLogger(); + + await migrateRepoStructureIfNeeded(baseDir, 'proj_f', 'git_repo_f', logger); + + for (const entry of logs) { + expect(entry).not.toContain('git_repo_f'); + } + }); + + it('returns false and includes stack trace in error log when migration fails', async () => { + await services.gitRepository.create({ _id: 'git_repo_g' }); + + const error = new Error('DB write failed'); + error.stack = 'Error: DB write failed\n at markMigrated (git-repo-migration.ts:70)'; + vi.spyOn(db, 'docUpdate').mockRejectedValueOnce(error); + + const { logs, logger } = makeLogger(); + const result = await migrateRepoStructureIfNeeded(baseDir, 'proj_g', 'git_repo_g', logger); + + expect(result).toBe(false); + const errorEntry = logs.find(l => l.startsWith('[ERROR]')); + expect(errorEntry).toBeDefined(); + expect(errorEntry).toContain('DB write failed'); + expect(errorEntry).toContain('at markMigrated'); + }); +}); diff --git a/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts b/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts index 0123f598bf..7473623066 100644 --- a/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import * as git from 'isomorphic-git'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR } from '../git-vcs'; +import GitVCS, { GIT_CLONE_DIR, GIT_INSOMNIA_DIR, MergeConflictError } from '../git-vcs'; import { MemClient } from '../mem-client'; describe('Git-VCS', () => { @@ -522,4 +522,271 @@ First commit! expect((await fsClient.promises.readFile(nestedFile)).toString()).toBe(originalContent + '\n'); }); }); + + describe('buildManualResolutionFromTrees()', () => { + it('should collect non-YAML conflicts as autoResolvedConflicts and only return YAML conflicts', async () => { + const fsClient = MemClient.createClient(); + const yamlFile = path.join(GIT_INSOMNIA_DIR, 'Environment', 'env_1.yaml'); + const gitignoreFile = '.gitignore'; + + // Create directories + await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); + await fsClient.promises.mkdir(path.join(GIT_INSOMNIA_DIR, 'Environment')); + + // Create initial files + await fsClient.promises.writeFile(yamlFile, 'name: base env\n'); + await fsClient.promises.writeFile(gitignoreFile, 'node_modules\n'); + + await GitVCS.init({ + uri: '', + repoId: '', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + // Stage and commit all files + const status = await GitVCS.status(); + await GitVCS.stageChanges(status.unstaged); + await GitVCS.commit('Initial commit'); + + // Create origin/main branch to simulate remote + await git.branch({ fs: fsClient, dir: GIT_CLONE_DIR, ref: 'origin/main', checkout: false }); + + // Make local changes on main + await fsClient.promises.writeFile(yamlFile, 'name: local env\n'); + await fsClient.promises.writeFile(gitignoreFile, 'node_modules\ndist\n'); + const status2 = await GitVCS.status(); + await GitVCS.stageChanges(status2.unstaged); + await GitVCS.commit('Local changes'); + + // Switch to origin/main and make different changes + await git.checkout({ fs: fsClient, dir: GIT_CLONE_DIR, ref: 'origin/main' }); + await fsClient.promises.writeFile(yamlFile, 'name: remote env\n'); + await fsClient.promises.writeFile(gitignoreFile, 'node_modules\nbuild\n'); + await git.add({ fs: fsClient, dir: GIT_CLONE_DIR, filepath: yamlFile }); + await git.add({ fs: fsClient, dir: GIT_CLONE_DIR, filepath: gitignoreFile }); + await git.commit({ + fs: fsClient, + dir: GIT_CLONE_DIR, + message: 'Remote changes', + author: { name: 'Remote User', email: 'remote@example.com' }, + }); + + // Switch back to main + await git.checkout({ fs: fsClient, dir: GIT_CLONE_DIR, ref: 'main' }); + + // Call buildManualResolutionFromTrees — it should throw MergeConflictError + try { + await GitVCS.buildManualResolutionFromTrees(); + expect.unreachable('Should have thrown MergeConflictError'); + } catch (err) { + expect(err).toBeInstanceOf(MergeConflictError); + + const mergeErr = err as MergeConflictError; + // Only YAML conflicts should appear in the conflicts array + expect(mergeErr.data.conflicts).toHaveLength(1); + expect(mergeErr.data.conflicts[0].key).toBe(yamlFile); + + // Non-YAML files should be in autoResolvedConflicts for deferred staging + expect(mergeErr.data.autoResolvedConflicts).toHaveLength(1); + expect(mergeErr.data.autoResolvedConflicts[0]).toEqual({ + filepath: gitignoreFile, + action: 'use-theirs', + }); + } + }); + + it('should auto-complete merge without throwing when all conflicts are non-YAML', async () => { + const fsClient = MemClient.createClient(); + const gitignoreFile = '.gitignore'; + const readmeFile = 'README.md'; + + // Create initial files (no YAML files that conflict) + await fsClient.promises.writeFile(gitignoreFile, 'node_modules\n'); + await fsClient.promises.writeFile(readmeFile, '# Project\n'); + + await GitVCS.init({ + uri: '', + repoId: '', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + // Stage and commit all files + const status = await GitVCS.status(); + await GitVCS.stageChanges(status.unstaged); + await GitVCS.commit('Initial commit'); + + // Create origin/main branch to simulate remote + await git.branch({ fs: fsClient, dir: GIT_CLONE_DIR, ref: 'origin/main', checkout: false }); + + // Make local changes on main + await fsClient.promises.writeFile(gitignoreFile, 'node_modules\ndist\n'); + await fsClient.promises.writeFile(readmeFile, '# Project\nLocal changes\n'); + const status2 = await GitVCS.status(); + await GitVCS.stageChanges(status2.unstaged); + await GitVCS.commit('Local changes'); + + // Switch to origin/main and make different changes + await git.checkout({ fs: fsClient, dir: GIT_CLONE_DIR, ref: 'origin/main' }); + await fsClient.promises.writeFile(gitignoreFile, 'node_modules\nbuild\n'); + await fsClient.promises.writeFile(readmeFile, '# Project\nRemote changes\n'); + await git.add({ fs: fsClient, dir: GIT_CLONE_DIR, filepath: gitignoreFile }); + await git.add({ fs: fsClient, dir: GIT_CLONE_DIR, filepath: readmeFile }); + await git.commit({ + fs: fsClient, + dir: GIT_CLONE_DIR, + message: 'Remote changes', + author: { name: 'Remote User', email: 'remote@example.com' }, + }); + + // Switch back to main + await git.checkout({ fs: fsClient, dir: GIT_CLONE_DIR, ref: 'main' }); + + // buildManualResolutionFromTrees should NOT throw — all conflicts are non-YAML + const result = await GitVCS.buildManualResolutionFromTrees(); + expect(result).toEqual({ autoResolved: true }); + + // Non-YAML files should be resolved to the remote (theirs) version + const gitignoreContent = (await fsClient.promises.readFile(gitignoreFile)).toString(); + expect(gitignoreContent).toBe('node_modules\nbuild\n'); + + const readmeContent = (await fsClient.promises.readFile(readmeFile)).toString(); + expect(readmeContent).toBe('# Project\nRemote changes\n'); + }); + }); + + describe('getBranchTrackingRemote', () => { + it('returns null when no tracking remote is set', async () => { + const fsClient = MemClient.createClient(); + await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + + await GitVCS.init({ + uri: '', + repoId: 'test-remote-info', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + const remote = await GitVCS.getBranchTrackingRemote(); + expect(remote).toBeNull(); + }); + + it('returns the configured tracking remote', async () => { + const fsClient = MemClient.createClient(); + await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + + await GitVCS.init({ + uri: '', + repoId: 'test-tracking-remote', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + // Manually set tracking remote via git config + const branch = await GitVCS.getCurrentBranch(); + await git.setConfig({ + fs: fsClient, + dir: GIT_CLONE_DIR, + path: `branch.${branch}.remote`, + value: 'upstream', + }); + + const remote = await GitVCS.getBranchTrackingRemote(); + expect(remote).toBe('upstream'); + }); + }); + + describe('getBranchRemoteInfo', () => { + it('returns isOrigin true when no tracking remote is set', async () => { + const fsClient = MemClient.createClient(); + await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + + await GitVCS.init({ + uri: '', + repoId: 'test-branch-info-origin', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + const info = await GitVCS.getBranchRemoteInfo(); + expect(info.trackingRemote).toBeNull(); + expect(info.isOrigin).toBe(true); + expect(info.remoteUrl).toBeNull(); + }); + + it('returns isOrigin true when tracking remote is origin', async () => { + const fsClient = MemClient.createClient(); + await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + + await GitVCS.init({ + uri: '', + repoId: 'test-branch-info-explicit-origin', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + const branch = await GitVCS.getCurrentBranch(); + await git.setConfig({ + fs: fsClient, + dir: GIT_CLONE_DIR, + path: `branch.${branch}.remote`, + value: 'origin', + }); + + const info = await GitVCS.getBranchRemoteInfo(); + expect(info.trackingRemote).toBe('origin'); + expect(info.isOrigin).toBe(true); + }); + + it('returns isOrigin false when tracking a non-origin remote', async () => { + const fsClient = MemClient.createClient(); + await fsClient.promises.mkdir(GIT_INSOMNIA_DIR); + await fsClient.promises.writeFile(path.join(GIT_INSOMNIA_DIR, fooTxt), 'foo'); + + await GitVCS.init({ + uri: '', + repoId: 'test-branch-info-non-origin', + directory: GIT_CLONE_DIR, + fs: fsClient, + legacyDiff: true, + }); + await GitVCS.setAuthor({ name: 'Karen Brown', email: 'karen@example.com' }); + + const branch = await GitVCS.getCurrentBranch(); + await git.setConfig({ + fs: fsClient, + dir: GIT_CLONE_DIR, + path: `branch.${branch}.remote`, + value: 'upstream', + }); + await git.setConfig({ + fs: fsClient, + dir: GIT_CLONE_DIR, + path: 'remote.upstream.url', + value: 'https://github.com/other/repo.git', + }); + + const info = await GitVCS.getBranchRemoteInfo(); + expect(info.trackingRemote).toBe('upstream'); + expect(info.isOrigin).toBe(false); + expect(info.remoteUrl).toBe('https://github.com/other/repo.git'); + }); + }); }); diff --git a/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts b/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts index e48d21859e..83edd47908 100644 --- a/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts @@ -11,11 +11,10 @@ import { createBuilder } from '@develohpanda/fluent-builder'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import YAML from 'yaml'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { database as db } from '../../../common/database'; -import * as models from '../../../models'; -import { workspaceModelSchema } from '../../../models/__schemas__/model-schemas'; +import { workspaceModelSchema } from '../../__schemas__/model-schemas'; import { GIT_CLONE_DIR, GIT_INSOMNIA_DIR, GIT_INSOMNIA_DIR_NAME } from '../git-vcs'; import { NeDBClient } from '../ne-db-client'; import { assertAsyncError } from './util'; diff --git a/packages/insomnia/src/sync/git/__tests__/parse-git-path.test.ts b/packages/insomnia/src/sync/git/__tests__/parse-git-path.test.ts index 0208cfcc06..14da043b17 100644 --- a/packages/insomnia/src/sync/git/__tests__/parse-git-path.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/parse-git-path.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import * as models from '../../../models'; +import { models } from '~/insomnia-data'; + import { GIT_INSOMNIA_DIR } from '../git-vcs'; import parseGitPath from '../parse-git-path'; diff --git a/packages/insomnia/src/sync/git/git-migration-version.ts b/packages/insomnia/src/sync/git/git-migration-version.ts new file mode 100644 index 0000000000..47efb95255 --- /dev/null +++ b/packages/insomnia/src/sync/git/git-migration-version.ts @@ -0,0 +1,5 @@ +/** + * Increment whenever a new migration step is added to git-repo-migration.ts. + * Shared between the main process (migration logic) and the renderer (route gate). + */ +export const CURRENT_MIGRATION_VERSION = 1; diff --git a/packages/insomnia/src/sync/git/git-repo-migration.ts b/packages/insomnia/src/sync/git/git-repo-migration.ts new file mode 100644 index 0000000000..62b5ae5468 --- /dev/null +++ b/packages/insomnia/src/sync/git/git-repo-migration.ts @@ -0,0 +1,378 @@ +/** + * Git Repository Structure Migration + * + * Migrates existing on-disk git repositories from the old layout to the new + * layout that lets users run native Git CLI commands directly against the repo. + * + * Old layout: + * {baseDir}/git/ ← git internals (isomorphic-git used 'git' as gitdir) + * {baseDir}/other/ ← non-YAML files + * (Insomnia YAML was virtual / DB-only) + * + * New layout: + * {baseDir}/.git/ ← standard git internals + * {baseDir}/ ← non-YAML files at root + * {baseDir}/insomnia.{id}.yaml ← Insomnia YAML on disk AND in DB + * + * The migration is: + * 1. Idempotent – version-stamped via `GitRepository.repoMigrationVersion` in + * the DB. When an older app version runs `docUpdate` on the same record it + * prunes unknown fields, so the stamp is cleared and the migration re-runs + * on the next upgrade (correct behavior after a version rollback). + * 2. Best-effort – errors are logged but never fatal; the app still loads. + * 3. Run once at repository load time (before VCS initialization). + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export type MigrationLogger = (level: 'info' | 'warn' | 'error', message: string) => void; + +import type { GitRepository, Workspace, WorkspaceMeta } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; + +import { getInsomniaV5DataExport } from '../../common/insomnia-v5'; +import { CURRENT_MIGRATION_VERSION } from './git-migration-version'; + +export { CURRENT_MIGRATION_VERSION }; + +// In-memory guard against concurrent migrations for the same repo within a +// single process. The DB version stamp handles cross-process / cross-session +// idempotency. +const inProgressMigrations = new Set(); + +// --------------------------------------------------------------------------- +// Idempotency helpers (DB-backed, version-stamped) +// --------------------------------------------------------------------------- + +/** + * Returns true if the migration has already run at the current version AND + * the on-disk layout looks correct. The disk check takes precedence so that a + * downgrade that recreates the old directories is always caught. + * + * Accepts a pre-fetched `gitRepo` so the caller avoids an extra DB round-trip. + */ +async function hasMigrated(baseDir: string, gitRepo: GitRepository | null | undefined): Promise { + // Disk override: old layout directories mean migration is definitely needed. + // Both checks run in parallel — they're independent stat calls. + const [hasOldGit, hasOldOther] = await Promise.all([ + dirExists(path.resolve(baseDir, 'git')), + dirExists(path.resolve(baseDir, 'other')), + ]); + if (hasOldGit || hasOldOther) return false; + + return (gitRepo?.repoMigrationVersion ?? 0) >= CURRENT_MIGRATION_VERSION; +} + +async function markMigrated(gitRepo: GitRepository): Promise { + await db.docUpdate(gitRepo, { + repoMigrationVersion: CURRENT_MIGRATION_VERSION, + }); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Recursively move everything inside `srcDir` into `destDir`, then remove + * `srcDir`. Files that already exist at the destination are overwritten. + * All entries at each level are processed in parallel. + */ +async function moveDirectoryContents(srcDir: string, destDir: string, logger?: MigrationLogger): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(srcDir, { withFileTypes: true }); + } catch { + return; // srcDir doesn't exist or isn't readable + } + + await Promise.all( + entries.map(async entry => { + const resolvedSrcDir = path.resolve(srcDir); + const resolvedDestDir = path.resolve(destDir); + const srcPath = path.resolve(resolvedSrcDir, entry.name); + const destPath = path.resolve(resolvedDestDir, entry.name); + + // Guard against crafted entry names containing traversal sequences. + const relSrc = path.relative(resolvedSrcDir, srcPath); + const relDest = path.relative(resolvedDestDir, destPath); + if (relSrc.startsWith('..') || path.isAbsolute(relSrc) || relDest.startsWith('..') || path.isAbsolute(relDest)) { + logger?.('warn', `Skipping entry with unsafe name: ${entry.name}`); + return; + } + + if (entry.isDirectory()) { + await fs.promises.mkdir(destPath, { recursive: true }); + await moveDirectoryContents(srcPath, destPath, logger); + try { + await fs.promises.rm(srcPath, { recursive: true }); + } catch { + // Ignore if already gone + } + } else if (entry.isSymbolicLink()) { + // Preserve symlinks — copyFile would dereference them, losing the link. + const linkTarget = await fs.promises.readlink(srcPath); + try { + await fs.promises.symlink(linkTarget, destPath); + } catch (err: unknown) { + // Only ignore EEXIST — any other failure (e.g. permissions) is real. + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } + await fs.promises.unlink(srcPath); + } else { + const destExists = await fs.promises + .access(destPath) + .then(() => true) + .catch(() => false); + if (destExists) { + console.warn('[git-migration] Overwriting existing file during move:', destPath); + logger?.('warn', `Overwriting existing file during move: ${destPath}`); + } + await fs.promises.rename(srcPath, destPath).catch(async () => { + // Cross-device rename falls back to copy + delete + await fs.promises.copyFile(srcPath, destPath); + await fs.promises.unlink(srcPath); + }); + } + }), + ); + + try { + await fs.promises.rm(srcDir, { recursive: true }); + } catch { + // Ignore if already gone + } +} + +/** + * Check whether a directory exists. + */ +async function dirExists(dirPath: string): Promise { + try { + const stat = await fs.promises.stat(dirPath); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Remove `core.worktree` from `.git/config` if present. + * + * isomorphic-git does not write `core.worktree`, but a user or an external + * tool might have added it. After migration the worktree is the default + * (parent of `.git/`), so any stale entry must be stripped to prevent native + * git commands from resolving to the wrong path. + */ +async function sanitizeGitConfig(gitDir: string, logger?: MigrationLogger): Promise { + const configPath = path.resolve(gitDir, 'config'); + try { + const original = await fs.promises.readFile(configPath, 'utf8'); + const sanitized = original + .split('\n') + .filter(line => !/^\s*worktree\s*=/.test(line)) + .join('\n'); + if (sanitized !== original) { + await fs.promises.writeFile(configPath, sanitized, 'utf8'); + console.log('[git-migration] Removed stale core.worktree from .git/config'); + logger?.('info', 'Removed stale core.worktree from .git/config'); + } + } catch { + // Config may not exist yet or is unreadable — not fatal + } +} + +// --------------------------------------------------------------------------- +// Exported migration entry point +// --------------------------------------------------------------------------- + +/** + * Migrate the on-disk structure of a git repository to the new layout. + * Safe to call on every app load — it is a no-op if already done. + * + * @param baseDir Absolute path to the repository root + * (e.g. `{userData}/version-control/git/{gitRepositoryId}`) + * @param projectId The project that owns this repository + * @param gitRepositoryId Used for the idempotency guard key + */ +export async function migrateRepoStructureIfNeeded( + baseDir: string, + projectId: string, + gitRepositoryId: string, + logger?: MigrationLogger, +): Promise { + // Reject non-absolute paths — a relative baseDir could be used to escape the + // intended data directory via traversal sequences. + if (!path.isAbsolute(baseDir)) { + logger?.('error', `Refusing migration for non-absolute baseDir: ${baseDir}`); + return false; + } + + // Fast synchronous guard first — avoids the async DB lookup for concurrent calls. + if (inProgressMigrations.has(gitRepositoryId)) { + return true; + } + + // Fetch the repo record once and reuse it for both the migration check and + // the version stamp update — avoids two round-trips to NeDB. + const gitRepo = await db.findOne(models.gitRepository.type, { + _id: gitRepositoryId, + }); + + if (await hasMigrated(baseDir, gitRepo)) { + return true; + } + + inProgressMigrations.add(gitRepositoryId); + + console.log(`[git-migration] Starting structure migration for repo ${gitRepositoryId}`); + logger?.('info', 'Starting structure migration'); + + let success = false; + try { + // Step 1: Rename git/ → .git/ + // If the process was interrupted mid-copy on a previous run, both dirs may + // exist. In that case we resume the copy rather than skipping. + const oldGitDir = path.join(baseDir, 'git'); + const newGitDir = path.join(baseDir, '.git'); + + if (await dirExists(oldGitDir)) { + console.log('[git-migration] Renaming git/ → .git/'); + logger?.('info', 'Renaming git/ → .git/'); + // .git already exists — resume copying any remaining files from git/ + await (!(await dirExists(newGitDir)) + ? fs.promises.rename(oldGitDir, newGitDir).catch(async () => { + // Fallback for cross-device issues (unlikely since same volume, but safe) + await fs.promises.mkdir(newGitDir, { recursive: true }); + await moveDirectoryContents(oldGitDir, newGitDir, logger); + }) + : moveDirectoryContents(oldGitDir, newGitDir, logger)); + + // Strip stale core.worktree entries — the new layout uses the default. + await sanitizeGitConfig(newGitDir, logger); + } + + // Step 2: Collapse other/ → repo root + const otherDir = path.join(baseDir, 'other'); + if (await dirExists(otherDir)) { + console.log('[git-migration] Moving other/ contents to repo root'); + logger?.('info', 'Moving other/ contents to repo root'); + await moveDirectoryContents(otherDir, baseDir, logger); + } + + // Step 3: Flush all Insomnia YAML workspaces to disk so they become real files. + // This is a best-effort bootstrap; the routable FS client will keep disk in sync + // for all subsequent Git operations. + await flushWorkspacesToDisk(baseDir, projectId, logger); + + if (gitRepo) { + await markMigrated(gitRepo); + } + console.log(`[git-migration] Migration complete for repo ${gitRepositoryId}`); + logger?.('info', 'Migration complete'); + success = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''; + console.error('[git-migration] Migration failed (non-fatal):', err); + logger?.('error', `Migration failed: ${message}${stack}`); + } finally { + inProgressMigrations.delete(gitRepositoryId); + } + return success; +} + +/** + * Write any workspace in `projectId` that doesn't yet have an on-disk YAML + * file to `baseDir`. This bootstraps the dual-sync state for existing repos. + * All workspaces are processed in parallel. + */ +async function flushWorkspacesToDisk(baseDir: string, projectId: string, logger?: MigrationLogger): Promise { + const workspaces = await db.find(models.workspace.type, { parentId: projectId }); + + // Batch-fetch all workspace metadata to avoid N+1 queries. + const workspaceIds = workspaces.map(w => w._id); + const allWorkspaceMeta = await db.find(models.workspaceMeta.type, { + parentId: { $in: workspaceIds }, + }); + const metaByWorkspaceId = Object.fromEntries(allWorkspaceMeta.map(m => [m.parentId, m])); + + await Promise.all( + workspaces.map(async workspace => { + const workspaceMeta = metaByWorkspaceId[workspace._id] as WorkspaceMeta | undefined; + + // Determine the target file name + const gitFilePath: string = workspaceMeta?.gitFilePath || `insomnia.${workspace._id}.yaml`; + + // Guard against absolute paths or traversal sequences in stored gitFilePath. + const absPath = path.resolve(baseDir, gitFilePath); + if (!absPath.startsWith(baseDir + path.sep)) { + console.warn('[git-migration] Skipping unsafe gitFilePath:', gitFilePath); + logger?.('warn', `Skipping unsafe gitFilePath: ${gitFilePath}`); + return; + } + + // Don't overwrite an existing file — trust disk as the primary store. + // Use an atomic write (tmp → rename) so a mid-write crash never leaves a + // truncated file that blocks future retries. + const fileAlreadyExists = await fs.promises + .access(absPath) + .then(() => true) + .catch(() => false); + + if (!fileAlreadyExists) { + try { + const yamlContent = await getInsomniaV5DataExport({ + workspaceId: workspace._id, + includePrivateEnvironments: false, + }); + + if (!yamlContent?.trim()) { + console.warn('[git-migration] Empty export for workspace', workspace._id, '— skipping'); + logger?.('warn', `Empty export for workspace ${workspace._id} — skipping`); + return; + } + + const tmpPath = `${absPath}.migration.tmp`; + await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); + await fs.promises.writeFile(tmpPath, yamlContent, 'utf8'); + await fs.promises.rename(tmpPath, absPath).catch(async err => { + await fs.promises.unlink(tmpPath).catch(() => {}); + throw err; + }); + console.log('[git-migration] Flushed workspace to disk:', absPath); + logger?.('info', `Flushed workspace to disk: ${absPath}`); + } catch (err) { + const flushMsg = err instanceof Error ? err.message : String(err); + console.warn('[git-migration] Could not flush workspace', workspace._id, err); + logger?.('warn', `Could not flush workspace ${workspace._id}: ${flushMsg}`); + return; // Skip DB reconciliation if the file was not written + } + } + + // Always reconcile the DB — runs whether we just wrote the file or it already + // existed. This ensures gitFilePath is persisted even if a previous run wrote + // the file but crashed before updating the DB. + try { + if (workspaceMeta && !workspaceMeta.gitFilePath) { + await db.docUpdate(workspaceMeta, { gitFilePath }); + } else if (!workspaceMeta) { + let meta = await db.findOne(models.workspaceMeta.type, { + parentId: workspace._id, + }); + if (!meta) { + meta = await db.docCreate(models.workspaceMeta.type, { + parentId: workspace._id, + }); + } + await db.docUpdate(meta, { gitFilePath }); + } + } catch (err) { + const metaMsg = err instanceof Error ? err.message : String(err); + console.warn('[git-migration] Could not update workspace metadata for', workspace._id, err); + logger?.('warn', `Could not update workspace metadata for ${workspace._id}: ${metaMsg}`); + } + }), + ); +} diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 26b46577e4..58f5e04756 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -11,7 +11,7 @@ import { GitVCSOperationErrors } from '~/sync/git/git-vcs-operation-errors'; import type { WriteFileMap } from '~/sync/git/project-routable-fs-client'; import { hasSignificantChanges } from '../../common/significant-diff-detection'; -import { type MergeConflict, RESOLUTION_SOURCE } from '../types'; +import { type AutoResolvedConflict, type MergeConflict, RESOLUTION_SOURCE } from '../types'; import { httpClient } from './http-client'; import { convertToPosixSep } from './path-sep'; import { getAuthorFromGitRepository, gitCallbacks } from './utils'; @@ -98,7 +98,7 @@ interface FileStatus { * We should set this explicitly (even if set to an empty string), because we have other code (such as fs clients and unit tests) that depend on the clone directory. */ export const GIT_CLONE_DIR = '.'; -const gitInternalDirName = 'git'; +const gitInternalDirName = '.git'; export const GIT_INSOMNIA_DIR_NAME = '.insomnia'; export const GIT_INTERNAL_DIR = path.join(GIT_CLONE_DIR, gitInternalDirName); // .git export const GIT_INSOMNIA_DIR = path.join(GIT_CLONE_DIR, GIT_INSOMNIA_DIR_NAME); // .insomnia @@ -240,14 +240,34 @@ export class GitVCS { return this._baseOpts.repoId === id; } - async getCurrentBranch() { + async getCurrentBranch(): Promise { const branch = await git.currentBranch({ ...this._baseOpts }); - if (typeof branch !== 'string') { - throw new TypeError('No active branch'); + if (typeof branch === 'string') { + return branch; } - return branch; + // During a rebase, HEAD can be detached and currentBranch() returns undefined. + // In that case, Git stores the original branch ref in rebase metadata. + const gitDir = this._baseOpts.gitdir || path.join(this._baseOpts.dir, gitInternalDirName); + const rebaseHeadNamePaths = [ + path.join(gitDir, 'rebase-merge', 'head-name'), + path.join(gitDir, 'rebase-apply', 'head-name'), + ]; + + for (const headNamePath of rebaseHeadNamePaths) { + try { + assertIsPromiseFsClient(this._baseOpts.fs); + const headName = (await this._baseOpts.fs.promises.readFile(headNamePath, 'utf8')).trim(); + if (headName.startsWith('refs/heads/')) { + return headName.replace('refs/heads/', ''); + } + } catch { + // Ignore and try the next known rebase metadata path. + } + } + + throw new TypeError('No active branch'); } async listBranches() { @@ -1037,6 +1057,42 @@ export class GitVCS { return git.listRemotes({ ...this._baseOpts }); } + async getBranchTrackingRemote(branch?: string): Promise { + const currentBranch = branch || (await this.getCurrentBranch()); + try { + const remote = await git.getConfig({ + ...this._baseOpts, + path: `branch.${currentBranch}.remote`, + }); + return remote || null; + } catch { + return null; + } + } + + async getRemoteUrl(remoteName: string): Promise { + try { + const url = await git.getConfig({ + ...this._baseOpts, + path: `remote.${remoteName}.url`, + }); + return url || null; + } catch { + return null; + } + } + + async getBranchRemoteInfo(branch?: string): Promise<{ + trackingRemote: string | null; + isOrigin: boolean; + remoteUrl: string | null; + }> { + const trackingRemote = await this.getBranchTrackingRemote(branch); + const isOrigin = trackingRemote === null || trackingRemote === 'origin'; + const remoteUrl = trackingRemote ? await this.getRemoteUrl(trackingRemote) : null; + return { trackingRemote, isOrigin, remoteUrl }; + } + async setAuthor(author?: GitAuthor) { let name = ''; let email = ''; @@ -1318,11 +1374,14 @@ export class GitVCS { commitParent: [oursHeadCommitOid, theirsHeadCommitOid], }; } + + return; } async buildManualResolutionFromTrees() { const { oursBranch, theirsBranch } = await this.getBranchPair(); const mergeConflicts: MergeConflict[] = []; + const autoResolvedConflicts: AutoResolvedConflict[] = []; const conflictPathsObj = await this.findConflictLikeChanges(oursBranch, theirsBranch); const conflictTypeList: (keyof ConflictPaths)[] = ['bothModified', 'deleteByUs', 'deleteByTheirs']; @@ -1368,6 +1427,16 @@ export class GitVCS { deleteByTheirs: 'they deleted and you modified', }[conflictType]; for (const conflictPath of conflictPaths) { + // Auto-resolve non-YAML files to theirs (remote) since Insomnia only manages YAML files. + // Collect for deferred staging in continueMerge() so cancel has zero side effects. + if (!conflictPath.endsWith('.yaml')) { + autoResolvedConflicts.push({ + filepath: conflictPath, + action: conflictType === 'deleteByTheirs' ? 'delete' : 'use-theirs', + }); + continue; + } + let mineBlobContent = null; let mineBlobId = null; @@ -1410,8 +1479,20 @@ export class GitVCS { } } + // If all conflicts were auto-resolved (no YAML conflicts), complete the merge automatically + if (mergeConflicts.length === 0 && autoResolvedConflicts.length > 0) { + await this.continueMerge({ + handledMergeConflicts: [], + autoResolvedConflicts, + commitMessage: `Merge branch '${theirsBranch}' into ${oursBranch}`, + commitParent: [oursHeadCommitOid, theirsHeadCommitOid], + }); + return { autoResolved: true }; + } + throw new MergeConflictError('Need to solve merge conflicts first', { conflicts: mergeConflicts, + autoResolvedConflicts, labels: { ours: `${oursBranch} ${oursHeadCommitOid}`, theirs: `${theirsBranch} ${theirsHeadCommitOid}`, @@ -1521,6 +1602,7 @@ export class GitVCS { const { filepaths, bothModified, deleteByUs, deleteByTheirs } = mergeConflictError.data; if (filepaths.length) { const mergeConflicts: MergeConflict[] = []; + const autoResolvedConflicts: AutoResolvedConflict[] = []; const conflictPathsObj = { bothModified, deleteByUs, @@ -1569,6 +1651,16 @@ export class GitVCS { deleteByTheirs: 'they deleted and you modified', }[conflictType]; for (const conflictPath of conflictPaths) { + // Auto-resolve non-YAML files to theirs (remote) since Insomnia only manages YAML files. + // Collect for deferred staging in continueMerge() so cancel has zero side effects. + if (!conflictPath.endsWith('.yaml')) { + autoResolvedConflicts.push({ + filepath: conflictPath, + action: conflictType === 'deleteByTheirs' ? 'delete' : 'use-theirs', + }); + continue; + } + let mineBlobContent = null; let mineBlobId = null; @@ -1631,8 +1723,20 @@ export class GitVCS { } } + // If all conflicts were auto-resolved (no YAML conflicts), complete the merge automatically + if (mergeConflicts.length === 0 && autoResolvedConflicts.length > 0) { + await this.continueMerge({ + handledMergeConflicts: [], + autoResolvedConflicts, + commitMessage: `Merge branch '${theirsBranch}' into ${oursBranch}`, + commitParent: [oursHeadCommitOid, theirsHeadCommitOid], + }); + return { autoResolved: true }; + } + throw new MergeConflictError('Need to solve merge conflicts first', { conflicts: mergeConflicts, + autoResolvedConflicts, labels: { ours: `${oursBranch} ${oursHeadCommitOid}`, theirs: `${theirsBranch} ${theirsHeadCommitOid}`, @@ -1648,15 +1752,33 @@ export class GitVCS { // create a commit after resolving merge conflicts async continueMerge({ handledMergeConflicts, + autoResolvedConflicts, commitMessage, commitParent, }: { handledMergeConflicts: MergeConflict[]; + autoResolvedConflicts?: AutoResolvedConflict[]; commitMessage: string; commitParent: string[]; }) { console.log('[git] continue to merge after resolving merge conflicts', await this.getCurrentBranch()); + // Stage auto-resolved non-YAML files (deferred from conflict collection) + for (const autoResolved of autoResolvedConflicts ?? []) { + if (autoResolved.action === 'delete') { + await git.remove({ ...this._baseOpts, filepath: autoResolved.filepath }); + } else { + await git.checkout({ + ...this._baseOpts, + ref: commitParent[1], + filepaths: [autoResolved.filepath], + noUpdateHead: true, + force: true, + }); + await git.add({ ...this._baseOpts, filepath: autoResolved.filepath }); + } + } + for (const conflict of handledMergeConflicts) { assertIsPromiseFsClient(this._baseOpts.fs); if (conflict.resolutionSource === RESOLUTION_SOURCE.MANUAL) { @@ -1977,6 +2099,7 @@ export class MergeConflictError extends Error { msg: string, data: { conflicts: MergeConflict[]; + autoResolvedConflicts: AutoResolvedConflict[]; labels: { ours: string; theirs: string; diff --git a/packages/insomnia/src/sync/git/ne-db-client.ts b/packages/insomnia/src/sync/git/ne-db-client.ts index c178ca950f..5f1e94b556 100644 --- a/packages/insomnia/src/sync/git/ne-db-client.ts +++ b/packages/insomnia/src/sync/git/ne-db-client.ts @@ -18,9 +18,10 @@ import path from 'node:path'; import type { PromiseFsClient } from 'isomorphic-git'; import YAML from 'yaml'; +import type { BaseModel } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; + import { database as db } from '../../common/database'; -import type { BaseModel } from '../../models'; -import * as models from '../../models'; import { resetKeys } from '../ignore-keys'; import { GIT_INSOMNIA_DIR_NAME } from './git-vcs'; import parseGitPath from './parse-git-path'; diff --git a/packages/insomnia/src/sync/git/parse-git-path.ts b/packages/insomnia/src/sync/git/parse-git-path.ts index 2cfd36f46d..f53754bafb 100644 --- a/packages/insomnia/src/sync/git/parse-git-path.ts +++ b/packages/insomnia/src/sync/git/parse-git-path.ts @@ -1,6 +1,7 @@ import path from 'node:path'; -import { type AllTypes, isValidType } from '~/models'; +import type { AllTypes } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { GIT_CLONE_DIR } from './git-vcs'; @@ -25,7 +26,7 @@ const parseGitPath = (filePath: string): GitPathSegments => { const id = typeof idRaw === 'string' ? idRaw.replace(/\.(json|yml)$/, '') : idRaw; return { root: root || null, - type: isValidType(type) ? type : null, + type: models.isValidType(type) ? type : null, id: id || null, }; }; diff --git a/packages/insomnia/src/sync/git/project-ne-db-client.ts b/packages/insomnia/src/sync/git/project-ne-db-client.ts deleted file mode 100644 index f9274f19b8..0000000000 --- a/packages/insomnia/src/sync/git/project-ne-db-client.ts +++ /dev/null @@ -1,278 +0,0 @@ -import path from 'node:path'; - -import type { PromiseFsClient } from 'isomorphic-git'; -import YAML from 'yaml'; - -import type { Workspace, WorkspaceMeta } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; - -import { database, database as db } from '../../common/database'; -import { extractErrorMessages } from '../../common/import'; -import { type InsomniaFile, InsomniaFileTypeValues } from '../../common/import-v5-parser'; -import { getInsomniaV5DataExport, tryImportV5Data } from '../../common/insomnia-v5'; -import * as models from '../../models'; -import Stat from './stat'; -import { SystemError } from './system-error'; - -/** - * A fs client to access workspace data stored in NeDB as files. - * Used by isomorphic-git - * https://isomorphic-git.org/docs/en/fs#implementing-your-own-fs - */ -export class GitProjectNeDBClient { - _projectId: string; - - constructor(projectId: string) { - this._projectId = projectId; - } - - static createClient(projectId: string): PromiseFsClient { - return { - promises: new GitProjectNeDBClient(projectId), - }; - } - - async readFile(filePath: string, options?: BufferEncoding | { encoding?: BufferEncoding }) { - if (!filePath.endsWith('.yaml')) { - throw this._errMissing(filePath); - } - - filePath = path.normalize(filePath); - options = options || {}; - - if (typeof options === 'string') { - options = { - encoding: options, - }; - } - - try { - const workspaceId = await this.getWorkspaceIdFromFilePath(filePath); - if (!workspaceId) { - throw this._errMissing(filePath); - } - - const workspaceFile = await getInsomniaV5DataExport({ workspaceId, includePrivateEnvironments: false }); - - const raw = Buffer.from(workspaceFile, 'utf8'); - - if (options.encoding) { - return raw.toString(options.encoding); - } - return raw; - } catch { - throw this._errMissing(filePath); - } - } - - async writeFile(filePath: string, data: Buffer | string) { - filePath = path.normalize(filePath); - - if (!filePath.endsWith('.yaml')) { - throw this._errMissing(filePath); - } - - const dataStr = data.toString(); - - const fileTypeStr = dataStr.split('\n')[0].trim(); - const doesFileContainInsomniaV5FormatTypeString = InsomniaFileTypeValues.some(fileType => - fileTypeStr.includes(fileType), - ); - - if (!doesFileContainInsomniaV5FormatTypeString) { - throw this._errMissing(filePath); - } - - // Skip the file if there is a conflict marker - if (dataStr.split('\n').includes('=======')) { - return; - } - const { data: dataToImport, error } = tryImportV5Data(dataStr); - if (error) { - const errorMsg = extractErrorMessages(error); - console.warn(`[git] Skipping import of ${filePath} due to error: ${errorMsg}. Fallback to default FS.`); - throw new Error(`Failed to import data from git file ${filePath}: ${errorMsg}`); - } - - const bufferId = await db.bufferChanges(); - - const workspace = dataToImport.find(models.workspace.isWorkspace) as Workspace | undefined; - - const isExistingWorkspace = workspace && (await services.workspace.getById(workspace._id)); - - if (isExistingWorkspace) { - const originDocs = await database.getWithDescendants(workspace); - // If the workspace already exists, we need to remove any documents that are not in the new data - const deletedDocs = originDocs.filter( - originDoc => !dataToImport.some(doc => doc._id === originDoc._id) && models.canSync(originDoc), - ); - deletedDocs.forEach(async doc => { - db.unsafeRemove(doc); - }); - } - - for (const doc of dataToImport) { - if (models.workspace.isWorkspace(doc)) { - console.log('[git] setting workspace parent to be that of the active project', { - original: doc.parentId, - new: this._projectId, - }); - // Whenever we write a workspace into nedb we should set the parentId to be that of the current project - // This is because the parentId (or a project) is not synced into git, so it will be cleared whenever git writes the workspace into the db, thereby removing it from the project on the client - // In order to reproduce this bug, comment out the following line, then clone a repository into a local project, then open the workspace, you'll notice it will have moved into the default project - doc.parentId = this._projectId; - - const workspaceMeta = await services.workspaceMeta.getOrCreateByParentId(doc._id); - await services.workspaceMeta.update(workspaceMeta, { gitFilePath: filePath }); - } - - await db.update(doc); - } - - await db.flushChanges(bufferId); - } - - async unlink(filePath: string) { - filePath = path.normalize(filePath); - const workspaceId = await this.getWorkspaceIdFromFilePath(filePath); - - if (!workspaceId) { - throw this._errMissing(filePath); - } - - const doc = await db.findOne(models.workspace.type, { _id: workspaceId }); - if (!doc) { - return; - } - - await db.unsafeRemove(doc); - } - - async readdir(filePath: string) { - filePath = path.normalize(filePath); - const workspaces = await db.find(models.workspace.type, { parentId: this._projectId }); - - const workspaceMetas = await db.find(models.workspaceMeta.type, { - parentId: { - $in: workspaces.map(w => w._id), - }, - }); - - const hasDirectoryInsomniaFiles = workspaceMetas.some( - ({ gitFilePath }) => gitFilePath && path.dirname(gitFilePath) === filePath, - ); - - if (hasDirectoryInsomniaFiles) { - const workspacePaths = workspaceMetas - // Filter out workspaces that don't have a gitFilePath or are not in the directory - .filter(workspaceMeta => workspaceMeta.gitFilePath && path.dirname(workspaceMeta.gitFilePath) === filePath) - // Return the basename of the paths - .map(workspaceMeta => path.basename(workspaceMeta.gitFilePath!)); - return workspacePaths; - } - - throw this._errMissing(filePath); - } - - async mkdir() { - throw new Error('NeDBClient is not writable'); - } - - async stat(filePath: string) { - filePath = path.normalize(filePath); - let fileBuff: Buffer | string | null = null; - let dir: string[] | null = null; - try { - fileBuff = await this.readFile(filePath); - } catch { - // console.log('[nedb] Failed to read file', err); - } - - if (fileBuff === null) { - try { - dir = await this.readdir(filePath); - } catch { - // console.log('[nedb] Failed to read dir', err); - } - } - - if (!fileBuff && !dir) { - throw this._errMissing(filePath); - } - - if (fileBuff) { - const doc: InsomniaFile = YAML.parse(fileBuff.toString()); - return new Stat({ - type: 'file', - mode: 0o777, - size: fileBuff.length, - // @ts-expect-error should be number instead of string https://nodejs.org/api/fs.html#fs_stats_ino - ino: doc?.meta?.id, - mtimeMs: doc?.meta?.modified || 0, - }); - } - return new Stat({ - type: 'dir', - mode: 0o777, - size: 0, - ino: 0, - mtimeMs: 0, - }); - } - - async readlink(filePath: string, ...x: any[]) { - return this.readFile(filePath, ...x); - } - - async lstat(filePath: string) { - return this.stat(filePath); - } - - async rmdir() { - throw new Error('NeDBClient symlink not supported'); - } - - async symlink() { - throw new Error('NeDBClient symlink not supported'); - } - - _errMissing(filePath: string) { - return new SystemError({ - message: `ENOENT: no such file or directory, scandir '${filePath}'`, - errno: -2, - code: 'ENOENT', - syscall: 'scandir', - path: filePath, - }); - } - - /** - * Given a file path, find the workspace ID associated with it. - * This is used to map a git file path to the corresponding workspace in the database. - */ - async getWorkspaceIdFromFilePath(filePath: string) { - // Normalize the file path to ensure consistency (handles OS differences, etc.) - filePath = path.normalize(filePath); - - // Find all workspaces that belong to the current project - const workspaces = await db.find(models.workspace.type, { - parentId: this._projectId, - }); - - // Find workspaceMeta entries that match the file path and belong to one of the found workspaces - const workspaceMeta = await db.find(models.workspaceMeta.type, { - gitFilePath: filePath, - parentId: { - $in: workspaces.map(w => w._id), // Only consider metas for workspaces in this project - }, - }); - - // If no matching workspaceMeta is found, return null (file is not tracked) - if (workspaceMeta.length === 0) { - return null; - } - - // Return the parentId (workspace ID) of the first matching workspaceMeta - return workspaceMeta[0].parentId; - } -} diff --git a/packages/insomnia/src/sync/git/project-routable-fs-client.ts b/packages/insomnia/src/sync/git/project-routable-fs-client.ts index 46001442a3..7e55f4bb87 100644 --- a/packages/insomnia/src/sync/git/project-routable-fs-client.ts +++ b/packages/insomnia/src/sync/git/project-routable-fs-client.ts @@ -17,79 +17,38 @@ type Methods = export type WriteFileMap = Record; /** - * An isometric-git FS client that can route to various client depending on what the filePath is. + * A pure disk FS client for isomorphic-git that routes by path prefix. * - * @param defaultFS – default client - * @param otherFS – map of path prefixes to clients - * @returns {{promises: *}} + * - `defaultFS` handles everything by default (the repo working tree). + * - `otherFS` maps path prefixes to specialised clients (e.g. `.git` → on-disk git data). + * + * YAML files are written to disk only. The {@link RepoFileWatcher} is solely + * responsible for syncing between disk and the NeDB database. + * + * `writeFileMap` can be enabled around pull/merge operations so the UI can + * surface merge-conflict content for manual resolution. */ -export function projectRoutableFSClient( - defaultFS: git.PromiseFsClient, - insomniaFS: git.PromiseFsClient, - otherFS: Record, -) { +export function projectRoutableFSClient(defaultFS: git.PromiseFsClient, otherFS: Record) { let writeFileMap: WriteFileMap | null = null; + const execMethod = async (method: Methods, filePath: string, ...args: any[]) => { filePath = path.normalize(filePath); // 1) Prefix routing: forward into any registered special FS (e.g. '.git') for (const prefix of Object.keys(otherFS)) { if (filePath.indexOf(path.normalize(prefix)) === 0) { - // TODO: remove non-null assertion - return otherFS[prefix].promises[method]!(filePath, ...args); } } - // Uncomment this to debug operations - // console.log('[routablefs] Executing', method, filePath, { args }); - // Fallback to default if no prefix matched - // TODO: remove non-null assertion - - // 2) Directory reads merge: DB-backed list (insomniaFS) + disk list (defaultFS) - // This exposes a unified directory view combining virtual YAML files and on-disk files. - if (method === 'readdir') { - let insomniaFiles = []; - try { - insomniaFiles = await insomniaFS.promises.readdir(filePath, ...args); - } catch { - // console.log('[routablefs] Failed to execute', method, filePath, { args }, err); - } - - // These are the default files on disk - let defaultFiles = []; - try { - defaultFiles = await defaultFS.promises.readdir(filePath, ...args); - } catch (err) { - if (insomniaFiles.length === 0) { - throw err; - } - } - - return [...new Set([...insomniaFiles, ...defaultFiles])]; - } - - // 3) YAML-first writes/reads: prefer insomniaFS (DB). If it throws, fall back to disk. - // Also, when writing, collect attempted content into writeFileMap to assist conflict UIs. - if (filePath.endsWith('.yaml')) { - try { - const result = await insomniaFS.promises[method]!(filePath, ...args); - if (method === 'writeFile' && writeFileMap) { - writeFileMap[filePath.split(path.win32.sep).join(path.posix.sep)] = args[0].toString(); - } - return result; - } catch { - const result = await defaultFS.promises[method]!(filePath, ...args); - - return result; - } - } - - // 4) Fallback: everything else goes to the default on-disk FS (e.g. 'other'). + // 2) Default: delegate to the on-disk FS const result = await defaultFS.promises[method]!(filePath, ...args); - // Uncomment this to debug operations - // console.log('[routablefs] Executing', method, filePath, { args }, { result }); + // 3) Collect YAML writes for conflict UI when enabled + if (method === 'writeFile' && filePath.endsWith('.yaml') && writeFileMap) { + writeFileMap[filePath.split(path.win32.sep).join(path.posix.sep)] = args[0].toString(); + } + return result; }; @@ -107,8 +66,8 @@ export function projectRoutableFSClient( methods.symlink = execMethod.bind(methods, 'symlink'); return { promises: methods, - // Collect attempted DB-backed YAML writes during operations like pull/merge so - // the UI can surface suggested merge results even if actual writes were skipped. + // @TODO The only reason we keep this file is for these two methods and the fileMap. + // We should consider a more elegant way to surface merge conflict content to the UI. startCollectWriteAction: (oriWriteFileMap: WriteFileMap) => { writeFileMap = oriWriteFileMap; }, diff --git a/packages/insomnia/src/sync/git/providers/custom.ts b/packages/insomnia/src/sync/git/providers/custom.ts index d90e90243d..78c025ecdf 100644 --- a/packages/insomnia/src/sync/git/providers/custom.ts +++ b/packages/insomnia/src/sync/git/providers/custom.ts @@ -1,6 +1,7 @@ import type { GitAuth } from 'isomorphic-git'; -import { type GitCredentials, models } from '~/insomnia-data'; +import type { GitCredentials } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import type { CustomProviderConfig, GitRemoteProvider, ValidationResult } from './types'; diff --git a/packages/insomnia/src/sync/git/providers/github.ts b/packages/insomnia/src/sync/git/providers/github.ts index ff2fdc0ab4..0e440cd545 100644 --- a/packages/insomnia/src/sync/git/providers/github.ts +++ b/packages/insomnia/src/sync/git/providers/github.ts @@ -3,7 +3,7 @@ import { net } from 'electron/main'; import type { GitAuth } from 'isomorphic-git'; import { v4 } from 'uuid'; -import { getApiBaseURL, getAppWebsiteBaseURL, PLAYWRIGHT } from '~/common/constants'; +import { getApiBaseURL, getAppWebsiteBaseURL, PLAYWRIGHT_TEST } from '~/common/constants'; import type { GitCredentials, GitCredentialsV2 } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import { expiresAtFromOAuthExpiresIn } from '~/sync/git/utils'; @@ -292,7 +292,7 @@ export class GitHubProvider implements GitRemoteProvider { async completeOAuth(code: string, state: string): Promise { try { // Validate state for security (CSRF protection) - if (!PLAYWRIGHT && !githubStatesCache.has(state)) { + if (!PLAYWRIGHT_TEST && !githubStatesCache.has(state)) { throw new Error('Invalid state parameter. It looks like the authorization flow was not initiated by the app.'); } diff --git a/packages/insomnia/src/sync/git/providers/gitlab.ts b/packages/insomnia/src/sync/git/providers/gitlab.ts index f92d755c02..f552ab5b53 100644 --- a/packages/insomnia/src/sync/git/providers/gitlab.ts +++ b/packages/insomnia/src/sync/git/providers/gitlab.ts @@ -5,7 +5,12 @@ import { net } from 'electron/main'; import type { GitAuth } from 'isomorphic-git'; import { v4 } from 'uuid'; -import { getApiBaseURL, INSOMNIA_GITLAB_CLIENT_ID, INSOMNIA_GITLAB_REDIRECT_URI, PLAYWRIGHT } from '~/common/constants'; +import { + getApiBaseURL, + INSOMNIA_GITLAB_CLIENT_ID, + INSOMNIA_GITLAB_REDIRECT_URI, + PLAYWRIGHT_TEST, +} from '~/common/constants'; import type { BaseGitCredentialsV2, GitCredentials, GitCredentialsV2 } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import { expiresAtFromOAuthExpiresIn } from '~/sync/git/utils'; @@ -377,7 +382,7 @@ export class GitLabProvider implements GitRemoteProvider { // Validate state and get verifier for PKCE let verifier = gitlabStatesCache.get(state); - if (PLAYWRIGHT) { + if (PLAYWRIGHT_TEST) { verifier = 'test-verifier'; } diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts new file mode 100644 index 0000000000..7895cd1cf9 --- /dev/null +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -0,0 +1,999 @@ +/** + * RepoFileWatcher — Bidirectional sync between on-disk Git repo and NeDB. + * + * Two pipelines, one serial queue: + * + * FS → DB (inbound) + * External tools (git CLI, VS Code, manual edits) modify YAML files on disk. + * Detected via `fs.watch` (primary) and periodic polling (fallback, 10 s). + * The file is parsed and upserted into NeDB. Orphaned DB documents that no + * longer appear in the YAML are removed. + * + * DB → FS (outbound) + * The Insomnia UI changes a synced document in NeDB. A `db.onChange` listener + * re-exports the workspace YAML and writes it to disk so that `git status` / + * `git diff` reflect the change. + * + * Initialisation (self-contained via `create()`): + * 1. Load workspace→file mappings from DB (for rename detection). + * 2. Import **all** YAML files from disk into the DB. This populates the + * mtime + content-hash tracking maps as a side-effect. + * 3. Start fs.watch, polling, and the DB→FS change listener. + * + * Because step 2 runs before step 3, the watchers never fire for files that + * were already imported — there is no ordering trap for callers. + * + * Loop prevention (content-hash + serial queue): + * All sync work is routed through a single serial {@link SyncQueue}. Tasks + * execute one at a time — an import and a flush can never race. + * + * When the DB→FS flush writes a file, it records the SHA-256 of the content it + * wrote in `lastWrittenHash`. When the FS→DB import reads a file, it computes + * the hash and compares: + * • Match → our own write echoing back via fs.watch — skip. + * • No match → genuine external change — import. + * + * `lastSyncMtime` is kept as a cheap fast-path: if the mtime hasn't changed + * since the last sync, the file is skipped without even reading it. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { BrowserWindow } from 'electron'; + +import type { Workspace, WorkspaceMeta } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; +import type { WorkspaceFileIssue } from '~/main/git-service'; + +import { database as db } from '../../common/database'; +import { InsomniaFileTypeValues } from '../../common/import-v5-parser'; +import { getInsomniaV5DataExport, tryImportV5Data } from '../../common/insomnia-v5'; +import { SyncQueue } from './sync-queue'; + +const POLL_INTERVAL_MS = 10_000; +const DEBOUNCE_MS = 300; +const GIT_DIR = '.git'; + +export type FileIssueKind = 'conflict' | 'parse-error'; + +export interface FileIssue { + /** Absolute path to the problematic file. */ + filePath: string; + /** Relative path from the repo root (posix separators). */ + relPath: string; + /** What went wrong. */ + kind: FileIssueKind; + /** Human-readable detail (e.g. parser error message). */ + message: string; +} + +export interface FileProblemsChangedPayload { + repoId: string; + problems: FileIssue[]; + workspaceIssues: WorkspaceFileIssue[]; + /** True when the main process is suppressing conflict display (e.g. SyncMergeModal is open). */ + conflictsSuppressed: boolean; +} + +/** Compute a SHA-256 hex digest of a string. */ +function contentHash(content: string): string { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} + +export interface WatcherNotifier { + onDbSynced: () => void; + onProblemsChanged: (payload: FileProblemsChangedPayload) => void; +} + +class RepoFileWatcher { + private readonly repoId: string; + private readonly repoDir: string; + private readonly projectId: string; + private readonly notifier: WatcherNotifier; + + private fsWatchers: fs.FSWatcher[] = []; + private pollTimer: ReturnType | null = null; + private debounceTimers = new Map>(); + /** Debounce timer for the DB→disk outbound flush */ + private flushDebounce: ReturnType | null = null; + /** Set to true by stop() so async callbacks can bail out cleanly */ + private stopped = false; + + /** + * Serial queue — every FS→DB import and DB→FS flush is enqueued here. + * Guarantees at most one sync task runs at a time. + */ + private queue = new SyncQueue(); + + /** mtime (ms) of the last successful sync for each normalised absolute path. */ + private lastSyncMtime = new Map(); + + /** + * SHA-256 of the YAML content last written to disk by the DB→FS flush. + * Used by the FS→DB import to detect and skip echo events (our own writes). + */ + private lastWrittenHash = new Map(); + + /** + * Last known absolute path for each workspace, keyed by workspace _id. + * Used to detect gitFilePath renames so the old file can be removed. + */ + private lastKnownGitFilePath = new Map(); + + /** + * Files that could not be imported due to conflicts or parse errors. + * Keyed by normalised absolute path. Cleared when the file is + * successfully imported or deleted. + */ + private problemFiles = new Map(); + + private constructor(repoId: string, repoDir: string, projectId: string, notifier: WatcherNotifier) { + this.repoId = repoId; + this.repoDir = repoDir; + this.projectId = projectId; + this.notifier = notifier; + } + + static async create( + repoId: string, + repoDir: string, + projectId: string, + notifier: WatcherNotifier, + ): Promise { + const watcher = new RepoFileWatcher(repoId, repoDir, projectId, notifier); + + // 1. Load workspace-to-file mappings from the DB for rename detection. + await watcher.loadKnownGitFilePaths(); + + // 1b. If the DB has newer data than what’s on disk (e.g. the user edited + // requests on the old app during a downgrade), write fresh YAML to + // disk BEFORE importing so those edits are not silently overwritten. + await watcher.flushNewerDbWorkspacesToDisk(); + + // 2. Import all YAML files into the DB so it reflects disk state. + // This populates lastSyncMtime + lastWrittenHash as a side-effect, + // which prevents step 3's watchers from re-importing the same files. + await watcher.importAllFiles(); + + // 3. Start watching for ongoing changes (fs.watch + polling + DB listener). + // Safe to start now because tracking state is already populated. + watcher.startFsWatch(); + watcher.startPolling(); + watcher.registerDbChangeListener(); + + return watcher; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + stop(): void { + this.stopped = true; + this.queue.stop(); + + for (const w of this.fsWatchers) { + try { + w.close(); + } catch { + /* ignore */ + } + } + + if (this.pollTimer) { + clearInterval(this.pollTimer); + } + + for (const t of this.debounceTimers.values()) { + clearTimeout(t); + } + + if (this.flushDebounce) { + clearTimeout(this.flushDebounce); + } + } + + /** + * Force an immediate DB→FS flush, bypassing the debounce timer. + * Resolves once all currently-enqueued work (including the flush) is done. + * + * The git service should call this before any git operation (status, diff, + * pull, merge, checkout, commit) to ensure the working tree is up-to-date. + */ + async flushNow(): Promise { + if (this.stopped) { + return; + } + + // Cancel any pending debounced flush — we're doing it immediately + if (this.flushDebounce) { + clearTimeout(this.flushDebounce); + this.flushDebounce = null; + } + + // Cancel all pending debounced imports and enqueue them immediately. + // This ensures all external changes are in the queue before we flush, + // preventing the flush from overwriting un-imported external edits. + for (const [absPath, timer] of this.debounceTimers) { + clearTimeout(timer); + this.debounceTimers.delete(absPath); + this.queue.enqueue(() => this.importFile(absPath)); + } + + this.queue.enqueue(() => this.flushProjectWorkspacesToDisk()); + await this.queue.waitUntilDone(); + } + + /** + * For each workspace linked to this project, if the DB was modified more + * recently than the on-disk YAML, write fresh YAML to disk before the + * initial `importAllFiles` scan. + * + * This prevents the stale-YAML-wins problem that occurs when: + * 1. User downgrades (old app has no RepoFileWatcher — DB changes aren\u2019t flushed to disk). + * 2. User edits requests via the old app (DB updated, no YAML written). + * 3. User re-upgrades; without this guard those edits would be silently lost. + * + * Written files are recorded in `lastWrittenHash` / `lastSyncMtime` so that + * `importAllFiles` skips them (they are already up-to-date). + */ + private async flushNewerDbWorkspacesToDisk(): Promise { + const workspaces = await services.workspace.findByParentId(this.projectId); + + await Promise.all( + workspaces.map(async workspace => { + try { + const meta = await services.workspaceMeta.getByParentId(workspace._id); + const gitFilePath = meta?.gitFilePath ?? `insomnia.${workspace._id}.yaml`; + const absPath = path.resolve(this.repoDir, gitFilePath); + + // Path-traversal guard + const rel = path.relative(this.repoDir, absPath); + if (rel.startsWith('..') || path.isAbsolute(rel)) return; + + // Get the most recently modified DB document in this workspace\u2019s tree + const allDocs = await db.getWithDescendants(workspace); + let maxDbModified: number = workspace.modified ?? 0; + for (const doc of allDocs) { + const m = (doc as { modified?: number }).modified ?? 0; + if (m > maxDbModified) maxDbModified = m; + } + + // Compare against the on-disk mtime + let fileMtime = 0; + try { + const stat = await fs.promises.stat(absPath); + fileMtime = stat.mtimeMs; + } catch { + // File doesn\u2019t exist yet \u2014 nothing to do; importAllFiles will handle creation. + return; + } + + if (maxDbModified <= fileMtime) return; // disk is up-to-date + + // DB is newer \u2014 write fresh YAML so importAllFiles doesn\u2019t overwrite it + const yamlContent = await getInsomniaV5DataExport({ + workspaceId: workspace._id, + includePrivateEnvironments: false, + }); + if (!yamlContent?.trim()) return; + + await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); + await fs.promises.writeFile(absPath, yamlContent, 'utf8'); + + const hash = contentHash(yamlContent); + const normalised = path.normalize(absPath); + this.lastWrittenHash.set(normalised, hash); + const newStat = await fs.promises.stat(absPath); + this.lastSyncMtime.set(normalised, newStat.mtimeMs); + + console.log( + '[repo-file-watcher] DB newer than disk for workspace', + workspace._id, + '— flushed to', + gitFilePath, + ); + } catch (err) { + console.warn('[repo-file-watcher] flushNewerDbWorkspacesToDisk error for workspace', workspace._id, err); + } + }), + ); + } + + /** + * Import all YAML files in the repo directory into the DB. + * + * Always bypasses the mtime fast-path (`forceRead`) so every file is read + * and compared by content-hash. This makes the method safe to call at any + * point — regardless of what tracking state has already been recorded. + * + * Also detects workspace YAML files that were removed from disk (e.g. deleted + * on the remote) and removes the corresponding workspaces from the DB. + */ + async importAllFiles(): Promise { + if (this.stopped) { + return; + } + + const yamlFiles = await this.collectYamlFiles(this.repoDir); + + // Import each file through the queue so they serialise with any + // concurrent flush that may still be pending. + // forceRead=true bypasses the mtime fast-path so every file is + // actually read and imported regardless of tracking state. + for (const absPath of yamlFiles) { + this.queue.enqueue(() => this.importFile(absPath, true)); + } + + // Detect deleted files: workspaces in DB whose YAML is no longer on disk. + this.queue.enqueue(() => this.removeOrphanedWorkspaces(yamlFiles)); + + await this.queue.waitUntilDone(); + } + + // --------------------------------------------------------------------------- + // DB → FS direction (outbound) + // --------------------------------------------------------------------------- + + /** + * Register a database onChange listener that flushes workspace YAML to disk + * whenever synced documents change. + */ + private registerDbChangeListener(): void { + db.onChange(changes => { + if (this.stopped) { + return; + } + + const hasSyncableChange = changes.some(([, doc]) => models.canSync(doc)); + if (!hasSyncableChange) { + return; + } + + // Debounce: coalesce rapid bursts into one flush + if (this.flushDebounce) { + clearTimeout(this.flushDebounce); + } + this.flushDebounce = setTimeout(() => { + this.flushDebounce = null; + this.queue.enqueue(() => this.flushProjectWorkspacesToDisk()); + }, DEBOUNCE_MS); + }); + } + + /** + * Re-export every workspace in the project to its on-disk YAML file. + * Skips writes when the exported content is identical to what was last + * written (content-hash dedup), or when the target file currently has a + * blocking import problem that the user must resolve first. + */ + private async flushProjectWorkspacesToDisk(): Promise { + const entries = await this.getWorkspacesWithMeta(); + + for (const { workspace, meta } of entries) { + if (this.stopped) { + return; + } + + const gitFilePath: string = meta?.gitFilePath || `insomnia.${workspace._id}.yaml`; + const absPath = path.normalize(path.join(this.repoDir, gitFilePath)); + + if (this.hasProblem(absPath)) { + continue; + } + + // Detect gitFilePath rename: if the path changed, we'll delete the old + // file *after* the new one is successfully written to avoid data loss. + const previousAbsPath = this.lastKnownGitFilePath.get(workspace._id); + const isRename = previousAbsPath && previousAbsPath !== absPath; + + try { + const yamlContent = await getInsomniaV5DataExport({ + workspaceId: workspace._id, + includePrivateEnvironments: false, + }); + + const hash = contentHash(yamlContent); + + // Skip writing if the content hasn't changed + if (this.lastWrittenHash.get(absPath) === hash) { + continue; + } + + await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); + await fs.promises.writeFile(absPath, yamlContent, 'utf8'); + + // New file written successfully — now safe to remove the old one + if (isRename) { + try { + await fs.promises.unlink(previousAbsPath); + console.log('[repo-file-watcher] Removed old file after rename:', previousAbsPath, '→', absPath); + } catch { + // Old file may already be gone — that's fine + } + // Clean up tracking for the old path so the watcher doesn't + // try to re-import a file that no longer exists + this.lastSyncMtime.delete(previousAbsPath); + this.lastWrittenHash.delete(previousAbsPath); + } + + // Record hash + mtime so the FS→DB side skips this echo + this.lastWrittenHash.set(absPath, hash); + this.lastKnownGitFilePath.set(workspace._id, absPath); + const stat = await fs.promises.stat(absPath); + this.lastSyncMtime.set(absPath, stat.mtimeMs); + } catch (err) { + console.warn('[repo-file-watcher] Could not flush workspace to disk:', workspace._id, err); + } + } + } + + // --------------------------------------------------------------------------- + // FS → DB direction (inbound) + // --------------------------------------------------------------------------- + + private startFsWatch(): void { + try { + const watcher = fs.watch(this.repoDir, { recursive: true }, (_eventType, filename) => { + if (!filename) { + return; + } + const absPath = path.join(this.repoDir, filename); + this.scheduleImport(absPath); + }); + + watcher.on('error', err => { + console.warn('[repo-file-watcher] fs.watch error:', err); + }); + + this.fsWatchers.push(watcher); + } catch (err) { + console.warn('[repo-file-watcher] Could not start fs.watch, relying on polling only:', err); + } + } + + private startPolling(): void { + this.pollTimer = setInterval(() => { + this.pollDirectory(this.repoDir).catch(err => { + console.warn('[repo-file-watcher] poll error:', err); + }); + }, POLL_INTERVAL_MS); + } + + private async pollDirectory(dir: string): Promise { + const yamlFiles = await this.collectYamlFiles(dir); + const seenPaths = new Set(yamlFiles); + + for (const absPath of yamlFiles) { + try { + const stat = await fs.promises.stat(absPath); + const lastMtime = this.lastSyncMtime.get(absPath) ?? 0; + if (stat.mtimeMs > lastMtime) { + this.queue.enqueue(() => this.importFile(absPath)); + } + } catch { + // File may have been removed between readdir and stat + } + } + + // Detect deletions: check tracked files that no longer exist on disk + for (const [trackedPath] of this.lastSyncMtime) { + if (!seenPaths.has(trackedPath)) { + this.queue.enqueue(() => this.importFile(trackedPath)); + } + } + } + + private scheduleImport(absPath: string): void { + if (this.stopped || !absPath.endsWith('.yaml') || this.isInGitDir(absPath)) { + return; + } + + const existing = this.debounceTimers.get(absPath); + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(() => { + this.debounceTimers.delete(absPath); + this.queue.enqueue(() => this.importFile(absPath)); + }, DEBOUNCE_MS); + + this.debounceTimers.set(absPath, timer); + } + + /** + * Read a YAML file from disk and import its documents into the DB. + * + * Loop prevention: + * 1. mtime fast-path — if mtime is unchanged, skip without reading. + * 2. content-hash — if the file hash matches `lastWrittenHash`, the file + * was written by our own DB→FS flush; skip. + * + * Orphan deletion: + * When an existing workspace is reimported, DB documents that no longer + * appear in the YAML are removed (e.g. a request deleted on the remote). + */ + private async importFile(absPath: string, forceRead = false): Promise { + const normalised = path.normalize(absPath); + + const result = await this.readIfChanged(absPath, normalised, forceRead); + if (!result) { + return; + } + + this.lastWrittenHash.set(normalised, result.hash); + this.lastSyncMtime.set(normalised, result.mtimeMs); + + const docs = this.parseAndValidate(absPath, normalised, result.content); + if (!docs) { + return; + } + + await this.deleteOrphans(docs); + await this.upsertDocs(absPath, normalised, result.mtimeMs, docs); + + this.notifyRenderer(); + } + + /** + * Read a file from disk if it has changed since the last sync. + * Returns the content, its hash, and the mtime — or null if skipped. + */ + private async readIfChanged( + absPath: string, + normalised: string, + forceRead = false, + ): Promise<{ content: string; hash: string; mtimeMs: number } | null> { + // ── Check if file still exists ─────────────────────────────────── + let fileStat: fs.Stats; + try { + fileStat = await fs.promises.stat(absPath); + } catch { + await this.handleFileDeletion(normalised); + return null; + } + + // ── Fast-path: mtime unchanged → skip ──────────────────────────── + // Bypassed when forceRead is true (e.g. importAllFiles after git + // operations) so every file is always read and compared by content. + if (!forceRead) { + const lastMtime = this.lastSyncMtime.get(normalised); + if (lastMtime !== undefined && fileStat.mtimeMs <= lastMtime) { + return null; + } + } + + // ── Read file ──────────────────────────────────────────────────── + let content: string; + try { + content = await fs.promises.readFile(absPath, 'utf8'); + } catch { + await this.handleFileDeletion(normalised); + return null; + } + + // ── Content-hash dedup: skip if this is our own write ──────────── + const hash = contentHash(content); + if (this.lastWrittenHash.get(normalised) === hash) { + this.lastSyncMtime.set(normalised, fileStat.mtimeMs); + return null; + } + + return { content, hash, mtimeMs: fileStat.mtimeMs }; + } + + /** + * Validate and parse YAML content. Returns parsed documents or null + * if the content is not valid Insomnia V5 YAML (with problems tracked). + */ + private parseAndValidate( + absPath: string, + normalised: string, + content: string, + ): ReturnType['data'] | null { + const firstLine = content.split('\n')[0].trim(); + if (!InsomniaFileTypeValues.some(t => firstLine.includes(t))) { + return null; + } + + if (content.split('\n').some(l => l.startsWith('<<<<<<<') || l.startsWith('>>>>>>>'))) { + this.addProblem(normalised, { + filePath: absPath, + relPath: this.toPosixRelPath(absPath), + kind: 'conflict', + message: 'File contains Git conflict markers and cannot be imported.', + }); + return null; + } + + const { data: docs, error } = tryImportV5Data(content); + if (error || !docs) { + this.addProblem(normalised, { + filePath: absPath, + relPath: this.toPosixRelPath(absPath), + kind: 'parse-error', + message: typeof error === 'string' ? error : `Failed to parse: ${String(error)}`, + }); + return null; + } + + this.clearProblem(normalised); + return docs; + } + + /** Remove DB documents that no longer appear in the imported YAML. */ + private async deleteOrphans(docs: NonNullable['data']>): Promise { + const workspace = docs.find(models.workspace.isWorkspace) as Workspace | undefined; + if (!workspace) { + return; + } + const existingWorkspace = await services.workspace.getById(workspace._id); + if (!existingWorkspace) { + return; + } + const originDocs = await db.getWithDescendants(existingWorkspace); + const deletedDocs = originDocs.filter( + originDoc => !docs.some(d => d._id === originDoc._id) && models.canSync(originDoc), + ); + for (const doc of deletedDocs) { + await db.unsafeRemove(doc); + } + } + + /** Upsert parsed documents into the DB and update tracking state. */ + private async upsertDocs( + absPath: string, + normalised: string, + syncTime: number, + docs: NonNullable['data']>, + ): Promise { + const bufferId = await db.bufferChanges(); + try { + for (const doc of docs) { + if (models.workspace.isWorkspace(doc)) { + doc.parentId = this.projectId; + const workspaceMeta = await services.workspaceMeta.getOrCreateByParentId(doc._id); + await services.workspaceMeta.update(workspaceMeta, { + gitFilePath: this.toPosixRelPath(absPath), + gitFileLastSyncTime: syncTime, + }); + this.lastKnownGitFilePath.set(doc._id, normalised); + } + await db.update(doc); + } + } finally { + await db.flushChanges(bufferId); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Handle a YAML file that was deleted from disk. + * Finds the workspace whose `gitFilePath` maps to this path and removes + * it (plus all descendants) from the DB. + */ + private async handleFileDeletion(normalised: string): Promise { + // Only act if we were previously tracking this file + if (!this.lastSyncMtime.has(normalised) && !this.lastWrittenHash.has(normalised)) { + return; + } + + const relPath = this.toPosixRelPath(normalised); + + // Find the workspace whose gitFilePath matches this deleted file + const entries = await this.getWorkspacesWithMeta(); + for (const { workspace, meta } of entries) { + if (meta?.gitFilePath === relPath) { + console.log('[repo-file-watcher] File deleted, removing workspace:', workspace._id, relPath); + await this.removeWorkspaceWithDescendants(workspace); + this.notifyRenderer(); + break; + } + } + + // Clean up tracking maps + this.lastSyncMtime.delete(normalised); + this.lastWrittenHash.delete(normalised); + this.clearProblem(normalised); + } + + /** Convert an absolute path to a posix-style path relative to the repo root. */ + private toPosixRelPath(absPath: string): string { + return path.relative(this.repoDir, absPath).split(path.sep).join(path.posix.sep); + } + + /** Remove a workspace and all its descendants from the DB inside a buffered batch. */ + private async removeWorkspaceWithDescendants(workspace: Workspace): Promise { + const descendants = await db.getWithDescendants(workspace); + const bufferId = await db.bufferChanges(); + try { + for (const doc of descendants) { + await db.unsafeRemove(doc); + } + } finally { + await db.flushChanges(bufferId); + } + } + + /** Fetch all workspaces in this project together with their metadata. */ + private async getWorkspacesWithMeta(): Promise<{ workspace: Workspace; meta: WorkspaceMeta | undefined }[]> { + const workspaces = await db.find(models.workspace.type, { parentId: this.projectId }); + const results: { workspace: Workspace; meta: WorkspaceMeta | undefined }[] = []; + for (const workspace of workspaces) { + const meta = await db.findOne(models.workspaceMeta.type, { + parentId: workspace._id, + }); + results.push({ workspace, meta }); + } + return results; + } + + private isInGitDir(absPath: string): boolean { + const rel = path.relative(this.repoDir, absPath); + return rel.startsWith(GIT_DIR + path.sep) || rel === GIT_DIR; + } + + /** Recursively collect all `.yaml` files under `dir` as normalised absolute paths, skipping `.git`. */ + private async collectYamlFiles(dir: string): Promise { + const result: string[] = []; + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return result; + } + for (const entry of entries) { + const absPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === GIT_DIR) { + continue; + } + const nested = await this.collectYamlFiles(absPath); + result.push(...nested); + } else if (entry.isFile() && entry.name.endsWith('.yaml')) { + result.push(path.normalize(absPath)); + } + } + return result; + } + + /** + * Remove workspaces from the DB whose YAML file no longer exists on disk. + * Handles the case where a workspace was deleted on the remote and the user + * pulls / checks out a branch that doesn't contain it. + */ + private async removeOrphanedWorkspaces(currentDiskFiles: string[]): Promise { + const diskFileSet = new Set(currentDiskFiles.map(f => path.normalize(f))); + const entries = await this.getWorkspacesWithMeta(); + for (const { workspace, meta } of entries) { + if (!meta?.gitFilePath) { + continue; + } + + const absPath = path.normalize(path.join(this.repoDir, meta.gitFilePath)); + if (!diskFileSet.has(absPath)) { + // Workspace YAML no longer on disk — remove from DB + console.log('[repo-file-watcher] Removing orphaned workspace:', workspace._id); + await this.removeWorkspaceWithDescendants(workspace); + } + } + } + + /** + * Load existing workspace → gitFilePath mappings from the DB so rename + * detection works from the start. + * + * Note: we intentionally do NOT pre-scan file mtimes here. The initial + * {@link importAllFiles} call in {@link create} populates both + * `lastSyncMtime` and `lastWrittenHash` as a side-effect of importing. + * Pre-scanning mtimes would cause `importAllFiles` to skip files it + * hasn't actually imported yet. + */ + private async loadKnownGitFilePaths(): Promise { + const entries = await this.getWorkspacesWithMeta(); + for (const { workspace, meta } of entries) { + if (meta?.gitFilePath) { + const absPath = path.normalize(path.join(this.repoDir, meta.gitFilePath)); + this.lastKnownGitFilePath.set(workspace._id, absPath); + } + } + } + + // --------------------------------------------------------------------------- + // Problem tracking + // --------------------------------------------------------------------------- + + /** Record a problem (conflict or parse error) for the given file path. */ + private addProblem(normalised: string, issue: FileIssue): void { + this.problemFiles.set(normalised, issue); + console.warn(`[repo-file-watcher] ${issue.kind}: ${issue.relPath} — ${issue.message}`); + this.notifyProblemsChanged(); + } + + /** Clear a previously recorded problem for the given file path. */ + private clearProblem(normalised: string): void { + if (this.problemFiles.delete(normalised)) { + this.notifyProblemsChanged(); + } + } + + /** Return a snapshot of all current file problems. */ + getProblems(): FileIssue[] { + return Array.from(this.problemFiles.values()); + } + + /** Return true when a normalized file path currently has a blocking import problem. */ + private hasProblem(normalisedPath: string): boolean { + return this.problemFiles.has(normalisedPath); + } + + /** Return the current problems mapped to workspace-level issues. */ + getWorkspaceIssues(): WorkspaceFileIssue[] { + const absPathToWorkspaceId = new Map(); + + for (const [workspaceId, absPath] of this.lastKnownGitFilePath.entries()) { + absPathToWorkspaceId.set(path.normalize(absPath), workspaceId); + } + + return Array.from(this.problemFiles.entries()).flatMap(([normalisedPath, issue]) => { + const workspaceId = absPathToWorkspaceId.get(normalisedPath); + if (!workspaceId) { + return []; + } + + return [ + { + workspaceId, + gitRepositoryId: this.repoId, + relPath: issue.relPath, + kind: issue.kind, + message: issue.message, + }, + ]; + }); + } + + // --------------------------------------------------------------------------- + // Notifications + // --------------------------------------------------------------------------- + + /** Notify the renderer that the DB was synced from disk. */ + private notifyRenderer(): void { + this.notifier.onDbSynced(); + } + + /** Notify the renderer that the set of file problems changed. */ + private notifyProblemsChanged(): void { + this.notifier.onProblemsChanged({ + repoId: this.repoId, + problems: this.getProblems(), + workspaceIssues: this.getWorkspaceIssues(), + conflictsSuppressed: false, + }); + } +} + +// --------------------------------------------------------------------------- +// Registry — manages per-repo watcher instances +// --------------------------------------------------------------------------- + +export class RepoFileWatcherRegistry { + private watchers = new Map(); + /** Tracks in-flight create() calls to prevent duplicate watchers. */ + private pending = new Map>(); + private readonly notifier: WatcherNotifier; + + constructor(notifier: WatcherNotifier) { + this.notifier = notifier; + } + + /** + * Start watching `repoDir` for external YAML changes. + * Safe to call multiple times for the same repoId; concurrent calls + * for the same repoId coalesce into a single create. + */ + async startWatcher(repoId: string, repoDir: string, projectId: string): Promise { + if (this.watchers.has(repoId)) { + return; + } + + // If a create is already in flight for this repoId, wait for it + const inflight = this.pending.get(repoId); + if (inflight) { + await inflight; + return; + } + + const promise = RepoFileWatcher.create(repoId, repoDir, projectId, this.notifier) + .then(watcher => { + this.watchers.set(repoId, watcher); + }) + .finally(() => { + this.pending.delete(repoId); + }); + + this.pending.set(repoId, promise); + await promise; + } + + /** Stop watching and clean up resources for a given repoId. */ + stopWatcher(repoId: string): void { + const watcher = this.watchers.get(repoId); + if (!watcher) { + return; + } + watcher.stop(); + this.watchers.delete(repoId); + } + + /** Stop all active watchers. Useful for app shutdown. */ + stopAll(): void { + for (const watcher of this.watchers.values()) { + watcher.stop(); + } + this.watchers.clear(); + } + + /** + * Force an immediate DB→FS flush for the given repo, then wait for all + * pending sync work to complete. + * + * Call before any git operation (status, diff, pull, merge, checkout, commit) + * to ensure the working tree reflects the latest DB state. + */ + flushNow(repoId: string): Promise { + const watcher = this.watchers.get(repoId); + if (!watcher) { + return Promise.resolve(); + } + return watcher.flushNow(); + } + + /** + * Import all YAML files in the repo directory into the DB. + * + * Call after bulk git operations (clone, pull, merge, checkout) so the DB + * reflects the new disk state. Content-hash dedup makes repeated calls cheap. + */ + importAllFiles(repoId: string): Promise { + const watcher = this.watchers.get(repoId); + if (!watcher) { + return Promise.resolve(); + } + return watcher.importAllFiles(); + } + + /** + * Return a snapshot of all current file problems (conflicts, parse errors) + * for the given repo. Returns an empty array if the watcher is not running. + */ + getProblems(repoId: string): FileIssue[] { + const watcher = this.watchers.get(repoId); + if (!watcher) { + return []; + } + return watcher.getProblems(); + } +} + +/** Default notifier that broadcasts to all Electron BrowserWindows. */ +export function createElectronNotifier(): WatcherNotifier { + return { + onDbSynced: () => { + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send('git.db-synced'); + } + }, + onProblemsChanged: payload => { + for (const w of BrowserWindow.getAllWindows()) { + w.webContents.send('git.file-problems-changed', payload); + } + }, + }; +} diff --git a/packages/insomnia/src/sync/git/sync-queue.test.ts b/packages/insomnia/src/sync/git/sync-queue.test.ts new file mode 100644 index 0000000000..6195106337 --- /dev/null +++ b/packages/insomnia/src/sync/git/sync-queue.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SyncQueue } from './sync-queue'; + +describe('SyncQueue', () => { + it('executes tasks in FIFO order', async () => { + const queue = new SyncQueue(); + const order: number[] = []; + + queue.enqueue(async () => { + order.push(1); + }); + queue.enqueue(async () => { + order.push(2); + }); + queue.enqueue(async () => { + order.push(3); + }); + + await queue.waitUntilDone(); + + expect(order).toEqual([1, 2, 3]); + }); + + it('runs at most one task at a time', async () => { + const queue = new SyncQueue(); + let concurrent = 0; + let maxConcurrent = 0; + + const makeTask = () => async () => { + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise(resolve => setTimeout(resolve, 10)); + concurrent--; + }; + + queue.enqueue(makeTask()); + queue.enqueue(makeTask()); + queue.enqueue(makeTask()); + + await queue.waitUntilDone(); + + expect(maxConcurrent).toBe(1); + }); + + it('waitUntilDone() resolves when all pending tasks are done', async () => { + const queue = new SyncQueue(); + const completed: number[] = []; + + queue.enqueue(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + completed.push(1); + }); + queue.enqueue(async () => { + completed.push(2); + }); + + await queue.waitUntilDone(); + + expect(completed).toEqual([1, 2]); + }); + + it('waitUntilDone() resolves immediately when queue is empty', async () => { + const queue = new SyncQueue(); + await queue.waitUntilDone(); // should not hang + }); + + it('waitUntilDone() waits for tasks enqueued during processing', async () => { + const queue = new SyncQueue(); + const completed: string[] = []; + + queue.enqueue(async () => { + completed.push('first'); + // Enqueue more work while the queue is processing + queue.enqueue(async () => { + completed.push('second'); + }); + }); + + await queue.waitUntilDone(); + + expect(completed).toEqual(['first', 'second']); + }); + + it('catches errors without blocking subsequent tasks', async () => { + const queue = new SyncQueue(); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const completed: number[] = []; + + queue.enqueue(async () => { + completed.push(1); + }); + queue.enqueue(async () => { + throw new Error('boom'); + }); + queue.enqueue(async () => { + completed.push(3); + }); + + await queue.waitUntilDone(); + + expect(completed).toEqual([1, 3]); + expect(consoleSpy).toHaveBeenCalledWith('[sync-queue] Task error:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it('stop() prevents new tasks from being processed', async () => { + const queue = new SyncQueue(); + const completed: number[] = []; + + // Stop the queue first, before any tasks are enqueued + queue.stop(); + + queue.enqueue(async () => { + completed.push(1); + }); + queue.enqueue(async () => { + completed.push(2); + }); + + // Give time for any async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(completed).toEqual([]); + }); + + it('multiple waitUntilDone() calls all resolve together', async () => { + const queue = new SyncQueue(); + const completed: number[] = []; + + queue.enqueue(async () => { + await new Promise(resolve => setTimeout(resolve, 20)); + completed.push(1); + }); + + const [r1, r2] = await Promise.all([queue.waitUntilDone(), queue.waitUntilDone()]); + + expect(r1).toBeUndefined(); + expect(r2).toBeUndefined(); + expect(completed).toEqual([1]); + }); +}); diff --git a/packages/insomnia/src/sync/git/sync-queue.ts b/packages/insomnia/src/sync/git/sync-queue.ts new file mode 100644 index 0000000000..377a3c3902 --- /dev/null +++ b/packages/insomnia/src/sync/git/sync-queue.ts @@ -0,0 +1,61 @@ +/** + * SyncQueue — Serial async task queue. + * + * Guarantees that enqueued async tasks execute one at a time in FIFO order. + * Used by {@link RepoFileWatcher} to serialise FS→DB imports and DB→FS flushes, + * eliminating race conditions between the two directions. + * + * Key features: + * - `enqueue(fn)` — adds a task; processing starts automatically. + * - `waitUntilDone()` — returns a promise that resolves once every task that + * was enqueued *at the time of the call* has finished. The git service calls + * this before git operations to ensure the working tree is up-to-date. + * - Error isolation — a failing task is logged but does not block subsequent tasks. + * - `stop()` — future `enqueue()` calls are no-ops and pending tasks are skipped. + */ + +type Task = () => Promise; + +export class SyncQueue { + private tail: Promise = Promise.resolve(); + private stopped = false; + + /** + * Add a task to the end of the queue. Processing starts automatically. + */ + enqueue(task: Task): void { + if (this.stopped) { + return; + } + this.tail = this.tail.then(() => { + if (this.stopped) { + return; + } + return task().catch(err => { + console.warn('[sync-queue] Task error:', err); + }); + }); + } + + /** + * Returns a promise that resolves once all currently-enqueued tasks (including + * any tasks they enqueue during execution) have completed. + * + * If the queue is idle, resolves immediately. + */ + async waitUntilDone(): Promise { + let snapshot: Promise; + do { + snapshot = this.tail; + await snapshot; + } while (snapshot !== this.tail); + } + + /** + * Stop the queue. Pending tasks are skipped and future `enqueue()` calls are + * no-ops. + */ + stop(): void { + this.stopped = true; + } +} diff --git a/packages/insomnia/src/sync/git/utils.ts b/packages/insomnia/src/sync/git/utils.ts index 8f297b13cb..c1907988f7 100644 --- a/packages/insomnia/src/sync/git/utils.ts +++ b/packages/insomnia/src/sync/git/utils.ts @@ -1,6 +1,7 @@ import type { AuthCallback, AuthFailureCallback, AuthSuccessCallback, GitAuth, MessageCallback } from 'isomorphic-git'; -import { type GitAuthor, models, services } from '~/insomnia-data'; +import type { GitAuthor } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { gitRemoteProviderRegistry } from '~/sync/git/providers'; import { invariant } from '~/utils/invariant'; diff --git a/packages/insomnia/src/sync/ignore-keys.ts b/packages/insomnia/src/sync/ignore-keys.ts index 3ba49b81f7..b1bb8211e8 100644 --- a/packages/insomnia/src/sync/ignore-keys.ts +++ b/packages/insomnia/src/sync/ignore-keys.ts @@ -1,8 +1,6 @@ -import type { Workspace } from '~/insomnia-data'; +import type { BaseModel, Workspace } from '~/insomnia-data'; import { models } from '~/insomnia-data'; -import type { BaseModel } from '../models'; - // Key for VCS to delete before computing changes const DELETE_KEY: keyof BaseModel = 'modified'; diff --git a/packages/insomnia/src/sync/types.ts b/packages/insomnia/src/sync/types.ts index faf8bb63f5..a9ff50e056 100644 --- a/packages/insomnia/src/sync/types.ts +++ b/packages/insomnia/src/sync/types.ts @@ -1,4 +1,4 @@ -import type { BaseModel } from '../models'; +import type { BaseModel } from '~/insomnia-data'; export interface Team { id: string; @@ -11,6 +11,14 @@ export interface BackendProject { rootDocumentId: string; } +export interface BackendProjectWithTeams extends BackendProject { + teams: Team[]; +} + +export interface BackendProjectWithTeam extends BackendProject { + team: Team; +} + export type DocumentKey = string; export type BlobId = string; @@ -108,6 +116,11 @@ export interface MergeConflict { resolutionSource?: ResolutionSource; } +export interface AutoResolvedConflict { + filepath: string; + action: 'use-theirs' | 'delete'; +} + export type Stage = Record; export interface StatusCandidate { diff --git a/packages/insomnia/src/sync/vcs/__tests__/insomnia-sync.test.ts b/packages/insomnia/src/sync/vcs/__tests__/insomnia-sync.test.ts new file mode 100644 index 0000000000..3c36e739f6 --- /dev/null +++ b/packages/insomnia/src/sync/vcs/__tests__/insomnia-sync.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { UserAbortResolveMergeConflictError } from '../errors'; + +vi.mock('../../../ui/components/modals', () => ({ + showModal: vi.fn(), +})); + +vi.mock('../../../ui/components/modals/sync-merge-modal', () => ({ + SyncMergeModal: Symbol('SyncMergeModal'), +})); + +describe('insomnia-sync', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('registers the merge conflict listener once', async () => { + const on = vi.fn(() => () => {}); + + global.window = { + main: { + sync: { + on, + resolveConflict: vi.fn(), + cancelConflict: vi.fn(), + }, + }, + } as Window & typeof globalThis; + + const { registerSyncMergeConflictListener } = await import('../insomnia-sync'); + + registerSyncMergeConflictListener(); + registerSyncMergeConflictListener(); + expect(on).toHaveBeenCalledWith('sync.merge-conflicts', expect.any(Function)); + expect(on).toHaveBeenCalledTimes(1); + }); + + it('routes merge conflict modal callbacks back through the sync bridge', async () => { + const resolveConflict = vi.fn(); + const cancelConflict = vi.fn(); + const on = vi.fn((_channel, listener) => { + listener(undefined, { + handlerId: 'req_123', + conflicts: [{ key: 'doc_1' }], + labels: { ours: 'ours', theirs: 'theirs' }, + }); + + return () => {}; + }); + + global.window = { + main: { + sync: { + on, + resolveConflict, + cancelConflict, + }, + }, + } as Window & typeof globalThis; + + const { showModal } = await import('../../../ui/components/modals'); + const { registerSyncMergeConflictListener } = await import('../insomnia-sync'); + + registerSyncMergeConflictListener(); + + expect(showModal).toHaveBeenCalledWith(expect.anything(), { + conflicts: [{ key: 'doc_1' }], + labels: { ours: 'ours', theirs: 'theirs' }, + onResolveAll: expect.any(Function), + onCancelUnresolved: expect.any(Function), + }); + + const modalOptions = vi.mocked(showModal).mock.calls[0][1]; + modalOptions.onResolveAll([{ key: 'doc_2' }]); + modalOptions.onCancelUnresolved(); + + expect(resolveConflict).toHaveBeenCalledWith({ handlerId: 'req_123', conflicts: [{ key: 'doc_2' }] }); + expect(cancelConflict).toHaveBeenCalledWith({ handlerId: 'req_123' }); + }); + + it('exports the renderer abort error class', async () => { + const { UserAbortResolveMergeConflictError: ExportedError } = await import('../insomnia-sync'); + + expect(new ExportedError().name).toBe(new UserAbortResolveMergeConflictError().name); + }); +}); diff --git a/packages/insomnia/src/sync/vcs/errors.ts b/packages/insomnia/src/sync/vcs/errors.ts new file mode 100644 index 0000000000..fbbc466dee --- /dev/null +++ b/packages/insomnia/src/sync/vcs/errors.ts @@ -0,0 +1,13 @@ +export class UserAbortResolveMergeConflictError extends Error { + constructor(message = 'User aborted merge') { + super(message); + } + + name = 'UserAbortResolveMergeConflictError'; +} + +export const isUserAbortResolveMergeConflictError = (error: unknown): error is UserAbortResolveMergeConflictError => + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'UserAbortResolveMergeConflictError'; diff --git a/packages/insomnia/src/sync/vcs/initialize-backend-project.ts b/packages/insomnia/src/sync/vcs/initialize-backend-project.ts index 085de9d852..4cdbcdfd67 100644 --- a/packages/insomnia/src/sync/vcs/initialize-backend-project.ts +++ b/packages/insomnia/src/sync/vcs/initialize-backend-project.ts @@ -1,23 +1,30 @@ -import type { Project, Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import type { BaseModel, Project, Workspace } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { database } from '../../common/database'; -import { type BaseModel, canSync } from '../../models'; -import type { StatusCandidate } from '../types'; -import type { VCS } from './vcs'; +import type { Stage, StageEntry, Status, StatusCandidate } from '../types'; + +export interface SyncVCSLike { + hasBackendProject: () => boolean | Promise; + push: (options: { teamId: string; teamProjectId: string }) => Promise; + stage: (stageEntries: StageEntry[]) => Promise; + status: (candidates: StatusCandidate[]) => Promise; + switchAndCreateBackendProjectIfNotExist: (rootDocumentId: string, name: string) => Promise; + takeSnapshot: (name: string) => Promise; +} export const initializeLocalBackendProjectAndMarkForSync = async ({ vcs, workspace, }: { - vcs: VCS; + vcs: SyncVCSLike; workspace: Workspace; }) => { // Create local project await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); // Everything unstaged - const candidates = (await database.getWithDescendants(workspace)).filter(canSync).map( + const candidates = (await database.getWithDescendants(workspace)).filter(models.canSync).map( (doc: BaseModel): StatusCandidate => ({ key: doc._id, name: doc.name || '', @@ -41,7 +48,7 @@ export const pushSnapshotOnInitialize = async ({ workspace, project: { _id: projectId, remoteId: projectRemoteId, parentId }, }: { - vcs: VCS; + vcs: SyncVCSLike; workspace: Workspace; project: Project; }) => { @@ -51,7 +58,7 @@ export const pushSnapshotOnInitialize = async ({ // One code path is that a React Key updates, forcing all children to unmount and remount (https://github.com/Kong/insomnia/blob/9a943879060927d6ab1c21d3e12daba39ad05eea/packages/insomnia-app/app/ui/containers/app.tsx#L1514-L1514) // At the same time, we set VCS to null, then set it to the correct value, in state in App.tsx, forcing downstream updates (https://github.com/Kong/insomnia/blob/9a943879060927d6ab1c21d3e12daba39ad05eea/packages/insomnia-app/app/ui/containers/app.tsx#L1149-L1149) // This race condition causes us to hit this codepath twice while activating a workspace but the first time it has no project so we shouldn't do anything - const hasProject = vcs.hasBackendProject(); + const hasProject = await vcs.hasBackendProject(); if (projectIsForWorkspace && projectRemoteId && hasProject) { await services.workspaceMeta.updateByParentId(workspace._id, { pushSnapshotOnInitialize: false }); diff --git a/packages/insomnia/src/sync/vcs/insomnia-sync.ts b/packages/insomnia/src/sync/vcs/insomnia-sync.ts index e8e7e53f65..6e62dc1a28 100644 --- a/packages/insomnia/src/sync/vcs/insomnia-sync.ts +++ b/packages/insomnia/src/sync/vcs/insomnia-sync.ts @@ -1,37 +1,27 @@ import { showModal } from '../../ui/components/modals'; import { SyncMergeModal } from '../../ui/components/modals/sync-merge-modal'; -import FileSystemDriver from '../store/drivers/file-system-driver'; import type { MergeConflict } from '../types'; -import { VCS } from './vcs'; -let vcs: VCS | null = null; +let hasRegisteredConflictListener = false; -export class UserAbortResolveMergeConflictError extends Error { - constructor(msg = 'User aborted merge') { - super(msg); +export { UserAbortResolveMergeConflictError } from './errors'; + +export const registerSyncMergeConflictListener = () => { + if (hasRegisteredConflictListener) { + return; } - name = 'UserAbortResolveMergeConflictError'; -} -export const VCSInstance = () => { - if (vcs) { - return vcs; - } - const driver = FileSystemDriver.create(process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData')); - vcs = new VCS(driver, async (conflicts, labels) => { - return new Promise((resolve, reject) => { - showModal(SyncMergeModal, { - conflicts, - labels, - onResolveAll: (conflicts: MergeConflict[]) => { - resolve(conflicts); - }, - onCancelUnresolved: () => { - reject(new UserAbortResolveMergeConflictError()); - }, - }); + hasRegisteredConflictListener = true; + window.main.sync.on('sync.merge-conflicts', (_event, { handlerId, conflicts, labels }) => { + showModal(SyncMergeModal, { + conflicts, + labels, + onResolveAll: (resolvedConflicts: MergeConflict[]) => { + window.main.sync.resolveConflict({ handlerId, conflicts: resolvedConflicts }); + }, + onCancelUnresolved: () => { + window.main.sync.cancelConflict({ handlerId }); + }, }); }); - - return vcs; }; diff --git a/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts b/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts index 5745ab592e..28e8cb4b60 100644 --- a/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts +++ b/packages/insomnia/src/sync/vcs/migrate-projects-into-organization.ts @@ -1,8 +1,7 @@ import type { Project, RemoteProject } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { database } from '../../common/database'; -import * as models from '../../models'; // Migration: // Team ~= Project > Workspaces diff --git a/packages/insomnia/src/sync/vcs/normalize-backend-project-team.ts b/packages/insomnia/src/sync/vcs/normalize-backend-project-team.ts deleted file mode 100644 index a36bb05264..0000000000 --- a/packages/insomnia/src/sync/vcs/normalize-backend-project-team.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { BackendProject, Team } from '../types'; - -export interface BackendProjectWithTeams extends BackendProject { - teams: Team[]; -} - -export interface BackendProjectWithTeam extends BackendProject { - team: Team; -} - -export const normalizeBackendProjectTeam = (backend: BackendProjectWithTeams): BackendProjectWithTeam => ({ - id: backend.id, - name: backend.name, - rootDocumentId: backend.rootDocumentId, - // A backend project is guaranteed to exist on exactly one team - team: backend.teams[0], -}); diff --git a/packages/insomnia/src/templating/base-extension.ts b/packages/insomnia/src/templating/base-extension.ts index 3c81f00839..80694ff293 100644 --- a/packages/insomnia/src/templating/base-extension.ts +++ b/packages/insomnia/src/templating/base-extension.ts @@ -6,11 +6,9 @@ import iconv from 'iconv-lite'; import { jarFromCookies } from '~/common/cookies'; import type { Request, RequestGroup, Workspace } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { database as db } from '../common/database'; -import * as models from '../models/index'; import * as pluginApp from '../plugins/context/app'; import * as pluginNetwork from '../plugins/context/network'; import * as pluginStore from '../plugins/context/store'; @@ -162,7 +160,7 @@ export default class BaseExtension { }, response: { getLatestForRequestId: services.response.getLatestForRequestId, - getBodyBuffer, + getBodyBuffer: services.helpers.getResponseBodyBuffer, }, settings: { get: services.settings.get, diff --git a/packages/insomnia/src/templating/types.ts b/packages/insomnia/src/templating/types.ts index 81cbe7fa7e..06202e4267 100644 --- a/packages/insomnia/src/templating/types.ts +++ b/packages/insomnia/src/templating/types.ts @@ -19,7 +19,6 @@ import type { WebSocketRequest, Workspace, } from '~/insomnia-data'; -import type { getBodyBuffer } from '~/models/helpers/response-operations'; import type { NodeCurlRequestOptions, NodeCurlResponseType } from '../plugins/context/network'; import type { PluginStore } from '../plugins/context/store'; @@ -296,7 +295,7 @@ export interface PluginTemplateTagContext { }; response: { getLatestForRequestId: Services['response']['getLatestForRequestId']; - getBodyBuffer: typeof getBodyBuffer; + getBodyBuffer: Services['helpers']['getResponseBodyBuffer']; }; settings: { get: Services['settings']['get']; diff --git a/packages/insomnia/src/ui/analytics.ts b/packages/insomnia/src/ui/analytics.ts index 2bb04ea13a..605dad2ae2 100644 --- a/packages/insomnia/src/ui/analytics.ts +++ b/packages/insomnia/src/ui/analytics.ts @@ -8,6 +8,9 @@ export enum SegmentEvent { importStarted = 'Import Started', importScanned = 'Import Scanned', importCompleted = 'Import Completed', + importLoginRequired = 'Import Login Required', + importResumedAfterLogin = 'Import Resumed After Login', + importedRequestFirstSend = 'Imported Request First Send', documentCreate = 'Document Created', mockCreateModalOpened = 'Mock Server Create Modal Opened', mockCreate = 'Mock Created', @@ -164,3 +167,28 @@ export function trackOnceDaily(event: SegmentEvent, properties?: Record `importAttribution:request:${requestId}`; + +export function readPendingImportAttribution(): ImportAttribution { + try { + const raw = window.sessionStorage.getItem(PENDING_IMPORT_ATTRIBUTION_KEY); + return raw ? (JSON.parse(raw) as ImportAttribution) : {}; + } catch { + return {}; + } +} + +export function trackImportEvent(event: SegmentEvent, properties: Record = {}): void { + window.main.trackSegmentEvent({ + event, + properties: { ...readPendingImportAttribution(), ...properties }, + }); +} diff --git a/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts b/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts index da76d8941d..ff01375e6c 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts +++ b/packages/insomnia/src/ui/components/.client/codemirror/lint/json-lint.ts @@ -39,12 +39,12 @@ async function validator(text: string): Promise { }; // Render any Nunjucks templates before attempting to parse - const renderedText: string | null = await render(text, {}); - if (renderedText) { - try { + try { + const renderedText: string | null = await render(text, {}); + if (renderedText) { jsonlint.parse(renderedText); - } catch {} - } + } + } catch {} return found; } diff --git a/packages/insomnia/src/ui/components/dropdowns/auth-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/auth-dropdown.tsx index 408281285f..c130c30a15 100644 --- a/packages/insomnia/src/ui/components/dropdowns/auth-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/auth-dropdown.tsx @@ -21,10 +21,13 @@ import type { RequestAuthentication, } from '~/insomnia-data'; -import { type AuthTypes, HAWK_ALGORITHM_SHA256 } from '../../../common/constants'; +import { + type AuthTypes, + GRANT_TYPE_AUTHORIZATION_CODE, + HAWK_ALGORITHM_SHA256, + SIGNATURE_METHOD_HMAC_SHA1, +} from '../../../common/constants'; import { getAuthObjectOrNull } from '../../../network/authentication'; -import { SIGNATURE_METHOD_HMAC_SHA1 } from '../../../network/o-auth-1/constants'; -import { GRANT_TYPE_AUTHORIZATION_CODE } from '../../../network/o-auth-2/constants'; import { useRequestGroupPatcher, useRequestPatcher } from '../../hooks/use-request'; import { Icon } from '../icon'; diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 8e825a9c02..4c45c6d0e8 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -16,7 +16,7 @@ import { useParams, useRevalidator } from 'react-router'; import * as reactUse from 'react-use'; import type { GitProject, GitRepository } from '~/insomnia-data'; -import { isScratchpadOrganizationId } from '~/models/organization'; +import { models } from '~/insomnia-data'; import { useGitProjectCheckoutBranchActionFetcher } from '~/routes/git.branch.checkout'; import { useGitProjectFetchActionFetcher } from '~/routes/git.fetch'; import { useGitProjectPushActionFetcher } from '~/routes/git.push'; @@ -32,6 +32,7 @@ import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import type { MergeConflict } from '../../../sync/types'; +import { GitNonOriginBranchBanner } from '../git/git-non-origin-branch-banner'; import { Icon } from '../icon'; import { showModal } from '../modals'; import { GitProjectBranchesModal } from '../modals/git-project-branches-modal'; @@ -73,7 +74,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); useEffect(() => { - if (!isScratchpadOrganizationId(organizationId)) { + if (!models.organization.isScratchpadOrganizationId(organizationId)) { const load = storageRuleFetcher.load; load({ organizationId }); } @@ -114,6 +115,13 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject ? gitRepoDataFetcher.data.legacyInsomniaWorkspace : null; + const branchRemoteInfo = + gitRepoDataFetcher.data && 'branchRemoteInfo' in gitRepoDataFetcher.data && gitRepoDataFetcher.data.branchRemoteInfo + ? gitRepoDataFetcher.data.branchRemoteInfo + : null; + + const isNonOriginBranch = branchRemoteInfo ? !branchRemoteInfo.isOrigin : false; + // Only fetch the repo status if we have a repo uri and we don't have the status already const shouldFetchGitRepoStatus = Boolean( gitRepository?.uri && @@ -232,11 +240,21 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject status: 'error', }); } else if (gitCheckoutFetcher.data && 'success' in gitCheckoutFetcher.data && gitCheckoutFetcher.data.success) { - showToast({ - icon, - title: `Checkout completed`, - status: 'success', - }); + const warnings = 'warnings' in gitCheckoutFetcher.data ? (gitCheckoutFetcher.data.warnings as string[]) : []; + if (warnings.length > 0) { + showToast({ + icon, + title: 'Checkout completed with warnings', + description: warnings.join('\n'), + status: 'warning', + }); + } else { + showToast({ + icon, + title: `Checkout completed`, + status: 'success', + }); + } } }, [gitCheckoutFetcher.data, icon]); @@ -355,6 +373,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject closeGitProjectStagingModalRef.current = showModal(GitProjectStagingModal, { mode: StagingModalModes.commitAndPull, callbackRef: gitProjectStagingModalCallbackPropsRef, + isNonOriginBranch, }); } else if ('errors' in pullResult && pullResult.errors) { if (pullResult.errors.includes(GitVCSOperationErrors.AuthenticationRequiredError)) { @@ -411,6 +430,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject .continueMerge({ projectId, handledMergeConflicts: conflicts, + autoResolvedConflicts: pullResult.autoResolvedConflicts, commitMessage: pullResult.commitMessage, commitParent: pullResult.commitParent, }) @@ -439,6 +459,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject }); }, onCancelUnresolved: () => { + window.main.git.abortMerge({ projectId }); closeGitProjectStagingModalRef.current?.(); setIsPulling(false); showToast({ @@ -504,6 +525,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject closeGitProjectStagingModalRef.current = showModal(GitProjectStagingModal, { mode: StagingModalModes.default, callbackRef: gitProjectStagingModalCallbackPropsRef, + isNonOriginBranch, }); }, }, @@ -511,14 +533,14 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject id: 'pull', icon: isPulling ? 'refresh' : 'cloud-download', label: 'Pull', - isDisabled: false, + isDisabled: isNonOriginBranch, action: async () => handlePull(), }, { id: 'push', icon: 'cloud-upload', label: 'Push', - isDisabled: false, + isDisabled: isNonOriginBranch, action: () => handlePush({ force: false }), }, { @@ -531,7 +553,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject { id: 'fetch', icon: 'refresh', - isDisabled: false, + isDisabled: isNonOriginBranch, label: 'Fetch', action: () => { setOperationError(null); @@ -547,6 +569,10 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject ] : []; + const repoPath = gitRepository?._id + ? window.path.join(window.app.getPath('userData'), 'version-control', 'git', gitRepository._id) + : ''; + const gitSyncActions: { id: string; label: string; @@ -554,6 +580,13 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject isDisabled?: boolean; action: () => void; }[] = [ + { + id: 'open-folder', + label: 'Open folder', + isDisabled: !repoPath, + icon: 'folder-open', + action: () => window.shell.openPath(repoPath), + }, { id: 'branches', label: 'Branches', @@ -604,6 +637,13 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject return ( <> + {isNonOriginBranch && branchRemoteInfo?.trackingRemote && currentBranch && ( + + )} {operationError && (
diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index 154f0caeb1..cc6da54a19 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -217,6 +217,7 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable projectId, workspaceId, handledMergeConflicts: conflicts, + autoResolvedConflicts: result.autoResolvedConflicts, commitMessage: result.commitMessage, commitParent: result.commitParent, }) diff --git a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx index 70bd3a1b7b..03a93b4b9c 100644 --- a/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/preview-mode-dropdown.tsx @@ -1,8 +1,7 @@ import React, { type FC, useCallback } from 'react'; import { Button } from 'react-aria-components'; -import { models } from '~/insomnia-data'; -import { getTimeline } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { getPreviewModeName, PREVIEW_MODE_SOURCE, PREVIEW_MODES } from '../../../common/constants'; import { exportHarCurrentRequest } from '../../../common/har'; @@ -62,7 +61,7 @@ export const PreviewModeDropdown: FC = ({ download, copyToClipboard }) => return; } - const timeline = getTimeline(activeResponse); + const timeline = await services.helpers.getResponseTimeline(activeResponse); const headers = timeline .filter(v => v.name === 'HeaderIn') .map(v => v.value) diff --git a/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx index e751bf0e62..a3e65a2f27 100644 --- a/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/response-history-dropdown.tsx @@ -12,12 +12,11 @@ import type { WebSocketRequest, WebSocketResponse, } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { useRequestResponseDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete'; import { useRequestResponseDeleteAllActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.response.delete-all'; import { decompressObject } from '../../../common/misc'; -import * as models from '../../../models/index'; import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useRequestMetaPatcher } from '../../hooks/use-request'; import { Dropdown, type DropdownHandle, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown'; diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index 7e23bb7f68..b291ee0ac6 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -302,7 +302,7 @@ export const WorkspaceCardDropdown: FC = props => { {getWorkspaceLabel(workspace).singular}

{models.project.isRemoteProject(project) && ( - +
= () => { {getWorkspaceLabel(activeWorkspace).singular}

{models.project.isRemoteProject(activeProject) && ( - +
{ } const isLocalProject = - !models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId && !models.project.isGitProject(activeProject); + !models.project.isRemoteProject(activeProject) && + !activeWorkspaceMeta?.gitRepositoryId && + !models.project.isGitProject(activeProject); if (isLocalProject) { return ; } - const shouldShowCloudSyncDropdown = models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId; + const shouldShowCloudSyncDropdown = + models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId; if (shouldShowCloudSyncDropdown) { return ; } const shouldShowGitSyncDropdown = - features.gitSync.enabled && (activeWorkspaceMeta?.gitRepositoryId || !models.project.isRemoteProject(activeProject)); + features.gitSync.enabled && + (activeWorkspaceMeta?.gitRepositoryId || !models.project.isRemoteProject(activeProject)); if (shouldShowGitSyncDropdown) { if (models.project.isGitProject(activeProject)) { return ( diff --git a/packages/insomnia/src/ui/components/editors/auth/o-auth-1-auth.tsx b/packages/insomnia/src/ui/components/editors/auth/o-auth-1-auth.tsx index 6019d0627d..9dbee46702 100644 --- a/packages/insomnia/src/ui/components/editors/auth/o-auth-1-auth.tsx +++ b/packages/insomnia/src/ui/components/editors/auth/o-auth-1-auth.tsx @@ -1,14 +1,14 @@ import React, { type FC } from 'react'; -import type { AuthTypeOAuth1 } from '~/insomnia-data'; - import { type OAuth1SignatureMethod, SIGNATURE_METHOD_HMAC_SHA1, SIGNATURE_METHOD_HMAC_SHA256, SIGNATURE_METHOD_PLAINTEXT, SIGNATURE_METHOD_RSA_SHA1, -} from '../../../../network/o-auth-1/constants'; +} from '~/common/constants'; +import type { AuthTypeOAuth1 } from '~/insomnia-data'; + import { type RequestLoaderData, useRequestLoaderData, diff --git a/packages/insomnia/src/ui/components/editors/auth/o-auth-2-auth.tsx b/packages/insomnia/src/ui/components/editors/auth/o-auth-2-auth.tsx index 0f21602ade..d8586c0584 100644 --- a/packages/insomnia/src/ui/components/editors/auth/o-auth-2-auth.tsx +++ b/packages/insomnia/src/ui/components/editors/auth/o-auth-2-auth.tsx @@ -2,12 +2,10 @@ import React, { type ChangeEvent, type FC, type ReactNode, useEffect, useMemo, u import type { AuthTypeOAuth2, OAuth2ResponseType, OAuth2Token, RequestAuthentication } from '~/insomnia-data'; import { services } from '~/insomnia-data'; +import { clearOAuthWindowSessionId } from '~/ui/spawn-oauth-window'; -import { getOauthRedirectUrl } from '../../../../common/constants'; -import { toKebabCase } from '../../../../common/misc'; -import accessTokenUrls from '../../../../datasets/access-token-urls'; -import authorizationUrls from '../../../../datasets/authorization-urls'; import { + getOauthRedirectUrl, GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_IMPLICIT, @@ -15,9 +13,10 @@ import { GRANT_TYPE_PASSWORD, PKCE_CHALLENGE_PLAIN, PKCE_CHALLENGE_S256, -} from '../../../../network/o-auth-2/constants'; -import { getOAuth2Token } from '../../../../network/o-auth-2/get-token'; -import { initNewOAuthSession } from '../../../../network/o-auth-2/get-token'; +} from '../../../../common/constants'; +import { toKebabCase } from '../../../../common/misc'; +import accessTokenUrls from '../../../../datasets/access-token-urls'; +import authorizationUrls from '../../../../datasets/authorization-urls'; import { type RequestLoaderData, useRequestLoaderData, @@ -427,7 +426,7 @@ export const OAuth2Auth = ({ showMcpAuthFlow, disabled }: { showMcpAuthFlow?: bo
@@ -626,7 +625,7 @@ const OAuth2Tokens = ({ hideRefresh }: { hideRefresh?: boolean }) => { try { const activeAuth = getActiveOAuth2AuthFields(authentication as AuthTypeOAuth2); const renderedAuthentication = (await handleRender(activeAuth)) as AuthTypeOAuth2; - const t = await getOAuth2Token(_id, renderedAuthentication, true); + const t = await window.main.getOAuth2Token(_id, renderedAuthentication, true); setToken(t); setLoading(false); } catch (err) { diff --git a/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx b/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx index a744ee6df8..c607111275 100644 --- a/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx @@ -24,7 +24,6 @@ import * as reactUse from 'react-use'; import type { Request } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import { getBodyBuffer } from '~/models/helpers/response-operations'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; import { CONTENT_TYPE_JSON } from '../../../../common/constants'; @@ -185,7 +184,7 @@ const fetchGraphQLSchemaForRequest = async ({ }, }; } - const bodyBuffer = await getBodyBuffer(response); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(response); if (bodyBuffer) { const { data, errors } = JSON.parse(bodyBuffer.toString()); if (errors?.length) { diff --git a/packages/insomnia/src/ui/components/git-credentials/git-repository-select.tsx b/packages/insomnia/src/ui/components/git-credentials/git-repository-select.tsx index 6f641f9251..8baac07655 100644 --- a/packages/insomnia/src/ui/components/git-credentials/git-repository-select.tsx +++ b/packages/insomnia/src/ui/components/git-credentials/git-repository-select.tsx @@ -47,6 +47,7 @@ export const GitRepositorySelect = ({
= ({ currentBranch }) => { + return ( +
+ + + This branch tracks a non-origin remote which is currently unsupported in Insomnia + + + + + + Set branch upstream to origin +

+ To continue pushing and pulling to the remote repo with this branch, complete the following steps using + the git CLI: +

+
    +
  1. +
    + 1. +

    Re-point to origin

    +
    +
    + + git branch --set-upstream-to=origin/{currentBranch} + + + + +
    +
  2. +
  3. +
    + 2. +

    Push to origin

    +
    +
    + + git push origin {currentBranch} + + + + +
    +
  4. +
+
+
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/header-invite-button.tsx b/packages/insomnia/src/ui/components/header-invite-button.tsx index 77c071fc9d..69911c6bc8 100644 --- a/packages/insomnia/src/ui/components/header-invite-button.tsx +++ b/packages/insomnia/src/ui/components/header-invite-button.tsx @@ -119,7 +119,7 @@ const MissingSomeoneModal = ({ isOpen, onClose }: any) => { onClose?.(); }; return ( - +

You're on a paid plan, so please contact your company's Insomnia admins to get anyone added to this account.

diff --git a/packages/insomnia/src/ui/components/header-user-button.tsx b/packages/insomnia/src/ui/components/header-user-button.tsx index 85f15fa5a8..10d4099ceb 100644 --- a/packages/insomnia/src/ui/components/header-user-button.tsx +++ b/packages/insomnia/src/ui/components/header-user-button.tsx @@ -1,4 +1,4 @@ -import { type CurrentPlan, type UserProfile } from 'insomnia-api'; +import { type CurrentPlan, type User } from 'insomnia-api'; import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; import { getAppWebsiteBaseURL } from '~/common/constants'; @@ -10,7 +10,7 @@ import { LogoutModal } from '~/ui/components/modals/logout-modal'; import { showSettingsModal } from '~/ui/components/modals/settings-modal'; interface UserButtonProps { - user: UserProfile; + user: User; currentPlan?: CurrentPlan; isMinimal?: boolean; } @@ -23,7 +23,7 @@ export const HeaderUserButton = ({ user, isMinimal = false }: UserButtonProps) = data-testid="user-dropdown" className="flex shrink-0 items-center justify-center gap-2 rounded-md px-1 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:bg-(--hl-sm)" > - + diff --git a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx index e04c47fe16..81b6698e93 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-url-bar.tsx @@ -10,7 +10,6 @@ import type { McpReadyState } from '~/main/mcp/types'; import { _buildBearerHeader } from '~/network/authentication'; import { getBasicAuthHeader } from '~/network/basic-auth/get-header'; import { getBearerAuthHeader } from '~/network/bearer-auth/get-header'; -import { getOAuth2Token } from '~/network/o-auth-2/get-token'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { type ConnectActionParams, @@ -123,7 +122,7 @@ export const McpUrlActionBar = ({ const { key, value } = authentication; headers.push({ name: key, value }); } else if (authentication.type === 'oauth2') { - const oAuth2Token = await getOAuth2Token(request._id, authentication as AuthTypeOAuth2); + const oAuth2Token = await window.main.getOAuth2Token(request._id, authentication as AuthTypeOAuth2); if (oAuth2Token) { const token = oAuth2Token.accessToken; const authHeader = _buildBearerHeader(token, authentication.tokenPrefix); diff --git a/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx b/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx index 1e6b4911c5..336a69326f 100644 --- a/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx +++ b/packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx @@ -5,7 +5,6 @@ import * as reactUse from 'react-use'; import type { MockRoute, MockServer, Response } from '~/insomnia-data'; import { services } from '~/insomnia-data'; -import { getBodyBuffer, getTimeline } from '~/models/helpers/response-operations'; import { useRootLoaderData } from '~/root'; import { useRequestNewMockSendActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new-mock-send'; import { useMockRouteLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId'; @@ -48,7 +47,7 @@ export const MockResponsePane = () => { useEffect(() => { const fn = async () => { if (activeResponse) { - const timeline = await getTimeline(activeResponse, true); + const timeline = await services.helpers.getResponseTimeline(activeResponse, true); setTimeline(timeline); } }; @@ -131,7 +130,7 @@ export const MockResponsePane = () => { filter={''} filterHistory={[]} bodyBuffer={activeResponse.bodyBuffer} - getBody={() => getBodyBuffer(activeResponse)} + getBody={() => services.helpers.getResponseBodyBuffer(activeResponse)} previewMode={previewMode} responseId={activeResponse._id} updateFilter={activeResponse.error ? undefined : () => {}} @@ -300,7 +299,7 @@ const PreviewModeDropdown = ({ icon="copy" label="Copy raw response" onClick={async () => { - const bodyBuffer = await getBodyBuffer(activeResponse); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(activeResponse); bodyBuffer && window.clipboard.writeText(bodyBuffer.toString('utf8')); }} /> @@ -332,7 +331,7 @@ const PreviewModeDropdown = ({ icon="save" label="Export prettified response" onClick={async () => { - const bodyBuffer = await getBodyBuffer(activeResponse); + const bodyBuffer = await services.helpers.getResponseBodyBuffer(activeResponse); const { canceled, filePath } = await window.dialog.showSaveDialog({ title: 'Save Full Response', buttonLabel: 'Save', @@ -364,7 +363,7 @@ const PreviewModeDropdown = ({ if (canceled || !filePath) { return; } - const timeline = getTimeline(activeResponse); + const timeline = await services.helpers.getResponseTimeline(activeResponse); const headers = timeline .filter(v => v.name === 'HeaderIn') .map(v => v.value) diff --git a/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts b/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts index ab500a247f..53419a1919 100644 --- a/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts +++ b/packages/insomnia/src/ui/components/modals/__tests__/import-export.test.ts @@ -1,10 +1,7 @@ import { exportRequestsHAR, exportWorkspacesHAR } from 'insomnia/src/common/har'; import { beforeEach, describe, expect, it } from 'vitest'; -import { services } from '~/insomnia-data'; - -import { database as db } from '../../../../common/database'; -import * as models from '../../../../models'; +import { database as db, services } from '~/insomnia-data'; // @vitest-environment jsdom describe('exportWorkspacesHAR() and exportRequestsHAR()', () => { diff --git a/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx b/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx index bdcb9cdc36..f625c1a3ee 100644 --- a/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx @@ -2,14 +2,12 @@ import React, { type FC, type MouseEventHandler, useEffect, useRef, useState } f import { OverlayContainer } from 'react-aria'; import { useParams } from 'react-router'; -import type { Project } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import type { BaseModel, Project } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new'; import { database } from '../../../common/database'; import { strings } from '../../../common/strings'; -import { sortProjects } from '../../../models/helpers/project'; -import * as models from '../../../models/index'; import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; @@ -30,8 +28,8 @@ export const AddRequestToCollectionModal: FC = ({ onHide } projectId: string; workspaceId: string; }; - const [projectOptions, setProjectOptions] = useState([]); - const [workspaceOptions, setWorkspaceOptions] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); + const [workspaceOptions, setWorkspaceOptions] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(''); const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(''); @@ -42,7 +40,7 @@ export const AddRequestToCollectionModal: FC = ({ onHide } const organizationProjects = await database.find(models.project.type, { parentId: organizationId, }); - setProjectOptions(sortProjects(organizationProjects)); + setProjectOptions(models.project.sortProjects(organizationProjects)); setSelectedProjectId(organizationProjects[0]?._id || ''); })(); }, [organizationId]); diff --git a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx index aa13320742..2f401771b7 100644 --- a/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/cloud-credential-modal/cloud-credential-modal.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; -import { type CloudProviderCredential, models } from '~/insomnia-data'; +import type { CloudProviderCredential } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { useUpdateCloudCredentialActionFetcher } from '~/routes/cloud-credentials.$cloudCredentialId.update'; import { useCreateCloudCredentialActionFetcher } from '~/routes/cloud-credentials.create'; diff --git a/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx b/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx index 57e7a9a48f..4e0c921ce9 100644 --- a/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx @@ -6,7 +6,6 @@ import { useParams } from 'react-router'; import type { GrpcRequest, Request, RequestGroup, SocketIORequest, WebSocketRequest } from '~/insomnia-data'; import { models } from '~/insomnia-data'; -import { requestGroup } from '../../../models'; import { type Child, useWorkspaceLoaderFetcher, @@ -237,9 +236,9 @@ export const ExportRequestsModal = ({ setState({ treeRoot: { doc: { - ...requestGroup.init(), + ...models.requestGroup.init(), _id: 'all', - type: requestGroup.type, + type: models.requestGroup.type, name: 'All requests', parentId: '', modified: 0, diff --git a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx index ca35bab544..e043901507 100644 --- a/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-branches-modal.tsx @@ -160,6 +160,7 @@ const LocalBranchItem = ({ projectId, workspaceId, handledMergeConflicts: conflicts, + autoResolvedConflicts: result.autoResolvedConflicts, commitMessage: result.commitMessage, commitParent: result.commitParent, }) diff --git a/packages/insomnia/src/ui/components/modals/git-project-branches-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-branches-modal.tsx index 80766dd31a..50a1ae5b3d 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-branches-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-branches-modal.tsx @@ -155,6 +155,7 @@ const LocalBranchItem = ({ .continueMerge({ projectId, handledMergeConflicts: conflicts, + autoResolvedConflicts: result.autoResolvedConflicts, commitMessage: result.commitMessage, commitParent: result.commitParent, }) @@ -165,7 +166,7 @@ const LocalBranchItem = ({ }, onCancelUnresolved: () => { // user aborted merge - window.main.git.abortMerge(); + window.main.git.abortMerge({ projectId }); // TODO: the abortMerge method provided by isomorphic-git is unreliable // clean up any partial merges here reject( @@ -310,6 +311,7 @@ export const GitProjectBranchesModal: FC = ({ currentBranch, branches, on }} isDismissable className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" + data-testid="git-project-branches-modal-overlay" > { @@ -327,6 +329,7 @@ export const GitProjectBranchesModal: FC = ({ currentBranch, branches, on diff --git a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index 068dfbddba..99ff5a8c9d 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx @@ -11,6 +11,7 @@ import React, { import { Button, Dialog, + DialogTrigger, GridList, GridListItem, Heading, @@ -18,6 +19,7 @@ import { Label, Modal, ModalOverlay, + Popover, TextArea, TextField, Tooltip, @@ -56,6 +58,7 @@ import { showSettingsModal } from '~/ui/components/modals/settings-modal'; import { SvgIcon } from '~/ui/components/svg-icon'; import { useAIFeatureStatus } from '~/ui/hooks/use-organization-features'; +import { platform } from '../../../common/platform'; import { DiffEditor } from '../diff-view-editor'; import { Icon } from '../icon'; import { showToast } from '../toast-notification'; @@ -124,6 +127,7 @@ interface GeneratedCommitsFormProps { gitRepository?: GitRepository | null; selectedCredential?: GitCredentials | null; selectedProvider?: GitProviderOption | null; + isNonOriginBranch?: boolean; } interface FileItem { @@ -288,6 +292,7 @@ const GeneratedCommitsForm: FC = ({ gitRepository, selectedCredential, selectedProvider, + isNonOriginBranch, }) => { const commitsFetcher = useGitProjectCommitsActionFetcher(); const committingActionRef = useRef<'commit' | 'commit-push' | null>(null); @@ -523,7 +528,7 @@ const GeneratedCommitsForm: FC = ({ - + {isNonOriginBranch ? ( + + + + Push action is not allowed for branches on non-origin remotes + + + ) : ( + + )}
)} {operationError && selectedProvider && isGitRepoLoadAuthHttp40Error([operationError]) ? ( @@ -785,8 +816,8 @@ const ManualCommitForm: FC = ({ ) : null} -
-
+
+
Staged changes @@ -812,7 +843,7 @@ const ManualCommitForm: FC = ({ {changes.staged.length} -
+
= ({
-
+
Unstaged changes
@@ -931,7 +962,7 @@ const ManualCommitForm: FC = ({
-
+
= ({
+ +
+
+ + PREVIEW + + Manage changes on the Git CLI + + + + + You can now browse Git Sync project files on your local file system and manage changes using your normal + Git workflows.{' '} + + Learn more ↗ + + + + +
+

Path to this project:

+
+ + {repoPath} + + + + + + Open in file system + + +
+
); }; @@ -1034,6 +1130,7 @@ export interface GitProjectStagingModalCallbackProps { export interface GitProjectStagingModalOptions { mode?: StagingModalMode; + isNonOriginBranch?: boolean; /* Why is callbackRef a ref object? * The callbacks passed to the modal (onClose, onPullAfterCommit, onPushAfterPull) may change after the show function is called. * If we were to pass the callbacks directly, the modal would capture the initial callbacks and not reflect any updates to them. @@ -1057,8 +1154,8 @@ export const GitProjectStagingModal = forwardRef(( }, []); useImperativeHandle(ref, () => ({ - show: ({ mode: newMode = StagingModalModes.default, callbackRef }) => { - setModalOptions({ mode: newMode, callbackRef }); + show: ({ mode: newMode = StagingModalModes.default, callbackRef, isNonOriginBranch }) => { + setModalOptions({ mode: newMode, callbackRef, isNonOriginBranch }); setIsOpen(true); }, hide, @@ -1082,6 +1179,7 @@ export const GitProjectStagingModal = forwardRef(( isOpen && ( = ({ mode = StagingModalModes.default, onClose, onPullAfterCommit, onPushAfterPull }) => { +> = ({ mode = StagingModalModes.default, isNonOriginBranch, onClose, onPullAfterCommit, onPushAfterPull }) => { const { projectId } = useParams() as { projectId: string }; const [commitGenerationKey, setCommitGenerationKey] = useState(0); @@ -1300,8 +1399,8 @@ const OriginalGitProjectStagingModal: FC<

)} -
-
+
+
{isGenerateCommitMessagesWithAIEnabled && (

@@ -1373,6 +1472,7 @@ const OriginalGitProjectStagingModal: FC< gitRepository={gitRepository} selectedCredential={selectedCredential} selectedProvider={selectedProvider} + isNonOriginBranch={isNonOriginBranch} /> )} @@ -1391,6 +1491,7 @@ const OriginalGitProjectStagingModal: FC< gitRepository={gitRepository} selectedCredential={selectedCredential} selectedProvider={selectedProvider} + isNonOriginBranch={isNonOriginBranch} /> )}

@@ -1547,6 +1648,7 @@ const ConfirmDiscardModal = ({ message, onConfirm, onClose }: ConfirmModalProps) Cancel
-
+
{ diff --git a/packages/insomnia/src/ui/components/modals/import-modal/import-modal.tsx b/packages/insomnia/src/ui/components/modals/import-modal/import-modal.tsx index 895edc3699..f6fd4e69bc 100644 --- a/packages/insomnia/src/ui/components/modals/import-modal/import-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/import-modal/import-modal.tsx @@ -21,7 +21,13 @@ import { type ScanResult, } from '../../../../common/import'; import { invariant } from '../../../../utils/invariant'; -import { SegmentEvent } from '../../../analytics'; +import { + importAttributionKey, + PENDING_IMPORT_ATTRIBUTION_KEY, + readPendingImportAttribution, + SegmentEvent, + trackImportEvent, +} from '../../../analytics'; import { Modal, type ModalHandle, type ModalProps } from '../../base/modal'; import { ModalHeader } from '../../base/modal-header'; import { HelpTooltip } from '../../help-tooltip'; @@ -269,16 +275,18 @@ export const ImportModal: FC = ({ // Track the import completion event, redirect to the new workspace and close the modal useEffect(() => { if (importFetcher?.data?.done === true && scanResourcesFetcherData?.length) { - window.main.trackSegmentEvent({ - event: SegmentEvent.importCompleted, - properties: { - workspaces: scanResourcesFetcherData.map(scanResult => scanResult.workspaces?.length || 0), - requests: scanResourcesFetcherData.map(scanResult => scanResult.requests?.length || 0), - }, + trackImportEvent(SegmentEvent.importCompleted, { + workspaces: scanResourcesFetcherData.map(scanResult => scanResult.workspaces?.length || 0), + requests: scanResourcesFetcherData.map(scanResult => scanResult.requests?.length || 0), }); const workspace = importFetcher?.data?.singleImportedWorkspace; const request = importFetcher?.data?.singleImportedRequest; const targetProjectId = importFetcher?.data?.singleImportedProjectId || createdProjectId || defaultProjectId; + const attribution = readPendingImportAttribution(); + if ((attribution.importSource || attribution.importSourceUrl) && request) { + window.localStorage.setItem(importAttributionKey(request._id), JSON.stringify(attribution)); + } + window.sessionStorage.removeItem(PENDING_IMPORT_ATTRIBUTION_KEY); if (workspace && request) { navigate( `/organization/${organizationId}/project/${targetProjectId}/workspace/${workspace._id}/debug/request/${request._id}`, @@ -397,10 +405,7 @@ export const ImportModal: FC = ({ .filter(({ errors }) => errors.length === 0) .forEach(scanResult => { const type = scanResult.type?.id ?? 'unknown'; - window.main.trackSegmentEvent({ - event: SegmentEvent.dataImport, - properties: { 'data-import-type': type }, - }); + trackImportEvent(SegmentEvent.dataImport, { 'data-import-type': type }); }); }} /> @@ -436,8 +441,7 @@ export const validateCurl = async (value: string): Promise<{ isValid: boolean; m : { isValid: false, message: 'Invalid cURL request' }; } catch (error) { const rawMessage = error instanceof Error ? error.message : String(error); - const cleanedMessage = rawMessage.replace("Error invoking remote method 'parseImport': Error: ", ''); - const finalMessage = rawMessage.includes('No importers found for file') ? 'Invalid cURL request' : cleanedMessage; + const finalMessage = rawMessage.includes('No importers found for file') ? 'Invalid cURL request' : rawMessage; console.log('[importer] error', finalMessage); return finalMessage.includes('No importers found for file') ? { isValid: false, message: 'Invalid cURL request' } diff --git a/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx b/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx index a7d10cb6a8..e896966497 100644 --- a/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx @@ -9,7 +9,7 @@ import { database } from '~/common/database'; import type { ScanResult } from '~/common/import'; import { selectFileOrFolder } from '~/common/select-file-or-folder'; import type { Project } from '~/insomnia-data'; -import * as models from '~/models'; +import { models } from '~/insomnia-data'; import { importScannedResources } from '~/routes/import.resources'; import { scanImportResources } from '~/routes/import.scan'; import { useOrganizationLoaderData } from '~/routes/organization'; diff --git a/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx b/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx index 325ffe5edb..b5b33c48ad 100644 --- a/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx +++ b/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx @@ -17,7 +17,7 @@ import { useParams, useSearchParams } from 'react-router'; import { getAppWebsiteBaseURL } from '~/common/constants'; import { docsPricingLearnMoreLink } from '~/common/documentation'; import { debounce } from '~/common/misc'; -import { isOwnerOfOrganization } from '~/models/organization'; +import { models } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useOrganizationLoaderData } from '~/routes/organization'; import { useCollaboratorsSearchLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-search'; @@ -126,7 +126,9 @@ export const InviteForm = ({ const organizationData = useOrganizationLoaderData(); const organization = organizationData?.organizations.find(o => o.id === organizationId); const isUserOwner = - organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId }); + organization && + userSession.accountId && + models.organization.isOwnerOfOrganization({ organization, accountId: userSession.accountId }); const sessionId = userSession.id; const [loading, setLoading] = useState(false); diff --git a/packages/insomnia/src/ui/components/modals/oauth-authorization-status-modal.tsx b/packages/insomnia/src/ui/components/modals/oauth-authorization-status-modal.tsx index 0e213c5e13..81a53f1ca2 100644 --- a/packages/insomnia/src/ui/components/modals/oauth-authorization-status-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/oauth-authorization-status-modal.tsx @@ -1,8 +1,8 @@ import React, { type FC, useEffect, useRef, useState } from 'react'; +import type { OAuth2AuthorizationStatusType } from '~/common/constants'; import { useDefaultBrowserRedirectActionFetcher } from '~/routes/auth.default-browser-redirect'; -import type { OAuth2AuthorizationStatusType } from '../../../network/o-auth-2/constants'; import { invariant } from '../../../utils/invariant'; import uiEventBus, { OAUTH2_AUTHORIZATION_STATUS_CHANGE } from '../../event-bus'; import { Modal, type ModalHandle } from '../base/modal'; diff --git a/packages/insomnia/src/ui/components/modals/project-modal.tsx b/packages/insomnia/src/ui/components/modals/project-modal.tsx index c00f1238d4..93adddbbe1 100644 --- a/packages/insomnia/src/ui/components/modals/project-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/project-modal.tsx @@ -64,6 +64,7 @@ export const ProjectModal = ({
-
+
{ diff --git a/packages/insomnia/src/ui/components/modals/upgrade-plan-modal.tsx b/packages/insomnia/src/ui/components/modals/upgrade-plan-modal.tsx index 19207a2037..043067d2a0 100644 --- a/packages/insomnia/src/ui/components/modals/upgrade-plan-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/upgrade-plan-modal.tsx @@ -63,6 +63,18 @@ export const UpgradePlanModal = () => { useEffect(() => { if (checkerData?.isEligible) { + // Don't show when a deep-link import is about to open (e.g. user just + // logged in to handle an insomnia://app/import link). + // Check both keys to cover the full timing window: pendingDeepLinkAfterAuthorize + // is present before the replay effect in root.tsx removes it, + // suppressWelcomeModals is set by that same effect just before replay. + if ( + window.sessionStorage.getItem('pendingDeepLinkAfterAuthorize') || + window.sessionStorage.getItem('suppressWelcomeModals') + ) { + window.sessionStorage.removeItem('suppressWelcomeModals'); + return; + } setOpen(true); } }, [checkerData?.isEligible]); diff --git a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx index adb7cd201f..863954df37 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx @@ -2,7 +2,8 @@ import React, { type FC, type MouseEventHandler, useEffect, useRef, useState } f import { OverlayContainer } from 'react-aria'; import { href, useParams } from 'react-router'; -import type { Project, Workspace } from '~/insomnia-data'; +import type { BaseModel, Project, Workspace } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { useOrganizationLoaderData } from '~/routes/organization'; import { useWorkspaceMoveActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.move'; @@ -10,8 +11,6 @@ import { database } from '../../../common/database'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '../../../common/get-workspace-label'; import { strings } from '../../../common/strings'; -import { sortProjects } from '../../../models/helpers/project'; -import * as models from '../../../models/index'; import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; @@ -30,7 +29,7 @@ export const WorkspaceDuplicateModal: FC = ({ work }; const organizationData = useOrganizationLoaderData(); const [selectedOrgId, setSelectedOrgId] = useState(organizationId); - const [projectOptions, setProjectOptions] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(''); const [newWorkspaceName, setNewWorkspaceName] = useState(workspace.name); useEffect(() => { @@ -38,7 +37,7 @@ export const WorkspaceDuplicateModal: FC = ({ work const organizationProjects = await database.find(models.project.type, { parentId: selectedOrgId, }); - setProjectOptions(sortProjects(organizationProjects)); + setProjectOptions(models.project.sortProjects(organizationProjects)); setSelectedProjectId(organizationProjects[0]?._id || ''); })(); }, [selectedOrgId]); diff --git a/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx index 73d1ad007f..63490174a0 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx @@ -14,13 +14,12 @@ import { import { useParams } from 'react-router'; import type { MockServer, Project, Workspace } from '~/insomnia-data'; -import { removeResponsesForRequest } from '~/models/helpers/response-operations'; +import { models, services } from '~/insomnia-data'; import { useGitProjectRepositoryTreeLoaderFetcher } from '~/routes/git.repository-tree'; import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update'; import { database as db } from '../../../common/database'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; -import * as models from '../../../models/index'; import { safeToUseInsomniaFileName, safeToUseInsomniaFileNameWithExt } from '../../../sync/git/insomnia-filename'; import { PromptButton } from '../base/prompt-button'; import { Icon } from '../icon'; @@ -198,7 +197,7 @@ export const WorkspaceSettingsModal = ({ workspace, gitFilePath, project, mockSe const docs = await db.getWithDescendants(workspace, [models.request.type]); const requests = docs.filter(models.request.isRequest); for (const req of requests) { - await removeResponsesForRequest(req._id); + await services.helpers.removeResponsesForRequest(req._id); } close(); }} diff --git a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx index 60d51a3463..91af32f3a2 100644 --- a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx @@ -4,7 +4,7 @@ import { useParams } from 'react-router'; import * as reactUse from 'react-use'; import type { GrpcRequest, GrpcRequestHeader, RequestGroup } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; import { OneLineEditor } from '~/ui/components/.client/codemirror/one-line-editor'; @@ -14,8 +14,6 @@ import { database as db } from '../../../common/database'; import { generateId } from '../../../common/misc'; import { getRenderedGrpcRequest, getRenderedGrpcRequestMessage } from '../../../common/render'; import type { GrpcMethodType } from '../../../main/ipc/grpc'; -import * as models from '../../../models'; -import { queryAllWorkspaceUrls } from '../../../models/helpers/query-all-workspace-urls'; import { getOrInheritHeaders } from '../../../network/network'; import { urlMatchesCertHost } from '../../../network/url-matches-cert-host'; import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; @@ -241,7 +239,9 @@ export const GrpcRequestPane: FunctionComponent = ({ grpcState, setGrpcSt defaultValue={activeRequest.url} placeholder="grpcb.in:9000" onChange={url => patchRequest(requestId, { url })} - getAutocompleteConstants={() => queryAllWorkspaceUrls(workspaceId, models.grpcRequest.type, requestId)} + getAutocompleteConstants={() => + services.helpers.queryAllWorkspaceUrls(workspaceId, models.grpcRequest.type, requestId) + } />
diff --git a/packages/insomnia/src/ui/components/panes/request-pane.tsx b/packages/insomnia/src/ui/components/panes/request-pane.tsx index c4caf2647a..adf7e2ab95 100644 --- a/packages/insomnia/src/ui/components/panes/request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/request-pane.tsx @@ -5,11 +5,10 @@ import { useParams } from 'react-router'; import * as reactUse from 'react-use'; import type { RequestParameter, Settings } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { OneLineEditor } from '~/ui/components/.client/codemirror/one-line-editor'; import { getContentTypeFromHeaders } from '../../../common/constants'; -import * as models from '../../../models'; -import { queryAllWorkspaceUrls } from '../../../models/helpers/query-all-workspace-urls'; import { getAuthObjectOrNull } from '../../../network/authentication'; import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { @@ -106,7 +105,9 @@ export const RequestPane: FC = ({ environmentId, settings, onPaste }) => queryAllWorkspaceUrls(workspaceId, models.request.type, requestId)} + handleAutocompleteUrls={() => + services.helpers.queryAllWorkspaceUrls(workspaceId, models.request.type, requestId) + } nunjucksPowerUserMode={settings.nunjucksPowerUserMode} onPaste={onPaste} ref={requestUrlBarRef} diff --git a/packages/insomnia/src/ui/components/panes/response-pane.tsx b/packages/insomnia/src/ui/components/panes/response-pane.tsx index 2585563d46..0cac3de500 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/response-pane.tsx @@ -1,8 +1,8 @@ -import { type FC, useCallback, useMemo } from 'react'; +import { type FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Tab, TabList, TabPanel, Tabs, Toolbar } from 'react-aria-components'; import { services } from '~/insomnia-data'; -import { getBodyBuffer, getTimeline } from '~/models/helpers/response-operations'; +import type { ResponseTimelineEntry } from '~/main/network/libcurl-promise'; import { useRootLoaderData } from '~/root'; import { SegmentEvent } from '~/ui/analytics'; @@ -36,6 +36,7 @@ import { downloadResponseBody } from './response-pane-utils'; interface Props { activeRequestId: string; } + export const ResponsePane: FC = ({ activeRequestId }) => { const { activeRequest, activeRequestMeta, activeResponse, responses, requestVersions } = useRequestLoaderData() as RequestLoaderData; @@ -69,6 +70,26 @@ export const ResponsePane: FC = ({ activeRequestId }) => { (prettify: boolean) => downloadResponseBody(activeRequest, activeResponse, prettify), [activeRequest, activeResponse], ); + const [timeline, setTimeline] = useState([]); + + useEffect(() => { + let isCancelled = false; + + if (!activeResponse) { + setTimeline([]); + return; + } + + services.helpers.getResponseTimeline(activeResponse).then(responseTimeline => { + if (!isCancelled) { + setTimeline(responseTimeline); + } + }); + + return () => { + isCancelled = true; + }; + }, [activeResponse]); const { passedTestCount, totalTestCount } = useMemo(() => { let passedTestCount = 0; @@ -103,7 +124,6 @@ export const ResponsePane: FC = ({ activeRequestId }) => { ); } - const timeline = getTimeline(activeResponse); const cookieHeaders = getSetCookieHeaders(activeResponse.headers); return ( @@ -197,7 +217,7 @@ export const ResponsePane: FC = ({ activeRequestId }) => { { - const bodyBuffer = activeResponse ? await getBodyBuffer(activeResponse) : null; + const bodyBuffer = activeResponse ? await services.helpers.getResponseBodyBuffer(activeResponse) : null; if (bodyBuffer) { window.clipboard.writeText(bodyBuffer.toString('utf8')); } @@ -216,7 +236,7 @@ export const ResponsePane: FC = ({ activeRequestId }) => { filter={filter} filterHistory={filterHistory} bodyBuffer={activeResponse.bodyBuffer} - getBody={() => getBodyBuffer(activeResponse)} + getBody={() => services.helpers.getResponseBodyBuffer(activeResponse)} previewMode={activeResponse.error ? PREVIEW_MODE_SOURCE : previewMode} responseId={activeResponse._id} updateFilter={activeResponse.error ? undefined : handleSetFilter} diff --git a/packages/insomnia/src/ui/components/project/git-repo-form.tsx b/packages/insomnia/src/ui/components/project/git-repo-form.tsx index 5595fcdbcf..7600200686 100644 --- a/packages/insomnia/src/ui/components/project/git-repo-form.tsx +++ b/packages/insomnia/src/ui/components/project/git-repo-form.tsx @@ -12,7 +12,8 @@ import { } from 'react-aria-components'; import { Icon } from '~/basic-components/icon'; -import { type GitCredentials, models, type ProviderEmail } from '~/insomnia-data'; +import type { GitCredentials, ProviderEmail } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { useAllConnectedReposLoaderFetcher } from '~/routes/git.all-connected-repos'; import type { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone'; import { useGitValidateCredentialFetcher } from '~/routes/git.validate-credential'; @@ -96,7 +97,7 @@ export const GitRepoForm: FC = ({ const [isEmailSelectOpen, setIsEmailSelectOpen] = useState(false); const isCredentialInvalid = - validateCredentialFetcher.state !== 'idle' || + (validateCredentialFetcher.state !== 'idle' && !validateCredentialFetcher.data) || Boolean( validateCredentialFetcher.data && 'errors' in validateCredentialFetcher.data && @@ -224,7 +225,7 @@ export const GitRepoForm: FC = ({
- {validateCredentialFetcher.state !== 'idle' && ( + {validateCredentialFetcher.state !== 'idle' && !validateCredentialFetcher.data && (
Validating credential... @@ -297,8 +298,8 @@ export const GitRepoForm: FC = ({ )} - {selectedProvider && !isCredentialInvalid && ( - <> + {selectedProvider && ( +
{selectedProvider.supportsFetchRepos ? ( = ({ }} /> )} - +
)} - {!isCredentialInvalid && ( - - )} +
+ +
)} diff --git a/packages/insomnia/src/ui/components/project/project-settings-form.tsx b/packages/insomnia/src/ui/components/project/project-settings-form.tsx index bbda284161..f070148134 100644 --- a/packages/insomnia/src/ui/components/project/project-settings-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-settings-form.tsx @@ -11,6 +11,8 @@ import { Select, SelectValue, TextField, + Tooltip, + TooltipTrigger, } from 'react-aria-components'; import { useParams } from 'react-router'; @@ -33,6 +35,7 @@ import { useActiveView } from '~/ui/components/project/utils'; import { useIsLightTheme } from '~/ui/hooks/theme'; import { useIsGitSyncEnabled } from '~/ui/hooks/use-organization-features'; +import { platform } from '../../../common/platform'; import { useProjectUpdateActionFetcher } from '../../../routes/organization.$organizationId.project.$projectId.update'; import { Icon } from '../icon'; @@ -156,6 +159,16 @@ export const ProjectSettingsForm: FC = ({ gitRepository?.credentialsId && selectedProvider; + const showRepoPath = + storageType === 'git' && + !isSwitchingStorageType(project!, storageType) && + project?.gitRepositoryId !== models.project.EMPTY_GIT_PROJECT_ID && + Boolean(gitRepository?._id); + + const repoPath = showRepoPath + ? window.path.join(window.app.getPath('userData'), 'version-control', 'git', gitRepository!._id) + : ''; + const showGitRepoForm = storageType === 'git' && ((isGitSyncEnabled && isSwitchingStorageType(project!, storageType)) || @@ -184,6 +197,7 @@ export const ProjectSettingsForm: FC = ({ const showEmailSelector = showGitConnectionInfo && canFetchEmails; const [isEmailSelectOpen, setIsEmailSelectOpen] = useState(false); + const [copied, setCopied] = useState(false); useEffect(() => { if (canFetchEmails && selectedCredential && emailsFetcher.state === 'idle' && !emailsFetcher.data) { @@ -199,13 +213,7 @@ export const ProjectSettingsForm: FC = ({ if (showGitConnectionInfo && gitRepository?.uri && gitRepository?._id && project?._id) { validateCredentialsFetcherLoad({ projectId: project._id }); } - }, [ - showGitConnectionInfo, - gitRepository?.uri, - gitRepository?._id, - project?._id, - validateCredentialsFetcherLoad, - ]); + }, [showGitConnectionInfo, gitRepository?.uri, gitRepository?._id, project?._id, validateCredentialsFetcherLoad]); const credentialsValidationErrors = validateCredentialsFetcher.data && 'errors' in validateCredentialsFetcher.data @@ -306,6 +314,57 @@ export const ProjectSettingsForm: FC = ({ /> )} + {showRepoPath && ( + <> +
+ +
+ Can be used to manage file changes with git.{' '} + + Learn more ↗ + +
+
+ + {repoPath} + + + + + + Open in file system + + +
+
+ + )} + {showGitConnectionInfo && ( <> diff --git a/packages/insomnia/src/ui/components/project/project-type-warning.tsx b/packages/insomnia/src/ui/components/project/project-type-warning.tsx index 648b6120fd..1676c0da86 100644 --- a/packages/insomnia/src/ui/components/project/project-type-warning.tsx +++ b/packages/insomnia/src/ui/components/project/project-type-warning.tsx @@ -7,7 +7,6 @@ import { LearnMoreLink } from '~/basic-components/link'; import { getAppWebsiteBaseURL } from '~/common/constants'; import { docsPricingLearnMoreLink } from '~/common/documentation'; import { models } from '~/insomnia-data'; -import { isOwnerOfOrganization } from '~/models/organization'; import { useRootLoaderData } from '~/root'; import { useOrganizationLoaderData } from '~/routes/organization'; import type { ProjectType } from '~/ui/components/project/utils'; @@ -28,7 +27,9 @@ export const ProjectTypeWarning = ({ isGitSyncEnabled, storageType, storageRules const organization = organizationData?.organizations.find(o => o.id === organizationId); // TODO: extract to a hook later const isUserOwner = - organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId }); + organization && + userSession.accountId && + models.organization.isOwnerOfOrganization({ organization, accountId: userSession.accountId }); return ( <> {storageType === 'git' && diff --git a/packages/insomnia/src/ui/components/rendered-query-string.tsx b/packages/insomnia/src/ui/components/rendered-query-string.tsx index cf1054c6ce..5b4b61cf4f 100644 --- a/packages/insomnia/src/ui/components/rendered-query-string.tsx +++ b/packages/insomnia/src/ui/components/rendered-query-string.tsx @@ -10,12 +10,12 @@ import type { SocketIORequest, WebSocketRequest, } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { SegmentEvent } from '~/ui/analytics'; import { showSettingsModal } from '~/ui/components/modals/settings-modal'; import { database as db } from '../../common/database'; import { SECURITY_SETTINGS_PATH_LABEL } from '../../common/misc'; -import * as models from '../../models'; import { getAuthObjectOrNull, isAuthEnabled } from '../../network/authentication'; import { getOrInheritAuthentication } from '../../network/network'; import { RenderError } from '../../templating/render-error'; diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index 51972071f0..e427d2cd97 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -5,7 +5,7 @@ import * as reactUse from 'react-use'; import { SECURITY_SETTINGS_PATH_LABEL } from '~/common/misc'; import type { Request, RequestGroup } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { type ConnectActionParams, @@ -19,7 +19,6 @@ import { OneLineEditor, type OneLineEditorHandle } from '~/ui/components/.client import { showSettingsModal } from '~/ui/components/modals/settings-modal'; import { database as db } from '../../common/database'; -import * as models from '../../models'; import { getOrInheritAuthentication, getOrInheritHeaders } from '../../network/network'; import { useWorkspaceLoaderData } from '../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { diff --git a/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx b/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx index 29a9496dcb..a79ff749a1 100644 --- a/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx +++ b/packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; -import { type CloudProviderCredential, type CloudProviderName, models } from '~/insomnia-data'; +import type { CloudProviderCredential, CloudProviderName } from '~/insomnia-data'; +import { models } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useDeleteCloudCredentialActionFetcher } from '~/routes/cloud-credentials.$cloudCredentialId.delete'; diff --git a/packages/insomnia/src/ui/components/settings/credentials.tsx b/packages/insomnia/src/ui/components/settings/credentials.tsx index e7dfb1e6e1..c352780097 100644 --- a/packages/insomnia/src/ui/components/settings/credentials.tsx +++ b/packages/insomnia/src/ui/components/settings/credentials.tsx @@ -696,7 +696,7 @@ const GitCredentialsList = () => { export const CredentialsSettings = () => { return ( -
+
diff --git a/packages/insomnia/src/ui/components/settings/folder-path.ts b/packages/insomnia/src/ui/components/settings/folder-path.ts new file mode 100644 index 0000000000..5212278f1b --- /dev/null +++ b/packages/insomnia/src/ui/components/settings/folder-path.ts @@ -0,0 +1,77 @@ +const getPathRoot = (value: string) => { + const windowsRootMatch = value.match(/^[A-Za-z]:\\/); + if (windowsRootMatch) { + return windowsRootMatch[0]; + } + + return value.startsWith('/') ? '/' : ''; +}; + +const normalizePathSegments = (value: string, separator: '/' | '\\') => { + const root = getPathRoot(value); + const startIndex = root.length; + const rawSegments = value + .slice(startIndex) + .split(/[\\/]+/) + .filter(Boolean); + const normalizedSegments: string[] = []; + + for (const segment of rawSegments) { + if (segment === '.') { + continue; + } + + if (segment === '..') { + if (normalizedSegments.length > 0 && normalizedSegments[normalizedSegments.length - 1] !== '..') { + normalizedSegments.pop(); + } else if (!root) { + normalizedSegments.push(segment); + } + continue; + } + + normalizedSegments.push(segment); + } + + const joinedSegments = normalizedSegments.join(separator); + if (!root) { + return joinedSegments; + } + + return `${root}${joinedSegments}`; +}; + +export const normalizeFolderPath = (value: string) => { + const separator = /^[A-Za-z]:[\\/]/.test(value) ? '\\' : '/'; + const collapsedSeparators = value.replace(/[\\/]+/g, separator); + const normalized = normalizePathSegments(collapsedSeparators, separator); + const root = getPathRoot(normalized); + + if (normalized === '' || normalized === root) { + return root || normalized; + } + + return normalized.replace(new RegExp(`${separator === '\\' ? '\\\\' : '/'}+$`), ''); +}; + +export type FolderValidationResult = + | { ok: true; normalizedValue: string } + | { ok: false; error: string }; + +export function validateFolderInput(input: string, existing: string[]): FolderValidationResult { + const trimmed = input.trim(); + if (trimmed === '') { + return { ok: false, error: 'Enter a folder path to add.' }; + } + + const normalized = normalizeFolderPath(trimmed); + if (trimmed !== normalized) { + return { ok: false, error: `Invalid folder path format. Did you mean "${normalized}"?` }; + } + + if (existing.some(value => normalizeFolderPath(value) === normalized)) { + return { ok: false, error: 'Duplicate folders are not allowed.' }; + } + + return { ok: true, normalizedValue: normalized }; +} \ No newline at end of file diff --git a/packages/insomnia/src/ui/components/settings/general.tsx b/packages/insomnia/src/ui/components/settings/general.tsx index 8790c8e8c2..a5e4b745f4 100644 --- a/packages/insomnia/src/ui/components/settings/general.tsx +++ b/packages/insomnia/src/ui/components/settings/general.tsx @@ -1,6 +1,7 @@ import React, { type FC, Fragment } from 'react'; import { useRootLoaderData } from '~/root'; +import { clearOAuthWindowSessionId } from '~/ui/spawn-oauth-window'; import { EditorKeyMap, @@ -14,7 +15,6 @@ import { docsKeyMaps } from '../../../common/documentation'; import { isMac } from '../../../common/platform'; import { type HttpVersion, HttpVersions, UpdateChannel } from '../../../common/settings'; import { strings } from '../../../common/strings'; -import { initNewOAuthSession } from '../../../network/o-auth-2/get-token'; import { Link } from '../base/link'; import { CheckForUpdatesButton } from '../check-for-updates-button'; import { BooleanSetting } from './boolean-setting'; @@ -226,7 +226,7 @@ export const General: FC = () => { /> diff --git a/packages/insomnia/src/ui/components/settings/import-export.tsx b/packages/insomnia/src/ui/components/settings/import-export.tsx index d5eab26fa4..f983818d74 100644 --- a/packages/insomnia/src/ui/components/settings/import-export.tsx +++ b/packages/insomnia/src/ui/components/settings/import-export.tsx @@ -1,15 +1,10 @@ import { format } from 'date-fns'; import { getProductName } from 'insomnia/src/common/constants'; -import { database } from 'insomnia/src/common/database'; import { getWorkspaceLabel } from 'insomnia/src/common/get-workspace-label'; import { exportRequestsHAR, exportWorkspacesHAR } from 'insomnia/src/common/har'; import { getInsomniaV5DataExport } from 'insomnia/src/common/insomnia-v5'; import { isNotNullOrUndefined } from 'insomnia/src/common/misc'; import { strings } from 'insomnia/src/common/strings'; -import * as requestOperations from 'insomnia/src/models/helpers/request-operations'; -import * as models from 'insomnia/src/models/index'; -import { type BaseModel, environment } from 'insomnia/src/models/index'; -import { isScratchpadOrganizationId } from 'insomnia/src/models/organization'; import { SegmentEvent } from 'insomnia/src/ui/analytics'; import { Icon } from 'insomnia/src/ui/components/icon'; import { showError, showModal } from 'insomnia/src/ui/components/modals'; @@ -22,7 +17,8 @@ import React, { type FC, Fragment, useEffect, useState } from 'react'; import { Button, Heading, ListBox, ListBoxItem, Popover, Select, SelectValue } from 'react-aria-components'; import { href, useParams } from 'react-router'; -import type { Environment, Project, Workspace } from '~/insomnia-data'; +import type { BaseModel, Environment, Project, Workspace } from '~/insomnia-data'; +import { database, models, services } from '~/insomnia-data'; import { useRootLoaderData } from '~/root'; import { useOrganizationLoaderData } from '~/routes/organization'; import { useProjectListWorkspacesLoaderFetcher } from '~/routes/organization.$organizationId.project.$projectId.list-workspaces'; @@ -150,11 +146,11 @@ export const exportProjectToFile = (activeProjectName: string, workspacesForActi showSelectExportTypeModal({ onDone: async selectedFormat => { - const baseEnvironments = await database.find(environment.type, { + const baseEnvironments = await database.find(models.environment.type, { parentId: { $in: workspacesForActiveProject.map(w => w._id) }, }); - const subEnvironments = await database.find(environment.type, { + const subEnvironments = await database.find(models.environment.type, { parentId: { $in: baseEnvironments.map(w => w._id) }, }); const shouldPrompt = subEnvironments.some(e => e.isPrivate); @@ -267,11 +263,11 @@ export const exportGlobalEnvironmentToFile = async (workspace: Workspace) => { return; } - const baseEnvironments = await database.find(environment.type, { + const baseEnvironments = await database.find(models.environment.type, { parentId: workspace._id, }); - const subEnvironments = await database.find(environment.type, { + const subEnvironments = await database.find(models.environment.type, { parentId: { $in: baseEnvironments.map(w => w._id) }, }); const shouldPrompt = subEnvironments.some(e => e.isPrivate); @@ -305,16 +301,16 @@ export const exportRequestsToFile = (workspaceId: string, requestIds: string[]) onDone: async selectedFormat => { const requests: BaseModel[] = []; for (const requestId of requestIds) { - const request = await requestOperations.getById(requestId); + const request = await services.helpers.getRequestById(requestId); if (request) { requests.push(request); } } - const [baseEnvironment] = await database.find(environment.type, { + const [baseEnvironment] = await database.find(models.environment.type, { parentId: workspaceId, }); - const subEnvironments = await database.find(environment.type, { + const subEnvironments = await database.find(models.environment.type, { parentId: baseEnvironment?._id, }); const shouldPrompt = subEnvironments.some(e => e.isPrivate); @@ -419,11 +415,11 @@ export async function exportWorkspaceData({ export async function exportAllData({ dirPath }: { dirPath: string }): Promise { const workspaces = await database.find(models.workspace.type); - const baseEnvironments = await database.find(environment.type, { + const baseEnvironments = await database.find(models.environment.type, { parentId: { $in: workspaces.map(w => w._id) }, }); - const subEnvironments = await database.find(environment.type, { + const subEnvironments = await database.find(models.environment.type, { parentId: { $in: baseEnvironments.map(w => w._id) }, }); const shouldPrompt = subEnvironments.some(e => e.isPrivate); @@ -665,7 +661,12 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => const workspacesFetcher = useProjectListWorkspacesLoaderFetcher(); useEffect(() => { const isIdleAndUninitialized = workspacesFetcher.state === 'idle' && !workspacesFetcher.data; - if (isIdleAndUninitialized && organizationId && projectId && !isScratchpadOrganizationId(organizationId)) { + if ( + isIdleAndUninitialized && + organizationId && + projectId && + !models.organization.isScratchpadOrganizationId(organizationId) + ) { workspacesFetcher.load({ organizationId, projectId, diff --git a/packages/insomnia/src/ui/components/settings/plugins.tsx b/packages/insomnia/src/ui/components/settings/plugins.tsx index ade6816f81..e079af144b 100644 --- a/packages/insomnia/src/ui/components/settings/plugins.tsx +++ b/packages/insomnia/src/ui/components/settings/plugins.tsx @@ -2,6 +2,7 @@ import React, { type FC, useEffect, useState } from 'react'; import { Button, Checkbox, + FieldError, FileTrigger, GridList, GridListItem, @@ -27,6 +28,24 @@ import { Icon } from '../icon'; import { Tooltip } from '../tooltip'; import { CreatePluginModal } from './create-plugin-modal'; +const getNpmRegistryUrlValidationError = (url: string): string | null => { + if (!url) { + return null; + } + + try { + const parsedUrl = new URL(url); + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return 'Enter a valid HTTP or HTTPS URL.'; + } + + return null; + } catch { + return 'Enter a valid HTTP or HTTPS URL.'; + } +}; + interface State { plugins: Plugin[]; npmPluginValue: string; @@ -35,6 +54,8 @@ interface State { isInstallingFromNpm: boolean; isRefreshingPlugins: boolean; pluginNodeExtraCerts: string; + npmRegistryUrl: string; + npmRegistryUrlError: string | null; } export const Plugins: FC = () => { @@ -50,6 +71,8 @@ export const Plugins: FC = () => { isRefreshingPlugins, npmPluginValue, pluginNodeExtraCerts, + npmRegistryUrl, + npmRegistryUrlError, }, setState, ] = useState({ @@ -60,6 +83,8 @@ export const Plugins: FC = () => { isInstallingFromNpm: false, isRefreshingPlugins: false, pluginNodeExtraCerts: settings.pluginNodeExtraCerts, + npmRegistryUrl: settings.npmRegistryUrl, + npmRegistryUrlError: null, }); // If all plugins are enabled, we show the checked state @@ -72,6 +97,10 @@ export const Plugins: FC = () => { setState(state => ({ ...state, pluginNodeExtraCerts: settings.pluginNodeExtraCerts })); }, [settings.pluginNodeExtraCerts]); + useEffect(() => { + setState(state => ({ ...state, npmRegistryUrl: settings.npmRegistryUrl, npmRegistryUrlError: null })); + }, [settings.npmRegistryUrl]); + useEffect(() => { handleReloadPlugins(); }, [settings.pluginConfig]); @@ -314,6 +343,78 @@ export const Plugins: FC = () => {
)}
+
+
+
+ + + + + +
+ +
+
+
+ { + setState(state => ({ ...state, npmRegistryUrl: value, npmRegistryUrlError: null })); + }} + > + + `flex h-(--line-height-xs) w-full items-center rounded-md border border-solid bg-(--hl-xxs) p-(--padding-sm) text-(--color-font) focus:border-(--hl-lg) focus:bg-transparent ${isInvalid ? 'border-(--color-danger)' : 'border-(--hl-md)'}` + } + onBlur={() => { + const trimmedRegistryUrl = npmRegistryUrl.trim(); + const validationError = getNpmRegistryUrlValidationError(trimmedRegistryUrl); + + if (validationError) { + setState(state => ({ ...state, npmRegistryUrlError: validationError })); + return; + } + + setState(state => ({ + ...state, + npmRegistryUrl: trimmedRegistryUrl, + npmRegistryUrlError: null, + })); + patchSettings({ npmRegistryUrl: trimmedRegistryUrl }); + }} + /> + + {npmRegistryUrlError} + + + {npmRegistryUrl && ( + + )} +
+ +
+
diff --git a/packages/insomnia/src/ui/components/settings/scripting-settings.tsx b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx new file mode 100644 index 0000000000..96a044ff4a --- /dev/null +++ b/packages/insomnia/src/ui/components/settings/scripting-settings.tsx @@ -0,0 +1,295 @@ +import { Switch } from 'react-aria-components'; + +import { useRootLoaderData } from '~/root'; + +import { type ASTRule, blockedPropertyRules, blockedRootRules, maskRules, type ThreatRule } from '../../../scripting/script-security-policy'; +import { useSettingsPatcher } from '../../hooks/use-request'; + +const DISABLED_TOOLTIP = 'Enable the script sandbox to configure individual rules'; + +const RuleToggle = ({ + name, + description, + isEnabled, + isDisabled, + onChange, +}: { + name?: string; + description: string; + isEnabled: boolean; + isDisabled: boolean; + onChange: (enabled: boolean) => void; +}) => ( +
+
+ {name && {name}} +

{description}

+
+ + +
+ +
+
+ {isDisabled && ( +
+ {DISABLED_TOOLTIP} +
+ )} +
+
+); + +interface RuleGroup { + title: string; + description: string; + rules: (ThreatRule | ASTRule)[]; +} + +const RuleCard = ({ + title, + description, + rules, + standaloneRules, + groups, + disabledNames, + sandboxEnabled, + onToggle, +}: { + title: string; + description: string; + rules?: (ThreatRule | ASTRule)[]; + standaloneRules?: (ThreatRule | ASTRule)[]; + groups?: RuleGroup[]; + disabledNames: string[]; + sandboxEnabled: boolean; + onToggle: (names: string[], enabled: boolean) => void; +}) => ( +
+
+

{title}

+

{description}

+
+
+ {rules?.map(rule => ( + onToggle([rule.name], enabled)} + /> + ))} + {standaloneRules && standaloneRules.length > 0 && ( +
+ {standaloneRules.map(rule => ( + onToggle([rule.name], enabled)} + /> + ))} +
+ )} + {groups?.map(group => { + const groupNames = group.rules.map(r => r.name); + const allEnabled = groupNames.every(n => !disabledNames.includes(n)); + return ( +
+

{group.title}

+ onToggle(groupNames, enabled)} + /> +
+ {group.rules.map(r => ( + + {r.name} + + ))} +
+
+ ); + })} +
+
+); + +export const ScriptingSettings = () => { + const { settings } = useRootLoaderData()!; + const patchSettings = useSettingsPatcher(); + + const sandboxEnabled = settings.scriptSandboxEnabled !== false; + const strictModeEnabled = settings.scriptStrictModeEnabled !== false; + const disabledRules = settings.disabledSecurityRules ?? []; + const disabledProperties = settings.disabledBlockedProperties ?? []; + const disabledRoots = settings.disabledBlockedRoots ?? []; + + const GROUPED_MASK_NAMES = new Set([ + 'globalThis', 'global', 'process', + 'setImmediate', 'queueMicrotask', + 'Proxy', 'Reflect', + 'Function', 'WebAssembly', + ]); + + const maskRuleGroups: RuleGroup[] = [ + { + title: 'Global & Node.js Internals', + description: 'References to the global scope and Node.js process information such as environment variables and runtime state.', + rules: maskRules.filter(r => ['globalThis', 'global', 'process'].includes(r.name)), + }, + { + title: 'Async Scheduling', + description: 'Schedule callbacks to run asynchronously after the current operation completes.', + rules: maskRules.filter(r => ['setImmediate', 'queueMicrotask'].includes(r.name)), + }, + { + title: 'Runtime APIs', + description: 'Used for meta-programming (Proxy, Reflect), creating functions dynamically from strings (Function), and running compiled binary modules (WebAssembly).', + rules: maskRules.filter(r => ['Proxy', 'Reflect', 'Function', 'WebAssembly'].includes(r.name)), + }, + ]; + + const ungroupedMaskRules = maskRules.filter(r => !GROUPED_MASK_NAMES.has(r.name)); + + const STANDALONE_PROPERTY_NAMES = new Set(['mainModule', 'constructor']); + + const GROUPED_PROPERTY_NAMES = new Set([ + 'prototype', '__proto__', 'getPrototypeOf', 'setPrototypeOf', + 'getFunction', 'getThis', 'prepareStackTrace', 'captureStackTrace', + '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', + 'defineProperty', 'defineProperties', 'getOwnPropertyDescriptor', 'getOwnPropertyDescriptors', + ]); + + const blockedPropertyGroups: RuleGroup[] = [ + { + title: 'Prototype Mutation', + description: 'Used to access and modify an object\'s prototype chain.', + rules: blockedPropertyRules.filter(r => ['prototype', '__proto__', 'getPrototypeOf', 'setPrototypeOf'].includes(r.name)), + }, + { + title: 'Stack Inspection', + description: 'Used to inspect and format JavaScript call stack information.', + rules: blockedPropertyRules.filter(r => ['prepareStackTrace', 'captureStackTrace', 'getFunction', 'getThis'].includes(r.name)), + }, + { + title: 'Accessor Helpers', + description: 'Legacy methods for defining and looking up getter and setter functions on objects.', + rules: blockedPropertyRules.filter(r => ['__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', 'defineProperty', 'defineProperties', 'getOwnPropertyDescriptor', 'getOwnPropertyDescriptors'].includes(r.name)), + }, + ]; + + const standaloneBlockedPropertyRules = blockedPropertyRules.filter(r => STANDALONE_PROPERTY_NAMES.has(r.name)); + const ungroupedBlockedPropertyRules = blockedPropertyRules.filter(r => !GROUPED_PROPERTY_NAMES.has(r.name) && !STANDALONE_PROPERTY_NAMES.has(r.name)); + + const GROUPED_ROOT_NAMES = new Set([ + 'globalThis', 'global', 'window', 'self', 'frames', + 'process', 'module', 'exports', 'Buffer', + 'this', 'constructor', 'arguments', + ]); + + const blockedRootGroups: RuleGroup[] = [ + { + title: 'Global Object Aliases', + description: 'Different ways to reference the global object depending on the JavaScript environment (browser, Node.js, Web Worker).', + rules: blockedRootRules.filter(r => ['globalThis', 'global', 'window', 'self', 'frames'].includes(r.name)), + }, + { + title: 'Node.js Internals', + description: 'Core Node.js globals for managing the current process, module system, and binary data.', + rules: blockedRootRules.filter(r => ['process', 'module', 'exports', 'Buffer'].includes(r.name)), + }, + { + title: 'Scopes', + description: 'Built-in references to the current execution context, function constructor, and call arguments.', + rules: blockedRootRules.filter(r => ['this', 'constructor', 'arguments'].includes(r.name)), + }, + ]; + + const ungroupedBlockedRootRules = blockedRootRules.filter(r => !GROUPED_ROOT_NAMES.has(r.name)); + + const makeToggler = (field: 'disabledSecurityRules' | 'disabledBlockedProperties' | 'disabledBlockedRoots', current: string[]) => + (names: string[], enabled: boolean) => { + const nameSet = new Set(names); + const next = enabled ? current.filter(n => !nameSet.has(n)) : [...new Set([...current, ...names])]; + patchSettings({ [field]: next }); + }; + + return ( +
+
+
+

Script Sandbox

+
+
+ Enable script sandbox +

+ Pre/post-request scripts run inside a security sandbox that restricts access to dangerous APIs. +

+
+ patchSettings({ scriptSandboxEnabled: enabled })} + className="group flex items-center gap-2" + > +
+ +
+
+
+
+ patchSettings({ scriptStrictModeEnabled: enabled })} + /> +
+
+
+ + + + + + +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/settings/text-array-setting.test.ts b/packages/insomnia/src/ui/components/settings/text-array-setting.test.ts index 42397ce0f8..5d102cd062 100644 --- a/packages/insomnia/src/ui/components/settings/text-array-setting.test.ts +++ b/packages/insomnia/src/ui/components/settings/text-array-setting.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { normalizeFolderPath, validateFolderInput } from '../../../common/misc'; +import { normalizeFolderPath, validateFolderInput } from './folder-path'; const isWindows = process.platform === 'win32'; diff --git a/packages/insomnia/src/ui/components/settings/text-array-setting.tsx b/packages/insomnia/src/ui/components/settings/text-array-setting.tsx index 52cdebc523..f1b9b7797d 100644 --- a/packages/insomnia/src/ui/components/settings/text-array-setting.tsx +++ b/packages/insomnia/src/ui/components/settings/text-array-setting.tsx @@ -4,11 +4,11 @@ import { ListBox, ListBoxItem } from 'react-aria-components'; import { useRootLoaderData } from '~/root'; import { invariant } from '~/utils/invariant'; -import { validateFolderInput } from '../../../common/misc'; import type { SettingsOfType } from '../../../common/settings'; import { useSettingsPatcher } from '../../hooks/use-request'; import { PromptButton } from '../base/prompt-button'; import { HelpTooltip } from '../help-tooltip'; +import { validateFolderInput } from './folder-path'; export const TextArraySetting: FC<{ disabled?: InputHTMLAttributes['disabled']; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts index 32ba5c0efa..7806f72a88 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts @@ -14,8 +14,8 @@ import type { WebSocketRequestMeta, Workspace, } from '~/insomnia-data'; +import type { BaseModel } from '~/insomnia-data'; import { models } from '~/insomnia-data'; -import type { BaseModel } from '~/models/types'; import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; export interface SlimRequestDoc extends BaseModel { diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 462bc9f605..65a3a8819b 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -42,7 +42,7 @@ import { } from './project-navigation-sidebar-utils'; import { ProjectNode } from './project-node'; import { PinnedHeaderNode, RequestNode } from './request-node'; -import type { EmptyNodeFlatItem, FlatItem } from './types'; +import type { FlatItem } from './types'; import { useProjectNavigationSidebarNavigation } from './use-project-navigation-sidebar-navigation'; import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop'; import { WorkspaceNode } from './workspace-node'; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts index 4057ae78e7..c244d8c4d2 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts @@ -1,6 +1,5 @@ import type { InsomniaFile } from '~/common/project'; -import type { GitRepository, Project, RequestGroup, Workspace, WorkspaceMeta } from '~/insomnia-data'; -import type { BaseModel } from '~/models/types'; +import type { BaseModel, GitRepository, Project, RequestGroup, Workspace, WorkspaceMeta } from '~/insomnia-data'; import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; export type ProjectWithPresence = Project & { diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx index d63946a996..2538a6d274 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx @@ -6,7 +6,20 @@ import { DropIndicator, useDragAndDrop } from 'react-aria-components'; import { models } from '~/insomnia-data'; import { useDebugReorderActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.reorder'; -import type { FlatItem } from './types'; +import type { CollectionChildFlatItem, FlatItem } from './types'; + +const allowDragKinds: FlatItem['kind'][] = ['workspace', 'collectionChild']; +const allowDropKinds: FlatItem['kind'][] = ['workspace', 'collectionChild', 'project']; +type AllowDragItem = Extract; +type AllowDropTarget = Extract; + +function isAllowDragItem(item: FlatItem): item is AllowDragItem { + return allowDragKinds.includes(item.kind); +} + +function isAllowDropTarget(item: FlatItem): item is AllowDropTarget { + return allowDropKinds.includes(item.kind); +} function canDrop( dragItem: FlatItem, @@ -16,15 +29,16 @@ function canDrop( ) { const realDropItem = dropPosition === 'before' ? dropPrevItem : dropItem; // drag and drop items are same. - if (!realDropItem || dragItem.doc._id === dropItem.doc._id || dragItem.doc._id === realDropItem.doc._id) { + if ( + !realDropItem || + dragItem.doc._id === dropItem.doc._id || + dragItem.doc._id === realDropItem.doc._id || + !isAllowDropTarget(realDropItem) + ) { return false; } - if (dragItem.kind === 'unsyncedWorkspace' || realDropItem.kind === 'unsyncedWorkspace') { - return false; - } - - if (dragItem.kind === 'project') { + if (!isAllowDragItem(dragItem)) { return false; } @@ -79,6 +93,7 @@ export const useSidebarDragAndDrop = ({ virtualizer, }: UseSidebarDragAndDropOptions): DragAndDropHooks => { const reorderFetcher = useDebugReorderActionFetcher(); + const flatItemsById = useMemo(() => { const visibles = flatItems.filter(item => !item.hidden); return new Map(visibles.map((item, index) => [item.doc._id, [item, visibles[index - 1]]] as const)); // keep previous item for "move into collection/project" logic @@ -120,8 +135,8 @@ export const useSidebarDragAndDrop = ({ const droppedKey = key.toString(); const [draggedKey] = event.keys; - const draggedItem = getCollectionItemByKey(draggedKey); - const targetItem = getCollectionItemByKey(droppedKey); + const draggedItem = getCollectionItemByKey(draggedKey) as AllowDragItem | null; + const targetItem = getCollectionItemByKey(droppedKey) as AllowDropTarget | null; const realTargetItem = isBefore ? flatItemsById.get(droppedKey)?.[1] : targetItem; if ( !draggedItem || @@ -131,11 +146,6 @@ export const useSidebarDragAndDrop = ({ return; } - if (draggedItem.kind === 'project' || draggedItem.kind === 'unsyncedWorkspace') { - // make type checker happy - return; - } - // move workspace to another project if (draggedItem.kind === 'workspace') { reorderFetcher.submit({ @@ -153,7 +163,10 @@ export const useSidebarDragAndDrop = ({ // move request or request group into collection if (realTargetItem?.kind === 'workspace' && models.workspace.isCollection(realTargetItem!.doc)) { - const siblingItem = flatItems.find(item => item.doc.parentId === realTargetItem!.doc._id); + const siblingItem = flatItems.find( + (item): item is CollectionChildFlatItem => + item.kind === 'collectionChild' && item.doc.parentId === realTargetItem!.doc._id, + ); reorderFetcher.submit({ organizationId, projectId: draggedItem.project._id, @@ -171,7 +184,8 @@ export const useSidebarDragAndDrop = ({ const id = draggedItem.doc._id; const targetId = targetItem.doc._id; const workspaceCollectionItems = flatItems.filter( - item => 'workspace' in item && item.workspace._id === draggedItem.workspace._id, + (item): item is CollectionChildFlatItem => + item.kind === 'collectionChild' && item.workspace._id === draggedItem.workspace._id, ); let metaSortKey = 0; const isMovingItemInsideFolder = @@ -181,19 +195,22 @@ export const useSidebarDragAndDrop = ({ const children = workspaceCollectionItems.filter(item => item.doc.parentId === targetId); metaSortKey = children.length > 0 ? children[0].doc.metaSortKey - 100 : -1 * Date.now(); } else { + // move before or after another request in same or different collection const siblingItems = workspaceCollectionItems.filter(item => item.doc.parentId === targetItem.doc.parentId); const targetIndex = siblingItems.findIndex(item => item.doc._id === targetId); - if (event.target.dropPosition === 'after') { - const afterItem = siblingItems[targetIndex + 1]; - metaSortKey = afterItem - ? targetItem.doc.metaSortKey - (targetItem.doc.metaSortKey - afterItem.doc.metaSortKey) / 2 - : targetItem.doc.metaSortKey + 100; - } else { - const beforeItem = siblingItems[targetIndex - 1]; - metaSortKey = beforeItem - ? targetItem.doc.metaSortKey - (targetItem.doc.metaSortKey - beforeItem.doc.metaSortKey) / 2 - : targetItem.doc.metaSortKey - 100; + if ('metaSortKey' in targetItem.doc && targetItem.doc.metaSortKey != null) { + if (event.target.dropPosition === 'after') { + const afterItem = siblingItems[targetIndex + 1]; + metaSortKey = afterItem + ? targetItem.doc.metaSortKey - (targetItem.doc.metaSortKey - afterItem.doc.metaSortKey) / 2 + : targetItem.doc.metaSortKey + 100; + } else { + const beforeItem = siblingItems[targetIndex - 1]; + metaSortKey = beforeItem + ? targetItem.doc.metaSortKey - (targetItem.doc.metaSortKey - beforeItem.doc.metaSortKey) / 2 + : targetItem.doc.metaSortKey - 100; + } } } diff --git a/packages/insomnia/src/ui/components/tabs/tab-list.tsx b/packages/insomnia/src/ui/components/tabs/tab-list.tsx index 4a1a601719..0444529ade 100644 --- a/packages/insomnia/src/ui/components/tabs/tab-list.tsx +++ b/packages/insomnia/src/ui/components/tabs/tab-list.tsx @@ -12,14 +12,14 @@ import { } from 'react-aria-components'; import { useParams } from 'react-router'; -import type { MockRoute, Request } from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import type { BaseModel, MockRoute, Request } from '~/insomnia-data'; +import { models, services } from '~/insomnia-data'; import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new'; +import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { useInsomniaTab } from '~/ui/hooks/use-insomnia-tab'; import { type ChangeBufferEvent, type ChangeType, database } from '../../../common/database'; import { debounce } from '../../../common/misc'; -import * as models from '../../../models/index'; import { INSOMNIA_TAB_HEIGHT } from '../../constant'; import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; import { type Size, useResizeObserver } from '../../hooks/use-resize-observer'; @@ -50,6 +50,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' const newRequestFetcher = useRequestNewActionFetcher(); const { organizationId, projectId } = useParams(); + const gitFileIssues = useGitFileIssues(); useInsomniaTab({ organizationId: organizationId || '' }); @@ -74,6 +75,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' } = useInsomniaTabContext(); const { tabList, activeTabId } = currentOrgTabs; + const issuesByWorkspaceId = gitFileIssues.issuesByWorkspaceId; // Register keyboard shortcuts for tab navigation useDocBodyKeyboardShortcuts({ @@ -139,7 +141,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' ); const handleUpdate = useCallback( - async (doc: models.BaseModel, patches: Partial[] = []) => { + async (doc: BaseModel, patches: Partial[] = []) => { const patchObj: Record = {}; patches.forEach(patch => { Object.assign(patchObj, patch); @@ -394,9 +396,10 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' className="flex h-[41px] w-fit" dragAndDropHooks={dragAndDropHooks} items={tabList} + dependencies={[issuesByWorkspaceId]} ref={tabListInnerRef} > - {item => } + {item => }