From ce19a785676298e5483fa9c895dcaeff1ff1f1ae Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Wed, 15 Apr 2026 06:14:41 +0200 Subject: [PATCH 01/61] refactor: route fs backed cleanup (#9806) --- eslint.config.mjs | 70 ++++++++++--------- .../NODE_INTEGRATION_MIGRATION_PR_PLAN.md | 1 + .../config/renderer-node-import-baseline.json | 16 ++--- packages/insomnia/src/entry.preload.ts | 1 + packages/insomnia/src/main/ipc/electron.ts | 3 +- packages/insomnia/src/main/ipc/main.ts | 30 ++++++++ ...kspaceId.debug.request.$requestId.send.tsx | 50 ++++++------- ...ionId.project.$projectId.workspace.new.tsx | 16 +++-- 8 files changed, 111 insertions(+), 76 deletions(-) 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/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md b/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md index de135d380c..5fa2640e2a 100644 --- a/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md +++ b/packages/insomnia/NODE_INTEGRATION_MIGRATION_PR_PLAN.md @@ -469,3 +469,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..46aba56c23 100644 --- a/packages/insomnia/config/renderer-node-import-baseline.json +++ b/packages/insomnia/config/renderer-node-import-baseline.json @@ -161,19 +161,19 @@ "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", + "importer": "src/routes/import.scan.tsx", "builtin": "path" }, { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx", - "builtin": "fs" + "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx", + "builtin": "path" }, { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx", + "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx", + "builtin": "path" + }, + { + "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.update.tsx", "builtin": "path" }, { diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index b8bd3b30b9..50e201b5f5 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -188,6 +188,7 @@ const main: Window['main'] = { curlRequest: options => ipcRenderer.invoke('curlRequest', options), cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options), writeFile: options => ipcRenderer.invoke('writeFile', options), + writeResponseBodyToFile: options => ipcRenderer.invoke('writeResponseBodyToFile', options), insecureReadFile: options => ipcRenderer.invoke('insecureReadFile', options), insecureReadFileWithEncoding: options => ipcRenderer.invoke('insecureReadFileWithEncoding', options), secureReadFile: options => ipcRenderer.invoke('secureReadFile', options), diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 91ca332f3f..25ce64bb51 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -135,7 +135,8 @@ export type HandleChannels = | 'webSocket.event.send' | 'webSocket.open' | 'webSocket.readyState' - | 'writeFile'; + | 'writeFile' + | 'writeResponseBodyToFile'; export const ipcMainHandle = ( channel: HandleChannels, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 3831194b0c..eaba0b58ec 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'; @@ -87,6 +89,28 @@ const readDir = async (_: unknown, options: { path: string }) => { } }; +const writeResponseBodyToFile = async ( + _: unknown, + options: { sourcePath: string; destinationPath: string; bodyCompression?: 'zip' | null }, +) => { + 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) { + throw new Error(err); + } +}; + export interface RendererToMainBridgeAPI { loginStateChange: () => void; openInBrowser: (url: string) => void; @@ -105,6 +129,11 @@ export interface RendererToMainBridgeAPI { 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; secureReadFile: (options: { path: string }) => Promise; insecureReadFile: (options: { path: string }) => Promise; insecureReadFileWithEncoding: (options: { @@ -252,6 +281,7 @@ export function registerMainHandlers() { throw new Error(err); } }); + ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile); ipcMainHandle('lintSpec', async (_, options: { documentContent: string; rulesetPath: string }) => { const { documentContent, rulesetPath } = options; 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 d0548a8c28..dbd58e066a 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'; @@ -18,7 +15,6 @@ import { 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, @@ -69,7 +65,7 @@ export interface RunnerContextForRequest { responseId: string; } -const writeToDownloadPath = ( +const writeToDownloadPath = async ( downloadPathAndName: string, responsePatch: ResponsePatch, requestMeta: RequestMeta, @@ -77,27 +73,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: @@ -317,8 +311,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, @@ -336,7 +330,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 }; }; 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 105b78b7f2..6c76d6cdaf 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,6 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; - import { upsertMockbin } from 'insomnia-api'; import { href, redirect } from 'react-router'; @@ -110,7 +107,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), }); } @@ -310,7 +307,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') { From 4818dd1defdca3e0869fb5d3eebfb02be9d5b142 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Wed, 15 Apr 2026 10:41:38 +0200 Subject: [PATCH 02/61] 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 --- package-lock.json | 7 ++ .../config/renderer-node-import-baseline.json | 16 --- packages/insomnia/package.json | 3 +- packages/insomnia/src/common/compression.ts | 40 +++++++ packages/insomnia/src/common/misc.ts | 46 +------- .../src/common/significant-diff-detection.ts | 4 +- packages/insomnia/src/main/ipc/main.ts | 6 +- .../network/grpc/proto-directory-loader.tsx | 102 ------------------ .../components/modals/proto-files-modal.tsx | 74 ++++++++++++- .../src/ui/components/settings/folder-path.ts | 77 +++++++++++++ .../settings/text-array-setting.test.ts | 2 +- .../settings/text-array-setting.tsx | 2 +- .../src/utils/url/querystring.test.ts | 5 + .../insomnia/src/utils/url/querystring.ts | 19 ++-- 14 files changed, 223 insertions(+), 180 deletions(-) create mode 100644 packages/insomnia/src/common/compression.ts delete mode 100644 packages/insomnia/src/network/grpc/proto-directory-loader.tsx create mode 100644 packages/insomnia/src/ui/components/settings/folder-path.ts diff --git a/package-lock.json b/package-lock.json index 4d2025743c..1924fedc7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16651,6 +16651,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", @@ -29165,6 +29171,7 @@ "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", diff --git a/packages/insomnia/config/renderer-node-import-baseline.json b/packages/insomnia/config/renderer-node-import-baseline.json index 46aba56c23..6ba8e0ca1b 100644 --- a/packages/insomnia/config/renderer-node-import-baseline.json +++ b/packages/insomnia/config/renderer-node-import-baseline.json @@ -16,18 +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" @@ -227,10 +215,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..33ae7e4219 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", @@ -74,6 +73,7 @@ "@tailwindcss/typography": "^0.5.16", "@tanstack/react-virtual": "3.13.12", "@xmldom/xmldom": "^0.9.8", + "ajv": "^8.17.1", "apiconnect-wsdl": "2.0.36", "aws4": "^1.13.2", "blakejs": "^1.2.1", @@ -96,6 +96,7 @@ "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", 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/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/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/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index eaba0b58ec..dcd9b6ff09 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -107,7 +107,11 @@ const writeResponseBodyToFile = async ( return options.destinationPath; } catch (err) { - throw new Error(err); + if (err instanceof Error) { + throw err; + } + + throw new Error(String(err)); } }; 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/ui/components/modals/proto-files-modal.tsx b/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx index c4ac39e4a8..1de9a69f63 100644 --- a/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx @@ -8,7 +8,6 @@ import * as models from '~/models'; import { type ChangeBufferEvent, database as db } from '../../../common/database'; import { selectFileOrFolder } from '../../../common/select-file-or-folder'; -import { ProtoDirectoryLoader } from '../../../network/grpc/proto-directory-loader'; import { writeProtoFile } from '../../../network/grpc/write-proto-file'; import { Modal, type ModalHandle } from '../base/modal'; import { ModalBody } from '../base/modal-body'; @@ -22,6 +21,12 @@ import { AlertModal } from './alert-modal'; const { isProtoDirectory } = models.protoDirectory; const { isProtoFile } = models.protoFile; +interface ProtoDirectoryImportResult { + createdDir: ProtoDirectory | null; + createdIds: string[]; + error: Error | null; +} + const tryToSelectFilePath = async () => { try { const { filePath, canceled } = await selectFileOrFolder({ itemTypes: ['file'], extensions: ['proto'] }); @@ -100,6 +105,71 @@ const getProtoDirectories = async (workspaceId: string) => { return expandedDirs; }; +const createProtoFileFromPath = async (filePath: string, parentId: string, createdIds: string[]) => { + const fileName = window.path.basename(filePath); + if (!fileName.toLowerCase().endsWith('.proto')) { + return false; + } + + const protoText = await window.main.insecureReadFile({ path: filePath }); + const { _id } = await services.protoFile.create({ + name: fileName, + parentId, + protoText, + }); + createdIds.push(_id); + return true; +}; + +const createProtoDirectoryFromPath = async ( + dirPath: string, + parentId: string, + createdIds: string[], +): Promise => { + const entries = await window.main.readDir({ path: dirPath }); + const newDirId = models.protoDirectory.createId(); + let filesFound = false; + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const entryHasProtoFiles = await (entry.type === 'directory' + ? createProtoDirectoryFromPath(entry.path, newDirId, createdIds).then(Boolean) + : createProtoFileFromPath(entry.path, newDirId, createdIds)); + + filesFound = filesFound || entryHasProtoFiles; + } + + if (!filesFound) { + return null; + } + + const createdProtoDir = await services.protoDirectory.create({ + _id: newDirId, + name: window.path.basename(dirPath), + parentId, + }); + createdIds.push(createdProtoDir._id); + return createdProtoDir; +}; + +const importProtoDirectory = async (dirPath: string, workspaceId: string): Promise => { + const createdIds: string[] = []; + + try { + const createdDir = await createProtoDirectoryFromPath(dirPath, workspaceId, createdIds); + return { + createdDir, + createdIds, + error: null, + }; + } catch (error) { + return { + createdDir: null, + createdIds, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +}; + export interface Props { defaultId?: string; onSave?: (arg0: string) => Promise; @@ -146,7 +216,7 @@ export const ProtoFilesModal: FC = ({ defaultId, onHide, onSave }) => { return; } try { - const result = await new ProtoDirectoryLoader(filePath, workspaceId).load(); + const result = await importProtoDirectory(filePath, workspaceId); createdIds = result.createdIds; const { error, createdDir } = result; 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/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/utils/url/querystring.test.ts b/packages/insomnia/src/utils/url/querystring.test.ts index 2b249d5cc5..bfea1377b8 100644 --- a/packages/insomnia/src/utils/url/querystring.test.ts +++ b/packages/insomnia/src/utils/url/querystring.test.ts @@ -205,6 +205,11 @@ describe('querystring', () => { }); describe('smartEncodeUrl()', () => { + it('returns empty string for empty url', () => { + const url = smartEncodeUrl(''); + expect(url).toBe(''); + }); + it('does not touch normal url', () => { const url = smartEncodeUrl('http://google.com'); expect(url).toBe('http://google.com/'); diff --git a/packages/insomnia/src/utils/url/querystring.ts b/packages/insomnia/src/utils/url/querystring.ts index a0c64d9351..d6ae950005 100644 --- a/packages/insomnia/src/utils/url/querystring.ts +++ b/packages/insomnia/src/utils/url/querystring.ts @@ -1,5 +1,3 @@ -import { format as urlFormat, parse as urlParse } from 'node:url'; - import { setDefaultProtocol } from './protocol'; const ESCAPE_REGEX_MATCH = /[-[\]/{}()*+?.\\^$|]/g; @@ -203,11 +201,15 @@ export const smartEncodeUrl = (url: string, encode?: boolean, options?: IQuerySt const { strictNullHandling = false } = options || {}; const urlWithProto = setDefaultProtocol(url); + if (!urlWithProto) { + return ''; + } + if (!encode) { return urlWithProto; } // Parse the URL into components - const parsedUrl = urlParse(urlWithProto); + const parsedUrl = new URL(urlWithProto); // ~~~~~~~~~~~ // // 1. Pathname // @@ -222,8 +224,9 @@ export const smartEncodeUrl = (url: string, encode?: boolean, options?: IQuerySt // 2. Querystring // // ~~~~~~~~~~~~~~ // - if (parsedUrl.query) { - const qsParams = deconstructQueryStringToParams(parsedUrl.query, true, { strictNullHandling }); + const rawQuery = parsedUrl.search.startsWith('?') ? parsedUrl.search.slice(1) : parsedUrl.search; + if (rawQuery) { + const qsParams = deconstructQueryStringToParams(rawQuery, true, { strictNullHandling }); const encodedQsParams = []; for (const { name, value } of qsParams) { encodedQsParams.push({ @@ -232,11 +235,11 @@ export const smartEncodeUrl = (url: string, encode?: boolean, options?: IQuerySt }); } - parsedUrl.query = buildQueryStringFromParams(encodedQsParams, true, { strictNullHandling }); - parsedUrl.search = `?${parsedUrl.query}`; + const query = buildQueryStringFromParams(encodedQsParams, true, { strictNullHandling }); + parsedUrl.search = query ? `?${query}` : ''; } - return urlFormat(parsedUrl); + return parsedUrl.toString(); }; /** From f871f6519e496ae3582dd2c937e98e26d382ab13 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:32:00 +0800 Subject: [PATCH 03/61] fix: unhandledrejection error (#9774) --- .../ui/components/.client/codemirror/lint/json-lint.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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; } From 6881ff81b86d15342630e3ae6a883d2bf8312e72 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:02:15 +0800 Subject: [PATCH 04/61] fix: resolve sentry promise error (#9786) * fix: resolve sentry promise error * fix: leave fallback when error --- .../insomnia/src/ui/hooks/image-cache.tsx | 113 +++++++----------- 1 file changed, 42 insertions(+), 71 deletions(-) diff --git a/packages/insomnia/src/ui/hooks/image-cache.tsx b/packages/insomnia/src/ui/hooks/image-cache.tsx index a83554c4ac..a449be48eb 100644 --- a/packages/insomnia/src/ui/hooks/image-cache.tsx +++ b/packages/insomnia/src/ui/hooks/image-cache.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import { useCallback, useSyncExternalStore } from 'react'; interface CacheEntry { value: Promise | string; @@ -16,55 +16,48 @@ class ImageCache { this.ttl = ttl; } - read(base: string, version: string) { + notifySubscribers(base: string) { + this.__cache[base]?.subscribers.forEach(callback => callback()); + } + + read(base: string, version = ''): string { const value = `${base}${version ? `?${version}` : ''}`; const now = Date.now(); - if (this.__cache[base] && this.__cache[base].value instanceof Promise) { + const existingEntry = this.__cache[base]; + + if (existingEntry && existingEntry.value instanceof Promise) { // If the value is a Promise, throw it to indicate that the cache is still loading - throw this.__cache[base].value; - } else if ( - this.__cache[base] && - (this.__cache[base].version === version || now - this.__cache[base].timestamp < this.ttl) - ) { - // If the value is an HTMLImageElement, the version matches, and hasn't expired, return it - return this.__cache[base].value; + throw existingEntry.value; + } else if (existingEntry && (existingEntry.version === version || now - existingEntry.timestamp < this.ttl)) { + return existingEntry.value as string; } else { // Otherwise, load the image and add it to the cache + const entry = existingEntry || { + value, + timestamp: now, + version, + subscribers: [], + }; + this.__cache[base] = entry; + const promise = new Promise(resolve => { const img = new Image(); img.onload = () => { - if (!this.__cache[base]) { - this.__cache[base] = { - value, - timestamp: now, - version, - subscribers: [], - }; - } else { - this.__cache[base].value = value; - this.__cache[base].timestamp = now; - this.__cache[base].version = version; - } + entry.value = value; + entry.timestamp = Date.now(); + entry.version = version; resolve(value); - // Notify all subscribers - if (!this.__cache[base].subscribers) { - this.__cache[base].subscribers = []; - } - this.__cache[base].subscribers.forEach(callback => callback()); + this.notifySubscribers(base); }; img.onerror = () => { - // infinitely suspended if the image fails to load - this.__cache[base].value = new Promise(() => {}); - throw this.__cache[base].value; + // Leave the pending promise unresolved so Suspense stays on the fallback UI. }; img.src = value; }); - this.__cache[base].value = promise; - this.__cache[base].timestamp = now; - this.__cache[base].version = version; - if (!this.__cache[base].subscribers) { - this.__cache[base].subscribers = []; - } + + entry.value = promise; + entry.timestamp = now; + entry.version = version; throw promise; } } @@ -92,9 +85,12 @@ class ImageCache { invalidate(src: string) { const [base, version] = src.split('?'); - if (this.__cache[base] && this.__cache[base].version !== version) { - this.__cache[base].timestamp = 0; - this.read(base, version); + const entry = this.__cache[base]; + + if (entry && entry.version !== version) { + entry.timestamp = 0; + entry.version = undefined; + this.notifySubscribers(base); } } @@ -105,7 +101,6 @@ class ImageCache { export function useImageCache(src: string, cache: ImageCache): string { const [base, version] = src.split('?'); - const [imageSrc, setImageSrc] = useState(null); const subscribe = useCallback( (callback: () => void) => { @@ -114,44 +109,20 @@ export function useImageCache(src: string, cache: ImageCache): string { [base, cache], ); - const getSnapshot = () => { + const getSnapshot = (): string => { try { return cache.read(base, version); - } catch (promise) { - if (promise instanceof Promise) { - throw promise; + } catch (maybePromise) { + if (maybePromise instanceof Promise) { + throw maybePromise; } - return null; + return src; } }; - const getServerSnapshot = () => null; + const getServerSnapshot = (): string => src; - useEffect(() => { - setImageSrc(() => { - try { - const result = cache.read(base, version); - if (result instanceof Promise) { - throw result; - } - - return result; - } catch (maybeResultPromise) { - if (maybeResultPromise instanceof Promise) { - throw maybeResultPromise; - } - return null; - } - }); - }, [cache, base, version]); - - const cacheSrc = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - - if (typeof cacheSrc === 'string') { - return cacheSrc; - } - - return imageSrc!; + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } export const avatarImageCache = new ImageCache({ From 2a98aba3e5f1498a7b7e1bed6a70be6bdd1a8135 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 16 Apr 2026 11:01:03 +0200 Subject: [PATCH 05/61] fix: improve credential validation handling in GitRepoForm to avoid a loop of re-loading the list of repos and branches (#9820) --- .../git-credentials/git-repository-select.tsx | 1 + .../ui/components/project/git-repo-form.tsx | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) 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 = ({
= ({ const [isEmailSelectOpen, setIsEmailSelectOpen] = useState(false); const isCredentialInvalid = - validateCredentialFetcher.state !== 'idle' || + (validateCredentialFetcher.state !== 'idle' && !validateCredentialFetcher.data) || Boolean( validateCredentialFetcher.data && 'errors' in validateCredentialFetcher.data && @@ -224,7 +224,7 @@ export const GitRepoForm: FC = ({
- {validateCredentialFetcher.state !== 'idle' && ( + {validateCredentialFetcher.state !== 'idle' && !validateCredentialFetcher.data && (
Validating credential... @@ -297,8 +297,8 @@ export const GitRepoForm: FC = ({ )} - {selectedProvider && !isCredentialInvalid && ( - <> + {selectedProvider && ( +
{selectedProvider.supportsFetchRepos ? ( = ({ }} /> )} - +
)} - {!isCredentialInvalid && ( - - )} +
+ +
)} From aaa14ff69afdbf799ca1991419b46c6b51f08e86 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Thu, 16 Apr 2026 18:10:19 +0200 Subject: [PATCH 06/61] add e2e and cli skills (#9818) * add e2e and cli skills * address feedback * address feedback * move to claude --- .claude/skills/fix-test-cli-ci/SKILL.md | 58 +++++++++++++++++++++ .claude/skills/fix-test-e2e-ci/SKILL.md | 69 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 .claude/skills/fix-test-cli-ci/SKILL.md create mode 100644 .claude/skills/fix-test-e2e-ci/SKILL.md 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/.claude/skills/fix-test-e2e-ci/SKILL.md b/.claude/skills/fix-test-e2e-ci/SKILL.md new file mode 100644 index 0000000000..b97ce44311 --- /dev/null +++ b/.claude/skills/fix-test-e2e-ci/SKILL.md @@ -0,0 +1,69 @@ +--- +name: fix-test-e2e-ci +description: 'Debug failures from test-e2e.yml locally. Includes both CI-parity reproduction (app-build + test:build) and faster dev-runtime triage for Smoke Playwright tests.' +argument-hint: 'Provide the failing test-e2e.yml logs, a link to the failing workflow run, and the failing test title or file if available' +--- + +# Fix test-e2e.yml CI Failures + +## When to Use + +- `.github/workflows/test-e2e.yml` or the `e2e App Tests` workflow failed in CI. +- You want CI-parity reproduction with the same build-mode test command used in CI. +- You want a faster dev-runtime loop after confirming the same failure locally. + +## Procedure + +1. Start from the failing CI evidence. + - Use the workflow run logs/artifacts to capture the failing test title, file, and first actionable error. + - Download CI traces from the smoke-test artifact when available. +2. Reproduce with CI-parity commands first (same mode as CI): + ```bash + npm run app-build + ``` + ```bash + npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke + ``` +3. Re-run only the failing test while iterating (instead of the full suite): + - By file: + + ```bash + npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke tests/smoke/.test.ts + ``` + + - By test title: + + ```bash + npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke --grep "" + ``` + +4. If CI-parity passes but you still need a faster loop for investigation, switch to dev runtime: + ```bash + npm run watch:app + ``` + ```bash + npm run test:dev -w packages/insomnia-smoke-test -- --project=Smoke + ``` + Keep `watch:app` running while iterating. + +## Notes + +- CI currently runs `npm run app-build` + `npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke`. +- Dev runtime (`watch:app` + `test:dev`) is useful for quick local triage, but not a strict CI match. +- Playwright debugging options: + - Inspector: `PWDEBUG=1 npm run test:smoke:dev` + - API logs: `DEBUG=pw:api npm run test:smoke:dev` + - Browser console logs: `DEBUG=pw:browser npm run test:smoke:dev` + - WebServer logs: `DEBUG=pw:WebServer npm run test:smoke:dev` +- Local traces are written under `packages/insomnia-smoke-test/traces` and can be opened with: + ```bash + npx playwright show-trace packages/insomnia-smoke-test/traces//trace.zip + ``` +- Success criteria: + - The failing Smoke test passes with the CI-parity command (`test:build`). + - The full Smoke project passes in build mode after your fix. + +## Teardown + +- Stop any long-running watch/test terminals (for example `watch:app`) after validation is complete. +- Playwright itself should teardown the smoke test server, but if you have any lingering processes or ports in use, stop those as well. From 307654cab328be1d680b15c96c3760f6974cc006 Mon Sep 17 00:00:00 2001 From: Shelby <13246465+shelby-moore@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:28:56 -0700 Subject: [PATCH 07/61] feat: konnect integration proxy url and regex support (#9811) --- .../src/konnect/__tests__/api.test.ts | 38 +- .../src/konnect/__tests__/sync.test.ts | 154 +++++++- .../src/konnect/__tests__/transform.test.ts | 373 ++++++++++++++++++ packages/insomnia/src/konnect/api.ts | 35 +- packages/insomnia/src/konnect/sync.ts | 120 ++---- packages/insomnia/src/konnect/transform.ts | 263 ++++++++++++ 6 files changed, 836 insertions(+), 147 deletions(-) create mode 100644 packages/insomnia/src/konnect/__tests__/transform.test.ts create mode 100644 packages/insomnia/src/konnect/transform.ts diff --git a/packages/insomnia/src/konnect/__tests__/api.test.ts b/packages/insomnia/src/konnect/__tests__/api.test.ts index 08f7eff11a..10fa839fc6 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', () => { diff --git a/packages/insomnia/src/konnect/__tests__/sync.test.ts b/packages/insomnia/src/konnect/__tests__/sync.test.ts index 38c368a155..5de789ddf5 100644 --- a/packages/insomnia/src/konnect/__tests__/sync.test.ts +++ b/packages/insomnia/src/konnect/__tests__/sync.test.ts @@ -296,7 +296,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 +307,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 +658,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) ────────────────────────────────── @@ -1128,6 +1197,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 ──────────────────────────────── 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..b0f161d0f7 --- /dev/null +++ b/packages/insomnia/src/konnect/__tests__/transform.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from 'vitest'; + +import { + deriveProxyVarDefaults, + extractRegionFromEndpoint, + generatePathPlaceholder, + konnectHeadersChanged, + mergeHeaders, + mergePathParameters, + pathParametersChanged, +} 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'); + }); +}); + +// ─── 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..25b0718f16 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 { @@ -70,34 +77,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; diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index ec9c64848f..c5e503d845 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -3,15 +3,25 @@ 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 { + buildRequestName, + deriveProxyVarDefaults, + extractRegionFromEndpoint, + KONNECT_PROXY_VAR_NAMES, + konnectHeadersChanged, + mergeHeaders, + mergePathParameters, + pathParametersChanged, + resolvePath, + routeDisplayName, +} from './transform'; interface SyncCounts { total: number; @@ -67,42 +77,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 +97,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 +147,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 +197,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 +219,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 +249,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 +272,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++; } } @@ -479,14 +428,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..5fc051b57c --- /dev/null +++ b/packages/insomnia/src/konnect/transform.ts @@ -0,0 +1,263 @@ +import type { KonnectProxyUrl } from './api'; + +// ─── 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; +} From 6d916253693cb6f21b29c74a0c3133ae6d281b10 Mon Sep 17 00:00:00 2001 From: Shelby <13246465+shelby-moore@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:58:31 -0700 Subject: [PATCH 08/61] chore: move konnect sync behind feature flag (#9832) --- packages/insomnia/src/common/constants.ts | 1 + ...organization.$organizationId.project.$projectId._index.tsx | 3 ++- .../routes/organization.$organizationId.project._index.tsx | 4 ++-- packages/insomnia/src/ui/components/modals/settings-modal.tsx | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 5e9aab4a41..44210b5f74 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -112,6 +112,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'; 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 462df85276..ac9463fdba 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -31,6 +31,7 @@ import { dashboardSortOrderName, DEFAULT_SIDEBAR_SIZE, getAppWebsiteBaseURL, + isKonnectSyncEnabled, } from '~/common/constants'; import { database } from '~/common/database'; import { scopeToBgColorMap, scopeToIconMap, scopeToLabelMap, scopeToTextColorMap } from '~/common/get-workspace-label'; @@ -793,7 +794,7 @@ const Component = () => { projects={projectsWithPresence} storageRules={storageRules} onCreateProject={() => setIsNewProjectModalOpen(true)} - konnectSyncEnabled={features.konnectSync.enabled} + konnectSyncEnabled={isKonnectSyncEnabled() && features.konnectSync.enabled} /> {activeProject && ( <> diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index 3704809a23..a5cb408675 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -4,7 +4,7 @@ import type { LoaderFunctionArgs } from 'react-router'; import { href, redirect, useLoaderData, useNavigate, useParams } from 'react-router'; import { logout } from '~/account/session'; -import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; +import { DEFAULT_SIDEBAR_SIZE, isKonnectSyncEnabled } from '~/common/constants'; import type { GitRepository, Project } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { sortProjects } from '~/models/helpers/project'; @@ -122,7 +122,7 @@ const Component = () => { projects={projectsWithPresence} storageRules={storageRules} onCreateProject={() => setIsNewProjectModalOpen(true)} - konnectSyncEnabled={features.konnectSync.enabled} + konnectSyncEnabled={isKonnectSyncEnabled() && features.konnectSync.enabled} />
diff --git a/packages/insomnia/src/ui/components/modals/settings-modal.tsx b/packages/insomnia/src/ui/components/modals/settings-modal.tsx index a182bfc62b..37f230ae58 100644 --- a/packages/insomnia/src/ui/components/modals/settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/settings-modal.tsx @@ -3,7 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 're import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { useParams } from 'react-router'; -import { AI_PLUGIN_NAME } from '~/common/constants'; +import { AI_PLUGIN_NAME, isKonnectSyncEnabled } from '~/common/constants'; import { isScratchpadOrganizationId } from '~/models/organization'; import { getBundlePlugins } from '~/plugins'; import { useRootLoaderData } from '~/root'; @@ -49,7 +49,7 @@ export const SettingsModal = forwardRef((props, const aiPlugin = plugins.find(p => p.name === AI_PLUGIN_NAME); setShouldShowAiSettingsTab(!!aiPlugin && !!userSession.id); - if (userSession.id && organizationId && !isScratchpadOrganizationId(organizationId)) { + if (isKonnectSyncEnabled() && userSession.id && organizationId && !isScratchpadOrganizationId(organizationId)) { try { const res = await getOrganizationFeatures({ organizationId, sessionId: userSession.id }); setShouldShowKonnectTab(res?.features?.konnectSync?.enabled ?? false); From b34241613dc3d034b96f57ea318e79e93fa9e055 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Fri, 17 Apr 2026 10:09:47 +0200 Subject: [PATCH 09/61] 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> --- .../config/renderer-node-import-baseline.json | 20 ----------- packages/insomnia/src/entry.preload.ts | 1 + .../src/main/ipc/__tests__/grpc.test.ts | 30 +++++++++++++++- packages/insomnia/src/main/ipc/electron.ts | 1 + packages/insomnia/src/main/ipc/grpc.ts | 34 +++++++++++++------ .../components/modals/proto-files-modal.tsx | 11 +----- 6 files changed, 56 insertions(+), 41 deletions(-) diff --git a/packages/insomnia/config/renderer-node-import-baseline.json b/packages/insomnia/config/renderer-node-import-baseline.json index 6ba8e0ca1b..b9b099974b 100644 --- a/packages/insomnia/config/renderer-node-import-baseline.json +++ b/packages/insomnia/config/renderer-node-import-baseline.json @@ -80,26 +80,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" diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 50e201b5f5..140f0a3faa 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -97,6 +97,7 @@ const grpc: gRPCBridgeAPI = { closeAll: () => ipcRenderer.send('grpc.closeAll'), loadMethods: options => ipcRenderer.invoke('grpc.loadMethods', options), loadMethodsFromReflection: options => ipcRenderer.invoke('grpc.loadMethodsFromReflection', options), + writeProtoFile: protoFileId => ipcRenderer.invoke('grpc.writeProtoFile', protoFileId), }; const secretStorage: secretStorageBridgeAPI = { 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.ts b/packages/insomnia/src/main/ipc/electron.ts index 25ce64bb51..2dadfcd7a7 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -78,6 +78,7 @@ export type HandleChannels = | 'git.getGitProviderEmails' | 'grpc.loadMethods' | 'grpc.loadMethodsFromReflection' + | 'grpc.writeProtoFile' | 'insecureReadFile' | 'insecureReadFileWithEncoding' | 'installPlugin' 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/ui/components/modals/proto-files-modal.tsx b/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx index 1de9a69f63..397e2337e2 100644 --- a/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/proto-files-modal.tsx @@ -8,7 +8,6 @@ import * as models from '~/models'; import { type ChangeBufferEvent, database as db } from '../../../common/database'; import { selectFileOrFolder } from '../../../common/select-file-or-folder'; -import { writeProtoFile } from '../../../network/grpc/write-proto-file'; import { Modal, type ModalHandle } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; @@ -245,15 +244,7 @@ export const ProtoFilesModal: FC = ({ defaultId, onHide, onSave }) => { for (const protoFile of loadedFiles) { try { - const { filePath, dirs } = await writeProtoFile(protoFile); - protoLoader.load(filePath, { - keepCase: true, - longs: String, - enums: String, - defaults: true, - oneofs: true, - includeDirs: dirs, - }); + await window.main.grpc.writeProtoFile(protoFile._id); } catch (error) { showError({ title: 'Invalid Proto File', From 67a9ce60f5791800a3340eb970df49e56fb92e77 Mon Sep 17 00:00:00 2001 From: Shelby <13246465+shelby-moore@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:22:25 -0700 Subject: [PATCH 10/61] feat: konnect integration strips nunjucks templates on sync (#9831) --- .../src/konnect/__tests__/transform.test.ts | 72 +++++++++++++++++++ packages/insomnia/src/konnect/sync.ts | 3 +- packages/insomnia/src/konnect/transform.ts | 52 +++++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/insomnia/src/konnect/__tests__/transform.test.ts b/packages/insomnia/src/konnect/__tests__/transform.test.ts index b0f161d0f7..c02188755e 100644 --- a/packages/insomnia/src/konnect/__tests__/transform.test.ts +++ b/packages/insomnia/src/konnect/__tests__/transform.test.ts @@ -8,6 +8,7 @@ import { mergeHeaders, mergePathParameters, pathParametersChanged, + sanitizeRoute, } from '../transform'; // ─── extractRegionFromEndpoint ─────────────────────────────────────────────── @@ -46,6 +47,77 @@ describe('extractRegionFromEndpoint', () => { }); }); +// ─── 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', () => { diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index c5e503d845..4c3515c9aa 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -21,6 +21,7 @@ import { pathParametersChanged, resolvePath, routeDisplayName, + sanitizeRoute, } from './transform'; interface SyncCounts { @@ -369,7 +370,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(); diff --git a/packages/insomnia/src/konnect/transform.ts b/packages/insomnia/src/konnect/transform.ts index 5fc051b57c..a32991ffb6 100644 --- a/packages/insomnia/src/konnect/transform.ts +++ b/packages/insomnia/src/konnect/transform.ts @@ -1,4 +1,54 @@ -import type { KonnectProxyUrl } from './api'; +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 ──────────────────────────────────────────────────────── From 86d9e2ba0c695b9bd0d95efadbf74ee0dc1ee163 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Mon, 20 Apr 2026 16:19:28 +0200 Subject: [PATCH 11/61] 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> --- packages/insomnia/src/main/git-service.ts | 5 +- .../src/sync/git/__tests__/git-vcs.test.ts | 139 +++++++++++++++++- packages/insomnia/src/sync/git/git-vcs.ts | 69 ++++++++- packages/insomnia/src/sync/types.ts | 5 + .../dropdowns/git-project-sync-dropdown.tsx | 1 + .../dropdowns/git-sync-dropdown.tsx | 1 + .../components/modals/git-branches-modal.tsx | 1 + .../modals/git-project-branches-modal.tsx | 1 + 8 files changed, 219 insertions(+), 3 deletions(-) diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 831e93f00d..53491d46ae 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -55,7 +55,7 @@ import { GitProjectNeDBClient } from '../sync/git/project-ne-db-client'; import { projectRoutableFSClient } from '../sync/git/project-routable-fs-client'; 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'; @@ -2092,12 +2092,14 @@ export const continueMerge = async ({ projectId, workspaceId, handledMergeConflicts, + autoResolvedConflicts, commitMessage, commitParent, }: { projectId: string; workspaceId?: string; handledMergeConflicts: MergeConflict[]; + autoResolvedConflicts?: AutoResolvedConflict[]; commitMessage: string; commitParent: string[]; }) => { @@ -2107,6 +2109,7 @@ export const continueMerge = async ({ await GitVCS.continueMerge({ handledMergeConflicts, + autoResolvedConflicts, commitMessage, commitParent, }); 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..8e33a17935 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,141 @@ 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'); + }); + }); }); diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 26b46577e4..dc2044bb19 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'; @@ -1318,11 +1318,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 +1371,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 +1423,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 +1546,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 +1595,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 +1667,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 +1696,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 +2043,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/types.ts b/packages/insomnia/src/sync/types.ts index faf8bb63f5..5eb7fdb59d 100644 --- a/packages/insomnia/src/sync/types.ts +++ b/packages/insomnia/src/sync/types.ts @@ -108,6 +108,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/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 8e825a9c02..414d3f951a 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 @@ -411,6 +411,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject .continueMerge({ projectId, handledMergeConflicts: conflicts, + autoResolvedConflicts: pullResult.autoResolvedConflicts, commitMessage: pullResult.commitMessage, commitParent: pullResult.commitParent, }) 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/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..9c0fbbeef2 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, }) From 02aa32f39361e1a1a35bd659620b5cbfc2ddcd0a Mon Sep 17 00:00:00 2001 From: Shelby <13246465+shelby-moore@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:50:01 -0700 Subject: [PATCH 12/61] feat: konnect integration expressions support (#9830) --- .../__tests__/expression-parser.test.ts | 173 +++++++++++++ .../src/konnect/__tests__/sync.test.ts | 237 ++++++++++++++++-- .../insomnia/src/konnect/expression-parser.ts | 103 ++++++++ packages/insomnia/src/konnect/sync.ts | 32 ++- 4 files changed, 511 insertions(+), 34 deletions(-) create mode 100644 packages/insomnia/src/konnect/__tests__/expression-parser.test.ts create mode 100644 packages/insomnia/src/konnect/expression-parser.ts 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 5de789ddf5..419afa2fe7 100644 --- a/packages/insomnia/src/konnect/__tests__/sync.test.ts +++ b/packages/insomnia/src/konnect/__tests__/sync.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { initDatabase, models, services as insoservices } from '~/insomnia-data'; +import { initDatabase, models, type Request,services as insoservices } from '~/insomnia-data'; import { database as db } from '../../common/database'; import { mainDatabase } from '../../main/database.main'; @@ -130,10 +130,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 +195,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 +234,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) { @@ -723,7 +723,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'); }); @@ -738,7 +738,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', @@ -823,11 +823,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 }}'); }); @@ -869,7 +869,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']); }); @@ -960,11 +960,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'); }); @@ -1006,7 +1006,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']); }); @@ -1376,12 +1376,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', @@ -1390,14 +1390,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({ @@ -1413,4 +1542,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/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 4c3515c9aa..0ee60e50e1 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -10,6 +10,7 @@ import { type KonnectRoute, type KonnectService, } from './api'; +import { applyExpressionFields } from './expression-parser'; import { buildRequestName, deriveProxyVarDefaults, @@ -378,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)); } } From 0f7ed50fc971205f421870579072aab5b7e9c8a8 Mon Sep 17 00:00:00 2001 From: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:36:38 +0800 Subject: [PATCH 13/61] Show more specific error when creating mock route fails (#9841) --- ...ce.$workspaceId.mock-server.mock-route.$mockRouteId.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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..4cdd385eb6 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 @@ -183,6 +183,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)) { From ef50e7a50f0c8d0e8cccc83b6dede4caa3f6c906 Mon Sep 17 00:00:00 2001 From: kwburns-kong Date: Tue, 21 Apr 2026 12:24:42 -0400 Subject: [PATCH 14/61] fix: insomnia-ai-plugin uses securedPath (INS-2244) (#9748) --- packages/insomnia/src/main/ipc/main.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index dcd9b6ff09..86dcf21875 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -19,11 +19,12 @@ import type { UtilityProcess } from 'electron/main'; import iconv from 'iconv-lite'; import { AI_PLUGIN_NAME } from '~/common/constants'; +import { cannotAccessPathError } from '~/common/misc'; import { type Services, services } from '~/insomnia-data'; 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, @@ -448,6 +449,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')); From cd8b74524f02ade813c9d52fad0b0e1746f431cd Mon Sep 17 00:00:00 2001 From: jeremyjpj0916 <31913027+jeremyjpj0916@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:25:30 -0400 Subject: [PATCH 15/61] feat: add custom npm registry mirror setting for plugin installation (#9837) --- packages/insomnia/src/common/settings.ts | 2 + .../src/insomnia-data/src/models/settings.ts | 1 + packages/insomnia/src/main/install-plugin.ts | 56 +++++++++- .../src/ui/components/settings/plugins.tsx | 101 ++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/packages/insomnia/src/common/settings.ts b/packages/insomnia/src/common/settings.ts index 4d6c3a45a6..0f8c716c47 100644 --- a/packages/insomnia/src/common/settings.ts +++ b/packages/insomnia/src/common/settings.ts @@ -163,4 +163,6 @@ export interface Settings { saveVaultKeyToOSSecretManager: boolean; vaultSecretCacheDuration: number; dataFolders: 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/insomnia-data/src/models/settings.ts b/packages/insomnia/src/insomnia-data/src/models/settings.ts index a5a5cd193a..7aa65edf1e 100644 --- a/packages/insomnia/src/insomnia-data/src/models/settings.ts +++ b/packages/insomnia/src/insomnia-data/src/models/settings.ts @@ -76,5 +76,6 @@ export function init(): BaseSettings { // The duration in mins for which the external vault secret is cached vaultSecretCacheDuration: 30, dataFolders: [], + npmRegistryUrl: '', }; } 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/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 && ( + + )} +
+ +
+
From 3870f770d2037e52b6d5d1812539617bbe79edad Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Tue, 21 Apr 2026 12:33:29 -0700 Subject: [PATCH 16/61] feat: default user-agent for cURL imports [INS-2416] (#9838) * feat: default user-agent for cURL imports * respect disableAppVersionUserAgent setting --- .../__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 ++ 4 files changed, 171 insertions(+), 20 deletions(-) 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', () => { From 145aeccab1b1154f2a4614b4c0e78af4fc0ffc04 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:30:16 +0800 Subject: [PATCH 17/61] fix: view transition error - [INS-2316] (#9792) * fix: view transition error * fix --- .../src/ui/components/toast-notification.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/insomnia/src/ui/components/toast-notification.tsx b/packages/insomnia/src/ui/components/toast-notification.tsx index aae2dc2728..a00d2941a2 100644 --- a/packages/insomnia/src/ui/components/toast-notification.tsx +++ b/packages/insomnia/src/ui/components/toast-notification.tsx @@ -23,14 +23,24 @@ export interface RAToastContent { time?: string; } +const logTransitionError = (error: unknown) => { + console.warn('Transition error:', error); +}; + // Create a global ToastQueue. export const queue = new ToastQueue({ // Wrap state updates in a CSS view transition. wrapUpdate(fn) { - if ('startViewTransition' in document) { - document.startViewTransition(() => { - flushSync(fn); - }); + if ('startViewTransition' in document && document.visibilityState === 'visible') { + try { + const transition = document.startViewTransition(() => { + flushSync(fn); + }); + transition.ready.catch(logTransitionError); + } catch (error) { + logTransitionError(error); + fn(); + } } else { fn(); } From c40c222623a319662cee81f97bb0a20f39dd6637 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Wed, 22 Apr 2026 12:21:39 +0800 Subject: [PATCH 18/61] change default behavior when delete cloud sync workspaces (#9844) --- .../src/ui/components/dropdowns/workspace-card-dropdown.tsx | 2 +- .../insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 0adc1f2a3e..8a5863a7ec 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -303,7 +303,7 @@ export const WorkspaceCardDropdown: FC = props => { {getWorkspaceLabel(workspace).singular}

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

{models.project.isRemoteProject(activeProject) && ( - +
Date: Wed, 22 Apr 2026 10:25:37 -0400 Subject: [PATCH 19/61] feat: integrate v3 user endpoints (#9785) * feat: integrate v3 user endpoints * feat: use public sdk for insomnia-api --- package-lock.json | 11 +- 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 + .../server/insomnia-api.ts | 56 ++-- .../src/account/__tests__/session.test.ts | 240 ++++++++++++++++++ packages/insomnia/src/account/session.ts | 16 +- packages/insomnia/src/entry.client.tsx | 7 +- packages/insomnia/src/routes/organization.tsx | 6 +- .../src/ui/components/header-user-button.tsx | 6 +- .../insomnia/src/ui/hooks/use-user-service.ts | 2 +- 13 files changed, 437 insertions(+), 106 deletions(-) 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/src/account/__tests__/session.test.ts diff --git a/package-lock.json b/package-lock.json index 1924fedc7f..f54b9288d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3527,6 +3527,12 @@ "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", @@ -29292,7 +29298,10 @@ }, "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", 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-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/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/session.ts b/packages/insomnia/src/account/session.ts index 6c00d9e3a5..838ccf5c52 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 diff --git a/packages/insomnia/src/entry.client.tsx b/packages/insomnia/src/entry.client.tsx index c9cc607f60..35863e7888 100644 --- a/packages/insomnia/src/entry.client.tsx +++ b/packages/insomnia/src/entry.client.tsx @@ -11,7 +11,12 @@ import { initDatabase, initServices, services } from '~/insomnia-data'; import { database as clientDatabase } from '~/ui/database.client'; import { migrateFromLocalStorage, type SessionData, setSessionData, setVaultSessionData } from './account/session'; -import { getInsomniaSession, getInsomniaVaultKey, getInsomniaVaultSalt, getSkipOnboarding } from './common/constants'; +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'; diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index 6ae58afd85..d1ade31547 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, @@ -49,7 +49,7 @@ import type { Route } from './+types/organization'; export interface OrganizationLoaderData { organizations: Organization[]; - user?: UserProfile; + user?: User; currentPlan?: CurrentPlan; } @@ -57,7 +57,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), 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/hooks/use-user-service.ts b/packages/insomnia/src/ui/hooks/use-user-service.ts index 8d56bc70df..90315e1e8c 100644 --- a/packages/insomnia/src/ui/hooks/use-user-service.ts +++ b/packages/insomnia/src/ui/hooks/use-user-service.ts @@ -18,7 +18,7 @@ export function useUserService() { isEssential, isEnterpriseLike, canUpgrade: !isEnterpriseLike, - displayName: user?.name || user?.email, + displayName: [user?.first_name, user?.last_name].filter(Boolean).join(' ') || user?.email, isTrailing, trialDaysLeft, }; From 812611c38d678ccd13dbb04725bd076b4513a1ce Mon Sep 17 00:00:00 2001 From: kwburns-kong Date: Wed, 22 Apr 2026 12:56:10 -0400 Subject: [PATCH 20/61] chore: applied PoLP to workflows (#9840) --- .github/workflows/homebrew.yml | 5 ++++- .github/workflows/release-build.yml | 30 +++++++++++++++---------- .github/workflows/release-publish.yml | 6 +++-- .github/workflows/release-recurring.yml | 12 ++++++---- .github/workflows/release-start.yml | 10 ++++++--- .github/workflows/sast.yml | 8 +++---- .github/workflows/test-cli.yml | 13 ++++++----- .github/workflows/test-e2e.yml | 13 ++++++----- .github/workflows/test.yml | 12 +++++----- .github/workflows/update-changelog.yml | 4 +++- 10 files changed, 68 insertions(+), 45 deletions(-) diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index 53ed15a0af..7f736fc76b 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@6f8efc29b200d32929f49075959781ed54ec270c # v3.5.0 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..d5bf75c492 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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 # 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: @@ -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 @@ -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..3688d4ad7f 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 @@ -36,14 +38,14 @@ jobs: packages: write steps: - 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' diff --git a/.github/workflows/release-recurring.yml b/.github/workflows/release-recurring.yml index 2de2a98ba0..f7bef6fb8d 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' @@ -83,7 +87,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 +95,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..0a2f2baf06 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' @@ -75,7 +79,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 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..b910eb1cbd 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 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 @@ -49,7 +50,7 @@ jobs: run: npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke - name: Upload smoke test - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: if-no-files-found: ignore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd37d2edae..54400e2dcb 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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.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 }} From d109903243b0bd20197106f94f1dd85a18a8b6f0 Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Wed, 22 Apr 2026 10:44:47 -0700 Subject: [PATCH 21/61] chore: resolve GHA warning annotations and reduce CI time [INS-2312] (#9839) --- .github/workflows/homebrew.yml | 2 +- .github/workflows/release-build.yml | 4 +- .github/workflows/release-publish.yml | 12 ++-- .github/workflows/release-recurring.yml | 1 + .github/workflows/test-e2e.yml | 58 ++++++++++++++++--- .github/workflows/test.yml | 2 +- .../tests/smoke/app.test.ts | 2 +- .../smoke/cookie-editor-interactions.test.ts | 20 +++---- 8 files changed, 73 insertions(+), 28 deletions(-) diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index 7f736fc76b..8b009a369e 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -25,7 +25,7 @@ jobs: - name: Cache Homebrew Bundler RubyGems id: cache - uses: actions/cache@6f8efc29b200d32929f49075959781ed54ec270c # v3.5.0 + 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 d5bf75c492..b5867bcf05 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -20,7 +20,7 @@ jobs: contents: read # Required for actions/checkout steps: - name: Checkout repository - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # Perform SCA / SBOM analysis for the entire monorepo code repository # Produces SCA(SBOM and CVE) report @@ -307,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 }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 3688d4ad7f..0dda579a6b 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -60,7 +60,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' - 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 @@ -84,13 +84,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 }} @@ -139,7 +139,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 }} @@ -292,7 +292,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 @@ -308,7 +308,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 f7bef6fb8d..983a942f18 100644 --- a/.github/workflows/release-recurring.yml +++ b/.github/workflows/release-recurring.yml @@ -68,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 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index b910eb1cbd..7bc6812afa 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -18,8 +18,8 @@ concurrency: permissions: {} jobs: - Test: - timeout-minutes: 40 + build: + timeout-minutes: 20 runs-on: ubuntu-24.04 permissions: contents: read @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .nvmrc cache: npm @@ -46,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 + 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 54400e2dcb..8577c7e1b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,7 +83,7 @@ jobs: - name: Check Circular References if: github.event_name == 'pull_request' && always() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: 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(); From b6c222e6717a6eea8439b5711a0be4fabb61165c Mon Sep 17 00:00:00 2001 From: kwburns-kong Date: Wed, 22 Apr 2026 22:10:29 -0400 Subject: [PATCH 22/61] fix: resolves INS-2366 (#9852) * fix: resolves INS-2366 dependency issues --- package-lock.json | 14 +++++++------- packages/insomnia/package.json | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index f54b9288d2..e8e557bbc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14624,9 +14624,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" @@ -27434,9 +27434,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" @@ -29173,7 +29173,7 @@ "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", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 33ae7e4219..82dc941fdb 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -92,7 +92,7 @@ "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", @@ -145,7 +145,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", From 60ff7448f845415a37a33a9106bc9afd05a5211d Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Thu, 23 Apr 2026 09:26:28 +0200 Subject: [PATCH 23/61] 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> --- .../common/__tests__/electron-storage.test.ts | 9 ++++ packages/insomnia/src/common/constants.ts | 1 + packages/insomnia/src/entry.client.tsx | 11 ++--- packages/insomnia/src/entry.main.ts | 6 ++- packages/insomnia/src/entry.preload.ts | 7 +++ .../insomnia/src/main/electron-storage.ts | 46 +++++++++++++++---- .../insomnia/src/main/ipc/electron-storage.ts | 20 ++++++++ packages/insomnia/src/main/ipc/electron.ts | 2 + packages/insomnia/src/main/ipc/main.ts | 2 + .../insomnia/src/main/ipc/secret-storage.ts | 12 +---- packages/insomnia/src/main/window-utils.ts | 20 ++------ .../src/network/o-auth-2/get-token.ts | 22 ++++----- packages/insomnia/src/sync/git/migrations.ts | 12 +---- .../components/editors/auth/o-auth-2-auth.tsx | 4 +- .../src/ui/components/settings/general.tsx | 4 +- .../insomnia/src/ui/spawn-oauth-window.ts | 5 ++ 16 files changed, 112 insertions(+), 71 deletions(-) create mode 100644 packages/insomnia/src/main/ipc/electron-storage.ts create mode 100644 packages/insomnia/src/ui/spawn-oauth-window.ts 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/constants.ts b/packages/insomnia/src/common/constants.ts index 44210b5f74..0ba56a5117 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -13,6 +13,7 @@ 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 OAUTH_WINDOW_SESSION_ID_KEY = 'current-oauth-session-id'; // App Stuff export const getSkipOnboarding = () => env.INSOMNIA_SKIP_ONBOARDING; diff --git a/packages/insomnia/src/entry.client.tsx b/packages/insomnia/src/entry.client.tsx index 35863e7888..36c1f516e4 100644 --- a/packages/insomnia/src/entry.client.tsx +++ b/packages/insomnia/src/entry.client.tsx @@ -9,15 +9,10 @@ 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 { getInsomniaSession, getInsomniaVaultKey, getInsomniaVaultSalt, getSkipOnboarding } from './common/constants'; import { init as initPlugins } from './plugins'; import { applyColorScheme } from './plugins/misc'; import { HtmlElementWrapper } from './ui/components/html-element-wrapper'; @@ -134,7 +129,7 @@ if (insomniaSession) { const appSettings = await services.settings.getOrCreate(); if (appSettings.clearOAuth2SessionOnRestart) { - initNewOAuthSession(); + await clearOAuthWindowSessionId(); } applyColorScheme(appSettings); diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index af42b2568f..79ca10591b 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -14,6 +14,7 @@ import type { Project, RemoteProject, Stats } from '~/insomnia-data'; import { database, initDatabase, initServices, services } from '~/insomnia-data'; import { servicesNodeImpl } from '~/insomnia-data/node'; import { mainDatabase } from '~/main/database.main'; +import { initElectronStorage } from '~/main/electron-storage'; import { registerPathHandlers } from '~/main/ipc/path'; import { registerLLMConfigServiceAPI } from '~/main/llm-config-service'; import { runGitCredentialsMigration } from '~/sync/git/migrations'; @@ -26,6 +27,7 @@ import { registerInsomniaProtocols } from './main/api.protocol'; import { backupIfNewerVersionAvailable } from './main/backup'; 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'; @@ -46,7 +48,9 @@ import * as models from './models/index'; 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,7 @@ app.on('ready', async () => { registerCurlHandlers(); registerMcpHandlers(); registerSecretStorageHandlers(); + registerElectronStorageHandlers(); /** * 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 +126,6 @@ app.on('ready', async () => { await backupIfNewerVersionAvailable(); sentryWatchAnalyticsEnabled(); watchProxySettings(); - windowUtils.init(); await runGitCredentialsMigration(); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 140f0a3faa..f2641e7663 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -5,6 +5,7 @@ import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-conf import type { GenerateMcpSamplingResponseFunction } from '~/plugins/types'; 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'; @@ -108,6 +109,11 @@ const secretStorage: secretStorageBridgeAPI = { decryptString: cipherText => ipcRenderer.invoke('secretStorage.decryptString', cipherText), }; +const electronStorage: electronStorageBridgeAPI = { + getItem: key => ipcRenderer.invoke('electronStorage.getItem', key), + setItem: (key, value) => ipcRenderer.invoke('electronStorage.setItem', key, value), +}; + const git: GitServiceAPI = { loadGitRepository: options => ipcRenderer.invoke('git.loadGitRepository', options), getGitBranches: options => ipcRenderer.invoke('git.getGitBranches', options), @@ -209,6 +215,7 @@ const main: Window['main'] = { grpc, curl, secretStorage, + electronStorage, trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options), trackPageView: options => ipcRenderer.send('trackPageView', options), setCurrentOrganizationId: organizationId => ipcRenderer.send('analytics.setOrganizationId', organizationId), diff --git a/packages/insomnia/src/main/electron-storage.ts b/packages/insomnia/src/main/electron-storage.ts index 8decf17f92..20c30e9ce0 100644 --- a/packages/insomnia/src/main/electron-storage.ts +++ b/packages/insomnia/src/main/electron-storage.ts @@ -1,6 +1,25 @@ 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() { + invariant(electronStorage, 'ElectronStorage has not been initialized.'); + return electronStorage; +} + class ElectronStorage { _buffer: Record = {}; _timeouts: Record = {}; @@ -15,24 +34,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 +66,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 +81,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/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 2dadfcd7a7..da66f94d2e 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -120,6 +120,8 @@ export type HandleChannels = | 'readDir' | 'readOrCreateDataDir' | 'restoreBackup' + | 'electronStorage.getItem' + | 'electronStorage.setItem' | 'secretStorage.decryptString' | 'secretStorage.deleteSecret' | 'secretStorage.encryptString' diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 86dcf21875..ba1e176db0 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -59,6 +59,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'; @@ -160,6 +161,7 @@ export interface RendererToMainBridgeAPI { git: GitServiceAPI; llm: LLMConfigServiceAPI; secretStorage: secretStorageBridgeAPI; + electronStorage: electronStorageBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; setCurrentOrganizationId: (organizationId: string | undefined) => void; 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/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index cd10034f93..497405c01c 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 @@ -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/network/o-auth-2/get-token.ts b/packages/insomnia/src/network/o-auth-2/get-token.ts index 63b51b2554..c599439e93 100644 --- a/packages/insomnia/src/network/o-auth-2/get-token.ts +++ b/packages/insomnia/src/network/o-auth-2/get-token.ts @@ -18,7 +18,7 @@ import { getBodyBuffer } from '~/models/helpers/response-operations'; import { encryptOAuthUrl } from '~/network/o-auth-2/utils'; import { version } from '../../../package.json'; -import { getOauthRedirectUrl } from '../../common/constants'; +import { getOauthRedirectUrl, OAUTH_WINDOW_SESSION_ID_KEY } from '../../common/constants'; import { escapeRegex } from '../../common/misc'; import uiEventBus, { OAUTH2_AUTHORIZATION_STATUS_CHANGE } from '../../ui/event-bus'; import { invariant } from '../../utils/invariant'; @@ -37,21 +37,17 @@ import { import { type AuthKeys, GRANT_TYPE_AUTHORIZATION_CODE, PKCE_CHALLENGE_S256 } from './constants'; const { isRequestGroup, isRequestGroupId } = models.requestGroup; -const LOCALSTORAGE_KEY_SESSION_ID = 'insomnia::current-oauth-session-id'; -export function initNewOAuthSession() { - // the value of this variable needs to start with 'persist:' - // otherwise sessions won't be persisted over application-restarts +async function getOAuthWindowHandleSession(): Promise { + const token = await window.main.electronStorage.getItem(OAUTH_WINDOW_SESSION_ID_KEY); + if (token) { + return token; + } const authWindowSessionId = `persist:oauth2_${uuidv4()}`; - window.localStorage.setItem(LOCALSTORAGE_KEY_SESSION_ID, authWindowSessionId); + await window.main.electronStorage.setItem(OAUTH_WINDOW_SESSION_ID_KEY, authWindowSessionId); return authWindowSessionId; } -export function getOAuthSession(): string { - const token = window.localStorage.getItem(LOCALSTORAGE_KEY_SESSION_ID); - 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 @@ -103,7 +99,7 @@ export const getOAuth2Token = async ( url: implicitUrl.toString(), urlSuccessRegex: /(access_token=|id_token=)/, urlFailureRegex: /(error=)/, - sessionId: getOAuthSession(), + sessionId: await getOAuthWindowHandleSession(), }); console.log('[oauth2] Detected redirect ' + redirectedTo); @@ -183,7 +179,7 @@ export const getOAuth2Token = async ( urlFailureRegex: authentication.redirectUrl ? new RegExp(`${escapeRegex(authentication.redirectUrl)}.*([?&]error=)`, 'i') : /([?&]error=)/i, - sessionId: getOAuthSession(), + sessionId: await getOAuthWindowHandleSession(), }); } diff --git a/packages/insomnia/src/sync/git/migrations.ts b/packages/insomnia/src/sync/git/migrations.ts index 83a2019093..f5357f280d 100644 --- a/packages/insomnia/src/sync/git/migrations.ts +++ b/packages/insomnia/src/sync/git/migrations.ts @@ -26,8 +26,7 @@ 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 { getElectronStorage } from '~/main/electron-storage'; import * as models from '../../models'; @@ -36,15 +35,6 @@ 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/ui/components/editors/auth/o-auth-2-auth.tsx b/packages/insomnia/src/ui/components/editors/auth/o-auth-2-auth.tsx index 0f21602ade..e91228f5ad 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,6 +2,7 @@ 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'; @@ -17,7 +18,6 @@ import { 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'; import { type RequestLoaderData, useRequestLoaderData, @@ -427,7 +427,7 @@ export const OAuth2Auth = ({ showMcpAuthFlow, disabled }: { showMcpAuthFlow?: bo
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/spawn-oauth-window.ts b/packages/insomnia/src/ui/spawn-oauth-window.ts new file mode 100644 index 0000000000..966a0ccc1d --- /dev/null +++ b/packages/insomnia/src/ui/spawn-oauth-window.ts @@ -0,0 +1,5 @@ +import { OAUTH_WINDOW_SESSION_ID_KEY } from '~/common/constants'; + +export const clearOAuthWindowSessionId = async () => { + await window.main.electronStorage.setItem(OAUTH_WINDOW_SESSION_ID_KEY, ''); +}; From e68fb4db485243beb08a86514ff1a20a553cd788 Mon Sep 17 00:00:00 2001 From: Jay Wu Date: Thu, 23 Apr 2026 17:27:44 +0800 Subject: [PATCH 24/61] chore: decouple releases (#9842) * INS-2145 Decouple releases * fix security error * fix * check version --- .github/workflows/release-publish.yml | 34 +++++++++++++++------------ .github/workflows/release-start.yml | 7 ++++-- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 0dda579a6b..36885fa20c 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -37,6 +37,15 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -59,6 +68,16 @@ 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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 with: @@ -261,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: diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml index 0a2f2baf06..a29d7a45f2 100644 --- a/.github/workflows/release-start.yml +++ b/.github/workflows/release-start.yml @@ -68,8 +68,11 @@ jobs: - 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 From a3a3ef490e6daa372aef5be3846f2951e9325922 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Fri, 24 Apr 2026 09:36:59 +0200 Subject: [PATCH 25/61] 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> --- .gitignore | 1 + packages/insomnia-inso/src/cli.test.ts | 2 + .../src/objects/auth.ts | 2 +- .../fixtures/auth-types.yaml | 95 ++++++++ .../insomnia-smoke-test/playwright/test.ts | 3 +- .../config/renderer-node-import-baseline.json | 56 ----- packages/insomnia/src/common/constants.ts | 49 +++++ packages/insomnia/src/common/har.ts | 5 +- packages/insomnia/src/entry.main.ts | 2 +- packages/insomnia/src/entry.preload.ts | 10 +- .../src/insomnia-data/src/models/request.ts | 2 +- .../insomnia/src/main/electron-storage.ts | 7 +- .../src/{sync => main}/git/migrations.ts | 0 packages/insomnia/src/main/ipc/electron.ts | 2 + packages/insomnia/src/main/ipc/main.ts | 16 +- .../src/main/mcp/oauth-client-provider.ts | 2 +- .../src/main/network/get-auth-header.ts | 156 +++++++++++++ .../{ => main}/network/o-auth-1/get-token.ts | 15 +- .../{ => main}/network/o-auth-2/get-token.ts | 206 +++++++++++------- .../network/__tests__/authentication.test.ts | 20 +- .../src/network/__tests__/network.test.ts | 17 +- .../insomnia/src/network/authentication.ts | 160 +------------- packages/insomnia/src/network/network.ts | 24 +- .../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 ---- ...aceId.debug.request.$requestId.connect.tsx | 3 +- .../ui/components/dropdowns/auth-dropdown.tsx | 9 +- .../components/editors/auth/o-auth-1-auth.tsx | 6 +- .../components/editors/auth/o-auth-2-auth.tsx | 13 +- .../src/ui/components/mcp/mcp-url-bar.tsx | 3 +- .../oauth-authorization-status-modal.tsx | 2 +- .../vite-plugin-electron-node-require.ts | 2 + packages/insomnia/vite.config.ts | 4 + 34 files changed, 538 insertions(+), 444 deletions(-) create mode 100644 packages/insomnia-smoke-test/fixtures/auth-types.yaml rename packages/insomnia/src/{sync => main}/git/migrations.ts (100%) 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 (77%) 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 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/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-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/playwright/test.ts b/packages/insomnia-smoke-test/playwright/test.ts index df3e107256..7eb6edfc3e 100644 --- a/packages/insomnia-smoke-test/playwright/test.ts +++ b/packages/insomnia-smoke-test/playwright/test.ts @@ -99,12 +99,13 @@ export const test = baseTest.extend<{ ...(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/config/renderer-node-import-baseline.json b/packages/insomnia/config/renderer-node-import-baseline.json index b9b099974b..a41e73f6f0 100644 --- a/packages/insomnia/config/renderer-node-import-baseline.json +++ b/packages/insomnia/config/renderer-node-import-baseline.json @@ -16,10 +16,6 @@ "importer": "../insomnia-testing/src/run/run.ts", "builtin": "path" }, - { - "importer": "src/main/importers/importers/curl.ts", - "builtin": "url" - }, { "importer": "src/main/importers/importers/openapi-3.ts", "builtin": "crypto" @@ -28,38 +24,6 @@ "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" @@ -88,10 +52,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" @@ -128,22 +88,6 @@ "importer": "src/plugins/index.ts", "builtin": "path" }, - { - "importer": "src/routes/import.scan.tsx", - "builtin": "path" - }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx", - "builtin": "path" - }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx", - "builtin": "path" - }, - { - "importer": "src/routes/organization.$organizationId.project.$projectId.workspace.update.tsx", - "builtin": "path" - }, { "importer": "src/script-executor.ts", "builtin": "fs/promises" diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 0ba56a5117..777f4e701b 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -258,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 0e87c9ecff..804a1c9dbe 100644 --- a/packages/insomnia/src/common/har.ts +++ b/packages/insomnia/src/common/har.ts @@ -8,7 +8,6 @@ import { getBodyBuffer } from '~/models/helpers/response-operations'; 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'; @@ -293,6 +292,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) { diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index 79ca10591b..2c3b239db3 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -15,9 +15,9 @@ import { database, initDatabase, initServices, 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'; diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index f2641e7663..5093bcfcf4 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, webUtils as webUtilities } from 'electron'; -import type { Services } from '~/insomnia-data'; +import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service'; import type { GenerateMcpSamplingResponseFunction } from '~/plugins/types'; @@ -13,6 +13,7 @@ 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>(); @@ -196,6 +197,13 @@ const main: Window['main'] = { cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options), writeFile: options => ipcRenderer.invoke('writeFile', options), writeResponseBodyToFile: options => ipcRenderer.invoke('writeResponseBodyToFile', options), + getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise => + ipcRenderer.invoke('getAuthHeader', renderedRequest, url), + getOAuth2Token: ( + requestId: string, + authentication: AuthTypeOAuth2, + forceRefresh?: boolean, + ): Promise => ipcRenderer.invoke('getOAuth2Token', requestId, authentication, forceRefresh), insecureReadFile: options => ipcRenderer.invoke('insecureReadFile', options), insecureReadFileWithEncoding: options => ipcRenderer.invoke('insecureReadFileWithEncoding', options), secureReadFile: options => ipcRenderer.invoke('secureReadFile', options), diff --git a/packages/insomnia/src/insomnia-data/src/models/request.ts b/packages/insomnia/src/insomnia-data/src/models/request.ts index ced255404a..ad46a86326 100644 --- a/packages/insomnia/src/insomnia-data/src/models/request.ts +++ b/packages/insomnia/src/insomnia-data/src/models/request.ts @@ -15,10 +15,10 @@ 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'; export const name = 'Request'; diff --git a/packages/insomnia/src/main/electron-storage.ts b/packages/insomnia/src/main/electron-storage.ts index 20c30e9ce0..68c919cdc6 100644 --- a/packages/insomnia/src/main/electron-storage.ts +++ b/packages/insomnia/src/main/electron-storage.ts @@ -12,10 +12,13 @@ export function initElectronStorage(dataPath: string) { 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}`); + invariant( + !electronStorage, + `ElectronStorage already initialized. Attempted re-init with: ${resolvedElectronStoragePath}`, + ); electronStorage = new ElectronStorage(resolvedElectronStoragePath); } -export function getElectronStorage() { +export function getElectronStorage(): ElectronStorage { invariant(electronStorage, 'ElectronStorage has not been initialized.'); return electronStorage; } diff --git a/packages/insomnia/src/sync/git/migrations.ts b/packages/insomnia/src/main/git/migrations.ts similarity index 100% rename from packages/insomnia/src/sync/git/migrations.ts rename to packages/insomnia/src/main/git/migrations.ts diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index da66f94d2e..dfe873ab1f 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' diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index ba1e176db0..4e9cb28b62 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -20,7 +20,8 @@ import iconv from 'iconv-lite'; import { AI_PLUGIN_NAME } from '~/common/constants'; import { cannotAccessPathError } from '~/common/misc'; -import { type Services, services } from '~/insomnia-data'; +import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; +import { services } from '~/insomnia-data'; 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'; @@ -33,7 +34,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 { @@ -46,8 +47,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, @@ -140,6 +143,8 @@ export interface RendererToMainBridgeAPI { 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: { @@ -289,7 +294,12 @@ export function registerMainHandlers() { } }); 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) => { 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/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/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 77% 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 c599439e93..762cb2f5fb 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,16 @@ import type { Response, } from '~/insomnia-data'; import { database as db, models, services } from '~/insomnia-data'; +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 { getBodyBuffer } from '~/models/helpers/response-operations'; -import { encryptOAuthUrl } from '~/network/o-auth-2/utils'; -import { version } from '../../../package.json'; -import { getOauthRedirectUrl, OAUTH_WINDOW_SESSION_ID_KEY } 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,32 +33,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; -async function getOAuthWindowHandleSession(): Promise { - const token = await window.main.electronStorage.getItem(OAUTH_WINDOW_SESSION_ID_KEY); - if (token) { - return token; - } +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() { const authWindowSessionId = `persist:oauth2_${uuidv4()}`; - await window.main.electronStorage.setItem(OAUTH_WINDOW_SESSION_ID_KEY, authWindowSessionId); + const storage = getElectronStorage(); + storage.setItem(OAUTH_WINDOW_SESSION_ID_KEY, authWindowSessionId); return authWindowSessionId; } -// 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 function getOAuthSession(): string { + const storage = getElectronStorage(); + const token = storage.getItem(OAUTH_WINDOW_SESSION_ID_KEY); + return token || initNewOAuthSession(); +} + +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; } @@ -95,11 +189,11 @@ 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=)/, - sessionId: await getOAuthWindowHandleSession(), + sessionId: getOAuthSession(), }); console.log('[oauth2] Detected redirect ' + redirectedTo); @@ -126,7 +220,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)) : ''; @@ -158,20 +251,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') @@ -179,7 +267,7 @@ export const getOAuth2Token = async ( urlFailureRegex: authentication.redirectUrl ? new RegExp(`${escapeRegex(authentication.redirectUrl)}.*([?&]error=)`, 'i') : /([?&]error=)/i, - sessionId: await getOAuthWindowHandleSession(), + sessionId: getOAuthSession(), }); } @@ -227,37 +315,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, @@ -289,8 +360,6 @@ async function getExistingAccessTokenAndRefreshIfExpired( return { oAuth2Token: token, closestAuthId }; } - // token is expired - if (!token.refreshToken) { return { oAuth2Token: undefined, closestAuthId }; } @@ -316,9 +385,6 @@ async function getExistingAccessTokenAndRefreshIfExpired( const bodyBuffer = await getBodyBuffer(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 }; @@ -328,9 +394,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); @@ -387,7 +450,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, @@ -395,14 +457,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, @@ -411,7 +470,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) @@ -432,7 +490,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, @@ -466,26 +523,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/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__/network.test.ts b/packages/insomnia/src/network/__tests__/network.test.ts index a3a9bf45a7..3f51f740e7 100644 --- a/packages/insomnia/src/network/__tests__/network.test.ts +++ b/packages/insomnia/src/network/__tests__/network.test.ts @@ -16,11 +16,26 @@ import { _getAwsAuthHeaders } from '../../main/network/parse-header-strings'; import * as models from '../../models'; import { getBodyBuffer } from '../../models/helpers/response-operations'; 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(); 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/network.ts b/packages/insomnia/src/network/network.ts index 07584daaec..ed95f202e8 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -53,7 +53,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 +878,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 +981,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/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..c07c30408a 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 @@ -5,7 +5,6 @@ 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 { RenderedRequest } from '~/templating/types'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -70,7 +69,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/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/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 e91228f5ad..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 @@ -4,11 +4,8 @@ import type { AuthTypeOAuth2, OAuth2ResponseType, OAuth2Token, RequestAuthentica 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, @@ -16,8 +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'; +} 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, @@ -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/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/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/vite-plugin-electron-node-require.ts b/packages/insomnia/vite-plugin-electron-node-require.ts index 764c455ea7..610aa78067 100644 --- a/packages/insomnia/vite-plugin-electron-node-require.ts +++ b/packages/insomnia/vite-plugin-electron-node-require.ts @@ -63,9 +63,11 @@ export function electronNodeRequire(options: Options): Plugin { return ` const electron = require('electron'); export { electron as default }; + export const BrowserWindow = electron.BrowserWindow; export const clipboard = electron.clipboard; export const contextBridge = electron.contextBridge; export const crashReporter = electron.crashReporter; + export const dialog = electron.dialog; export const ipcRenderer = electron.ipcRenderer; export const nativeImage = electron.nativeImage; export const shell = electron.shell; diff --git a/packages/insomnia/vite.config.ts b/packages/insomnia/vite.config.ts index ba8761d402..2ea1167d17 100644 --- a/packages/insomnia/vite.config.ts +++ b/packages/insomnia/vite.config.ts @@ -19,6 +19,10 @@ export default defineConfig(({ mode }) => { '__DEV__': JSON.stringify(__DEV__), 'process.env.NODE_ENV': JSON.stringify(mode), 'process.env.INSOMNIA_ENV': JSON.stringify(mode), + // Only apply in production builds: Rollup does text substitution (safe). + // In dev mode Vite uses runtime assignment via env.mjs, which throws + // TypeError because process.type is read-only in Electron's renderer process. + ...(!__DEV__ ? { 'process.type': JSON.stringify('renderer') } : {}), }, server: { port: pkg.dev['dev-server-port'], From 41ee5c9865448845e06b5efc0f1dab3e1cc88cae Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Fri, 24 Apr 2026 16:47:41 +0800 Subject: [PATCH 26/61] fix: Support pin and unpin websocket and socketio requests (#9865) * support pin websocket and socketio requests --- .../node-src/database/database-nedb.ts | 8 +++ .../insomnia-data/node-src/services/index.ts | 4 ++ .../services/socket-io-request-meta.ts | 59 +++++++++++++++++++ .../services/websocket-request-meta.ts | 59 +++++++++++++++++++ .../src/insomnia-data/src/models/db-models.ts | 2 + .../src/models/socket-io-request-meta.ts | 26 ++++++++ .../src/insomnia-data/src/models/types.ts | 2 + .../src/models/websocket-request-meta.ts | 26 ++++++++ packages/insomnia/src/models/index.ts | 2 + packages/insomnia/src/models/types.ts | 2 + ...d.debug.request.$requestId.update-meta.tsx | 16 ++++- ...ject.$projectId.workspace.$workspaceId.tsx | 17 +++++- 12 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts create mode 100644 packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts create mode 100644 packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.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 0323b86407..2226493abc 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 @@ -326,6 +326,10 @@ export const createNedbDatabase = ( ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.SocketIORequest.db'), }), + SocketIORequestMeta: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.SocketIORequestMeta.db'), + }), SocketIOResponse: new NeDB({ ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.SocketIOResponse.db'), @@ -358,6 +362,10 @@ export const createNedbDatabase = ( ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.WebSocketRequest.db'), }), + WebSocketRequestMeta: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.WebSocketRequestMeta.db'), + }), WebSocketResponse: new NeDB({ ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.WebSocketResponse.db'), 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 f5079aecb7..a914ba3046 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/index.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/index.ts @@ -28,6 +28,7 @@ import * as runnerTestResultService from './runner-test-result'; import * as settingsService from './settings'; import * as socketIOPayloadService from './socket-io-payload'; import * as socketIORequestService from './socket-io-request'; +import * as socketIORequestMetaService from './socket-io-request-meta'; import * as socketIOResponseService from './socket-io-response'; import * as statsService from './stats'; import * as unitTestService from './unit-test'; @@ -36,6 +37,7 @@ import * as unitTestSuiteService from './unit-test-suite'; import * as userSessionService from './user-session'; import * as webSocketPayloadService from './websocket-payload'; import * as webSocketRequestService from './websocket-request'; +import * as webSocketRequestMetaService from './websocket-request-meta'; import * as webSocketResponseService from './websocket-response'; import * as workspaceService from './workspace'; import * as workspaceMetaService from './workspace-meta'; @@ -81,8 +83,10 @@ export const servicesNodeImpl = { unitTestSuite: unitTestSuiteService, socketIOPayload: socketIOPayloadService, socketIORequest: socketIORequestService, + socketIORequestMeta: socketIORequestMetaService, socketIOResponse: socketIOResponseService, webSocketPayload: webSocketPayloadService, webSocketRequest: webSocketRequestService, + webSocketRequestMeta: webSocketRequestMetaService, webSocketResponse: webSocketResponseService, } satisfies Record Promise>>; diff --git a/packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts b/packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts new file mode 100644 index 0000000000..cd01953ff1 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts @@ -0,0 +1,59 @@ +import type { SocketIORequestMeta } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; + +const { type } = models.socketIORequestMeta; +const { isSocketIORequestId } = models.socketIORequest; + +function expectParentToBeSocketIORequest(parentId: string | null) { + if (!isSocketIORequestId(parentId)) { + throw new Error('Expected the parent of SocketIORequestMeta to be a SocketIORequest'); + } +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New SocketIORequestMeta missing `parentId`'); + } + + expectParentToBeSocketIORequest(patch.parentId); + return db.docCreate(type, patch); +} + +export function update(requestMeta: SocketIORequestMeta, patch: Partial) { + expectParentToBeSocketIORequest(patch.parentId || requestMeta.parentId); + return db.docUpdate(requestMeta, patch); +} + +export function getByParentId(parentId: string) { + expectParentToBeSocketIORequest(parentId); + return db.findOne(type, { parentId }); +} + +export async function getOrCreateByParentId(parentId: string) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return requestMeta; + } + + return create({ parentId }); +} + +export async function updateOrCreateByParentId(parentId: string, patch: Partial) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return update(requestMeta, patch); + } + const newPatch = Object.assign( + { + parentId, + }, + patch, + ); + return create(newPatch); +} + +export function all() { + return db.find(type); +} diff --git a/packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts b/packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts new file mode 100644 index 0000000000..02fc170c8c --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts @@ -0,0 +1,59 @@ +import type { WebSocketRequestMeta } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; + +const { type } = models.webSocketRequestMeta; +const { isWebSocketRequestId } = models.webSocketRequest; + +function expectParentToBeWebSocketRequest(parentId: string | null) { + if (!isWebSocketRequestId(parentId)) { + throw new Error('Expected the parent of WebSocketRequestMeta to be a WebSocketRequest'); + } +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New WebSocketRequestMeta missing `parentId`'); + } + + expectParentToBeWebSocketRequest(patch.parentId); + return db.docCreate(type, patch); +} + +export function update(requestMeta: WebSocketRequestMeta, patch: Partial) { + expectParentToBeWebSocketRequest(patch.parentId || requestMeta.parentId); + return db.docUpdate(requestMeta, patch); +} + +export function getByParentId(parentId: string) { + expectParentToBeWebSocketRequest(parentId); + return db.findOne(type, { parentId }); +} + +export async function getOrCreateByParentId(parentId: string) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return requestMeta; + } + + return create({ parentId }); +} + +export async function updateOrCreateByParentId(parentId: string, patch: Partial) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return update(requestMeta, patch); + } + const newPatch = Object.assign( + { + parentId, + }, + patch, + ); + return create(newPatch); +} + +export function all() { + return db.find(type); +} diff --git a/packages/insomnia/src/insomnia-data/src/models/db-models.ts b/packages/insomnia/src/insomnia-data/src/models/db-models.ts index b08c9eddf1..cfacae5533 100644 --- a/packages/insomnia/src/insomnia-data/src/models/db-models.ts +++ b/packages/insomnia/src/insomnia-data/src/models/db-models.ts @@ -29,6 +29,7 @@ export * as settings from './settings'; export * as socketIOPayload from './socket-io-payload'; export * as socketIORequest from './socket-io-request'; export * as socketIOResponse from './socket-io-response'; +export * as socketIORequestMeta from './socket-io-request-meta'; export * as stats from './stats'; export * as unitTest from './unit-test'; export * as unitTestResult from './unit-test-result'; @@ -37,5 +38,6 @@ export * as userSession from './user-session'; export * as webSocketPayload from './websocket-payload'; export * as webSocketRequest from './websocket-request'; export * as webSocketResponse from './websocket-response'; +export * as webSocketRequestMeta from './websocket-request-meta'; export * as workspace from './workspace'; export * as workspaceMeta from './workspace-meta'; 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 new file mode 100644 index 0000000000..2804cac343 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts @@ -0,0 +1,26 @@ +import type { BaseModel } from '~/models/types'; + +export const name = 'Socket.IO Request Meta'; + +export const type = 'SocketIORequestMeta'; + +export const prefix = 'socketio-req-meta'; + +export const canDuplicate = false; + +export const canSync = false; + +interface BaseSocketIORequestMeta { + pinned: boolean; +} + +export type SocketIORequestMeta = BaseModel & BaseSocketIORequestMeta; + +export const isSocketIORequestMeta = (model: Pick): model is SocketIORequestMeta => + model.type === type; + +export function init() { + return { + pinned: false, + }; +} diff --git a/packages/insomnia/src/insomnia-data/src/models/types.ts b/packages/insomnia/src/insomnia-data/src/models/types.ts index 0fe725b7e3..f411200d6d 100644 --- a/packages/insomnia/src/insomnia-data/src/models/types.ts +++ b/packages/insomnia/src/insomnia-data/src/models/types.ts @@ -92,6 +92,8 @@ export type { UnitTestSuite } from './unit-test-suite'; export type { SocketIOPayload } from './socket-io-payload'; export type { BaseSocketIORequest, SocketIOEventListener, SocketIORequest } from './socket-io-request'; export type { SocketIOResponse } from './socket-io-response'; +export type { SocketIORequestMeta } from './socket-io-request-meta'; export type { WebSocketPayload } from './websocket-payload'; export type { BaseWebSocketRequest, WebSocketRequest } from './websocket-request'; export type { WebSocketResponse } from './websocket-response'; +export type { WebSocketRequestMeta } from './websocket-request-meta'; 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 new file mode 100644 index 0000000000..ead0dc9e67 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts @@ -0,0 +1,26 @@ +import type { BaseModel } from '~/models/types'; + +export const name = 'WebSocket Request Meta'; + +export const type = 'WebSocketRequestMeta'; + +export const prefix = 'ws-req-meta'; + +export const canDuplicate = false; + +export const canSync = false; + +interface BaseWebSocketRequestMeta { + pinned: boolean; +} + +export type WebSocketRequestMeta = BaseModel & BaseWebSocketRequestMeta; + +export const isWebSocketRequestMeta = (model: Pick): model is WebSocketRequestMeta => + model.type === type; + +export function init() { + return { + pinned: false, + }; +} diff --git a/packages/insomnia/src/models/index.ts b/packages/insomnia/src/models/index.ts index 09fe861ef3..df988a1dd5 100644 --- a/packages/insomnia/src/models/index.ts +++ b/packages/insomnia/src/models/index.ts @@ -36,8 +36,10 @@ 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; diff --git a/packages/insomnia/src/models/types.ts b/packages/insomnia/src/models/types.ts index 23a7546726..253a48e490 100644 --- a/packages/insomnia/src/models/types.ts +++ b/packages/insomnia/src/models/types.ts @@ -27,6 +27,7 @@ export type AllTypes = | 'SocketIOPayload' | 'SocketIORequest' | 'SocketIOResponse' + | 'SocketIORequestMeta' | 'Stats' | 'UnitTest' | 'UnitTestResult' @@ -35,6 +36,7 @@ export type AllTypes = | 'WebSocketPayload' | 'WebSocketRequest' | 'WebSocketResponse' + | 'WebSocketRequestMeta' | 'McpRequest' | 'McpResponse' | 'McpPayload' 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 50033409c5..adf6dc5672 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,6 +1,6 @@ import { href } from 'react-router'; -import type { GrpcRequestMeta, RequestMeta } from '~/insomnia-data'; +import type { GrpcRequestMeta, RequestMeta, SocketIORequestMeta, WebSocketRequestMeta } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import * as models from '~/models'; import { invariant } from '~/utils/invariant'; @@ -11,11 +11,21 @@ import type { Route } from './+types/organization.$organizationId.project.$proje export async function clientAction({ params, request }: Route.ClientActionArgs) { const { requestId } = params; invariant(typeof requestId === 'string', 'Request ID is required'); - const patch = (await request.json()) as Partial; + const patch = (await request.json()) as Partial< + RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta + >; if (models.grpcRequest.isGrpcRequestId(requestId)) { await services.grpcRequestMeta.updateOrCreateByParentId(requestId, patch); return null; } + if (models.webSocketRequest.isWebSocketRequestId(requestId)) { + await services.webSocketRequestMeta.updateOrCreateByParentId(requestId, patch); + return null; + } + if (models.socketIORequest.isSocketIORequestId(requestId)) { + await services.socketIORequestMeta.updateOrCreateByParentId(requestId, patch); + return null; + } await services.requestMeta.updateOrCreateByParentId(requestId, patch); return null; } @@ -33,7 +43,7 @@ export const useRequestUpdateMetaActionFetcher = createFetcherSubmitHook( projectId: string; workspaceId: string; requestId: string; - patch: Partial; + patch: Partial; }) => { const url = href( '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId/update-meta', 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 ad834185d4..4359150fc2 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 @@ -19,7 +19,9 @@ import type { RequestGroupMeta, RequestMeta, SocketIORequest, + SocketIORequestMeta, WebSocketRequest, + WebSocketRequestMeta, Workspace, WorkspaceMeta, } from '~/insomnia-data'; @@ -178,7 +180,18 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const grpcRequestMetas = await database.find(models.grpcRequestMeta.type, { parentId: { $in: grpcReqs.map(r => r._id) }, }); - const grpcAndRequestMetas = [...requestMetas, ...grpcRequestMetas] as (RequestMeta | GrpcRequestMeta)[]; + const webSocketRequestMetas = await database.find(models.webSocketRequestMeta.type, { + parentId: { $in: wsReqs.map(r => r._id) }, + }); + const socketIORequestMetas = await database.find(models.socketIORequestMeta.type, { + parentId: { $in: socketIORequests.map(r => r._id) }, + }); + const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...webSocketRequestMetas, ...socketIORequestMetas] as ( + | RequestMeta + | GrpcRequestMeta + | WebSocketRequestMeta + | SocketIORequestMeta + )[]; const requestGroupMetas = (await database.find(models.requestGroupMeta.type, { parentId: { $in: listOfParentIds }, })) as RequestGroupMeta[]; @@ -202,7 +215,7 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) levelReqs.sort(sortFunction).map(async (doc): Promise => { const hidden = parentIsCollapsed; - const pinned = (!isRequestGroup(doc) && grpcAndRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false; + const pinned = (!isRequestGroup(doc) && allRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false; const collapsed = parentIsCollapsed || (isRequestGroup(doc) && requestGroupMetas.find(m => m.parentId === doc._id)?.collapsed) || From f49fef506e5f8336cc8a8d1ede422360d81f6f28 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Fri, 24 Apr 2026 13:37:10 +0200 Subject: [PATCH 27/61] feat(Git Sync): Add support for canonical repository output (#9789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../insomnia/src/basic-components/modal.tsx | 4 +- packages/insomnia/src/entry.preload.ts | 3 + .../src/models/git-repository.ts | 9 + .../src/models/workspace-meta.ts | 2 + packages/insomnia/src/main/git-service.ts | 349 ++++++- packages/insomnia/src/main/ipc/electron.ts | 7 +- packages/insomnia/src/root.tsx | 24 +- .../insomnia/src/routes/git-migration.$.tsx | 206 ++++ ...ganizationId.project.$projectId._index.tsx | 36 +- ...ion.$organizationId.project.$projectId.tsx | 24 +- ...aceId.spec.generate-request-collection.tsx | 2 +- ...$projectId.workspace.$workspaceId.spec.tsx | 2 +- ...ject.$projectId.workspace.$workspaceId.tsx | 56 +- ...ization.$organizationId.project._index.tsx | 2 - .../git/__tests__/git-repo-migration.test.ts | 145 +++ .../src/sync/git/__tests__/git-vcs.test.ts | 130 +++ .../src/sync/git/git-migration-version.ts | 5 + .../src/sync/git/git-repo-migration.ts | 384 ++++++++ packages/insomnia/src/sync/git/git-vcs.ts | 66 +- .../src/sync/git/project-ne-db-client.ts | 278 ------ .../sync/git/project-routable-fs-client.ts | 79 +- .../src/sync/git/repo-file-watcher.ts | 918 ++++++++++++++++++ .../insomnia/src/sync/git/sync-queue.test.ts | 143 +++ packages/insomnia/src/sync/git/sync-queue.ts | 61 ++ .../dropdowns/git-project-sync-dropdown.tsx | 43 +- .../git/git-non-origin-branch-banner.tsx | 84 ++ .../ui/components/header-invite-button.tsx | 2 +- .../modals/git-project-staging-modal.tsx | 106 +- .../project/project-settings-form.tsx | 65 +- .../src/ui/components/tabs/tab-list.tsx | 6 +- .../insomnia/src/ui/components/tabs/tab.tsx | 8 +- .../src/ui/hooks/use-git-file-issues.ts | 89 ++ .../insomnia/src/ui/hooks/use-vcs-version.ts | 7 +- packages/insomnia/src/utils/router.ts | 22 +- 34 files changed, 2955 insertions(+), 412 deletions(-) create mode 100644 packages/insomnia/src/routes/git-migration.$.tsx 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/ui/components/git/git-non-origin-branch-banner.tsx create mode 100644 packages/insomnia/src/ui/hooks/use-git-file-issues.ts 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" > ipcRenderer.invoke('git.loadGitRepository', options), getGitBranches: options => ipcRenderer.invoke('git.getGitBranches', options), fetchGitRemoteBranches: options => ipcRenderer.invoke('git.fetchGitRemoteBranches', options), + getProjectGitFileIssues: options => ipcRenderer.invoke('git.getProjectGitFileIssues', options), validateGitRepositoryCredentials: options => ipcRenderer.invoke('git.validateGitRepositoryCredentials', options), validateGitCredentialById: options => ipcRenderer.invoke('git.validateGitCredentialById', options), gitFetchAction: options => ipcRenderer.invoke('git.gitFetchAction', options), @@ -155,6 +156,8 @@ const git: GitServiceAPI = { getGitProviderRepositories: options => ipcRenderer.invoke('git.getGitProviderRepositories', options), getGitProviderEmails: options => ipcRenderer.invoke('git.getGitProviderEmails', options), getCurrentBranchByRepositoryId: options => ipcRenderer.invoke('git.getCurrentBranchByRepositoryId', options), + getBranchRemoteInfo: options => ipcRenderer.invoke('git.getBranchRemoteInfo', options), + runAllGitRepoMigrations: () => ipcRenderer.invoke('git.runAllGitRepoMigrations'), }; const llm: LLMConfigServiceAPI = { 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..066266c521 100644 --- a/packages/insomnia/src/insomnia-data/src/models/git-repository.ts +++ b/packages/insomnia/src/insomnia-data/src/models/git-repository.ts @@ -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/workspace-meta.ts b/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts index 3002b9b19e..909877ae1e 100644 --- a/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts +++ b/packages/insomnia/src/insomnia-data/src/models/workspace-meta.ts @@ -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/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 53491d46ae..00934e9089 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -20,7 +20,14 @@ 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 type { + GitProject, + GitRemoteProviderType, + GitRepository, + Workspace, + WorkspaceMeta, + WorkspaceScope, +} from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { GitVCSOperationErrors } from '~/sync/git/git-vcs-operation-errors'; import { @@ -29,6 +36,7 @@ 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'; @@ -37,6 +45,7 @@ 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,8 +60,8 @@ 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 { repoFileWatcherRegistry } from '../sync/git/repo-file-watcher'; import { routableFSClient } from '../sync/git/routable-fs-client'; import { shallowClone } from '../sync/git/shallow-clone'; import type { AutoResolvedConflict, MergeConflict } from '../sync/types'; @@ -119,6 +128,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 @@ -176,6 +199,125 @@ async function getGitRepository({ projectId, workspaceId }: { projectId: string; 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.isGitProject(project) || + !project.gitRepositoryId || + models.project.isEmptyGitProject(project) + ) { + return []; + } + + if (gitRepositoryId && gitRepositoryId !== project.gitRepositoryId) { + return []; + } + + return mapWorkspaceFileIssues({ + issues: repoFileWatcherRegistry.getProblems(project.gitRepositoryId), + repoId: project.gitRepositoryId, + 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 +363,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 +474,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 +486,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 +495,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 +537,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 +556,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 +604,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 +680,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 +719,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); @@ -1020,6 +1191,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'); @@ -1363,6 +1541,9 @@ 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); + await database.flushChanges(flushId); return null; @@ -1383,6 +1564,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 +1610,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 +1680,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 +1693,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 +1860,7 @@ export const createNewGitBranchAction = async ({ export interface CheckoutGitBranchResult { errors?: string[]; success?: boolean; + warnings?: string[]; } export const checkoutGitBranchAction = async ({ @@ -1689,6 +1878,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 +1905,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, }; @@ -1785,6 +1989,11 @@ 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); + trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName, @@ -1876,8 +2085,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 { @@ -2019,6 +2232,7 @@ export async function fetchGitRemoteBranches({ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) { try { + await assertBranchOnOrigin('pull'); const gitRepository = await getGitRepository({ projectId, workspaceId }); invariant(gitRepository.credentialsId, 'Git Credentials ID is required'); const credentials = await services.gitCredentials.getById(gitRepository.credentialsId); @@ -2026,6 +2240,10 @@ export async function pullFromGitRemote({ projectId, workspaceId }: { projectId: 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); + trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'pull'), providerName: credentials.provider, @@ -2114,6 +2332,9 @@ export const continueMerge = async ({ commitParent, }); + // Import all YAML files from disk into the DB after merge resolution + await repoFileWatcherRegistry.importAllFiles(gitRepository._id); + const log = (await GitVCS.log({ depth: 1 })) || []; const author = log[0] ? log[0].commit.author : null; @@ -2175,6 +2396,8 @@ export const discardChangesAction = async ({ await GitVCS.discardChanges(files); + await repoFileWatcherRegistry.importAllFiles(gitRepository._id); + await services.gitRepository.update(gitRepository, { cachedGitLastCommitTime: Date.now(), }); @@ -2210,6 +2433,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; @@ -2616,6 +2841,90 @@ async function getCurrentBranchByRepositoryId({ }); } +export interface MigrationSummary { + logs: string[]; + failedProjects: { id: string; name: string }[]; +} + +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.isGitProject(p) && !models.project.isEmptyGitProject(p), + ); + + if (gitProjects.length === 0) return { logs, failedProjects }; + + // Batch-fetch all git repositories in one query instead of N individual lookups. + const repoIds = gitProjects.map(p => p.gitRepositoryId); + 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}`); + + await Promise.all( + gitProjects.map(async project => { + const gitRepository = repoById.get(project.gitRepositoryId); + 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 }); + } + }), + ); + + // 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 || !project.gitRepositoryId) { + logs.push(`${ts()} [WARN] ["${name}"] Project not found or already local — skipping`); + return; + } + + const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + if (gitRepository) { + await services.gitRepository.remove(gitRepository); + logs.push(`${ts()} [INFO] ["${name}"] Removed git repository ${project.gitRepositoryId}`); + } + + 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 }; +} + export interface GitServiceAPI { loadGitRepository: typeof loadGitRepository; getGitBranches: typeof getGitBranches; @@ -2649,6 +2958,7 @@ export interface GitServiceAPI { fetchGitRemoteBranches: typeof fetchGitRemoteBranches; validateGitRepositoryCredentials: typeof validateGitRepositoryCredentials; validateGitCredentialById: typeof validateGitCredentialById; + getProjectGitFileIssues: typeof getProjectGitFileIssues; initSignInToGitProvider: typeof initSignInToGitProvider; completeSignInToGitProvider: typeof completeSignInToGitProvider; @@ -2657,6 +2967,8 @@ export interface GitServiceAPI { getGitProviderRepositories: typeof getGitProviderRepositories; getGitProviderEmails: typeof getGitProviderEmails; listGitProviders: typeof listGitProviders; + getBranchRemoteInfo: typeof getBranchRemoteInfo; + runAllGitRepoMigrations: typeof runAllGitRepoMigrations; } export const registerGitServiceAPI = () => { @@ -2669,12 +2981,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)); @@ -2759,4 +3072,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/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index dfe873ab1f..b9883e7340 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -53,6 +53,7 @@ export type HandleChannels = | 'git.diffFileLoader' | 'git.discardChanges' | 'git.fetchGitRemoteBranches' + | 'git.getProjectGitFileIssues' | 'git.validateGitRepositoryCredentials' | 'git.validateGitCredentialById' | 'git.getGitBranches' @@ -69,7 +70,9 @@ export type HandleChannels = | 'git.pullFromGitRemote' | 'git.pushToGitRemote' | 'git.resetGitRepo' + | 'git.runAllGitRepoMigrations' | 'git.getCurrentBranchByRepositoryId' + | 'git.getBranchRemoteInfo' | 'git.stageChanges' | 'git.unstageChanges' | 'git.updateGitRepo' @@ -216,7 +219,9 @@ export type RendererOnChannels = | '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/root.tsx b/packages/insomnia/src/root.tsx index 67bfb36ecd..5f42f160bc 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -13,10 +13,13 @@ 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'; @@ -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 @@ -569,10 +585,10 @@ const Root = () => {
{errorDetailKeys.length > 0 ? errorDetailKeys.map(k => ( - - {k}: {restParams[k]} - - )) + + {k}: {restParams[k]} + + )) : 'Unknown error'}
), diff --git a/packages/insomnia/src/routes/git-migration.$.tsx b/packages/insomnia/src/routes/git-migration.$.tsx new file mode 100644 index 0000000000..742f82bd4b --- /dev/null +++ b/packages/insomnia/src/routes/git-migration.$.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; + +import { Button } from '~/basic-components/button'; +import { CopyButton } from '~/ui/components/base/copy-button'; +import { InsomniaLogo } from '~/ui/components/insomnia-icon'; +import { TrailLinesContainer } from '~/ui/components/trail-lines-container'; +import git_for_all from '~/ui/images/onboarding/git_for_all.png'; + +type MigrationStatus = 'default' | 'running' | 'completed' | 'partiallyCompleted' | 'error'; + +const MigrationView = () => { + const [status, setStatus] = useState('default'); + const [migrationLogs, setMigrationLogs] = useState([]); + const [failedProjects, setFailedProjects] = useState<{ id: string; name: string }[]>([]); + + const handleMigration = () => { + setStatus('running'); + window.main.git + .runAllGitRepoMigrations() + .then((result: { logs: string[]; failedProjects: { id: string; name: string }[] }) => { + setMigrationLogs(result.logs); + setFailedProjects(result.failedProjects); + setStatus(result.failedProjects.length > 0 ? 'partiallyCompleted' : 'completed'); + }) + .catch((err: unknown) => { + const errorMsg = err instanceof Error ? err.message : 'An unexpected error occurred.'; + setMigrationLogs(prev => [...prev, `[ERROR] ${errorMsg}`]); + setStatus('error'); + }); + }; + + const isUpdateRunning = status === 'running'; + const isUpdateCompletedSuccessfully = status === 'completed'; + const isUpdateErrored = status === 'error'; + const isUpdateCompletedWithErrors = status === 'partiallyCompleted'; + + return ( +
+
+
+

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

+ + {isUpdateCompletedSuccessfully ? ( +

+ Your file system has been successfully updated. Now you can explore all of your Insomnia files on your + local system and use git on your CLI to manage changes. +

+ ) : 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.

+

+ If the issue persists, please{' '} + + raise a support ticket. + {' '} + You may also re-install the previous version by following the steps{' '} + + here + + . +

+ + ) : ( + <> +

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

+

+ {isUpdateRunning + ? 'Note: Your data is safe and the update only takes seconds.' + : 'Note: This update does NOT change your data 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/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index ac9463fdba..83f6e32c1b 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -76,6 +76,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'; @@ -101,6 +102,10 @@ export interface InsomniaFile { hasUncommittedChanges?: boolean; hasUnpushedChanges?: boolean; gitFilePath?: string | null; + fileIssue?: { + kind: 'conflict' | 'parse-error'; + message: string; + }; } export interface ProjectLoaderData { @@ -475,10 +480,16 @@ const Component = () => { const organizationData = useOrganizationLoaderData(); const { presence } = useInsomniaEventStreamContext(); + const { issuesByWorkspaceId } = useGitFileIssues(); const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); const createNewWorkspaceFetcher = useWorkspaceNewActionFetcher(); const { billing, features } = 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)) { const load = storageRuleFetcher.load; @@ -541,6 +552,7 @@ const Component = () => { }); return { ...file, + fileIssue: file.workspace ? issuesByWorkspaceId[file.workspace._id] : undefined, loading: loadingBackendProjects.includes(file.remoteId) || (pullFileFetcher.formData?.get('backendProjectId') && @@ -919,6 +931,18 @@ const Component = () => {
) : null} + {hasProjectFileIssues ? ( +
+
+

+ + {projectFileIssuesMessage} +

+
+
+ ) : null} {isProjectInconsistent && (
@@ -1113,10 +1137,10 @@ const Component = () => { }} className={`flex aspect-square w-full flex-1 flex-col overflow-hidden rounded-md p-(--padding-md) ring-1 ring-(--hl-md) outline-hidden transition-all select-none hover:bg-(--hl-xs) hover:shadow-md hover:ring-(--hl-sm) focus:bg-(--hl-sm) focus:ring-(--hl-lg) ${item.loading ? 'animate-pulse' : ''}`} > -
+
{ {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.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 7ff409df58..9060dae2fc 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -1,6 +1,8 @@ -import { href, redirect, useRouteLoaderData } from 'react-router'; +import { href, Outlet, redirect, useRouteLoaderData } from 'react-router'; import { services } from '~/insomnia-data'; +import * as models from '~/models'; +import { GitFileIssuesProvider, useProjectGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { invariant } from '~/utils/invariant'; import type { Route } from './+types/organization.$organizationId.project.$projectId'; @@ -23,3 +25,23 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { export function useProjectLoaderData() { return useRouteLoaderData('routes/organization.$organizationId.project.$projectId'); } + +const Component = () => { + const data = useProjectLoaderData(); + const gitRepositoryId = + data && models.project.isGitProject(data.activeProject) && !models.project.isEmptyGitProject(data.activeProject) + ? data.activeProject.gitRepositoryId + : null; + const gitFileIssues = useProjectGitFileIssues({ + projectId: data?.activeProject._id, + gitRepositoryId, + }); + + return ( + + + + ); +}; + +export default Component; 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..72c983ecf3 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 @@ -32,7 +32,7 @@ export async function clientAction({ params }: Route.ClientActionArgs) { : 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 b3c75db5b7..30d20c9a9a 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 @@ -90,7 +90,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { // 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; 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..29407f4274 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'; @@ -30,7 +32,9 @@ import * as models from '~/models'; import { sortProjects } from '~/models/helpers/project'; 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 +75,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; @@ -381,9 +397,47 @@ 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 } = 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); + return (
+ + {modalText ? ( +
+ +
+

{modalText.modalTitle}

+

{modalText.summary}

+
+ +
+ ) : null} +
); }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index a5cb408675..cf4dd06da2 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -19,7 +19,6 @@ import { NoProjectView } from '~/ui/components/panes/no-project-view'; import { NoSelectedProjectView } from '~/ui/components/panes/no-selected-project-view'; import { OrganizationSelect } from '~/ui/components/project/organization-select'; import { ProjectListSidebar } from '~/ui/components/project/project-list-sidebar'; -import { OrganizationTabList } from '~/ui/components/tabs/tab-list'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; @@ -128,7 +127,6 @@ const Component = () => { - {projects.length > 0 ? : } 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 8e33a17935..7473623066 100644 --- a/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/git-vcs.test.ts @@ -659,4 +659,134 @@ First commit! 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/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..afd8e70dfb --- /dev/null +++ b/packages/insomnia/src/sync/git/git-repo-migration.ts @@ -0,0 +1,384 @@ +/** + * 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 } from '../../common/database'; +import { getInsomniaV5DataExport } from '../../common/insomnia-v5'; +import * as models from '../../models'; +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 dc2044bb19..58f5e04756 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -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 = ''; 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/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts new file mode 100644 index 0000000000..13b90cc9be --- /dev/null +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -0,0 +1,918 @@ +/** + * 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 { models, services, type Workspace, type WorkspaceMeta } 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 { canSync } from '../../models'; +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[]; +} + +/** 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(); + + // 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(); + } + + /** + * Import all YAML files in the repo directory into the DB. + * + * Called during watcher creation and after bulk git operations (clone, pull, + * merge, checkout) so the DB reflects the current disk state. + * + * 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]) => 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) && 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(), + }); + } +} + +// --------------------------------------------------------------------------- +// 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. */ +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); + } + }, + }; +} + +export const repoFileWatcherRegistry = new RepoFileWatcherRegistry(createElectronNotifier()); 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/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 414d3f951a..f365f628bf 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 @@ -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'; @@ -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)) { @@ -505,6 +524,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject closeGitProjectStagingModalRef.current = showModal(GitProjectStagingModal, { mode: StagingModalModes.default, callbackRef: gitProjectStagingModalCallbackPropsRef, + isNonOriginBranch, }); }, }, @@ -512,14 +532,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 }), }, { @@ -532,7 +552,7 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject { id: 'fetch', icon: 'refresh', - isDisabled: false, + isDisabled: isNonOriginBranch, label: 'Fetch', action: () => { setOperationError(null); @@ -605,6 +625,13 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject return ( <> + {isNonOriginBranch && branchRemoteInfo?.trackingRemote && currentBranch && ( + + )} {operationError && (
diff --git a/packages/insomnia/src/ui/components/git/git-non-origin-branch-banner.tsx b/packages/insomnia/src/ui/components/git/git-non-origin-branch-banner.tsx new file mode 100644 index 0000000000..a0ff00bca3 --- /dev/null +++ b/packages/insomnia/src/ui/components/git/git-non-origin-branch-banner.tsx @@ -0,0 +1,84 @@ +import type { FC } from 'react'; +import { Button, Dialog, DialogTrigger, Heading, Popover } from 'react-aria-components'; + +import { CopyButton } from '../base/copy-button'; +import { Icon } from '../icon'; + +interface Props { + trackingRemote: string; + remoteUrl: string | null; + currentBranch: string; +} + +export const GitNonOriginBranchBanner: FC = ({ 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/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index 068dfbddba..fed6d35181 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 @@ -124,6 +124,7 @@ interface GeneratedCommitsFormProps { gitRepository?: GitRepository | null; selectedCredential?: GitCredentials | null; selectedProvider?: GitProviderOption | null; + isNonOriginBranch?: boolean; } interface FileItem { @@ -288,6 +289,7 @@ const GeneratedCommitsForm: FC = ({ gitRepository, selectedCredential, selectedProvider, + isNonOriginBranch, }) => { const commitsFetcher = useGitProjectCommitsActionFetcher(); const committingActionRef = useRef<'commit' | 'commit-push' | null>(null); @@ -523,7 +525,7 @@ const GeneratedCommitsForm: FC = ({ - + {isNonOriginBranch ? ( + + + + Push action is not allowed for branches on non-origin remotes + + + ) : ( + + )}
)} {operationError && selectedProvider && isGitRepoLoadAuthHttp40Error([operationError]) ? ( @@ -1022,6 +1050,45 @@ 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} + + +
+ +
); }; @@ -1034,6 +1101,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 +1125,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 +1150,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); @@ -1373,6 +1443,7 @@ const OriginalGitProjectStagingModal: FC< gitRepository={gitRepository} selectedCredential={selectedCredential} selectedProvider={selectedProvider} + isNonOriginBranch={isNonOriginBranch} /> )} @@ -1391,6 +1462,7 @@ const OriginalGitProjectStagingModal: FC< gitRepository={gitRepository} selectedCredential={selectedCredential} selectedProvider={selectedProvider} + isNonOriginBranch={isNonOriginBranch} /> )}
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..ae5b9c35ca 100644 --- a/packages/insomnia/src/ui/components/project/project-settings-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-settings-form.tsx @@ -156,6 +156,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 +194,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 +210,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 +311,52 @@ export const ProjectSettingsForm: FC = ({ /> )} + {showRepoPath && ( + <> +
+ +
+ Can be used to manage file changes with git.{' '} + + Learn more ↗ + +
+
+ + {repoPath} + + + +
+
+ + )} + {showGitConnectionInfo && ( <> diff --git a/packages/insomnia/src/ui/components/tabs/tab-list.tsx b/packages/insomnia/src/ui/components/tabs/tab-list.tsx index 4a1a601719..3e93ae906c 100644 --- a/packages/insomnia/src/ui/components/tabs/tab-list.tsx +++ b/packages/insomnia/src/ui/components/tabs/tab-list.tsx @@ -15,6 +15,7 @@ import { useParams } from 'react-router'; import type { MockRoute, Request } from '~/insomnia-data'; import { 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'; @@ -50,6 +51,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' const newRequestFetcher = useRequestNewActionFetcher(); const { organizationId, projectId } = useParams(); + const gitFileIssues = useGitFileIssues(); useInsomniaTab({ organizationId: organizationId || '' }); @@ -74,6 +76,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' } = useInsomniaTabContext(); const { tabList, activeTabId } = currentOrgTabs; + const issuesByWorkspaceId = gitFileIssues.issuesByWorkspaceId; // Register keyboard shortcuts for tab navigation useDocBodyKeyboardShortcuts({ @@ -394,9 +397,10 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' className="flex h-[41px] w-fit" dragAndDropHooks={dragAndDropHooks} items={tabList} + dependencies={[issuesByWorkspaceId]} ref={tabListInnerRef} > - {item => } + {item => }
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 = ({