From bdd21bd52b93d059073415871a9c70bb78849fdf Mon Sep 17 00:00:00 2001 From: Timothy Naughton Date: Mon, 26 Oct 2026 14:57:30 -0500 Subject: [PATCH 1/3] fix(cross-dataset-duplicator): use destination workspace auth for cross-project duplication Co-Authored-By: Claude Opus 5.5 --- .changeset/cdd-cross-project-auth.md | 5 ++ .../src/components/Duplicator.tsx | 43 +++++++-- .../src/helpers/getDestinationClient.test.ts | 89 +++++++++++++++++++ .../src/helpers/getDestinationClient.ts | 56 ++++++++++++ 4 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 .changeset/cdd-cross-project-auth.md create mode 100644 plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts create mode 100644 plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts diff --git a/.changeset/cdd-cross-project-auth.md b/.changeset/cdd-cross-project-auth.md new file mode 100644 index 0000000000..3364fc5327 --- /dev/null +++ b/.changeset/cdd-cross-project-auth.md @@ -0,0 +1,5 @@ +--- +'@sanity/cross-dataset-duplicator': patch +--- + +Fix duplicating to a Workspace in another Project failing with "Session does not match project host". The destination client now comes from the destination Workspace's own auth store instead of re-pointing the origin client, whose credentials are scoped to the origin Project. diff --git a/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx b/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx index 52bc8d2fae..c11f3b2acc 100644 --- a/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx +++ b/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx @@ -32,6 +32,7 @@ import { } from 'sanity' import {stickyStyles, createInitialMessage} from '../helpers' +import {getDestinationClient} from '../helpers/getDestinationClient' import {getDocumentsInArray} from '../helpers/getDocumentsInArray' import {isAllowedMigrationTarget} from '../helpers/migrationFilters' import type {PluginConfig} from '../types' @@ -62,6 +63,13 @@ type Message = { tone: CardTone } +function createNotLoggedInMessage(destination: WorkspaceOption): Message { + return { + tone: 'critical', + text: `You are not logged in to the "${destination.title ?? destination.name}" Workspace. Open it and log in, then try again.`, + } +} + function isAssetDocument(doc: SanityDocument): doc is SanityDocument & SanityAssetDocument { return isAssetId(doc._id) } @@ -654,10 +662,18 @@ export default function Duplicator(props: DuplicatorProps) { } const payloadIds = payloadActual.map(({doc}) => doc._id) - const destinationClient = originClient.withConfig({ - dataset: dest.dataset, - projectId: dest.projectId, + const destinationClient = await getDestinationClient({ + originClient, + destination: dest, + apiVersion: pluginConfig.apiVersion, }) + + if (!destinationClient) { + setMessage(createNotLoggedInMessage(dest)) + + return + } + const destinationData = await destinationClient.fetch( `*[_id in $payloadIds]{ _id, _updatedAt }`, {payloadIds}, @@ -758,6 +774,21 @@ export default function Duplicator(props: DuplicatorProps) { return } + const resolvedClient = await getDestinationClient({ + originClient, + destination, + apiVersion: pluginConfig.apiVersion, + }) + + if (!resolvedClient) { + setMessage(createNotLoggedInMessage(destination)) + + return + } + + // Narrowed for use inside the hoisted `processDoc` below + const destinationClient: SanityClient = resolvedClient + setIsDuplicating(true) const assetsCount = payload.filter(({doc, include}) => include && isAssetId(doc._id)).length @@ -766,12 +797,6 @@ export default function Duplicator(props: DuplicatorProps) { setMessage({text: 'Duplicating...', tone: `transparent`}) - const destinationClient = originClient.withConfig({ - apiVersion: pluginConfig.apiVersion, - dataset: destination.dataset, - projectId: destination.projectId, - }) - const transactionDocs: SanityDocument[] = [] const svgMaps: {old: string; new: string}[] = [] diff --git a/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts new file mode 100644 index 0000000000..a5c402ce5e --- /dev/null +++ b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts @@ -0,0 +1,89 @@ +import type {SanityClient} from '@sanity/client' +import type {AuthState, AuthStore} from 'sanity' +import {describe, expect, test, vi} from 'vitest' + +import {getDestinationClient} from './getDestinationClient' + +type ClientConfig = {projectId: string; dataset?: string; apiVersion?: string; token?: string} + +function createClient(config: ClientConfig): SanityClient { + return { + config: () => config, + withConfig: (next: Partial) => createClient({...config, ...next}), + } as unknown as SanityClient +} + +// Minimal auth store whose `state` emits synchronously on subscribe, like the +// Studio's replayed auth state +function createAuth(state: Partial) { + const unsubscribe = vi.fn() + const auth = { + state: { + subscribe: ({next}: {next: (value: AuthState) => void}) => { + next(state as AuthState) + return {unsubscribe} + }, + }, + } as unknown as AuthStore + + return {auth, unsubscribe} +} + +const originClient = createClient({ + projectId: 'projA', + dataset: 'production', + token: 'projA-token', +}) + +describe('getDestinationClient', () => { + test('re-points the origin client for a destination in the same project', async () => { + const {auth} = createAuth({authenticated: false}) + const client = await getDestinationClient({ + originClient, + destination: {projectId: 'projA', dataset: 'staging', auth}, + apiVersion: '2025-02-19', + }) + + expect(client?.config()).toEqual({ + projectId: 'projA', + dataset: 'staging', + apiVersion: '2025-02-19', + token: 'projA-token', + }) + }) + + test("uses the destination workspace's client for a destination in another project", async () => { + const {auth, unsubscribe} = createAuth({ + authenticated: true, + client: createClient({projectId: 'projB', dataset: 'production', token: 'projB-token'}), + }) + const client = await getDestinationClient({ + originClient, + destination: {projectId: 'projB', dataset: 'staging', auth}, + apiVersion: '2025-02-19', + }) + + expect(client?.config()).toEqual({ + projectId: 'projB', + dataset: 'staging', + apiVersion: '2025-02-19', + token: 'projB-token', + }) + + await Promise.resolve() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + test('returns null when not logged in to a destination in another project', async () => { + const {auth} = createAuth({ + authenticated: false, + client: createClient({projectId: 'projB'}), + }) + const client = await getDestinationClient({ + originClient, + destination: {projectId: 'projB', dataset: 'production', auth}, + }) + + expect(client).toBeNull() + }) +}) diff --git a/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts new file mode 100644 index 0000000000..a43e483579 --- /dev/null +++ b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.ts @@ -0,0 +1,56 @@ +import type {SanityClient} from '@sanity/client' +import type {AuthState, AuthStore, WorkspaceSummary} from 'sanity' + +type GetDestinationClientOptions = { + originClient: SanityClient + destination: Pick + apiVersion?: string +} + +// Resolve with the first auth state the store emits. The subscription may emit +// synchronously, so unsubscribe on the next microtask rather than inside `next`. +function getCurrentAuthState(auth: AuthStore): Promise { + return new Promise((resolve, reject) => { + const subscription = auth.state.subscribe({ + next: (state) => { + resolve(state) + queueMicrotask(() => subscription.unsubscribe()) + }, + error: reject, + }) + }) +} + +/** + * Get a client that can read from and write to the destination Workspace. + * + * Within the same Project the origin client's credentials are valid, so it is + * re-pointed at the destination Dataset. Credentials are scoped to a Project + * though (e.g. the token stored by the default `dual` login method), so for a + * destination in another Project the client comes from that Workspace's own + * auth store instead. + * + * Returns `null` if the user is not logged in to the destination Workspace. + */ +export async function getDestinationClient( + options: GetDestinationClientOptions, +): Promise { + const {originClient, destination, apiVersion} = options + const config = apiVersion ? {apiVersion} : {} + + if (destination.projectId === originClient.config().projectId) { + return originClient.withConfig({ + ...config, + dataset: destination.dataset, + projectId: destination.projectId, + }) + } + + const {authenticated, client} = await getCurrentAuthState(destination.auth) + + if (!authenticated) { + return null + } + + return client.withConfig({...config, dataset: destination.dataset}) +} From 9aea088a142eddac9492d5c1a982304369e7d945 Mon Sep 17 00:00:00 2001 From: Timothy Naughton Date: Mon, 26 Oct 2026 15:08:04 -0500 Subject: [PATCH 2/3] test(cross-dataset-duplicator): annotate mock type assertions for oxlint Co-Authored-By: Claude Opus 5.5 --- .../src/helpers/getDestinationClient.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts index a5c402ce5e..cc01ecf4e8 100644 --- a/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts +++ b/plugins/@sanity/cross-dataset-duplicator/src/helpers/getDestinationClient.test.ts @@ -7,24 +7,31 @@ import {getDestinationClient} from './getDestinationClient' type ClientConfig = {projectId: string; dataset?: string; apiVersion?: string; token?: string} function createClient(config: ClientConfig): SanityClient { - return { + const client = { config: () => config, withConfig: (next: Partial) => createClient({...config, ...next}), - } as unknown as SanityClient + } + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only config() and withConfig() are called + return client as unknown as SanityClient } // Minimal auth store whose `state` emits synchronously on subscribe, like the // Studio's replayed auth state function createAuth(state: Partial) { const unsubscribe = vi.fn() - const auth = { + const store = { state: { subscribe: ({next}: {next: (value: AuthState) => void}) => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only authenticated and client are read next(state as AuthState) return {unsubscribe} }, }, - } as unknown as AuthStore + } + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only state.subscribe() is called + const auth = store as unknown as AuthStore return {auth, unsubscribe} } From f8c71cf2d4fc2b8da9a575e2f8433827b1057949 Mon Sep 17 00:00:00 2001 From: Timothy Naughton Date: Mon, 26 Oct 2026 15:43:46 -0500 Subject: [PATCH 3/3] fix(cross-dataset-duplicator): keep payload listed when not logged in to destination Co-Authored-By: Claude Opus 5.5 --- .../cross-dataset-duplicator/src/components/Duplicator.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx b/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx index c11f3b2acc..2634f6f459 100644 --- a/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx +++ b/plugins/@sanity/cross-dataset-duplicator/src/components/Duplicator.tsx @@ -669,6 +669,9 @@ export default function Duplicator(props: DuplicatorProps) { }) if (!destinationClient) { + // Still list the payload so it can be duplicated elsewhere, or here once + // logged in, just without destination statuses + setPayload(payloadActual.map((item) => ({...item, status: undefined}))) setMessage(createNotLoggedInMessage(dest)) return @@ -967,6 +970,8 @@ export default function Duplicator(props: DuplicatorProps) { if (targeted) { setDestination(targeted) + // Clear any message about the previous destination + setMessage(null) updatePayloadStatuses(payload, targeted).catch(console.error) } }