Repository: stateful/vscode-runme
Pull request #713 (2023-07-24 15:21:44)

Custom app config relies on base domains now
Plus additional safe-guards and tests.

Btw, merging this PR will effectively default every app user to production.


Patch:
diff --git a/package.json b/package.json
index e33cac41..452062e6 100644
--- a/package.json
+++ b/package.json
@@ -474,10 +474,11 @@
             "default": false,
             "markdownDescription": "Whether to use full path to integrated runme executable when running CLI commands. This is mostly useful for development purposes."
           },
-          "runme.app.apiUrl": {
+          "runme.app.baseDomain": {
             "type": "string",
             "default": "",
-            "markdownDescription": "App API URL"
+            "scope": "window",
+            "markdownDescription": "Base domain to be use for Runme app"
           },
           "runme.app.enableShare": {
             "type": "boolean",
diff --git a/src/extension/api/client.ts b/src/extension/api/client.ts
index 331f6930..a6c83a29 100644
--- a/src/extension/api/client.ts
+++ b/src/extension/api/client.ts
@@ -1,8 +1,9 @@
 import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
 import fetch from 'cross-fetch'
 import { setContext } from '@apollo/client/link/context'
+import { Uri } from 'vscode'
 
-import { getRunmeApiUrl } from '../../utils/configuration'
+import { getRunmeAppUrl } from '../../utils/configuration'
 
 export function InitializeClient({
   uri,
@@ -19,7 +20,8 @@ export function InitializeClient({
       },
     }
   })
-  const link = new HttpLink({ fetch, uri: uri || `${getRunmeApiUrl()}/graphql` })
+  const appApiUrl = Uri.joinPath(Uri.parse(getRunmeAppUrl(['api']), true), '/graphql').toString()
+  const link = new HttpLink({ fetch, uri: uri || appApiUrl })
   const client = new ApolloClient({
     cache: new InMemoryCache(),
     credentials: 'include',
diff --git a/src/extension/panels/panel.ts b/src/extension/panels/panel.ts
index ef876157..4af3a78e 100644
--- a/src/extension/panels/panel.ts
+++ b/src/extension/panels/panel.ts
@@ -6,6 +6,7 @@ import { Observable, Subscription } from 'rxjs'
 import { fetchStaticHtml, getAuthSession } from '../utils'
 import { IAppToken, RunmeService } from '../services/runme'
 import { SyncSchemaBus } from '../../types'
+import { getRunmeAppUrl } from '../../utils/configuration'
 
 export type DefaultUx = 'panels'
 export interface InitPayload {
@@ -16,7 +17,7 @@ export interface InitPayload {
 }
 
 class PanelBase extends TelemetryViewProvider implements Disposable {
-  protected readonly appUrl: string = 'http://localhost:4001'
+  protected readonly appUrl: string = getRunmeAppUrl(['app'])
   protected readonly defaultUx: DefaultUx = 'panels'
 
   constructor(protected readonly context: ExtensionContext) {
diff --git a/src/extension/services/runme.ts b/src/extension/services/runme.ts
index 5fd08b6a..7493f60c 100644
--- a/src/extension/services/runme.ts
+++ b/src/extension/services/runme.ts
@@ -1,6 +1,7 @@
 import fetch from 'cross-fetch'
+import { Uri } from 'vscode'
 
-import { getRunmeApiUrl } from '../../utils/configuration'
+import { getRunmeAppUrl } from '../../utils/configuration'
 
 export interface IUserToken {
   token: string
@@ -12,13 +13,16 @@ export interface IAppToken {
 
 export class RunmeService {
   protected githubAccessToken: string
+  private readonly apiBase = Uri.parse(getRunmeAppUrl(['api']), true)
+
   constructor({ githubAccessToken }: { githubAccessToken: string }) {
     this.githubAccessToken = githubAccessToken
   }
   async getUserToken(): Promise<IUserToken> {
+    const userAuthEndpoint = Uri.joinPath(this.apiBase, '/auth/vscode').toString()
     let response
     try {
-      response = await fetch(`${getRunmeApiUrl()}/auth/vscode`, {
+      response = await fetch(userAuthEndpoint, {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
@@ -40,9 +44,10 @@ export class RunmeService {
     return response.json()
   }
   async getAppToken(userToken: IUserToken): Promise<IAppToken> {
+    const appAuthEndpoint = Uri.joinPath(this.apiBase, '/auth/user/app').toString()
     let response
     try {
-      response = await fetch(`${getRunmeApiUrl()}/auth/user/app`, {
+      response = await fetch(appAuthEndpoint, {
         method: 'POST',
         headers: {
           Authorization: `Bearer ${userToken.token}`,
diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts
index ffb51e0f..d24dae6f 100644
--- a/src/utils/configuration.ts
+++ b/src/utils/configuration.ts
@@ -19,7 +19,13 @@ const APP_SECTION_NAME = 'runme.app'
 export const OpenViewInEditorAction = z.enum(['split', 'toggle'])
 export const DEFAULT_TLS_DIR = path.join(os.tmpdir(), 'runme', uuidv4(), 'tls')
 const DEFAULT_WORKSPACE_FILE_ORDER = ['.env.local', '.env']
-const DEFAULT_RUNME_APP_API_URL = 'https://api.runme.dev/graphql'
+const DEFAULT_RUNME_APP_API_URL = 'https://api.runme.dev'
+const DEFAULT_RUNME_BASE_DOMAIN = 'runme.dev'
+const APP_LOOPBACKS = ['127.0.0.1', 'localhost']
+const APP_LOOPBACK_MAPPING = new Map<string, string>([
+  ['api.', ':4000'],
+  ['app.', ':4001'],
+])
 
 type NotebookTerminalValue = keyof typeof configurationSchema.notebookTerminal
 
@@ -54,6 +60,7 @@ const configurationSchema = {
   },
   app: {
     apiUrl: z.string().default(DEFAULT_RUNME_APP_API_URL),
+    baseDomain: z.string().default(DEFAULT_RUNME_BASE_DOMAIN),
     enableShare: z.boolean().default(false),
   },
 }
@@ -252,8 +259,32 @@ const getCLIUseIntegratedRunme = (): boolean => {
   return getCLIConfigurationValue('useIntegratedRunme', false)
 }
 
-const getRunmeApiUrl = (): string => {
-  return getCloudConfigurationValue('apiUrl', DEFAULT_RUNME_APP_API_URL)
+const getRunmeAppUrl = (subdomain: string[]): string => {
+  const base = getRunmeBaseDomain()
+  const isLoopback = APP_LOOPBACKS.map((host) => base.includes(host)).reduce((p, c) => p || c)
+  const scheme = isLoopback ? 'http' : 'https'
+
+  let sub = subdomain.join('.')
+  if (sub.length > 0) {
+    sub = `${sub}.`
+  }
+
+  let port = ''
+  if (isLoopback && sub.length > 0) {
+    port = APP_LOOPBACK_MAPPING.get(sub) ?? ''
+    sub = ''
+  }
+
+  const url = Uri.parse(`${scheme}://${sub}${base}${port}`, true)
+  return url.toString()
+}
+
+const getRunmeBaseDomain = (): string => {
+  const baseDomain = getCloudConfigurationValue('baseDomain', DEFAULT_RUNME_BASE_DOMAIN)
+  if (baseDomain.length === 0) {
+    return DEFAULT_RUNME_BASE_DOMAIN
+  }
+  return baseDomain
 }
 
 const isRunmeApiEnabled = (): boolean => {
@@ -279,6 +310,6 @@ export {
   getEnvWorkspaceFileOrder,
   getEnvLoadWorkspaceFiles,
   getCLIUseIntegratedRunme,
-  getRunmeApiUrl,
+  getRunmeAppUrl,
   isRunmeApiEnabled,
 }


Test patch:
diff --git a/tests/extension/configuration.test.ts b/tests/extension/configuration.test.ts
index f1b1ec93..3e6a519f 100644
--- a/tests/extension/configuration.test.ts
+++ b/tests/extension/configuration.test.ts
@@ -4,6 +4,7 @@ import { suite, test, expect, vi, beforeEach, afterEach } from 'vitest'
 import { Uri, workspace } from 'vscode'
 
 import {
+  getRunmeAppUrl,
   getPortNumber,
   enableServerLogs,
   getBinaryPath,
@@ -29,11 +30,13 @@ const SETTINGS_MOCK:
         binaryPath: string | undefined
         enableLogger: string | boolean | undefined
         tlsDir: string | undefined
+        baseDomain: string | undefined
     } = {
     port: undefined,
     binaryPath: undefined,
     enableLogger: undefined,
-    tlsDir: undefined
+    tlsDir: undefined,
+    baseDomain: undefined,
 }
 
 beforeEach(() => {
@@ -82,7 +85,7 @@ function platformPathMocks(platform: path.PlatformPath) {
 
 afterEach(() => {
     Object.keys(SETTINGS_MOCK).forEach(key => {
-        SETTINGS_MOCK[key] = undefined
+          SETTINGS_MOCK[key] = undefined
     })
 })
 
@@ -97,34 +100,34 @@ suite('Configuration', () => {
       expect(fontSize).toBeUndefined()
     })
 
-    test('Should default to a valid port number', () => {
+    test('should default to a valid port number', () => {
         const portNumber = getPortNumber()
         expect(portNumber).toStrictEqual(7863)
     })
 
-    test('Should use a valid specified port number', () => {
+    test('should use a valid specified port number', () => {
         const portNumber = getPortNumber()
         expect(portNumber).toStrictEqual(SERVER_PORT)
     })
 
-    test('Should disable server logs with an invalid value', () => {
+    test('should disable server logs with an invalid value', () => {
       SETTINGS_MOCK.enableLogger = undefined
       const path = enableServerLogs()
       expect(path).toBeFalsy()
     })
 
-    test('Should disable server logs with an invalid string', () => {
+    test('should disable server logs with an invalid string', () => {
         SETTINGS_MOCK.enableLogger = 'true'
         const path = enableServerLogs()
         expect(path).toBeFalsy()
     })
 
-    test('Should get default TLS dir by default', () => {
+    test('should get default TLS dir by default', () => {
       SETTINGS_MOCK.tlsDir = undefined
       expect(getTLSDir()).toBe(DEFAULT_TLS_DIR)
     })
 
-    test('Should get set TLS dir if set', () => {
+    test('should get set TLS dir if set', () => {
       SETTINGS_MOCK.tlsDir = '/tmp/runme/tls'
       expect(getTLSDir()).toBe('/tmp/runme/tls')
     })
@@ -160,12 +163,12 @@ suite('Configuration', () => {
         platformPathMocks(path.posix)
       })
 
-      test('Should default to a valid binaryPath', () => {
+      test('should default to a valid binaryPath', () => {
           const binary = getBinaryPath(Uri.file(FAKE_UNIX_EXT_PATH), 'linux')
           expect(binary.fsPath).toStrictEqual('/Users/user/.vscode/extension/stateful.runme/bin/runme')
       })
 
-      test('Should default to a valid relative binaryPath when specified', () => {
+      test('should default to a valid relative binaryPath when specified', () => {
           SETTINGS_MOCK.binaryPath = 'newBin'
           // @ts-expect-error
           workspace.workspaceFolders = [{ uri: Uri.file('/Users/user/Projects/project') }]
@@ -173,13 +176,13 @@ suite('Configuration', () => {
           expect(binary.fsPath).toStrictEqual('/Users/user/Projects/project/newBin')
       })
 
-      test('Should default to a valid absolute binaryPath when specified', () => {
+      test('should default to a valid absolute binaryPath when specified', () => {
         SETTINGS_MOCK.binaryPath = '/opt/homebrew/bin/runme'
         const binary = getBinaryPath(Uri.file(FAKE_UNIX_EXT_PATH), 'linux')
         expect(binary.fsPath).toStrictEqual('/opt/homebrew/bin/runme')
       })
 
-      test('Should use runme for non-windows platforms', () => {
+      test('should use runme for non-windows platforms', () => {
           SETTINGS_MOCK.binaryPath = '/opt/homebrew/bin/runme'
           const binary = getBinaryPath(Uri.file(FAKE_UNIX_EXT_PATH), 'darwin')
           expect(binary.fsPath).toStrictEqual('/opt/homebrew/bin/runme')
@@ -191,21 +194,21 @@ suite('Configuration', () => {
         platformPathMocks(path.win32)
       })
 
-      test('Should default to a valid binaryPath exe on windows', () => {
+      test('should default to a valid binaryPath exe on windows', () => {
         const binary = getBinaryPath(Uri.file(FAKE_WIN_EXT_PATH), 'win')
         expect(binary.fsPath).toStrictEqual(
           'c:\\Users\\.vscode\\extensions\\stateful.runme\\bin\\runme.exe'
         )
       })
 
-      test('Should use runme.exe for windows platforms with absolute path', () => {
+      test('should use runme.exe for windows platforms with absolute path', () => {
         SETTINGS_MOCK.binaryPath = 'C:\\custom\\path\\to\\bin\\runme.exe'
 
         const binary = getBinaryPath(Uri.file(FAKE_WIN_EXT_PATH), 'win32')
         expect(binary.fsPath).toStrictEqual('c:\\custom\\path\\to\\bin\\runme.exe')
       })
 
-      test('Should use runme.exe for windows platforms with relative path', () => {
+      test('should use runme.exe for windows platforms with relative path', () => {
           SETTINGS_MOCK.binaryPath = 'newBin.exe'
           // @ts-expect-error
           workspace.workspaceFolders = [{ uri: Uri.file('c:\\Users\\Projects\\project') }]
@@ -213,4 +216,44 @@ suite('Configuration', () => {
           expect(binary.fsPath).toStrictEqual('c:\\Users\\Projects\\project\\newBin.exe')
       })
     })
+
+    suite('app domain resolution', () => {
+      test('should return URL for api with subdomain', () => {
+        const url = getRunmeAppUrl(['api'])
+        expect(url).toStrictEqual('https://api.runme.dev/')
+      })
+
+      test('should return URL for api with deep subdomain', () => {
+        const url = getRunmeAppUrl(['l4', 'l3', 'api'])
+        expect(url).toStrictEqual('https://l4.l3.api.runme.dev/')
+      })
+
+      test('should return URL without subdomain', () => {
+        const url = getRunmeAppUrl([])
+        expect(url).toStrictEqual('https://runme.dev/')
+      })
+
+      test('should return URL without subdomain', () => {
+        const url = getRunmeAppUrl([])
+        expect(url).toStrictEqual('https://runme.dev/')
+      })
+
+      test('should allow api URL with http for 127.0.0.1', async () => {
+        SETTINGS_MOCK.baseDomain = '127.0.0.1'
+        const url = getRunmeAppUrl(['api'])
+        expect(url).toStrictEqual('http://127.0.0.1:4000/')
+      })
+
+      test('should allow app URL with http for localhost', async () => {
+        SETTINGS_MOCK.baseDomain = 'localhost'
+        const url = getRunmeAppUrl(['app'])
+        expect(url).toStrictEqual('http://localhost:4001/')
+      })
+
+      test('should allow app URL with http for localhost without subdomain', async () => {
+        SETTINGS_MOCK.baseDomain = 'localhost'
+        const url = getRunmeAppUrl([])
+        expect(url).toStrictEqual('http://localhost/')
+      })
+    })
 })
diff --git a/tests/extension/panels/panel.test.ts b/tests/extension/panels/panel.test.ts
index d421f483..a9ea48ef 100644
--- a/tests/extension/panels/panel.test.ts
+++ b/tests/extension/panels/panel.test.ts
@@ -1,4 +1,4 @@
-import { suite, test, expect, vi } from 'vitest'
+import { suite, test, expect, vi, afterEach, beforeEach } from 'vitest'
 import { Uri, type ExtensionContext, type WebviewView } from 'vscode'
 
 import Panel from '../../../src/extension/panels/panel'
@@ -9,6 +9,37 @@ vi.mock('vscode-telemetry')
 vi.mock('../../../src/extension/grpc/client', () => ({}))
 vi.mock('../../../src/extension/runner', () => ({}))
 
+const SETTINGS_MOCK:
+    {
+        baseDomain: string | undefined
+    } = {
+    baseDomain: undefined,
+}
+
+beforeEach(() => {
+  vi.mock('vscode', async () => {
+    const mocked = await import('../../../__mocks__/vscode')
+
+    return ({
+      ...mocked,
+      workspace: {
+        getConfiguration: vi.fn().mockReturnValue({
+          get: (configurationName) => {
+            return SETTINGS_MOCK[configurationName]
+          }
+        }),
+      },
+      Uri: mocked.Uri,
+    })
+  })
+})
+
+afterEach(() => {
+    Object.keys(SETTINGS_MOCK).forEach(key => {
+          SETTINGS_MOCK[key] = undefined
+    })
+})
+
 vi.mock('../../../src/extension/utils', () => {
   return {
     fetchStaticHtml: vi.fn().mockResolvedValue({
@@ -38,7 +69,7 @@ suite('Panel', () => {
       defaultUx: 'panels',
     })
 
-    expect(hydrated).toContain('<base href="http://localhost:4001">')
+    expect(hydrated).toContain('<base href="https://app.runme.dev/">')
     expect(hydrated).toContain(
       '{"appToken":"a.b.c","ide":"code","panelId":"main","defaultUx":"panels"}'
     )
@@ -50,7 +81,7 @@ suite('Panel', () => {
 
     await p.resolveWebviewTelemetryView(view)
 
-    expect(view.webview.html).toContain('<base href="http://localhost:4001">')
+    expect(view.webview.html).toContain('<base href="https://app.runme.dev/">')
     expect(view.webview.html).toContain(
       '{"ide":"code","panelId":"testing","appToken":"webview.auth.token","defaultUx":"panels"}'
     )
@@ -62,7 +93,33 @@ suite('Panel', () => {
 
     await p.resolveWebviewTelemetryView(view)
 
-    expect(view.webview.html).toContain('<base href="http://localhost:4001">')
+    expect(view.webview.html).toContain('<base href="https://app.runme.dev/">')
+    expect(view.webview.html).toContain(
+      '{"ide":"code","panelId":"testing","appToken":"EMPTY","defaultUx":"panels"}'
+    )
+  })
+
+  test('resovles authed localhost', async () => {
+    SETTINGS_MOCK.baseDomain = 'localhost'
+    const p = new Panel(contextMock, 'testing')
+    p.getAppToken = vi.fn().mockResolvedValue({ token: 'webview.auth.token' })
+
+    await p.resolveWebviewTelemetryView(view)
+
+    expect(view.webview.html).toContain('<base href="http://localhost:4001/">')
+    expect(view.webview.html).toContain(
+      '{"ide":"code","panelId":"testing","appToken":"webview.auth.token","defaultUx":"panels"}'
+    )
+  })
+
+  test('resovles unauthed localhost', async () => {
+    SETTINGS_MOCK.baseDomain = 'localhost'
+    const p = new Panel(contextMock, 'testing')
+    p.getAppToken = vi.fn().mockResolvedValue(null)
+
+    await p.resolveWebviewTelemetryView(view)
+
+    expect(view.webview.html).toContain('<base href="http://localhost:4001/">')
     expect(view.webview.html).toContain(
       '{"ide":"code","panelId":"testing","appToken":"EMPTY","defaultUx":"panels"}'
     )