From 24ba9f49ff66eee13039d73c0c63c2302a57de41 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 24 Sep 2026 17:31:27 +0900 Subject: [PATCH 1/5] feat(client): support isolated RPC connections --- docs/content/8.references/5.browser-api.md | 5 + .../src/client/connection-isolation.test.ts | 130 ++++++++++++++++++ packages/devframe/src/client/connection.ts | 42 ++++-- .../client/rpc-connection-isolation.test.ts | 105 ++++++++++++++ packages/devframe/src/client/rpc.ts | 14 +- .../tsnapi/devframe/client.snapshot.d.ts | 1 + 6 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 packages/devframe/src/client/connection-isolation.test.ts create mode 100644 packages/devframe/src/client/rpc-connection-isolation.test.ts diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index adee7668..c1534663 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -13,6 +13,7 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c | Option | Description | |--------|-------------| +| `isolateConnection` | `true` keeps endpoint discovery and credentials local to this RPC client. Skips shared window/localStorage caches and authentication broadcasts. Default `false`. | | `connection` | Connection prepared by `setupDevframeConnection()`. | | `baseURL` | Mount path to probe for `__connection.json` (array = fallback). Default `'./'` (relative to `document.baseURI`); use an absolute path (`'/__devframe/'`) from outside the SPA. | | `authToken` | Override the auth token (default: a locally-persisted id). | @@ -23,6 +24,10 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c | `connectionMeta` | Descriptor that skips the `__connection.json` fetch. | | `webmcp` | Mirror `agent`-flagged client RPC functions onto the page's WebMCP model context as tools; `false` opts out. Default `true` (applies only when the browser provides one). See [Agent-Native](/guide/agent-native#browser-side-tools-over-webmcp). | +Use `isolateConnection: true` for an external viewer that manages independent endpoints or supplies its own credential persistence. Explicit `connection`, `connectionMeta`, `authToken` and `baseURL` inputs retain their precedence. Token and one-time-code authentication update the RPC client's `connection`; the caller can retain that descriptor for reconnection. + +The option applies separately to `setupDevframeConnection()` and `connectDevframe()`. When handing a prepared descriptor to another RPC client, pass `isolateConnection: true` again. URL code consumption and the authentication prompt remain controlled by `otpParam` and `simpleAuth`. + ## RPC client events Emitted over `rpc.events`: [Events](/guide/client#events). diff --git a/packages/devframe/src/client/connection-isolation.test.ts b/packages/devframe/src/client/connection-isolation.test.ts new file mode 100644 index 00000000..6d35cea1 --- /dev/null +++ b/packages/devframe/src/client/connection-isolation.test.ts @@ -0,0 +1,130 @@ +import type { + DevframeConnection, +} from './index' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setupDevframeConnection } from './index' + +const storedConnection: DevframeConnection = { + connectionMeta: { backend: 'static' }, + metaBaseUrl: 'http://stored.example/__connection.json', + authToken: 'stored-token', +} +const explicitConnection: DevframeConnection = { + connectionMeta: { backend: 'static' }, + metaBaseUrl: 'http://explicit.example/__connection.json', +} +const getItem = vi.fn() +const setItem = vi.fn() +const fetchMetadata = vi.fn() +function readGlobal(name: string): unknown { + return Reflect.get(globalThis, name) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('window', globalThis) + vi.stubGlobal('parent', { window: globalThis }) + vi.stubGlobal('location', new URL('http://viewer.example/index.html')) + vi.stubGlobal('localStorage', { getItem, setItem }) + vi.stubGlobal('fetch', fetchMetadata) + vi.stubGlobal('__DEVFRAME_CONNECTION__', storedConnection) + vi.stubGlobal('__DEVFRAME_CONNECTION_META__', storedConnection.connectionMeta) + vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'stored-token') + getItem.mockReturnValue('stored-token') + fetchMetadata.mockResolvedValue(Response.json({})) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isolated connection setup', () => { + it('does not discover or persist shared credentials for an explicit isolated connection', async () => { + expect.assertions(5) + const connection = await setupDevframeConnection({ + connection: explicitConnection, + isolateConnection: true, + }) + expect(connection).toBe(explicitConnection) + expect(connection.authToken).toBeUndefined() + expect(getItem).not.toHaveBeenCalled() + expect(setItem).not.toHaveBeenCalled() + expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection) + }) + + it('fetches the requested base despite a different cached connection', async () => { + expect.assertions(6) + fetchMetadata.mockResolvedValue( + Response.json({ backend: 'static', authToken: 'metadata-token' }), + ) + const connection = await setupDevframeConnection({ + baseURL: 'http://requested.example/provider/', + isolateConnection: true, + }) + expect(fetchMetadata).toHaveBeenCalledExactlyOnceWith( + 'http://requested.example/provider/__connection.json', + ) + expect(connection.metaBaseUrl).toBe('http://requested.example/provider/__connection.json') + expect(connection.authToken).toBe('metadata-token') + expect(getItem).not.toHaveBeenCalled() + expect(setItem).not.toHaveBeenCalled() + expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection) + }) + + it('accepts explicit metadata and token without reading or writing shared caches', async () => { + expect.assertions(6) + const connection = await setupDevframeConnection({ + connectionMeta: { backend: 'static', authToken: 'metadata-token' }, + baseURL: 'http://requested.example/', + authToken: 'explicit-token', + isolateConnection: true, + }) + expect(connection.authToken).toBe('explicit-token') + expect(connection.metaBaseUrl).toBe('http://requested.example/__connection.json') + expect(getItem).not.toHaveBeenCalled() + expect(setItem).not.toHaveBeenCalled() + expect(fetchMetadata).not.toHaveBeenCalled() + expect(readGlobal('__DEVFRAME_CONNECTION_META__')).toBe(storedConnection.connectionMeta) + }) + + it.each([{}, { isolateConnection: false }])( + 'retains default cache discovery with %j', + async (options) => { + expect.assertions(5) + const connection = await setupDevframeConnection({ + baseURL: 'http://ignored.example/', + ...options, + }) + expect(connection).toBe(storedConnection) + expect(getItem).toHaveBeenCalled() + expect(fetchMetadata).not.toHaveBeenCalled() + expect(setItem).toHaveBeenCalledExactlyOnceWith( + '__DEVFRAME_CONNECTION_AUTH_TOKEN__', + 'stored-token', + ) + expect(readGlobal('__DEVFRAME_CONNECTION__')).toEqual(storedConnection) + }, + ) + + it('ignores accessible-parent caches while retaining fetched metadata resolution', async () => { + expect.assertions(5) + vi.stubGlobal('__DEVFRAME_CONNECTION__', undefined) + vi.stubGlobal('__DEVFRAME_CONNECTION_META__', undefined) + vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', undefined) + const parentWindow = { + __DEVFRAME_CONNECTION__: storedConnection, + __DEVFRAME_CONNECTION_AUTH_TOKEN__: 'parent-token', + } + vi.stubGlobal('parent', { window: parentWindow }) + fetchMetadata.mockResolvedValue(Response.json({ backend: 'static', baseUrl: './nested/__connection.json' })) + const connection = await setupDevframeConnection({ + baseURL: 'http://requested.example/', + isolateConnection: true, + }) + expect(connection.metaBaseUrl).toBe('http://requested.example/nested/__connection.json') + expect(connection.authToken).toBeUndefined() + expect(getItem).not.toHaveBeenCalled() + expect(setItem).not.toHaveBeenCalled() + expect(parentWindow.__DEVFRAME_CONNECTION__).toBe(storedConnection) + }) +}) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index 3d7de3b5..ec0bdb96 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -26,6 +26,15 @@ export interface DevframeConnection { } export interface SetupDevframeConnectionOptions { + /** + * Keep connection discovery and credentials local to this RPC client. + * Skips shared window/localStorage reads and writes, and authentication + * broadcasts. Explicit connections, metadata and credentials still apply. + * Pass this option again when reusing a prepared connection. + * + * @default false + */ + isolateConnection?: boolean /** Reuse a complete connection prepared in another viewer or JavaScript realm. */ connection?: DevframeConnection /** Use a pre-known descriptor while deriving its source URL from `baseURL`. */ @@ -111,17 +120,27 @@ export function getDevframeConnection(): DevframeConnection | undefined { export async function setupDevframeConnection( options: SetupDevframeConnectionOptions = {}, ): Promise { + const isolateConnection = options.isolateConnection === true + function resolveAuthToken(authToken: string | undefined): string | undefined { + if (isolateConnection) + return authToken + return readStoredAuthToken(authToken) + } + function finishConnectionSetup(connection: DevframeConnection): DevframeConnection { + if (!isolateConnection) + storeConnection(connection) + return connection + } if (options.connection) { const connection = withAuthToken( options.connection, - readStoredAuthToken( + resolveAuthToken( options.authToken ?? options.connection.authToken ?? options.connection.connectionMeta.authToken, ), ) - storeConnection(connection) - return connection + return finishConnectionSetup(connection) } const bases = Array.isArray(options.baseURL) @@ -136,26 +155,24 @@ export async function setupDevframeConnection( * supplied descriptor resolves from the caller's explicit base. */ metaBaseUrl: resolveMetaBaseUrl(bases[0] ?? './'), - authToken: readStoredAuthToken( + authToken: resolveAuthToken( options.authToken ?? options.connectionMeta.authToken, ), } - storeConnection(connection) - return connection + return finishConnectionSetup(connection) } - const existing = getDevframeConnection() + const existing = isolateConnection ? undefined : getDevframeConnection() if (existing) { const connection = withAuthToken( existing, - readStoredAuthToken( + resolveAuthToken( options.authToken ?? existing.authToken ?? existing.connectionMeta.authToken, ), ) - storeConnection(connection) - return connection + return finishConnectionSetup(connection) } const errors: Error[] = [] @@ -179,12 +196,11 @@ export async function setupDevframeConnection( metaBaseUrl: connectionMeta.baseUrl ? new URL(connectionMeta.baseUrl, loadedFrom).href : loadedFrom, - authToken: readStoredAuthToken( + authToken: resolveAuthToken( options.authToken ?? connectionMeta.authToken, ), } - storeConnection(connection) - return connection + return finishConnectionSetup(connection) } catch (error) { errors.push(error as Error) diff --git a/packages/devframe/src/client/rpc-connection-isolation.test.ts b/packages/devframe/src/client/rpc-connection-isolation.test.ts new file mode 100644 index 00000000..e5e4651a --- /dev/null +++ b/packages/devframe/src/client/rpc-connection-isolation.test.ts @@ -0,0 +1,105 @@ +import type { DevframeConnection } from './connection' +import { DEVFRAME_CONNECTION_KEY } from 'devframe/constants' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDevframeRpcClient } from './rpc' + +const transport = vi.hoisted(() => ({ close: vi.fn() })) + +vi.mock('devframe/rpc/transports/ws-client', () => ({ + createWsRpcChannel: () => ({ post: vi.fn(), on: vi.fn(), close: transport.close }), +})) +vi.mock('devframe/rpc/client', () => ({ + createRpcClient: () => ({ + $callEvent: vi.fn(), + $call: async (method: string, input: { code?: string }) => { + if (method === 'anonymous:devframe:auth:exchange') + return { authToken: `issued-${input.code}` } + if (method === 'anonymous:devframe:auth') + return { isTrusted: true } + return {} + }, + }), +})) + +const sharedConnection: DevframeConnection = { + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } }, + metaBaseUrl: 'http://shared.example/__connection.json', + authToken: 'shared-token', +} +const storage = { getItem: vi.fn(), setItem: vi.fn() } +const closeChannel = vi.fn() +const channel = vi.fn(class { + postMessage = vi.fn() + close = closeChannel +}) +const options = { isolateConnection: true, otpParam: false, simpleAuth: false, webmcp: false } as const + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('location', new URL('http://viewer.example/')) + vi.stubGlobal('navigator', { userAgent: 'test' }) + vi.stubGlobal('localStorage', storage) + vi.stubGlobal('BroadcastChannel', channel) + vi.stubGlobal(DEVFRAME_CONNECTION_KEY, sharedConnection) + vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'shared-token') +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +it('keeps independent authentication and reconnection local while closing transports', async () => { + expect.assertions(11) + const first = await getDevframeRpcClient({ + ...options, + connection: { ...sharedConnection, metaBaseUrl: 'http://first.example/__connection.json', authToken: 'first-token' }, + }) + const second = await getDevframeRpcClient({ + ...options, + connection: { ...sharedConnection, metaBaseUrl: 'http://second.example/__connection.json', authToken: 'second-token' }, + }) + try { + expect(await first.requestTrustWithCode('first-code')).toBe(true) + expect(first.connection.authToken).toBe('issued-first-code') + expect(second.connection.authToken).toBe('second-token') + const recreated = await getDevframeRpcClient({ ...options, connection: second.connection }) + try { + expect(recreated.connection.metaBaseUrl).toBe('http://second.example/__connection.json') + expect(recreated.connection.authToken).toBe('second-token') + expect(await recreated.requestTrustWithToken('second-updated')).toBe(true) + expect(first.connection.authToken).toBe('issued-first-code') + expect(Reflect.get(globalThis, '__DEVFRAME_CONNECTION_AUTH_TOKEN__')).toBe('shared-token') + expect(storage.setItem).not.toHaveBeenCalled() + expect(channel).not.toHaveBeenCalled() + } + finally { + recreated.close?.() + } + } + finally { + first.close?.() + second.close?.() + } + expect(transport.close).toHaveBeenCalledTimes(3) +}) + +it.each([{}, { isolateConnection: false }])('preserves shared OTP persistence and broadcasts with %j', async (settings) => { + expect.assertions(6) + const rpcClient = await getDevframeRpcClient({ + ...options, + isolateConnection: undefined, + ...settings, + connection: sharedConnection, + }) + try { + expect(await rpcClient.requestTrustWithCode('shared-code')).toBe(true) + expect(rpcClient.connection.authToken).toBe('issued-shared-code') + expect(storage.setItem).toHaveBeenLastCalledWith('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'issued-shared-code') + expect(channel).toHaveBeenCalledExactlyOnceWith('devframe-auth') + expect(channel.mock.results[0]?.value.postMessage).toHaveBeenCalledExactlyOnceWith({ type: 'auth-update', authToken: 'issued-shared-code' }) + } + finally { + rpcClient.close?.() + } + expect(closeChannel).toHaveBeenCalledOnce() +}) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 4f09a287..2cb1bde2 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -330,6 +330,7 @@ export function resolveClientTransport( export async function getDevframeRpcClient( options: DevframeRpcClientOptions = {}, ): Promise { + const isolateConnection = options.isolateConnection === true // Default to a relative base: the SPA owns its mount path at runtime, so // connection meta and dump shards live alongside `index.html`. An embedded // surface inside a host page must pass an explicit `baseURL` - its @@ -428,7 +429,8 @@ export async function getDevframeRpcClient( // Channel name kept for cross-tab interop with the Vite DevTools auth page. let authChannel: BroadcastChannel | undefined try { - authChannel = new BroadcastChannel('devframe-auth') + if (!isolateConnection) + authChannel = new BroadcastChannel('devframe-auth') } catch {} @@ -485,8 +487,8 @@ export async function getDevframeRpcClient( ensureTrusted: mode.ensureTrusted, requestTrust: mode.requestTrust, requestTrustWithToken: async (token: string) => { - // Update stored token for future reconnections - storeAuthToken(token) + if (!isolateConnection) + storeAuthToken(token) connection = { ...connection, authToken: token } return mode.requestTrustWithToken(token) }, @@ -494,9 +496,9 @@ export async function getDevframeRpcClient( const token = await mode.requestTrustWithCode(code) if (!token) return false - // Persist the node-issued token and share it with sibling tabs so they - // become trusted without re-entering the code. - storeAuthToken(token) + /** Shared mode also persists the issued token for sibling tabs. */ + if (!isolateConnection) + storeAuthToken(token) connection = { ...connection, authToken: token } try { authChannel?.postMessage({ type: 'auth-update', authToken: token }) diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 0eebf21b..9c81a885 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -125,6 +125,7 @@ export interface RpcStreamingClientHost { upload: (_: string, _: string) => StreamSink; } export interface SetupDevframeConnectionOptions { + isolateConnection?: boolean; connection?: DevframeConnection; connectionMeta?: ConnectionMeta; baseURL?: string | string[]; From 9a4cacf4ed0392532a2cf5f5ef4d4d8f0593d07e Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 24 Sep 2026 18:55:39 +0900 Subject: [PATCH 2/5] refactor(client): centralize connection credentials --- packages/devframe/src/client/connection.ts | 69 ++++++++-------------- packages/devframe/src/client/rpc.ts | 33 ++++++----- 2 files changed, 44 insertions(+), 58 deletions(-) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index ec0bdb96..b8322bf7 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -120,60 +120,44 @@ export function getDevframeConnection(): DevframeConnection | undefined { export async function setupDevframeConnection( options: SetupDevframeConnectionOptions = {}, ): Promise { - const isolateConnection = options.isolateConnection === true - function resolveAuthToken(authToken: string | undefined): string | undefined { - if (isolateConnection) - return authToken - return readStoredAuthToken(authToken) - } - function finishConnectionSetup(connection: DevframeConnection): DevframeConnection { - if (!isolateConnection) - storeConnection(connection) - return connection - } - if (options.connection) { - const connection = withAuthToken( - options.connection, - resolveAuthToken( - options.authToken - ?? options.connection.authToken - ?? options.connection.connectionMeta.authToken, - ), - ) - return finishConnectionSetup(connection) - } + const connection = await resolveDevframeConnection(options) + const authToken = options.authToken ?? connection.authToken ?? connection.connectionMeta.authToken + const resolvedConnection = withAuthToken( + connection, + options.isolateConnection ? authToken : readStoredAuthToken(authToken), + ) + if (options.isolateConnection) + return resolvedConnection + + storeConnection(resolvedConnection) + return resolvedConnection +} + +async function resolveDevframeConnection( + options: SetupDevframeConnectionOptions, +): Promise { + if (options.connection) + return options.connection const bases = Array.isArray(options.baseURL) ? options.baseURL : [options.baseURL ?? './'] if (options.connectionMeta) { - const connection: DevframeConnection = { + return { connectionMeta: options.connectionMeta, /** * Preserve the established connectionMeta behavior: an explicitly * supplied descriptor resolves from the caller's explicit base. */ metaBaseUrl: resolveMetaBaseUrl(bases[0] ?? './'), - authToken: resolveAuthToken( - options.authToken ?? options.connectionMeta.authToken, - ), + authToken: options.connectionMeta.authToken, } - return finishConnectionSetup(connection) } - const existing = isolateConnection ? undefined : getDevframeConnection() - if (existing) { - const connection = withAuthToken( - existing, - resolveAuthToken( - options.authToken - ?? existing.authToken - ?? existing.connectionMeta.authToken, - ), - ) - return finishConnectionSetup(connection) - } + const existing = options.isolateConnection ? undefined : getDevframeConnection() + if (existing) + return existing const errors: Error[] = [] for (const base of bases) { @@ -186,7 +170,7 @@ export async function setupDevframeConnection( const connectionMeta = await response.json() as ConnectionMeta const loadedFrom = response.url || metaUrl - const connection: DevframeConnection = { + return { connectionMeta, /** * A served `baseUrl` re-points relative resolution (RPC dump shards, @@ -196,11 +180,8 @@ export async function setupDevframeConnection( metaBaseUrl: connectionMeta.baseUrl ? new URL(connectionMeta.baseUrl, loadedFrom).href : loadedFrom, - authToken: resolveAuthToken( - options.authToken ?? connectionMeta.authToken, - ), + authToken: connectionMeta.authToken, } - return finishConnectionSetup(connection) } catch (error) { errors.push(error as Error) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 2cb1bde2..86c1f2fb 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -327,10 +327,20 @@ export function resolveClientTransport( throw new Error('[devframe] This server advertises no RPC transport (backend "none"), so there is nothing to connect to. Enable the WebSocket or SSE endpoint on the server, or use its static/MCP surfaces instead.') } +function createAuthChannel(isolateConnection = false): BroadcastChannel | undefined { + if (isolateConnection) + return undefined + + try { + /** Channel name kept for cross-tab interop with the Vite DevTools auth page. */ + return new BroadcastChannel('devframe-auth') + } + catch {} +} + export async function getDevframeRpcClient( options: DevframeRpcClientOptions = {}, ): Promise { - const isolateConnection = options.isolateConnection === true // Default to a relative base: the SPA owns its mount path at runtime, so // connection meta and dump shards live alongside `index.html`. An embedded // surface inside a host page must pass an explicit `baseURL` - its @@ -426,13 +436,13 @@ export async function getDevframeRpcClient( wsOptions: options.wsOptions, }) - // Channel name kept for cross-tab interop with the Vite DevTools auth page. - let authChannel: BroadcastChannel | undefined - try { - if (!isolateConnection) - authChannel = new BroadcastChannel('devframe-auth') + const authChannel = createAuthChannel(options.isolateConnection) + + function updateAuthToken(token: string): void { + connection = { ...connection, authToken: token } + if (!options.isolateConnection) + storeAuthToken(token) } - catch {} // Gate outbound calls behind the auth bootstrap below. Without it, a // caller's first RPC calls, fired the moment `connectDevframe()` resolves, @@ -487,19 +497,14 @@ export async function getDevframeRpcClient( ensureTrusted: mode.ensureTrusted, requestTrust: mode.requestTrust, requestTrustWithToken: async (token: string) => { - if (!isolateConnection) - storeAuthToken(token) - connection = { ...connection, authToken: token } + updateAuthToken(token) return mode.requestTrustWithToken(token) }, requestTrustWithCode: async (code: string) => { const token = await mode.requestTrustWithCode(code) if (!token) return false - /** Shared mode also persists the issued token for sibling tabs. */ - if (!isolateConnection) - storeAuthToken(token) - connection = { ...connection, authToken: token } + updateAuthToken(token) try { authChannel?.postMessage({ type: 'auth-update', authToken: token }) } From 22c320642ad10a174a5b91799b798a417d0a4812 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Thu, 24 Sep 2026 22:26:45 +0900 Subject: [PATCH 3/5] refactor(client): retain isolation on connection descriptors --- docs/content/8.references/5.browser-api.md | 7 ++--- .../src/client/connection-isolation.test.ts | 16 +++++----- packages/devframe/src/client/connection.ts | 30 +++++++++---------- .../client/rpc-connection-isolation.test.ts | 15 +++++----- packages/devframe/src/client/rpc.ts | 20 +++++-------- .../tsnapi/devframe/client.snapshot.d.ts | 9 ++++-- 6 files changed, 48 insertions(+), 49 deletions(-) diff --git a/docs/content/8.references/5.browser-api.md b/docs/content/8.references/5.browser-api.md index c1534663..9fa88c58 100644 --- a/docs/content/8.references/5.browser-api.md +++ b/docs/content/8.references/5.browser-api.md @@ -13,8 +13,7 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c | Option | Description | |--------|-------------| -| `isolateConnection` | `true` keeps endpoint discovery and credentials local to this RPC client. Skips shared window/localStorage caches and authentication broadcasts. Default `false`. | -| `connection` | Connection prepared by `setupDevframeConnection()`. | +| `connection` | Prepared connection, or `{ isolated: true }` to discover one without shared browser caches or authentication broadcasts. A prepared connection retains its `isolated` setting when reused. Omitted or `false` uses shared behavior. | | `baseURL` | Mount path to probe for `__connection.json` (array = fallback). Default `'./'` (relative to `document.baseURI`); use an absolute path (`'/__devframe/'`) from outside the SPA. | | `authToken` | Override the auth token (default: a locally-persisted id). | | `cacheOptions` | `true` for default caching, or an options object. | @@ -24,9 +23,9 @@ The options of `connectDevframe()` / `getDevframeRpcClient()`: [Client](/guide/c | `connectionMeta` | Descriptor that skips the `__connection.json` fetch. | | `webmcp` | Mirror `agent`-flagged client RPC functions onto the page's WebMCP model context as tools; `false` opts out. Default `true` (applies only when the browser provides one). See [Agent-Native](/guide/agent-native#browser-side-tools-over-webmcp). | -Use `isolateConnection: true` for an external viewer that manages independent endpoints or supplies its own credential persistence. Explicit `connection`, `connectionMeta`, `authToken` and `baseURL` inputs retain their precedence. Token and one-time-code authentication update the RPC client's `connection`; the caller can retain that descriptor for reconnection. +Use `connection: { isolated: true }` for an external viewer that manages independent endpoints or supplies its own credential persistence. Explicit `connection`, `connectionMeta`, `authToken` and `baseURL` inputs retain their precedence. Token and one-time-code authentication update the RPC client's `connection`; the caller can retain that descriptor for reconnection. -The option applies separately to `setupDevframeConnection()` and `connectDevframe()`. When handing a prepared descriptor to another RPC client, pass `isolateConnection: true` again. URL code consumption and the authentication prompt remain controlled by `otpParam` and `simpleAuth`. +When handing a prepared descriptor to another RPC client, pass `{ connection }`; its `isolated` setting is retained. To explicitly change the setting, pass a copied descriptor such as `{ connection: { ...connection, isolated: false } }`. URL code consumption and the authentication prompt remain controlled by `otpParam` and `simpleAuth`. ## RPC client events diff --git a/packages/devframe/src/client/connection-isolation.test.ts b/packages/devframe/src/client/connection-isolation.test.ts index 6d35cea1..a42204c0 100644 --- a/packages/devframe/src/client/connection-isolation.test.ts +++ b/packages/devframe/src/client/connection-isolation.test.ts @@ -12,6 +12,7 @@ const storedConnection: DevframeConnection = { const explicitConnection: DevframeConnection = { connectionMeta: { backend: 'static' }, metaBaseUrl: 'http://explicit.example/__connection.json', + isolated: true, } const getItem = vi.fn() const setItem = vi.fn() @@ -43,7 +44,6 @@ describe('isolated connection setup', () => { expect.assertions(5) const connection = await setupDevframeConnection({ connection: explicitConnection, - isolateConnection: true, }) expect(connection).toBe(explicitConnection) expect(connection.authToken).toBeUndefined() @@ -52,20 +52,22 @@ describe('isolated connection setup', () => { expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection) }) - it('fetches the requested base despite a different cached connection', async () => { - expect.assertions(6) + it('fetches the requested base and retains isolation when reusing its descriptor', async () => { + expect.assertions(8) fetchMetadata.mockResolvedValue( Response.json({ backend: 'static', authToken: 'metadata-token' }), ) const connection = await setupDevframeConnection({ baseURL: 'http://requested.example/provider/', - isolateConnection: true, + connection: { isolated: true }, }) expect(fetchMetadata).toHaveBeenCalledExactlyOnceWith( 'http://requested.example/provider/__connection.json', ) expect(connection.metaBaseUrl).toBe('http://requested.example/provider/__connection.json') expect(connection.authToken).toBe('metadata-token') + expect(connection.isolated).toBe(true) + expect(await setupDevframeConnection({ connection })).toBe(connection) expect(getItem).not.toHaveBeenCalled() expect(setItem).not.toHaveBeenCalled() expect(readGlobal('__DEVFRAME_CONNECTION__')).toBe(storedConnection) @@ -77,7 +79,7 @@ describe('isolated connection setup', () => { connectionMeta: { backend: 'static', authToken: 'metadata-token' }, baseURL: 'http://requested.example/', authToken: 'explicit-token', - isolateConnection: true, + connection: { isolated: true }, }) expect(connection.authToken).toBe('explicit-token') expect(connection.metaBaseUrl).toBe('http://requested.example/__connection.json') @@ -87,7 +89,7 @@ describe('isolated connection setup', () => { expect(readGlobal('__DEVFRAME_CONNECTION_META__')).toBe(storedConnection.connectionMeta) }) - it.each([{}, { isolateConnection: false }])( + it.each([{}, { connection: { isolated: false } }])( 'retains default cache discovery with %j', async (options) => { expect.assertions(5) @@ -119,7 +121,7 @@ describe('isolated connection setup', () => { fetchMetadata.mockResolvedValue(Response.json({ backend: 'static', baseUrl: './nested/__connection.json' })) const connection = await setupDevframeConnection({ baseURL: 'http://requested.example/', - isolateConnection: true, + connection: { isolated: true }, }) expect(connection.metaBaseUrl).toBe('http://requested.example/nested/__connection.json') expect(connection.authToken).toBeUndefined() diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index b8322bf7..e6326e05 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -23,20 +23,18 @@ export interface DevframeConnection { metaBaseUrl: string /** Previously issued bearer token, when the connection is already trusted. */ authToken?: string + /** Skip shared browser caches and authentication broadcasts. Retained when reconnecting. */ + isolated?: boolean } export interface SetupDevframeConnectionOptions { - /** - * Keep connection discovery and credentials local to this RPC client. - * Skips shared window/localStorage reads and writes, and authentication - * broadcasts. Explicit connections, metadata and credentials still apply. - * Pass this option again when reusing a prepared connection. - * - * @default false - */ - isolateConnection?: boolean - /** Reuse a complete connection prepared in another viewer or JavaScript realm. */ - connection?: DevframeConnection + /** Reuse a prepared connection, or configure isolation before resolving its metadata. */ + connection?: DevframeConnection | { + isolated: boolean + connectionMeta?: never + metaBaseUrl?: never + authToken?: never + } /** Use a pre-known descriptor while deriving its source URL from `baseURL`. */ connectionMeta?: ConnectionMeta /** Base URL, or fallback list, used to locate `__connection.json`. */ @@ -124,9 +122,9 @@ export async function setupDevframeConnection( const authToken = options.authToken ?? connection.authToken ?? connection.connectionMeta.authToken const resolvedConnection = withAuthToken( connection, - options.isolateConnection ? authToken : readStoredAuthToken(authToken), + connection.isolated ? authToken : readStoredAuthToken(authToken), ) - if (options.isolateConnection) + if (connection.isolated) return resolvedConnection storeConnection(resolvedConnection) @@ -136,7 +134,7 @@ export async function setupDevframeConnection( async function resolveDevframeConnection( options: SetupDevframeConnectionOptions, ): Promise { - if (options.connection) + if (options.connection?.connectionMeta) return options.connection const bases = Array.isArray(options.baseURL) @@ -152,10 +150,11 @@ async function resolveDevframeConnection( */ metaBaseUrl: resolveMetaBaseUrl(bases[0] ?? './'), authToken: options.connectionMeta.authToken, + isolated: options.connection?.isolated, } } - const existing = options.isolateConnection ? undefined : getDevframeConnection() + const existing = options.connection?.isolated ? undefined : getDevframeConnection() if (existing) return existing @@ -181,6 +180,7 @@ async function resolveDevframeConnection( ? new URL(connectionMeta.baseUrl, loadedFrom).href : loadedFrom, authToken: connectionMeta.authToken, + isolated: options.connection?.isolated, } } catch (error) { diff --git a/packages/devframe/src/client/rpc-connection-isolation.test.ts b/packages/devframe/src/client/rpc-connection-isolation.test.ts index e5e4651a..2aa21ac5 100644 --- a/packages/devframe/src/client/rpc-connection-isolation.test.ts +++ b/packages/devframe/src/client/rpc-connection-isolation.test.ts @@ -32,7 +32,7 @@ const channel = vi.fn(class { postMessage = vi.fn() close = closeChannel }) -const options = { isolateConnection: true, otpParam: false, simpleAuth: false, webmcp: false } as const +const options = { otpParam: false, simpleAuth: false, webmcp: false } as const beforeEach(() => { vi.clearAllMocks() @@ -49,14 +49,14 @@ afterEach(() => { }) it('keeps independent authentication and reconnection local while closing transports', async () => { - expect.assertions(11) + expect.assertions(12) const first = await getDevframeRpcClient({ ...options, - connection: { ...sharedConnection, metaBaseUrl: 'http://first.example/__connection.json', authToken: 'first-token' }, + connection: { ...sharedConnection, metaBaseUrl: 'http://first.example/__connection.json', authToken: 'first-token', isolated: true }, }) const second = await getDevframeRpcClient({ ...options, - connection: { ...sharedConnection, metaBaseUrl: 'http://second.example/__connection.json', authToken: 'second-token' }, + connection: { ...sharedConnection, metaBaseUrl: 'http://second.example/__connection.json', authToken: 'second-token', isolated: true }, }) try { expect(await first.requestTrustWithCode('first-code')).toBe(true) @@ -66,6 +66,7 @@ it('keeps independent authentication and reconnection local while closing transp try { expect(recreated.connection.metaBaseUrl).toBe('http://second.example/__connection.json') expect(recreated.connection.authToken).toBe('second-token') + expect(recreated.connection.isolated).toBe(true) expect(await recreated.requestTrustWithToken('second-updated')).toBe(true) expect(first.connection.authToken).toBe('issued-first-code') expect(Reflect.get(globalThis, '__DEVFRAME_CONNECTION_AUTH_TOKEN__')).toBe('shared-token') @@ -83,13 +84,11 @@ it('keeps independent authentication and reconnection local while closing transp expect(transport.close).toHaveBeenCalledTimes(3) }) -it.each([{}, { isolateConnection: false }])('preserves shared OTP persistence and broadcasts with %j', async (settings) => { +it.each([{}, { isolated: false }])('preserves shared OTP persistence and broadcasts with %j', async (settings) => { expect.assertions(6) const rpcClient = await getDevframeRpcClient({ ...options, - isolateConnection: undefined, - ...settings, - connection: sharedConnection, + connection: { ...sharedConnection, ...settings }, }) try { expect(await rpcClient.requestTrustWithCode('shared-code')).toBe(true) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 86c1f2fb..15b7a5ee 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -327,17 +327,6 @@ export function resolveClientTransport( throw new Error('[devframe] This server advertises no RPC transport (backend "none"), so there is nothing to connect to. Enable the WebSocket or SSE endpoint on the server, or use its static/MCP surfaces instead.') } -function createAuthChannel(isolateConnection = false): BroadcastChannel | undefined { - if (isolateConnection) - return undefined - - try { - /** Channel name kept for cross-tab interop with the Vite DevTools auth page. */ - return new BroadcastChannel('devframe-auth') - } - catch {} -} - export async function getDevframeRpcClient( options: DevframeRpcClientOptions = {}, ): Promise { @@ -436,11 +425,16 @@ export async function getDevframeRpcClient( wsOptions: options.wsOptions, }) - const authChannel = createAuthChannel(options.isolateConnection) + /** Channel name kept for cross-tab interop with the Vite DevTools auth page. */ + let authChannel: BroadcastChannel | undefined + try { + authChannel = connection.isolated ? undefined : new BroadcastChannel('devframe-auth') + } + catch {} function updateAuthToken(token: string): void { connection = { ...connection, authToken: token } - if (!options.isolateConnection) + if (!connection.isolated) storeAuthToken(token) } diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 9c81a885..3b29369c 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -6,6 +6,7 @@ export interface DevframeConnection { connectionMeta: ConnectionMeta; metaBaseUrl: string; authToken?: string; + isolated?: boolean; } export interface DevframeRpcClient { events: EventEmitter; @@ -125,8 +126,12 @@ export interface RpcStreamingClientHost { upload: (_: string, _: string) => StreamSink; } export interface SetupDevframeConnectionOptions { - isolateConnection?: boolean; - connection?: DevframeConnection; + connection?: DevframeConnection | { + isolated: boolean; + connectionMeta?: never; + metaBaseUrl?: never; + authToken?: never; + }; connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string; From 6d66d7e9abea9a1f5fa23abb9567671f36437db9 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Fri, 25 Sep 2026 09:57:58 +0900 Subject: [PATCH 4/5] fix(client): clarify isolated connection defaults and auth flow --- .../src/client/connection-isolation.test.ts | 33 ++++++++++++++++++- packages/devframe/src/client/connection.ts | 3 +- packages/devframe/src/client/rpc.ts | 14 +++++--- .../tsnapi/devframe/client.snapshot.d.ts | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/devframe/src/client/connection-isolation.test.ts b/packages/devframe/src/client/connection-isolation.test.ts index a42204c0..f8c71fe3 100644 --- a/packages/devframe/src/client/connection-isolation.test.ts +++ b/packages/devframe/src/client/connection-isolation.test.ts @@ -1,5 +1,6 @@ import type { DevframeConnection, + SetupDevframeConnectionOptions, } from './index' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setupDevframeConnection } from './index' @@ -89,7 +90,7 @@ describe('isolated connection setup', () => { expect(readGlobal('__DEVFRAME_CONNECTION_META__')).toBe(storedConnection.connectionMeta) }) - it.each([{}, { connection: { isolated: false } }])( + it.each([{}, { connection: {} }, { connection: { isolated: false } }])( 'retains default cache discovery with %j', async (options) => { expect.assertions(5) @@ -130,3 +131,33 @@ describe('isolated connection setup', () => { expect(parentWindow.__DEVFRAME_CONNECTION__).toBe(storedConnection) }) }) + +describe.each(['provided', 'fetched'] as const)('shared credentials with %s metadata', (metadataSource) => { + it.each([ + { name: 'explicit token overrides metadata and storage', authToken: 'explicit-token', metadataToken: 'metadata-token', storedToken: 'stored-token', expectedToken: 'explicit-token' }, + { name: 'metadata token overrides storage', authToken: undefined, metadataToken: 'metadata-token', storedToken: 'stored-token', expectedToken: 'metadata-token' }, + { name: 'local storage supplies a missing token', authToken: undefined, metadataToken: undefined, storedToken: 'stored-token', expectedToken: 'stored-token' }, + { name: 'window storage supplies a missing token', authToken: undefined, metadataToken: undefined, storedToken: null, expectedToken: 'window-token' }, + ])('$name', async ({ authToken, metadataToken, storedToken, expectedToken }) => { + expect.assertions(4) + vi.stubGlobal('__DEVFRAME_CONNECTION__', undefined) + vi.stubGlobal('__DEVFRAME_CONNECTION_META__', undefined) + vi.stubGlobal('__DEVFRAME_CONNECTION_AUTH_TOKEN__', 'window-token') + getItem.mockReturnValue(storedToken) + const connectionMeta = { backend: 'static' as const, authToken: metadataToken } + const options: SetupDevframeConnectionOptions = { + baseURL: 'http://requested.example/', + authToken, + } + if (metadataSource === 'provided') + options.connectionMeta = connectionMeta + else + fetchMetadata.mockResolvedValue(Response.json(connectionMeta)) + + const connection = await setupDevframeConnection(options) + expect(connection.authToken).toBe(expectedToken) + expect(readGlobal('__DEVFRAME_CONNECTION__')).toStrictEqual(connection) + expect(setItem).toHaveBeenCalledExactlyOnceWith('__DEVFRAME_CONNECTION_AUTH_TOKEN__', expectedToken) + expect(fetchMetadata).toHaveBeenCalledTimes(metadataSource === 'fetched' ? 1 : 0) + }) +}) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index e6326e05..e1d3c38d 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -30,7 +30,7 @@ export interface DevframeConnection { export interface SetupDevframeConnectionOptions { /** Reuse a prepared connection, or configure isolation before resolving its metadata. */ connection?: DevframeConnection | { - isolated: boolean + isolated?: boolean connectionMeta?: never metaBaseUrl?: never authToken?: never @@ -119,6 +119,7 @@ export async function setupDevframeConnection( options: SetupDevframeConnectionOptions = {}, ): Promise { const connection = await resolveDevframeConnection(options) + /** Apply token precedence once, regardless of how the connection metadata was resolved. */ const authToken = options.authToken ?? connection.authToken ?? connection.connectionMeta.authToken const resolvedConnection = withAuthToken( connection, diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 15b7a5ee..5e220256 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -427,15 +427,19 @@ export async function getDevframeRpcClient( /** Channel name kept for cross-tab interop with the Vite DevTools auth page. */ let authChannel: BroadcastChannel | undefined - try { - authChannel = connection.isolated ? undefined : new BroadcastChannel('devframe-auth') + if (!connection.isolated) { + try { + authChannel = new BroadcastChannel('devframe-auth') + } + catch {} } - catch {} function updateAuthToken(token: string): void { connection = { ...connection, authToken: token } - if (!connection.isolated) - storeAuthToken(token) + if (connection.isolated) + return + + storeAuthToken(token) } // Gate outbound calls behind the auth bootstrap below. Without it, a diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 3b29369c..70da9130 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -127,7 +127,7 @@ export interface RpcStreamingClientHost { } export interface SetupDevframeConnectionOptions { connection?: DevframeConnection | { - isolated: boolean; + isolated?: boolean; connectionMeta?: never; metaBaseUrl?: never; authToken?: never; From b0856763d0eb227687fe186f60028f12c37c947e Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sun, 27 Sep 2026 09:03:42 +0900 Subject: [PATCH 5/5] refactor(client): name connection discovery options and explain isolation --- packages/devframe/src/client/connection.ts | 29 ++++++++++++++----- packages/devframe/src/client/rpc.ts | 5 +++- .../tsnapi/devframe/client.snapshot.d.ts | 13 +++++---- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/devframe/src/client/connection.ts b/packages/devframe/src/client/connection.ts index e1d3c38d..73f96e0a 100644 --- a/packages/devframe/src/client/connection.ts +++ b/packages/devframe/src/client/connection.ts @@ -23,18 +23,33 @@ export interface DevframeConnection { metaBaseUrl: string /** Previously issued bearer token, when the connection is already trusted. */ authToken?: string - /** Skip shared browser caches and authentication broadcasts. Retained when reconnecting. */ + /** + * Skip shared browser caches and authentication broadcasts. Retained when reconnecting. + * The origin-wide `devframe-auth` channel carries no backend identity, so a token + * from another connection could otherwise overwrite this connection's credentials. + * Authentication still uses this connection's own RPC transport. + */ + isolated?: boolean +} + +/** + * Configure discovery before metadata and its source URL have been resolved. + * Shared behavior is the default. Set `isolated: true` when connecting to independent + * backends from one viewer. Browser credential caches are not scoped per backend, and + * `devframe-auth` broadcasts carry no backend identity, so they can mix credentials. + * An isolated connection still authenticates through its own RPC transport. + */ +export interface DevframeConnectionDiscoveryOptions { + /** Use connection-local credentials; see {@link DevframeConnection.isolated}. Defaults to shared behavior. */ isolated?: boolean + connectionMeta?: never + metaBaseUrl?: never + authToken?: never } export interface SetupDevframeConnectionOptions { /** Reuse a prepared connection, or configure isolation before resolving its metadata. */ - connection?: DevframeConnection | { - isolated?: boolean - connectionMeta?: never - metaBaseUrl?: never - authToken?: never - } + connection?: DevframeConnection | DevframeConnectionDiscoveryOptions /** Use a pre-known descriptor while deriving its source URL from `baseURL`. */ connectionMeta?: ConnectionMeta /** Base URL, or fallback list, used to locate `__connection.json`. */ diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 5e220256..21d56250 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -425,7 +425,10 @@ export async function getDevframeRpcClient( wsOptions: options.wsOptions, }) - /** Channel name kept for cross-tab interop with the Vite DevTools auth page. */ + /** + * Shared with the Vite DevTools auth page; messages carry no backend identity, + * so isolated connections must neither publish nor consume credentials here. + */ let authChannel: BroadcastChannel | undefined if (!connection.isolated) { try { diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 70da9130..f2c3f320 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -8,6 +8,12 @@ export interface DevframeConnection { authToken?: string; isolated?: boolean; } +export interface DevframeConnectionDiscoveryOptions { + isolated?: boolean; + connectionMeta?: never; + metaBaseUrl?: never; + authToken?: never; +} export interface DevframeRpcClient { events: EventEmitter; readonly isTrusted: boolean | null; @@ -126,12 +132,7 @@ export interface RpcStreamingClientHost { upload: (_: string, _: string) => StreamSink; } export interface SetupDevframeConnectionOptions { - connection?: DevframeConnection | { - isolated?: boolean; - connectionMeta?: never; - metaBaseUrl?: never; - authToken?: never; - }; + connection?: DevframeConnection | DevframeConnectionDiscoveryOptions; connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string;