Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"lint": "eslint --cache .",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"release": "pnpm test && bumpp -r -x \"pnpm run changelog\" --all",
"test": "pnpm lint",
"test": "pnpm lint && pnpm test:unit",
"test:unit": "pnpm -r --filter \"./packages/*\" run test:unit",
"docs": "nuxi dev docs",
"docs:build": "CI=true nuxi generate docs",
"typecheck": "vue-tsc --noEmit",
Expand Down
6 changes: 3 additions & 3 deletions packages/devtools-kit/src/_types/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export interface ServerFunctions {

// Options
getOptions: <T extends keyof NuxtDevToolsOptions>(tab: T) => Promise<NuxtDevToolsOptions[T]>
updateOptions: <T extends keyof NuxtDevToolsOptions>(tab: T, settings: Partial<NuxtDevToolsOptions[T]>) => Promise<void>
clearOptions: () => Promise<void>
updateOptions: <T extends keyof NuxtDevToolsOptions>(token: string, tab: T, settings: Partial<NuxtDevToolsOptions[T]>) => Promise<void>
clearOptions: (token: string) => Promise<void>

// Updates
checkForUpdateFor: (name: string) => Promise<PackageUpdateInfo | undefined>
Expand Down Expand Up @@ -68,7 +68,7 @@ export interface ServerFunctions {
telemetryEvent: (payload: object, immediate?: boolean) => void
customTabAction: (name: string, action: number) => Promise<boolean>
runWizard: <T extends WizardActions>(token: string, name: T, ...args: GetWizardArgs<T>) => Promise<void>
openInEditor: (filepath: string) => Promise<boolean>
openInEditor: (token: string, filepath: string) => Promise<boolean>
restartNuxt: (token: string, hard?: boolean) => Promise<void>
installNuxtModule: (token: string, name: string, dry?: boolean) => Promise<InstallModuleReturn>
uninstallNuxtModule: (token: string, name: string, dry?: boolean) => Promise<InstallModuleReturn>
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools/client/composables/editor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useClipboard } from '@vueuse/core'
import { useRouter } from '#app/composables/router'
import { devtoolsUiShowNotification } from '#imports'
import { ensureDevAuthToken } from './dev-auth'
import { rpc } from './rpc'
import { useServerConfig, useVirtualFiles } from './state'
import { useCurrentVirtualFile } from './state-routes'
Expand Down Expand Up @@ -33,7 +34,7 @@ export function useOpenInEditor() {
router.push('/modules/virtual-files')
}
else {
await rpc.openInEditor(filepath)
await rpc.openInEditor(await ensureDevAuthToken(), filepath)
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion packages/devtools/client/composables/storage-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { NuxtDevToolsOptions } from '../../types'
import { watchDebounced } from '@vueuse/core'
import { reactive, toRefs } from 'vue'
import { defaultTabOptions } from '../../src/constant'
import { devAuthToken, isDevAuthed } from './dev-auth'
import { rpc } from './rpc'

const cache = new Map<string, any>()
Expand All @@ -22,7 +23,12 @@ function getTabOptions<T extends keyof NuxtDevToolsOptions>(tab: T): ToRefs<Nuxt
watchDebounced(
source,
async (options) => {
rpc.updateOptions(tab, options)
// Persisting options writes to disk and is a token-gated action.
// Only persist when the session is already authenticated, and never
// trigger an auth prompt from this passive watcher.
if (!isDevAuthed.value || !devAuthToken.value)
return
rpc.updateOptions(devAuthToken.value, tab, options)
},
{ deep: true, flush: 'post', debounce: 500, maxWait: 1000 },
)
Expand Down
39 changes: 30 additions & 9 deletions packages/devtools/client/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { watchEffect } from 'vue'
import { definePageMeta } from '#imports'
import { useClient } from '~/composables/client'
import { ensureDevAuthToken, isDevAuthed } from '~/composables/dev-auth'
import { rpc } from '~/composables/rpc'
import { getCategorizedTabs, useAllTabs } from '~/composables/state-tabs'
import { telemetryEnabled } from '~/composables/telemetry'
Expand Down Expand Up @@ -62,28 +63,48 @@ const MinimizeInactiveOptions = [

const categories = getCategorizedTabs(useAllTabs())

// Persisting settings is a token-gated action (see storage-options watcher).
// When the user deliberately edits a setting on this page, ensure the session
// is authenticated once so the change is actually written to disk.
let authEnsured = false
async function onEdit() {
if (authEnsured || isDevAuthed.value)
return
authEnsured = true
try {
await ensureDevAuthToken()
}
catch {
authEnsured = false
}
}

function toggleTab(name: string, v?: boolean) {
onEdit()
if (v)
hiddenTabs.value = hiddenTabs.value.filter(i => i !== name)
else
hiddenTabs.value.push(name)
}

function toggleTabCategory(name: string, v?: boolean) {
onEdit()
if (v)
hiddenTabCategories.value = hiddenTabCategories.value.filter(i => i !== name)
else
hiddenTabCategories.value.push(name)
}

function togglePinTab(name: string) {
onEdit()
if (pinnedTabs.value.includes(name))
pinnedTabs.value = pinnedTabs.value.filter(i => i !== name)
else
pinnedTabs.value.push(name)
}

function pinMove(name: string, delta: number) {
onEdit()
const index = pinnedTabs.value.indexOf(name)
if (index === -1)
return
Expand All @@ -105,7 +126,7 @@ async function clearOptions() {
if (key.startsWith('nuxt-devtools-'))
localStorage.removeItem(key)
})
await rpc.clearOptions()
await rpc.clearOptions(await ensureDevAuthToken())
client.value?.app?.reload?.()
window.location.reload()
}
Expand Down Expand Up @@ -200,18 +221,18 @@ watchEffect(() => {
</div>
<div mx--2 my1 h-1px border="b base" op75 />
<p>UI Scale</p>
<NSelect v-model="scale" n="primary">
<NSelect v-model="scale" n="primary" @update:model-value="onEdit">
<option v-for="i of scaleOptions" :key="i[0]" :value="i[1]">
{{ i[0] }}
</option>
</NSelect>
<div mx--2 my1 h-1px border="b base" op75 />
<NCheckbox v-model="sidebarExpanded" n-primary>
<NCheckbox v-model="sidebarExpanded" n-primary @update:model-value="onEdit">
<span>
Expand Sidebar
</span>
</NCheckbox>
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary @update:model-value="onEdit">
<span>
Scrollable Sidebar
</span>
Expand All @@ -222,24 +243,24 @@ watchEffect(() => {
Features
</h3>
<NCard p4 flex="~ col gap-2">
<NCheckbox v-model="interactionCloseOnOutsideClick" n-primary>
<NCheckbox v-model="interactionCloseOnOutsideClick" n-primary @update:model-value="onEdit">
<span>Close DevTools when clicking outside</span>
</NCheckbox>
<!-- <NCheckbox v-model="showExperimentalFeatures" n-primary>
<span>Show experimental features</span>
</NCheckbox> -->
<NCheckbox v-model="showHelpButtons" n-primary>
<NCheckbox v-model="showHelpButtons" n-primary @update:model-value="onEdit">
<span>Show help buttons</span>
</NCheckbox>

<NCheckbox v-model="showPanel" n-primary>
<NCheckbox v-model="showPanel" n-primary @update:model-value="onEdit">
<span>Show the floating panel</span>
</NCheckbox>

<div mx--2 my1 h-1px border="b base" op75 />

<p>Minimize floating panel on inactive</p>
<NSelect v-model.number="minimizePanelInactive" n-primary>
<NSelect v-model.number="minimizePanelInactive" n-primary @update:model-value="onEdit">
<option v-for="i of MinimizeInactiveOptions" :key="i[0]" :value="i[1]">
{{ i[0] }}
</option>
Expand All @@ -248,7 +269,7 @@ watchEffect(() => {
<div mx--2 my1 h-1px border="b base" op75 />

<p>Open In Editor</p>
<NSelect v-model="openInEditor" n-primary>
<NSelect v-model="openInEditor" n-primary @update:model-value="onEdit">
<option v-for="i of editorOptions" :key="i[0]" :value="i[1]">
{{ i[0] }}
</option>
Expand Down
5 changes: 3 additions & 2 deletions packages/devtools/client/plugins/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NuxtDevtoolsHostClient } from '@nuxt/devtools-kit/types'
import { triggerRef } from 'vue'
import { defineNuxtPlugin, useRouter } from '#imports'
import { useClient } from '../composables/client'
import { ensureDevAuthToken } from '../composables/dev-auth'
import { rpc } from '../composables/rpc'

export default defineNuxtPlugin(() => {
Expand All @@ -13,8 +14,8 @@ export default defineNuxtPlugin(() => {
client.value.revision.value += 1
}

function onInspectorClick(path: string) {
rpc.openInEditor(path)
async function onInspectorClick(path: string) {
rpc.openInEditor(await ensureDevAuthToken(), path)
}

function setupClient(_client: NuxtDevtoolsHostClient) {
Expand Down
4 changes: 3 additions & 1 deletion packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"dev:playground": "pnpm build && nuxi dev playground",
"dev:prepare": "pnpm run stub && nuxi prepare client",
"prepare": "tsx scripts/prepare.ts",
"prepack": "pnpm build"
"prepack": "pnpm build",
"test:unit": "vitest run"
},
"peerDependencies": {
"@vitejs/devtools": "*",
Expand Down Expand Up @@ -139,6 +140,7 @@
"vanilla-jsoneditor": "catalog:frontend",
"vis-data": "catalog:frontend",
"vis-network": "catalog:frontend",
"vitest": "catalog:cli",
"vue-tsc": "catalog:cli",
"vue-virtual-scroller": "catalog:frontend"
}
Expand Down
9 changes: 5 additions & 4 deletions packages/devtools/src/server-rpc/general.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ModuleOptions, NuxtLayout } from '@nuxt/schema'
import type { Component, NuxtApp, NuxtPage } from 'nuxt/schema'
import type { Component, NuxtApp, NuxtOptions, NuxtPage } from 'nuxt/schema'
import type { Import, Unimport } from 'unimport'
import type { AutoImportsWithMetadata, HookInfo, NuxtDevtoolsServerContext, ServerDebugContext, ServerFunctions } from '../types'
import { existsSync } from 'node:fs'
Expand Down Expand Up @@ -86,8 +86,8 @@ export function setupGeneralRPC({
})

return {
getServerConfig() {
return nuxt.options
getServerConfig(): NuxtOptions {
return nuxt.options as unknown as NuxtOptions
},
async getServerDebugContext() {
if (!nuxt._debug)
Expand Down Expand Up @@ -191,7 +191,8 @@ export function setupGeneralRPC({
getServerHooks(): HookInfo[] {
return Object.values(serverHooks)
},
async openInEditor(input: string): Promise<boolean> {
async openInEditor(token: string, input: string): Promise<boolean> {
await ensureDevAuthToken(token)
if (input.startsWith('./') || !ABSOLUTE_PATH_RE.test(input))
input = resolve(process.cwd(), input)

Expand Down
8 changes: 5 additions & 3 deletions packages/devtools/src/server-rpc/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function getOptions() {
return options
}

export function setupOptionsRPC({ nuxt }: NuxtDevtoolsServerContext) {
export function setupOptionsRPC({ nuxt, ensureDevAuthToken }: NuxtDevtoolsServerContext) {
async function getOptions<T extends keyof NuxtDevToolsOptions>(tab: T): Promise<NuxtDevToolsOptions[T]> {
if (!options || options[tab]) {
options = defaultTabOptions
Expand All @@ -28,15 +28,17 @@ export function setupOptionsRPC({ nuxt }: NuxtDevtoolsServerContext) {

getOptions('ui')

async function clearOptions() {
async function clearOptions(token: string) {
await ensureDevAuthToken(token)
options = undefined
await clearLocalOptions({
root: nuxt.options.rootDir,
})
}

return {
async updateOptions(tab, _settings) {
async updateOptions(token, tab, _settings) {
await ensureDevAuthToken(token)
const settings = await getOptions(tab)
Object.assign(settings, _settings)
await writeLocalOptions(
Expand Down
99 changes: 99 additions & 0 deletions packages/devtools/test/server-rpc-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHooks } from 'hookable'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setupGeneralRPC } from '../src/server-rpc/general'
import { setupOptionsRPC } from '../src/server-rpc/options'

const VALID_TOKEN = 'valid-token'

// Mutating RPC methods must gate on the dev auth token, exactly like the rest
// of the RPC surface (restartNuxt, storage writes, npm, terminals, ...). These
// tests assert the guard runs before any side effect.
function createAuthStub() {
return vi.fn(async (token: string) => {
if (token !== VALID_TOKEN)
throw new Error('[Nuxt DevTools] Invalid dev auth token.')
})
}

function createNuxtStub(rootDir: string) {
const hooks = createHooks()
return {
hooks,
hook: hooks.hook.bind(hooks),
callHook: hooks.callHook.bind(hooks),
options: { rootDir },
} as any
}

describe('options rpc auth', () => {
let rootDir: string

beforeEach(async () => {
rootDir = await mkdtemp(join(tmpdir(), 'nuxt-devtools-test-'))
// sandbox where local options are persisted (see getHomeDir)
process.env.XDG_CONFIG_HOME = rootDir
})

it('updateOptions rejects an invalid token', async () => {
const ensureDevAuthToken = createAuthStub()
const rpc = setupOptionsRPC({ nuxt: createNuxtStub(rootDir), ensureDevAuthToken } as any)

await expect(rpc.updateOptions('invalid', 'behavior', { openInEditor: 'malicious' }))
.rejects
.toThrow(/invalid dev auth token/i)
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
})

it('updateOptions proceeds with a valid token', async () => {
const ensureDevAuthToken = createAuthStub()
const rpc = setupOptionsRPC({ nuxt: createNuxtStub(rootDir), ensureDevAuthToken } as any)

await expect(rpc.updateOptions(VALID_TOKEN, 'ui', { scale: 2 })).resolves.toBeUndefined()
expect(ensureDevAuthToken).toHaveBeenCalledWith(VALID_TOKEN)
})

it('clearOptions rejects an invalid token', async () => {
const ensureDevAuthToken = createAuthStub()
const rpc = setupOptionsRPC({ nuxt: createNuxtStub(rootDir), ensureDevAuthToken } as any)

await expect(rpc.clearOptions('invalid')).rejects.toThrow(/invalid dev auth token/i)
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
})
})

describe('general rpc auth', () => {
function createGeneralRPC(ensureDevAuthToken: ReturnType<typeof createAuthStub>) {
return setupGeneralRPC({
nuxt: createNuxtStub(process.cwd()),
options: {},
refresh: vi.fn(),
ensureDevAuthToken,
openInEditorHooks: [],
} as any)
}

it('openInEditor rejects an invalid token before touching the filesystem', async () => {
const ensureDevAuthToken = createAuthStub()
const general = createGeneralRPC(ensureDevAuthToken)

await expect(general.openInEditor('invalid', './some-file.ts'))
.rejects
.toThrow(/invalid dev auth token/i)
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
})

it('openInEditor proceeds past auth with a valid token', async () => {
const ensureDevAuthToken = createAuthStub()
const general = createGeneralRPC(ensureDevAuthToken)

// A non-existent file resolves to `false` (never spawns an editor), which
// proves the auth guard passed rather than short-circuiting with a throw.
await expect(general.openInEditor(VALID_TOKEN, '/definitely/not/a/real/path-xyz'))
.resolves
.toBe(false)
expect(ensureDevAuthToken).toHaveBeenCalledWith(VALID_TOKEN)
})
})
8 changes: 8 additions & 0 deletions packages/devtools/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
})
Loading
Loading