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
2 changes: 1 addition & 1 deletion docs/content/2.module/1.utils-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
onDevtoolsClientConnected(async (client) => {
// client is NuxtDevtoolsIframeClient

const config = client.devtools.rpc.getServerConfig()
const components = await client.devtools.rpc.getComponents()
// ...
})
```
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 @@ -12,7 +12,7 @@ import type { GetWizardArgs, WizardActions } from './wizard'

export interface ServerFunctions {
// Static RPCs (can be provide on production build in the future)
getServerConfig: () => NuxtOptions
getServerConfig: (token: string) => Promise<NuxtOptions>
getServerDebugContext: () => Promise<ServerDebugContext | undefined>
getServerData: (token: string) => Promise<NuxtServerData>
getServerRuntimeConfig: (token: string) => Promise<Record<string, any>>
Expand Down Expand Up @@ -45,8 +45,8 @@ export interface ServerFunctions {
runTerminalAction: (token: string, id: string, action: TerminalAction) => Promise<boolean>

// Storage
getStorageMounts: () => Promise<StorageMounts>
getStorageKeys: (base?: string) => Promise<string[]>
getStorageMounts: (token: string) => Promise<StorageMounts>
getStorageKeys: (token: string, base?: string) => Promise<string[]>
getStorageItem: (token: string, key: string) => Promise<StorageValue>
setStorageItem: (token: string, key: string, value: StorageValue) => Promise<void>
removeStorageItem: (token: string, key: string) => Promise<void>
Expand Down
4 changes: 2 additions & 2 deletions packages/devtools/client/components/StorageDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ const currentStorage = useSessionState<string>('storage:current', '')
const currentItem = ref()
const fileKey = useSessionState<string>('storage:file:state', '')

const { data: storageMounts } = await useAsyncData('storageMounts', () => rpc.getStorageMounts())
const { data: storageMounts } = await useAsyncData('storageMounts', async () => rpc.getStorageMounts(await ensureDevAuthToken()))
const { data: storageKeys, refresh: refreshStorageKeys } = await useAsyncData('storageKeys', async () => {
if (currentStorage.value)
return await rpc.getStorageKeys(currentStorage.value)
return await rpc.getStorageKeys(await ensureDevAuthToken(), currentStorage.value)
return []
})

Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/client/composables/dev-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async function authConfirmAction() {

const result = await Promise.race([
AuthConfirm.start(),
until(devAuthToken.value).toBeTruthy(),
until(devAuthToken).toBeTruthy(),
])

if (result === false) {
Expand Down
5 changes: 3 additions & 2 deletions packages/devtools/client/composables/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { objectPick } from '@antfu/utils'
import { computed } from 'vue'
import { useFetch } from '#app/composables/fetch'
import { useClientRouter } from './client'
import { ensureDevAuthToken } from './dev-auth'
import { ensureDevAuthToken, isDevAuthed } from './dev-auth'
import { rpc } from './rpc'
import { useAsyncState } from './utils'

Expand Down Expand Up @@ -37,7 +37,8 @@ export function useStaticAssets() {
}

export function useServerConfig() {
return useAsyncState('getServerConfig', () => rpc.getServerConfig())
// Refetch once authorized, e.g. after the initial prompt was dismissed.
return useAsyncState('getServerConfig', async () => rpc.getServerConfig(await ensureDevAuthToken()), { watch: [isDevAuthed] })
}

export function useServerDebugContext() {
Expand Down
7 changes: 6 additions & 1 deletion packages/devtools/client/pages/modules/overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ function authorize() {

<template>
<NPanelGrids h-screen w-full flex>
<div v-if="!config">
<button v-if="!config && !isDevAuthed" title="Authorize" @click="authorize">
<NTip n="orange5" icon="i-carbon-locked" justify-center>
Project data requires authorization. Click to authorize this browser.
</NTip>
</button>
<div v-else-if="!config">
Loading...
</div>
<div v-else flex="~ col gap2" ma h-full max-w-300 w-full p5 px5 md:px20>
Expand Down
21 changes: 7 additions & 14 deletions packages/devtools/src/server-rpc/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,13 @@ export function setupGeneralRPC({
})

return {
getServerConfig(): NuxtOptions {
// This method is intentionally reachable without a dev auth token (the
// client reads it eagerly across many views), so strip the private
// `runtimeConfig` values — they can hold secrets — from the payload.
// The Runtime Config tab reads them separately via the token-gated
// `getServerRuntimeConfig`. `public`/`app` are non-secret and kept.
const { runtimeConfig, ...rest } = nuxt.options
return {
...rest,
runtimeConfig: {
app: runtimeConfig?.app,
public: runtimeConfig?.public,
},
} as unknown as NuxtOptions
async getServerConfig(token: string): Promise<NuxtOptions> {
// Secrets live all over the resolved options (runtimeConfig and its
// `nitro` copy, `_layers[].config`, module options, storage drivers), so
// the whole object is gated rather than redacted.
await ensureDevAuthToken(token)
// `@nuxt/schema` and `nuxt/schema` resolve to copies with different module augmentations
return nuxt.options as unknown as NuxtOptions
},
async getServerDebugContext() {
if (!nuxt._debug)
Expand Down
3 changes: 0 additions & 3 deletions packages/devtools/src/server-rpc/server-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@ export function setupServerDataRPC({
})

return {
getServerConfig() {
return nuxt.options
},
async getServerData(token: string) {
await ensureDevAuthToken(token)
return {
Expand Down
7 changes: 5 additions & 2 deletions packages/devtools/src/server-rpc/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ export function setupStorageRPC({
})

return {
async getStorageMounts() {
async getStorageMounts(token: string) {
// Mount options carry driver credentials (passwords, access keys).
await ensureDevAuthToken(token)
return storageMounts
},
async getStorageKeys(base?: string) {
async getStorageKeys(token: string, base?: string) {
await ensureDevAuthToken(token)
if (!storage)
return []
try {
Expand Down
69 changes: 69 additions & 0 deletions packages/devtools/test/server-rpc-private-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runWithNuxtContext } from '@nuxt/kit'
import { createHooks } from 'hookable'
import { beforeAll, describe, expect, it } from 'vitest'
import { defaultOptions } from '../src/constant'
import { getDevAuthToken } from '../src/dev-auth'
import { setupRPC } from '../src/server-rpc'

const SECRET = 'synthetic-secret'

// Exercise the composed RPC table the HMR socket dispatches to, so a later
// module re-registering a method without the token guard is caught.
async function setupServer() {
const hooks = createHooks()
const rootDir = await mkdtemp(join(tmpdir(), 'nuxt-devtools-test-'))
const nuxt = {
hooks,
hook: hooks.hook.bind(hooks),
callHook: hooks.callHook.bind(hooks),
options: {
rootDir,
srcDir: rootDir,
dir: { public: 'public' },
app: { baseURL: '/' },
runtimeConfig: { secret: SECRET, public: {} },
nitro: { runtimeConfig: { secret: SECRET } },
_layers: [{ config: { runtimeConfig: { secret: SECRET } } }],
},
} as any
const { rpc } = runWithNuxtContext(nuxt, () => setupRPC(nuxt, defaultOptions))
await nuxt.callHook('nitro:init', {
storage: {},
options: { storage: { db: { driver: 'redis', password: SECRET } }, devStorage: {} },
})
return { nuxt, functions: rpc.functions }
}

describe('private server data rpc', () => {
let server: Awaited<ReturnType<typeof setupServer>>

beforeAll(async () => {
// sandbox where the dev auth token is persisted (see getHomeDir)
process.env.XDG_CONFIG_HOME = await mkdtemp(join(tmpdir(), 'nuxt-devtools-home-'))
server = await setupServer()
})

it('getServerConfig rejects a missing or invalid token', async () => {
// @ts-expect-error simulating a tokenless client
await expect(server.functions.getServerConfig()).rejects.toThrow(/invalid dev auth token/i)
await expect(server.functions.getServerConfig('invalid')).rejects.toThrow(/invalid dev auth token/i)
})

it('getServerConfig returns the options with a valid token', async () => {
await expect(server.functions.getServerConfig(await getDevAuthToken())).resolves.toBe(server.nuxt.options)
})

it('getStorageMounts and getStorageKeys reject an invalid token', async () => {
await expect(server.functions.getStorageMounts('invalid')).rejects.toThrow(/invalid dev auth token/i)
await expect(server.functions.getStorageKeys('invalid', 'db')).rejects.toThrow(/invalid dev auth token/i)
})

it('getStorageMounts returns mounts with a valid token', async () => {
await expect(server.functions.getStorageMounts(await getDevAuthToken()))
.resolves
.toEqual({ db: { driver: 'redis', password: SECRET } })
})
})
Loading