From 9d7ec9b365778202d5e04631222620781b2a0467 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Fri, 22 May 2026 13:31:11 +0200 Subject: [PATCH] feat(store): import cli_identity_bootstrap on store create preview Consumes the new `cli_identity_bootstrap` and `store_auth_bootstrap` fields returned by Donald's PreviewStoresController extension, so the canonical `store create preview` command lands the user in a real (placeholder-backed) Identity session in addition to the per-shop Admin token. Adds `importIdentitySession` to cli-kit, makes `throwOnNoPrompt` non-destructive, and makes app-token exchange tolerant of per-audience failures. --- .../cli-kit/src/private/node/constants.ts | 2 + .../cli-kit/src/private/node/session.test.ts | 15 +- packages/cli-kit/src/private/node/session.ts | 59 +++- .../src/private/node/session/exchange.test.ts | 30 ++ .../src/private/node/session/exchange.ts | 65 +++- .../cli-kit/src/public/node/environment.ts | 18 +- packages/cli-kit/src/public/node/session.ts | 301 +++++++++++++++++- .../services/store/create/preview/client.ts | 70 ++++ .../store/create/preview/index.test.ts | 133 +++++++- .../services/store/create/preview/index.ts | 172 +++++++++- .../services/store/create/preview/result.ts | 11 + 11 files changed, 830 insertions(+), 46 deletions(-) diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index 3b777bc3196..b049b8db858 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -42,6 +42,8 @@ export const environmentVariables = { spinAppHost: 'SPIN_APP_HOST', organization: 'SHOPIFY_CLI_ORGANIZATION', identityToken: 'SHOPIFY_CLI_IDENTITY_TOKEN', + identityTokenUserId: 'SHOPIFY_CLI_IDENTITY_USER_ID', + identityTokenExpiresAt: 'SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT', refreshToken: 'SHOPIFY_CLI_REFRESH_TOKEN', otelURL: 'SHOPIFY_CLI_OTEL_EXPORTER_OTLP_ENDPOINT', themeKitAccessDomain: 'SHOPIFY_CLI_THEME_KIT_ACCESS_DOMAIN', diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index 8d3593fd1be..bba390fdce8 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -177,7 +177,7 @@ describe('ensureAuthenticated when previous session is invalid', () => { expect(fetchSessions).toHaveBeenCalledOnce() }) - test('throws an error and logs out if there is no session and prompting is disabled,', async () => { + test('throws an error when there is no session and prompting is disabled, without wiping the Sessions store', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') vi.mocked(fetchSessions).mockResolvedValue(undefined) @@ -189,13 +189,12 @@ describe('ensureAuthenticated when previous session is invalid', () => { The CLI is currently unable to prompt for reauthentication.`, ) - expect(secureRemove).toHaveBeenCalled() - - // Then - await expect(getLastSeenAuthMethod()).resolves.toEqual('none') - - // If there never was an auth event, the userId is 'unknown' - await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('unknown') + // `throwOnNoPrompt` intentionally does NOT call `logout()` anymore: wiping the + // full Sessions store on every noPrompt failure is destructive for callers that + // rely on a backend-issued session (e.g. preview-store placeholders) that can't + // be reconstructed without re-creating a shop. Users who explicitly want to + // clear sessions can run `shopify auth logout`. + expect(secureRemove).not.toHaveBeenCalled() }) test('executes complete auth flow if session is for a different fqdn', async () => { diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3b750d1cdcb..fbf6b2d1e28 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -228,22 +228,39 @@ ${outputToken.json(applications)} const validationResult = await validateSession(scopes, applications, currentSession) let newSession = {} + const canAuthenticateWithoutPrompt = canAuthenticateWithoutPromptFromEnvironment(_env) if (validationResult === 'needs_full_auth') { - await throwOnNoPrompt(noPrompt) + if (!canAuthenticateWithoutPrompt) { + await throwOnNoPrompt(noPrompt) + } outputDebug(outputContent`Initiating the full authentication flow...`) - newSession = await executeCompleteFlow(applications, currentSession?.identity.alias) + newSession = await executeCompleteFlow(applications, _env, currentSession?.identity.alias) } else if (validationResult === 'needs_refresh' || forceRefresh) { outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`) try { newSession = await refreshTokens(currentSession!, applications) } catch (error) { if (error instanceof InvalidGrantError) { - await throwOnNoPrompt(noPrompt) - newSession = await executeCompleteFlow(applications, currentSession?.identity.alias) + if (!canAuthenticateWithoutPrompt) { + await throwOnNoPrompt(noPrompt) + } + newSession = await executeCompleteFlow(applications, _env, currentSession?.identity.alias) } else if (error instanceof InvalidRequestError) { - await sessionStore.remove() - throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again") + // Surface the error scoped to the failed refresh; do NOT wipe the entire + // Sessions store. The previous behavior (`sessionStore.remove()`) was + // destructive when called against backend-issued sessions (preview-store + // placeholders): a single Identity-side refresh rejection — e.g. because + // a downstream command called `ensureAuthenticatedAdmin` without a + // `storeFqdn` and validation tried to refresh a non-existent application + // token — wiped the imported placeholder session that can't be + // reconstructed without re-creating the shop. Now the error message tells + // the user how to recover (`shopify auth logout` if they actually want + // to clear) without doing the wipe automatically. + throw new AbortError( + '\nError validating auth session', + "The active session couldn't be refreshed. If you need to clear it, run `shopify auth logout` and try again.", + ) } else { throw error } @@ -275,14 +292,25 @@ ${outputToken.json(applications)} return tokens } +function canAuthenticateWithoutPromptFromEnvironment(env?: NodeJS.ProcessEnv): boolean { + return Boolean(getIdentityTokenInformation(env) ?? getAppAutomationToken()) +} + async function throwOnNoPrompt(noPrompt: boolean) { if (!noPrompt) return - await logout() + // Intentionally NOT calling `logout()` here. The original behavior was to wipe the + // entire Sessions store on every noPrompt failure, but that's destructive when the + // caller is in a 401-retry cascade (e.g. an `unauthorizedHandler` calling + // `unsafeRefreshToken({noPrompt: true})` after a single API rejects the token). + // For backend-issued / imported sessions (preview-store placeholders, app-automation + // tokens), the cached session is the only source of truth on disk and we'd rather + // surface a clear error than silently destroy state the user can't reconstruct. + // Users who explicitly want to clear sessions can run `shopify auth logout`. throw new AbortError( `The currently available CLI credentials are invalid. The CLI is currently unable to prompt for reauthentication.`, - 'Restart the CLI process you were running. If in an interactive terminal, you will be prompted to reauthenticate. If in a non-interactive terminal, ensure the correct credentials are available in the program environment.', + 'Restart the CLI process you were running. If in an interactive terminal, you will be prompted to reauthenticate. If in a non-interactive terminal, ensure the correct credentials are available in the program environment. If you imported a backend-issued session (e.g. via `store create preview`), run `shopify auth logout` to clear it before retrying.', ) } @@ -292,7 +320,11 @@ The CLI is currently unable to prompt for reauthentication.`, * @param applications - An object containing the applications we need to be authenticated with. * @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails. */ -async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise { +async function executeCompleteFlow( + applications: OAuthApplications, + _env?: NodeJS.ProcessEnv, + existingAlias?: string, +): Promise { const scopes = getFlattenScopes(applications) const exchangeScopes = getExchangeScopes(applications) const store = applications.adminApi?.storeFqdn @@ -302,7 +334,7 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia } let identityToken: IdentityToken - const identityTokenInformation = getIdentityTokenInformation() + const identityTokenInformation = getIdentityTokenInformation(_env) if (identityTokenInformation) { identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation) } else { @@ -442,11 +474,12 @@ function getExchangeScopes(apps: OAuthApplications): ExchangeScopes { function buildIdentityTokenFromEnv( scopes: string[], - identityTokenInformation: {accessToken: string; refreshToken: string; userId: string}, + identityTokenInformation: {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date}, ) { + const {expiresAt, ...rest} = identityTokenInformation return { - ...identityTokenInformation, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ...rest, + expiresAt: expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), scopes, alias: undefined, } diff --git a/packages/cli-kit/src/private/node/session/exchange.test.ts b/packages/cli-kit/src/private/node/session/exchange.test.ts index d0f2c95c97e..2dc5cd3754e 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -135,6 +135,36 @@ describe('exchange identity token for application tokens', () => { } expect(got).toEqual(expected) }) + + test('tolerates per-audience invalid_request rejections and returns the subset of tokens that did mint', async () => { + // Simulates the preview-store / placeholder case: the Identity token is + // bound to a single OAuth application, so the exchanges for the audiences + // that aren't authorized come back with `invalid_request`, while the ones + // that are authorized mint normally. + const okBody = new Response(JSON.stringify(data)) + const invalidRequest = () => new Response(JSON.stringify({error: 'invalid_request'}), {status: 400}) + vi.mocked(shopifyFetch) + .mockResolvedValueOnce(invalidRequest()) // partners + .mockResolvedValueOnce(invalidRequest()) // storefront-renderer + .mockResolvedValueOnce(okBody.clone()) // business-platform + .mockResolvedValueOnce(okBody.clone()) // admin (we pass a store) + .mockResolvedValueOnce(invalidRequest()) // app-management + + const got = await exchangeAccessForApplicationTokens(identityToken, scopes, 'storeFQDN') + + expect(Object.keys(got).sort()).toEqual(['business-platform', 'storeFQDN-admin']) + expect(got['business-platform']?.accessToken).toBe('access_token') + expect(got['storeFQDN-admin']?.accessToken).toBe('access_token') + }) + + test('re-throws InvalidRequestError when every exchange fails so the outer flow prompts for re-auth', async () => { + const invalidRequest = () => new Response(JSON.stringify({error: 'invalid_request'}), {status: 400}) + vi.mocked(shopifyFetch).mockImplementation(async () => invalidRequest()) + + await expect( + exchangeAccessForApplicationTokens(identityToken, scopes, 'storeFQDN'), + ).rejects.toBeInstanceOf(InvalidRequestError) + }) }) describe('refresh access tokens', () => { diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index e5a60bac820..6c6fe54632b 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -6,6 +6,7 @@ import {identityFqdn} from '../../../public/node/context/fqdn.js' import {shopifyFetch} from '../../../public/node/http.js' import {err, ok, Result} from '../../../public/node/result.js' import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js' +import {outputContent, outputDebug, outputToken} from '../../../public/node/output.js' import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js' import {nonRandomUUID} from '../../../public/node/crypto.js' @@ -24,10 +25,26 @@ export interface ExchangeScopes { } /** - * Given an identity token, request an application token. + * Given an identity token, request an application token for each Shopify API + * (partners / storefront-renderer / business-platform / admin / app-management). + * + * Per-API failures are tolerated: a token exchange that returns `invalid_request` + * or other non-fatal errors for one audience is logged at debug level and skipped, + * while the successful exchanges are merged into the returned record. Callers + * already validate that the specific API token they need is present (see the + * `No token found after ensuring authenticated` BugError throws in + * `public/node/session.ts`), so partial success surfaces a clear, scoped error at + * the call site rather than a confusing `invalid_request` mid-Promise.all. + * + * The motivating case is server-issued Identity bootstraps (e.g. preview-store + * `cli_identity_bootstrap`) whose Identity token is bound to a single OAuth + * application and therefore can only be exchanged for a subset of audiences. For + * normal device-auth logins all five exchanges still succeed exactly as before. + * * @param identityToken - access token obtained in a previous step + * @param scopes - per-API scope sets to request * @param store - the store to use, only needed for admin API - * @returns An array with the application access tokens. + * @returns A merged record of every application token that was successfully minted. */ export async function exchangeAccessForApplicationTokens( identityToken: IdentityToken, @@ -36,21 +53,49 @@ export async function exchangeAccessForApplicationTokens( ): Promise> { const token = identityToken.accessToken - const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ + const settled = await Promise.allSettled([ requestAppToken('partners', token, scopes.partners), requestAppToken('storefront-renderer', token, scopes.storefront), requestAppToken('business-platform', token, scopes.businessPlatform), - store ? requestAppToken('admin', token, scopes.admin, store) : {}, + store ? requestAppToken('admin', token, scopes.admin, store) : Promise.resolve({}), requestAppToken('app-management', token, scopes.appManagement), ]) - return { - ...partners, - ...storefront, - ...businessPlatform, - ...admin, - ...appManagement, + const apiOrder = ['partners', 'storefront-renderer', 'business-platform', 'admin', 'app-management'] as const + const result: Record = {} + const rejections: unknown[] = [] + + for (const [index, outcome] of settled.entries()) { + if (outcome.status === 'fulfilled') { + Object.assign(result, outcome.value) + continue + } + rejections.push(outcome.reason) + outputDebug( + outputContent`Application-token exchange skipped for ${outputToken.raw( + apiOrder[index] ?? 'unknown', + )}: ${outputToken.raw( + outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason), + )}`, + ) } + + // If at least one exchange succeeded the identity token is intact and the + // remaining failures are per-audience authorization issues we tolerate. If + // *every* exchange failed and the failures are identity-level (InvalidGrant / + // InvalidRequest), surface the first one so the outer flow can prompt for + // re-auth as before. Any non-identity-level failure (e.g. InvalidTargetError) + // is also surfaced verbatim so users see the actionable message. + if (Object.keys(result).length === 0 && rejections.length > 0) { + const fatal = + rejections.find((reason) => reason instanceof InvalidGrantError) ?? + rejections.find((reason) => reason instanceof InvalidRequestError) ?? + rejections.find((reason) => !(reason instanceof InvalidGrantError || reason instanceof InvalidRequestError)) ?? + rejections[0] + throw fatal + } + + return result } /** diff --git a/packages/cli-kit/src/public/node/environment.ts b/packages/cli-kit/src/public/node/environment.ts index 844ae277b04..6052df2dd9c 100644 --- a/packages/cli-kit/src/public/node/environment.ts +++ b/packages/cli-kit/src/public/node/environment.ts @@ -55,14 +55,24 @@ export function getBackendPort(): number | undefined { * * @returns The identity token information in case it exists. */ -export function getIdentityTokenInformation(): {accessToken: string; refreshToken: string; userId: string} | undefined { - const identityToken = getEnvironmentVariables()[environmentVariables.identityToken] - const refreshToken = getEnvironmentVariables()[environmentVariables.refreshToken] +export function getIdentityTokenInformation( + env: NodeJS.ProcessEnv = getEnvironmentVariables(), +): {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date} | undefined { + const identityToken = env[environmentVariables.identityToken] + const refreshToken = env[environmentVariables.refreshToken] if (!identityToken || !refreshToken) return undefined + + const explicitUserId = env[environmentVariables.identityTokenUserId] + const explicitExpiresAtIso = env[environmentVariables.identityTokenExpiresAt] + const parsedExpiresAt = explicitExpiresAtIso ? new Date(explicitExpiresAtIso) : undefined + const validExpiresAt = + parsedExpiresAt && !Number.isNaN(parsedExpiresAt.getTime()) ? parsedExpiresAt : undefined + return { accessToken: identityToken, refreshToken, - userId: nonRandomUUID(identityToken), + userId: explicitUserId ?? nonRandomUUID(identityToken), + ...(validExpiresAt ? {expiresAt: validExpiresAt} : {}), } } diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 1ebffdeaf47..1a1ac676998 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -2,8 +2,10 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' import {getAppAutomationToken} from './environment.js' import {AbortError, BugError} from './error.js' -import {outputContent, outputToken, outputDebug} from './output.js' +import {outputCompleted, outputContent, outputDebug, outputToken} from './output.js' +import {identityFqdn} from './context/fqdn.js' import * as sessionStore from '../../private/node/session/store.js' +import {getCurrentSessionId, setCurrentSessionId} from '../../private/node/conf-store.js' import { exchangeCustomPartnerToken, exchangeAppAutomationTokenForAppManagementAccessToken, @@ -21,6 +23,11 @@ import { setLastSeenUserIdAfterAuth, } from '../../private/node/session.js' import {isThemeAccessSession} from '../../private/node/api/rest.js' +import {environmentVariables} from '../../private/node/constants.js' +import {applicationId} from '../../private/node/session/identity.js' +import {allDefaultScopes} from '../../private/node/session/scopes.js' +import {firstPartyDev} from './context/local.js' +import type {Session as IdentityStoredSession, Sessions} from '../../private/node/session/schema.js' /** * Session Object to access the Admin API, includes the token and the store FQDN. @@ -85,6 +92,298 @@ export function isServiceAccount(account: AccountInfo): account is ServiceAccoun return account.type === 'ServiceAccount' } +/** + * Diagnostic snapshot of the currently-active CLI session. Returned by + * `getCurrentSessionInfo`. Intended for `shopify auth whoami` and similar + * inspection commands; not part of the normal request-execution path. + * + * All token values are masked to their first 8 characters + length to avoid + * leaking credentials into logs/screenshots, while still letting an operator + * eyeball that a token is present and roughly which one. + */ +export interface CurrentSessionInfo { + /** True when a Sessions row resolves to the current session id. */ + loggedIn: boolean + /** Identity FQDN this run resolved to (e.g. `accounts.shopify.com`, `identity.shop.dev`). */ + identityFqdn: string + /** Active session id (= bucket key in `Sessions[fqdn]`). */ + userId?: string + /** Heuristic: `userId` looks like a UUID and matches the imported placeholder convention. */ + looksLikePlaceholder?: boolean + /** Display alias from the identity record (typically the user email, undefined for placeholders). */ + alias?: string + /** Number of scopes claimed by the identity token. */ + scopeCount?: number + /** Scopes claimed by the identity token. */ + scopes?: string[] + /** Masked preview of the identity access token + raw length. */ + identityToken?: {preview: string; length: number; expiresAt: string} + /** Whether the identity refresh token is present + its length. */ + refreshToken?: {present: true; length: number} | {present: false} + /** Per-audience application tokens cached for this session. */ + applications?: { + appId: string + preview: string + length: number + expiresAt: string + storeFqdn?: string + }[] + /** + * The raw, unredacted session row read from disk. Populated only when the + * caller explicitly opts in via `getCurrentSessionInfo({raw: true})`. Use + * sparingly — contains live access tokens and refresh tokens. + */ + raw?: IdentityStoredSession + /** + * The full `Sessions` blob (all fqdns, all users) read from disk. Populated + * only when `{raw: true}`. Useful for diagnosing why a particular session + * is/isn't being resolved as the current one. + */ + rawAllSessions?: Sessions +} + +function maskToken(token: string | undefined): {preview: string; length: number} | undefined { + if (!token) return undefined + const visible = token.length <= 8 ? token : `${token.slice(0, 8)}…` + return {preview: visible, length: token.length} +} + +/** + * Options for `getCurrentSessionInfo`. + */ +export interface GetCurrentSessionInfoOptions { + /** + * When true, include the raw (unredacted) session row and the full Sessions + * blob on disk. Contains live tokens — do not log this in shared channels. + * Intended for `shopify auth whoami --raw` and equivalent diagnostic uses. + */ + raw?: boolean +} + +/** + * Read the currently-active CLI session from disk and return a non-secret + * snapshot of its shape. Does NOT validate scopes, refresh expired tokens, or + * make any network calls — it's a pure inspection of `sessionStore.fetch()`. + * + * Returns `{ loggedIn: false }` when there is no current session row on disk. + * + * @param options - Optional flags. Pass `{raw: true}` to include unredacted + * tokens and the full Sessions blob in the returned object. + */ +export async function getCurrentSessionInfo( + options: GetCurrentSessionInfoOptions = {}, +): Promise { + const fqdn = await identityFqdn() + const sessions = await sessionStore.fetch() + const currentUserId = getCurrentSessionId() + const fqdnBucket = sessions?.[fqdn] + const session = currentUserId ? fqdnBucket?.[currentUserId] : undefined + + if (!session) { + return { + loggedIn: false, + identityFqdn: fqdn, + ...(currentUserId ? {userId: currentUserId} : {}), + ...(options.raw && sessions ? {rawAllSessions: sessions} : {}), + } + } + + // Placeholder heuristic: imported preview-store sessions use the placeholder + // account UUID as both `identity.userId` and the bucket key, with no alias. + // Real `auth login` sessions either store a numeric Identity user id or have + // a fetched email alias. + const looksLikePlaceholder = !session.identity.alias && /^[0-9a-f]{8}-[0-9a-f]{4}/i.test(currentUserId ?? '') + + return { + loggedIn: true, + identityFqdn: fqdn, + userId: currentUserId, + looksLikePlaceholder, + ...(session.identity.alias ? {alias: session.identity.alias} : {}), + scopeCount: session.identity.scopes.length, + scopes: session.identity.scopes, + identityToken: { + ...maskToken(session.identity.accessToken)!, + expiresAt: session.identity.expiresAt.toISOString?.() ?? String(session.identity.expiresAt), + }, + refreshToken: session.identity.refreshToken + ? {present: true, length: session.identity.refreshToken.length} + : {present: false}, + applications: Object.entries(session.applications).map(([appId, app]) => ({ + appId, + ...maskToken(app.accessToken)!, + expiresAt: app.expiresAt.toISOString?.() ?? String(app.expiresAt), + ...(app.storeFqdn ? {storeFqdn: app.storeFqdn} : {}), + })), + ...(options.raw + ? { + raw: session, + ...(sessions ? {rawAllSessions: sessions} : {}), + } + : {}), + } +} + +/** + * A bootstrap payload describing a backend-issued Identity OAuth session that + * the CLI should adopt as if `auth login` had just succeeded. + * + * Used by `importIdentitySession` to make `preview create` (and similar + * server-driven account-provisioning flows) leave the CLI authenticated with + * a real `IdentityToken` + `refreshToken` + per-application tokens, persisted + * under the standard `Sessions[identityFqdn][userId]` storage. + */ +export interface IdentitySessionBootstrap { + /** Identity access token (`shpat_...`-style or Identity-issued). Required. */ + accessToken: string + /** Identity refresh token. Required. */ + refreshToken: string + /** When the `accessToken` is expected to expire. Required. */ + expiresAt: Date + /** + * The Identity-side user id this session represents. Optional; when omitted, + * a deterministic UUID derived from `accessToken` is used. For placeholder + * accounts the backend should pass the placeholder UUID directly so the + * resulting bucket lines up with `ResourceOwner` rows on the server. + */ + userId?: string + /** + * Per-shop Admin API tokens to cache alongside the Identity session. + * + * The Admin API only accepts shop-app tokens (`shpat_*`), not Identity-issued + * OAuth access tokens. The bootstrap's `accessToken` above is the Identity + * OAuth token — valid for Identity-fronted APIs (partners / BP / storefront- + * renderer / app-management) but rejected by the Admin API with + * `[API] Service is not valid for authentication`. To make + * `ensureAuthenticatedAdmin(storeFqdn)` resolve to a working token, callers + * must pass the per-shop `shpat_*` token here (e.g. the value of + * `store_auth_bootstrap.access_token` from a preview-store create response). + * + * Each entry's key should be the same domain the user will type as `--store` + * (e.g. `preview-X.dev-api.shop.dev` on the rig, where `.my.shop.dev` isn't + * routable). The token is seeded into `applications[`${storeFqdn}-${adminAppId}`]` + * with a 1-year expiry; that's the exact key `tokensFor` looks up when an + * Admin API call requests `storeFqdn`. Omit when the bootstrap isn't per-shop. + */ + adminStoreTokens?: Record +} + +/** + * Adopt a backend-issued Identity OAuth session as the active CLI account. + * + * Writes the bootstrap tokens directly into the `Sessions[identityFqdn][userId]` + * storage and marks the row as the current session. To satisfy `validateSession` + * without triggering the device-auth re-prompt or the multi-audience token + * exchange, the import pre-seeds: + * + * - `identity.scopes`: the union of all default CLI scopes (plus `employee` + * when running as a first-party dev). The placeholder's effective scopes + * are opaque to the CLI; we claim coverage so `validateScopes` returns true + * and the cached session is used verbatim. + * - `applications`: the same bootstrap accessToken aliased under every + * standard appId (`admin`, `partners`, `business-platform`, + * `storefront-renderer`, `app-management`). The placeholder bootstrap is + * usable directly against the audiences the backend authorized for it + * (typically Admin + Business Platform); aliasing it lets + * `ensureAuthenticatedBusinessPlatform` / `ensureAuthenticatedAdmin` read + * it straight out of the cache without re-running the exchange. APIs that + * the bootstrap is not authorized for will reject the request with a + * clean 401/403 at the call site — better than a mid-import re-auth loop. + * + * The caller is expected to have already obtained a valid Identity refresh + * token from a trusted backend path (e.g. `POST /services/preview-stores`). + * No browser, no device-code prompt, no consent UI. + * + * @param bootstrap - Backend-issued Identity tokens to import. + * @returns The userId under which the session was persisted. + */ +export async function importIdentitySession( + bootstrap: IdentitySessionBootstrap, +): Promise<{userId: string}> { + const fqdn = await identityFqdn() + const userId = bootstrap.userId ?? nonRandomUUID(bootstrap.accessToken) + + // Pre-seed scopes so `validateScopes` always returns true for any default + // CLI command. `employee` is included conditionally because `validateScopes` + // requires the firstPartyDev flag and the `employee` scope to track each + // other (`firstPartyDev() !== currentScopes.includes('employee')` fails the + // check otherwise). + const scopes = allDefaultScopes(firstPartyDev() ? ['employee'] : []) + + // Build the `applications` map. Two distinct token shapes flow in here and + // they're not interchangeable: + // + // - Identity OAuth tokens (`atkn_2.CvMB...`, what the backend mints via + // `OAuth::PublicClientAccessToken.from_refresh_token`). These work + // against APIs that accept Identity-issued audience tokens — BP, + // partners, storefront-renderer, app-management. They do NOT work + // against the Admin API, which only accepts shop-app tokens (the + // `shpat_*` format minted by `Apps::Installations::EnsureInstalled`). + // - Shop-app Admin tokens (`shpat_*`, from the per-shop install). These + // are per-shop. The CLI gets one per preview shop via + // `store_auth_bootstrap.access_token`. + // + // `tokensFor` reads non-admin audiences from the bare `applicationId(api)` + // key and admin from the store-prefixed `${storeFqdn}-${adminAppId}` key. + // We seed each with the right token shape. + const farFuture = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) + const identityScopedToken = {accessToken: bootstrap.accessToken, expiresAt: farFuture, scopes} + const adminAppId = applicationId('admin') + const storeScopedAdminEntries = Object.fromEntries( + Object.entries(bootstrap.adminStoreTokens ?? {}).map(([storeFqdn, shpatToken]) => [ + `${storeFqdn}-${adminAppId}`, + {accessToken: shpatToken, expiresAt: farFuture, scopes}, + ]), + ) + const applications: IdentityStoredSession['applications'] = { + [applicationId('partners')]: identityScopedToken, + [applicationId('business-platform')]: identityScopedToken, + [applicationId('storefront-renderer')]: identityScopedToken, + [applicationId('app-management')]: identityScopedToken, + ...storeScopedAdminEntries, + } + + const newSession: IdentityStoredSession = { + identity: { + accessToken: bootstrap.accessToken, + refreshToken: bootstrap.refreshToken, + expiresAt: bootstrap.expiresAt, + scopes, + userId, + // `alias` is intentionally undefined — `fetchEmail` would require a + // working business-platform API call and the placeholder has no + // human-meaningful email anyway. Downstream renderers should fall back + // to the userId. + alias: undefined, + }, + applications, + } + + // Merge into existing sessions rather than clobbering: a developer may already + // have a real user logged in, and importing a placeholder session should be + // additive (the bucket key is the placeholder's userId, distinct from the + // real user's). `setCurrentSessionId` then flips the active selector to the + // imported session so subsequent commands resolve to it by default. + const existing = (await sessionStore.fetch()) ?? ({} as Sessions) + const updated: Sessions = { + ...existing, + [fqdn]: {...existing[fqdn], [userId]: newSession}, + } + await sessionStore.store(updated) + setCurrentSessionId(userId) + setLastSeenAuthMethod('device_auth') + setLastSeenUserIdAfterAuth(userId) + + outputDebug( + outputContent`Imported backend-issued Identity session for user ${outputToken.raw(userId)} (fqdn: ${outputToken.raw( + fqdn, + )}).`, + ) + outputCompleted(`Identity session imported.`) + + return {userId} +} + /** * Ensure that we have a valid session with no particular scopes. * diff --git a/packages/store/src/cli/services/store/create/preview/client.ts b/packages/store/src/cli/services/store/create/preview/client.ts index 1e7de30094e..a6344fe85ba 100644 --- a/packages/store/src/cli/services/store/create/preview/client.ts +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -29,6 +29,37 @@ interface PreviewStoreCreateRequest { country?: string } +/** + * Identity-side OAuth bootstrap returned by Core when the orchestrator was able + * to mint a real `IdentityToken` + `refreshToken` for the placeholder account + * (see `PlaceholderSessions::TokenBuilder` in shop/world). + * + * When present, the CLI imports this into the standard `Sessions[identityFqdn][userId]` + * storage via `importIdentitySession`, so the placeholder behaves like a real + * logged-in account for subsequent commands (organization list, partners API, + * business-platform API, etc.). Optional for back-compat with the original PoC + * orchestrator that only returned an Admin API token. + */ +export interface PreviewCliIdentityBootstrap { + accessToken: string + refreshToken: string + expiresIn: number + userId?: string +} + +/** + * Per-store auth bootstrap returned by Core describing the Admin API session + * that should be persisted in the store-auth bucket. When present, the CLI uses + * its `clientId` / `scopes` / `accessToken` / `shopDomain` fields verbatim in + * place of the legacy `admin_api_token` + sentinel scopes path. Optional. + */ +export interface PreviewStoreAuthBootstrap { + accessToken: string + scopes: string[] + apiKey: string + shopDomain: string +} + /** * Response from `POST /services/preview-stores`. Field names mirror the snake_case * JSON contract emitted by `Services::PreviewStoresController#create`. @@ -39,6 +70,8 @@ export interface PreviewStoreCreateResponse { placeholderAccountUuid: string adminApiToken: string magicLinkUrl: string + cliIdentityBootstrap?: PreviewCliIdentityBootstrap + storeAuthBootstrap?: PreviewStoreAuthBootstrap } interface RawPreviewStoreCreateResponse { @@ -47,6 +80,8 @@ interface RawPreviewStoreCreateResponse { placeholder_account_uuid?: unknown admin_api_token?: unknown magic_link_url?: unknown + cli_identity_bootstrap?: unknown + store_auth_bootstrap?: unknown } export function defaultPreviewStoreClientOptions( @@ -127,11 +162,46 @@ function narrowResponse(parsed: RawPreviewStoreCreateResponse, url: string): Pre ) } + const cliIdentityBootstrap = narrowCliIdentityBootstrap(parsed.cli_identity_bootstrap) + const storeAuthBootstrap = narrowStoreAuthBootstrap(parsed.store_auth_bootstrap) + return { shopId, shopPermanentDomain, placeholderAccountUuid, adminApiToken, magicLinkUrl, + ...(cliIdentityBootstrap ? {cliIdentityBootstrap} : {}), + ...(storeAuthBootstrap ? {storeAuthBootstrap} : {}), } } + +function narrowCliIdentityBootstrap(value: unknown): PreviewCliIdentityBootstrap | undefined { + if (!value || typeof value !== 'object') return undefined + const raw = value as Record + const accessToken = typeof raw.access_token === 'string' ? raw.access_token : undefined + const refreshToken = typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined + const expiresIn = typeof raw.expires_in === 'number' && Number.isFinite(raw.expires_in) ? raw.expires_in : undefined + const userId = typeof raw.user_id === 'string' ? raw.user_id : undefined + + // All three required fields must be present and well-typed; partial payloads + // are dropped silently so a malformed bootstrap can't strand the create flow. + if (!accessToken || !refreshToken || expiresIn === undefined) return undefined + + return {accessToken, refreshToken, expiresIn, ...(userId ? {userId} : {})} +} + +function narrowStoreAuthBootstrap(value: unknown): PreviewStoreAuthBootstrap | undefined { + if (!value || typeof value !== 'object') return undefined + const raw = value as Record + const accessToken = typeof raw.access_token === 'string' ? raw.access_token : undefined + const apiKey = typeof raw.api_key === 'string' ? raw.api_key : undefined + const shopDomain = typeof raw.shop_domain === 'string' ? raw.shop_domain : undefined + const scopes = Array.isArray(raw.scopes) && raw.scopes.every((scope) => typeof scope === 'string') + ? (raw.scopes as string[]) + : undefined + + if (!accessToken || !apiKey || !shopDomain || !scopes) return undefined + + return {accessToken, scopes, apiKey, shopDomain} +} diff --git a/packages/store/src/cli/services/store/create/preview/index.test.ts b/packages/store/src/cli/services/store/create/preview/index.test.ts index f0c6b0fde6f..6520203a005 100644 --- a/packages/store/src/cli/services/store/create/preview/index.test.ts +++ b/packages/store/src/cli/services/store/create/preview/index.test.ts @@ -3,7 +3,7 @@ import {createPreviewStoreCommand, placeholderUserId, PLACEHOLDER_USER_ID_PREFIX import {STORE_AUTH_APP_CLIENT_ID} from '../../auth/config.js' import {setStoredStoreAppSession} from '../../auth/session-store.js' import {recordStoreFqdnMetadata} from '../../attribution.js' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {importIdentitySession, setLastSeenUserId} from '@shopify/cli-kit/node/session' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('./client.js', async () => { @@ -44,6 +44,7 @@ describe('createPreviewStoreCommand', () => { vi.mocked(setStoredStoreAppSession).mockReset() vi.mocked(recordStoreFqdnMetadata).mockReset() vi.mocked(setLastSeenUserId).mockReset() + vi.mocked(importIdentitySession).mockReset() vi.mocked(recordStoreFqdnMetadata).mockResolvedValue(undefined) }) @@ -115,6 +116,7 @@ describe('createPreviewStoreCommand', () => { magicLinkUrl: fakeResponse.magicLinkUrl, magicLinkExpiresAt: '2026-03-27T00:30:00.000Z', userId: `${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`, + identityImported: false, }) }) @@ -124,5 +126,134 @@ describe('createPreviewStoreCommand', () => { await expect(createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow)).rejects.toThrow('boom') expect(setStoredStoreAppSession).not.toHaveBeenCalled() expect(setLastSeenUserId).not.toHaveBeenCalled() + expect(importIdentitySession).not.toHaveBeenCalled() + }) + + describe('when the orchestrator returns a cli_identity_bootstrap', () => { + const bootstrapResponse = { + ...fakeResponse, + cliIdentityBootstrap: { + accessToken: 'identity_access_token', + refreshToken: 'identity_refresh_token', + expiresIn: 3600, + userId: fakeResponse.placeholderAccountUuid, + }, + } + + test('persists the store-auth bucket under the synthetic id and additively imports the Identity session', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(bootstrapResponse) + vi.mocked(importIdentitySession).mockResolvedValue({userId: fakeResponse.placeholderAccountUuid}) + + const result = await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + // The store-auth bucket is always keyed under the synthetic placeholder id + // so a failed Identity import can't orphan the shop on the CLI side. + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: `${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`, + kind: 'preview', + }), + ) + // The Identity import is additive, keyed under the Identity-resolved UUID. + // We also pass the result's `shopPermanentDomain` (the routable shop FQDN) + // mapped to the Admin shop-app token (`shpat_*`) so `importIdentitySession` + // seeds the store-prefixed Admin entry with a token the Admin API actually + // accepts. The Identity OAuth token (`bootstrap.accessToken`) is rejected + // by Admin with `[API] Service is not valid for authentication`; only the + // shop-app token works there. + expect(importIdentitySession).toHaveBeenCalledTimes(1) + expect(importIdentitySession).toHaveBeenCalledWith({ + accessToken: 'identity_access_token', + refreshToken: 'identity_refresh_token', + expiresAt: expect.any(Date), + userId: fakeResponse.placeholderAccountUuid, + adminStoreTokens: {[fakeResponse.shopPermanentDomain]: fakeResponse.adminApiToken}, + }) + expect(setLastSeenUserId).toHaveBeenCalledWith(`${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`) + expect(result.userId).toBe(`${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`) + expect(result.identityImported).toBe(true) + expect(result.identityUserId).toBe(fakeResponse.placeholderAccountUuid) + }) + + test('continues to persist the store-auth bucket when the Identity import throws', async () => { + vi.mocked(createPreviewStore).mockResolvedValue(bootstrapResponse) + vi.mocked(importIdentitySession).mockRejectedValue(new Error('Identity unreachable')) + + const result = await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: `${PLACEHOLDER_USER_ID_PREFIX}${fakeResponse.placeholderAccountUuid}`, + kind: 'preview', + }), + ) + expect(result.identityImported).toBe(false) + expect(result.identityUserId).toBeUndefined() + }) + + test('falls back to the placeholder UUID when the bootstrap omits an explicit user id', async () => { + const {userId: _omitted, ...bootstrapWithoutUserId} = bootstrapResponse.cliIdentityBootstrap + vi.mocked(createPreviewStore).mockResolvedValue({ + ...bootstrapResponse, + cliIdentityBootstrap: bootstrapWithoutUserId, + }) + vi.mocked(importIdentitySession).mockResolvedValue({userId: fakeResponse.placeholderAccountUuid}) + + await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(importIdentitySession).toHaveBeenCalledWith( + expect.objectContaining({userId: fakeResponse.placeholderAccountUuid}), + ) + }) + + test('rejects bootstraps with a non-positive or absurdly long expiry but still persists the store-auth bucket', async () => { + vi.mocked(createPreviewStore).mockResolvedValue({ + ...bootstrapResponse, + cliIdentityBootstrap: {...bootstrapResponse.cliIdentityBootstrap, expiresIn: -1}, + }) + + const result = await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + // The validation fires inside the best-effort import block; the shop is + // still persisted to local storage so the user can `store execute` against it. + expect(setStoredStoreAppSession).toHaveBeenCalledWith(expect.objectContaining({kind: 'preview'})) + expect(importIdentitySession).not.toHaveBeenCalled() + expect(result.identityImported).toBe(false) + }) + }) + + describe('when the orchestrator returns a store_auth_bootstrap', () => { + test('keys the bucket under the bootstrap shopDomain (the routable Admin API host) and surfaces it as shopPermanentDomain', async () => { + // Donald's BE returns two distinct domains: `shop_permanent_domain` is the + // canonical display name (`*.my.shop.dev`) while `store_auth_bootstrap.shop_domain` + // is where the Admin API is actually served (`*.dev-api.shop.dev` on the rig). + // On the rig the canonical domain has no Spin routing, so we must use + // `shopDomain` as the operationally-correct host for bucket lookup AND for + // building the Admin URL downstream. + vi.mocked(createPreviewStore).mockResolvedValue({ + ...fakeResponse, + storeAuthBootstrap: { + accessToken: 'bootstrap_admin_token', + scopes: ['read_products', 'write_products'], + apiKey: 'bootstrap_api_key', + shopDomain: 'preview-1.dev-api.shop.dev', + }, + }) + + const result = await createPreviewStoreCommand({shopName: 'preview-demo'}, () => fixedNow) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'preview-1.dev-api.shop.dev', + clientId: 'bootstrap_api_key', + accessToken: 'bootstrap_admin_token', + scopes: ['read_products', 'write_products'], + kind: 'preview', + }), + ) + // The result struct surfaces the routable shopDomain so `--json` consumers + // can pipe directly into `store execute --store ...`. + expect(result.shopPermanentDomain).toBe('preview-1.dev-api.shop.dev') + }) }) }) diff --git a/packages/store/src/cli/services/store/create/preview/index.ts b/packages/store/src/cli/services/store/create/preview/index.ts index 19b5bcf75f3..e56fef53646 100644 --- a/packages/store/src/cli/services/store/create/preview/index.ts +++ b/packages/store/src/cli/services/store/create/preview/index.ts @@ -1,4 +1,6 @@ import { + PreviewCliIdentityBootstrap, + PreviewStoreAuthBootstrap, PreviewStoreClientOptions, PreviewStoreCreateResponse, createPreviewStore, @@ -7,7 +9,14 @@ import { import {STORE_AUTH_APP_CLIENT_ID} from '../../auth/config.js' import {setStoredStoreAppSession} from '../../auth/session-store.js' import {recordStoreFqdnMetadata} from '../../attribution.js' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputWarn} from '@shopify/cli-kit/node/output' +import {importIdentitySession, setLastSeenUserId} from '@shopify/cli-kit/node/session' + +// Cap the lifetime of an imported placeholder Identity session at one year. Beyond +// this, the bootstrap is almost certainly a mistake (or worse, a malicious response +// from a compromised orchestrator) and we'd rather refuse than persist it. +const MAX_BOOTSTRAP_IDENTITY_LIFETIME_SECONDS = 365 * 24 * 60 * 60 /** * Preview-store sessions are issued by Core's preview-stores orchestrator with the @@ -47,8 +56,24 @@ export interface CreatePreviewStoreResult { adminApiToken: string magicLinkUrl: string magicLinkExpiresAt: string - /** The synthetic user id under which the session was persisted in the local store. */ + /** + * The synthetic `placeholder:` user id under which the store-auth + * bucket was persisted. Always present. + */ userId: string + /** + * Whether the orchestrator's `cli_identity_bootstrap` was successfully + * imported into the standard `Sessions[identityFqdn][]` storage, + * making the placeholder the active CLI account for Identity-backed + * commands. False when no bootstrap was returned, or when the import + * itself failed (the store-auth bucket is still persisted in that case). + */ + identityImported: boolean + /** + * The Identity-side userId under which the imported session was keyed. + * Present only when `identityImported` is true. + */ + identityUserId?: string } export function placeholderUserId(placeholderAccountUuid: string): string { @@ -77,7 +102,80 @@ export async function createPreviewStoreCommand( clientOptions, ) - return persistPreviewStoreSession(response, clientOptions, now) + // We persist the store-auth bucket FIRST, then best-effort the Identity import. + // Rationale: a failed Identity import (e.g. single-audience token, network blip) + // must not leave the just-created shop orphaned on the CLI side — `store execute` + // / `store list` against the new shop should keep working regardless. The + // Identity session is purely additive: when it succeeds, downstream commands + // resolve the placeholder as the active CLI user; when it fails, the legacy + // synthetic `placeholder:` userId path is what we already have. + // + // Note: this means the store-auth bucket is keyed under the synthetic id on + // every run. If we ever want the bucket and Identity session to share a userId + // we'd need to do the import first AND have it succeed reliably; not worth the + // coupling today. + const result = persistPreviewStoreSession(response, clientOptions, now) + + if (response.cliIdentityBootstrap) { + // The Admin entry in the imported Identity session has to carry a *shop-app* + // token (the `shpat_*` from `store_auth_bootstrap.access_token`), not the + // Identity OAuth token from `cli_identity_bootstrap`. The Admin API rejects + // Identity-issued tokens with `[API] Service is not valid for authentication`. + // We pull it from the same `storeAuthBootstrap` block that drove the + // store-auth bucket above (preferred) and fall back to the top-level + // legacy `adminApiToken` for back-compat with the original PoC orchestrator. + const adminShopToken = response.storeAuthBootstrap?.accessToken ?? response.adminApiToken + const identityImport = await adoptCliIdentityBootstrap( + response.cliIdentityBootstrap, + response.placeholderAccountUuid, + {[result.shopPermanentDomain]: adminShopToken}, + ).catch((error: unknown) => { + // Best-effort: log and continue. The shop is already persisted on disk + // above, so the user can still execute against it via the Admin token. + const message = error instanceof Error ? error.message : String(error) + outputWarn(`Identity session import failed; continuing without an Identity-backed session.\n Reason: ${message}`) + return undefined + }) + if (identityImport) { + result.identityImported = true + result.identityUserId = identityImport.userId + } + } + + return result +} + +async function adoptCliIdentityBootstrap( + bootstrap: PreviewCliIdentityBootstrap, + placeholderAccountUuid: string, + adminStoreTokens: Record, +): Promise<{userId: string}> { + if ( + !Number.isFinite(bootstrap.expiresIn) || + bootstrap.expiresIn <= 0 || + bootstrap.expiresIn > MAX_BOOTSTRAP_IDENTITY_LIFETIME_SECONDS + ) { + throw new AbortError( + 'Preview store returned an invalid CLI identity bootstrap expiry.', + `Expected a positive seconds value <= ${MAX_BOOTSTRAP_IDENTITY_LIFETIME_SECONDS}; got ${String(bootstrap.expiresIn)}.`, + ) + } + + return importIdentitySession({ + accessToken: bootstrap.accessToken, + refreshToken: bootstrap.refreshToken, + expiresAt: new Date(Date.now() + bootstrap.expiresIn * 1000), + // Prefer the explicit user id from the orchestrator; fall back to the + // placeholder account UUID so the session bucket always matches the + // ResourceOwner id on the Identity side. + userId: bootstrap.userId ?? placeholderAccountUuid, + // Pass the per-shop Admin (`shpat_*`) tokens so `importIdentitySession` + // seeds the store-prefixed Admin entry that + // `ensureAuthenticatedAdmin(storeFqdn)` looks up. The Identity OAuth token + // alone is rejected by the Admin API; we have to cache the shop-app + // token explicitly under the right key. + adminStoreTokens, + }) } function persistPreviewStoreSession( @@ -88,6 +186,10 @@ function persistPreviewStoreSession( const acquiredAt = now() const acquiredAtIso = acquiredAt.toISOString() const magicLinkExpiresAt = new Date(acquiredAt.getTime() + MAGIC_LINK_TTL_MS).toISOString() + // The store-auth bucket is always keyed under the synthetic `placeholder:` + // userId. The Identity bootstrap (when present) is persisted under a *separate* + // bucket in `Sessions[identityFqdn][]` by `importIdentitySession`, and the + // two are intentionally decoupled so a failed import doesn't orphan the shop. const userId = placeholderUserId(response.placeholderAccountUuid) // Record fqdn metadata before and after so analytics see the same shape we emit @@ -95,12 +197,22 @@ function persistPreviewStoreSession( // once we have a usable token. recordStoreFqdnMetadata(response.shopPermanentDomain, false).catch(() => undefined) + const storeAuth = resolveStoreAuth(response.storeAuthBootstrap, response) + // When the orchestrator returns a `store_auth_bootstrap`, its `shopDomain` + // is the host that actually serves the Admin API for this shop in the + // current environment (`*.dev-api.shop.dev` on the rig). The top-level + // `shop_permanent_domain` is the canonical/display domain (`*.my.shop.dev`) + // but doesn't route to a running Spin instance. Surface the routable host + // as `shopPermanentDomain` so users can pass it straight to + // `store execute --store ...` and the URL the CLI builds resolves. + const routableShopDomain = storeAuth.store + setStoredStoreAppSession({ - store: response.shopPermanentDomain, - clientId: STORE_AUTH_APP_CLIENT_ID, + store: storeAuth.store, + clientId: storeAuth.clientId, userId, - accessToken: response.adminApiToken, - scopes: PREVIEW_STORE_SCOPES, + accessToken: storeAuth.accessToken, + scopes: storeAuth.scopes, acquiredAt: acquiredAtIso, kind: 'preview', preview: { @@ -116,11 +228,53 @@ function persistPreviewStoreSession( return { shopId: response.shopId, - shopPermanentDomain: response.shopPermanentDomain, + shopPermanentDomain: routableShopDomain, placeholderAccountUuid: response.placeholderAccountUuid, - adminApiToken: response.adminApiToken, + adminApiToken: storeAuth.accessToken, magicLinkUrl: response.magicLinkUrl, magicLinkExpiresAt, userId, + identityImported: false, + } +} + +/** + * Reconciles the two possible sources of the persisted store-auth fields: + * + * - `store_auth_bootstrap` from the orchestrator (preferred when present; + * carries the real per-shop `apiKey` / granted `scopes` from Core), or + * - The legacy top-level `admin_api_token` from the original PoC contract, + * keyed by the CLI-side `STORE_AUTH_APP_CLIENT_ID` and the empty-scopes + * sentinel. + * + * Note: the bucket is *always* keyed under `response.shopPermanentDomain` + * (the `.my.shop.dev` form), regardless of whether the bootstrap reports a + * separate `shopDomain` (`.dev-api.shop.dev`). The permanent domain is what + * the user passes to downstream commands (`store execute --store ...`, + * `store list`), so the lookup key has to match it. The bootstrap's + * `shopDomain` is treated as informational only. + */ +function resolveStoreAuth( + bootstrap: PreviewStoreAuthBootstrap | undefined, + response: PreviewStoreCreateResponse, +): {store: string; clientId: string; accessToken: string; scopes: string[]} { + if (bootstrap) { + return { + // Key the bucket under the bootstrap's `shopDomain` because that's the + // host that actually serves the Admin API for this shop in the current + // environment. The top-level `shop_permanent_domain` is canonical but + // doesn't route to a live Spin instance on the rig, so we surface + // `shopDomain` to the user as the value to pass to `--store`. + store: bootstrap.shopDomain, + clientId: bootstrap.apiKey, + accessToken: bootstrap.accessToken, + scopes: bootstrap.scopes, + } + } + return { + store: response.shopPermanentDomain, + clientId: STORE_AUTH_APP_CLIENT_ID, + accessToken: response.adminApiToken, + scopes: PREVIEW_STORE_SCOPES, } } diff --git a/packages/store/src/cli/services/store/create/preview/result.ts b/packages/store/src/cli/services/store/create/preview/result.ts index 4302b35193e..6cd253eb012 100644 --- a/packages/store/src/cli/services/store/create/preview/result.ts +++ b/packages/store/src/cli/services/store/create/preview/result.ts @@ -25,6 +25,11 @@ function serializeAsJson(result: CreatePreviewStoreResult): string { magicLinkUrl: result.magicLinkUrl, magicLinkExpiresAt: result.magicLinkExpiresAt, userId: result.userId, + // True when the orchestrator returned a `cli_identity_bootstrap` and the CLI + // imported it as an active Identity session. False signals the legacy + // (synthetic `placeholder:` userId) fallback path — typically because + // the backend isn't yet on the bootstrap-emitting branch. + identityImported: result.identityImported, }, null, 2, @@ -51,6 +56,12 @@ function renderTextResult(result: CreatePreviewStoreResult): void { title: 'Magic link (one-time-use, expires in ~30 minutes)', body: result.magicLinkUrl, }, + { + title: 'CLI identity', + body: result.identityImported + ? `Logged in as placeholder ${result.placeholderAccountUuid}. Run shopify-authed commands (e.g. \`shopify organization list\`) without an extra login.` + : `Stored under synthetic user id \`${result.userId}\`. The orchestrator did not return a CLI identity bootstrap, so no Identity-backed session was imported.`, + }, ], nextSteps: [ [