Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cdd-cross-project-auth.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -654,10 +662,21 @@ 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) {
// 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
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

const destinationData = await destinationClient.fetch<SanityDocument[]>(
`*[_id in $payloadIds]{ _id, _updatedAt }`,
{payloadIds},
Expand Down Expand Up @@ -758,6 +777,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
Expand All @@ -766,12 +800,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}[] = []

Expand Down Expand Up @@ -942,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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 {
const client = {
config: () => config,
withConfig: (next: Partial<ClientConfig>) => createClient({...config, ...next}),
}

// 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<AuthState>) {
const unsubscribe = vi.fn()
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}
},
},
}

// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only state.subscribe() is called
const auth = store 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()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {SanityClient} from '@sanity/client'
import type {AuthState, AuthStore, WorkspaceSummary} from 'sanity'

type GetDestinationClientOptions = {
originClient: SanityClient
destination: Pick<WorkspaceSummary, 'projectId' | 'dataset' | 'auth'>
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<AuthState> {
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<SanityClient | null> {
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})
}
Loading