Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756)

This commit is contained in:
martmull
2026-07-10 12:10:00 +02:00
committed by GitHub
parent f8d3555fe7
commit 1c8b8970fd
14 changed files with 780 additions and 292 deletions
@@ -8,6 +8,7 @@ import {
ensureAppAccessTokenIsValidOrRefresh,
ensureAppRegistration,
} from '@/cli/utilities/auth';
import { buildAppTokenPairFetcher } from '@/cli/utilities/auth/build-app-token-pair-fetcher';
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
@@ -354,7 +355,13 @@ const innerAppDevOnce = async (
try {
const appAccessToken = await ensureAppAccessTokenIsValidOrRefresh(
configService,
{ clientId, clientSecret },
{
credentials: clientSecret ? { clientId, clientSecret } : undefined,
fetchTokenPair: buildAppTokenPairFetcher(
apiService,
createDevAppResult.data.id,
),
},
);
const clientService = new ClientService();
@@ -76,6 +76,12 @@ export class ApiService {
return this.applicationApi.createDevelopmentApplication(...args);
}
generateApplicationToken(
...args: Parameters<ApplicationApi['generateApplicationToken']>
) {
return this.applicationApi.generateApplicationToken(...args);
}
syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
@@ -209,6 +209,61 @@ export class ApplicationApi {
}
}
async generateApplicationToken(applicationId: string): Promise<
ApiResponse<{
applicationAccessToken: { token: string; expiresAt: string };
applicationRefreshToken: { token: string; expiresAt: string };
}>
> {
try {
const mutation = `
mutation GenerateApplicationToken($applicationId: UUID!) {
generateApplicationToken(applicationId: $applicationId) {
applicationAccessToken {
token
expiresAt
}
applicationRefreshToken {
token
expiresAt
}
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { applicationId },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.generateApplicationToken,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createDevelopmentApplication(input: {
universalIdentifier: string;
name: string;
@@ -0,0 +1,22 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
// Builds the fetchTokenPair token source for
// ensureAppAccessTokenIsValidOrRefresh: mints a workspace-scoped app token
// pair via the generateApplicationToken mutation.
export const buildAppTokenPairFetcher =
(apiService: ApiService, applicationId: string) =>
async (): Promise<
{ accessToken: string; refreshToken?: string } | undefined
> => {
const tokenResult =
await apiService.generateApplicationToken(applicationId);
if (!tokenResult.success || !tokenResult.data) {
return undefined;
}
return {
accessToken: tokenResult.data.applicationAccessToken.token,
refreshToken: tokenResult.data.applicationRefreshToken.token,
};
};
@@ -17,9 +17,20 @@ const isTokenExpired = (token: string): boolean => {
}
};
export type AppTokenSources = {
credentials?: { clientId: string; clientSecret: string };
// Workspace-scoped token minting (generateApplicationToken mutation). Used
// when the registration is not owned by this workspace (e.g. an app synced
// from the marketplace catalog), where no client secret is available and
// rotating the shared one would break the published app.
fetchTokenPair?: () => Promise<
{ accessToken: string; refreshToken?: string } | undefined
>;
};
export const ensureAppAccessTokenIsValidOrRefresh = async (
configService: ConfigService,
credentials?: { clientId: string; clientSecret: string },
tokenSources?: AppTokenSources,
): Promise<string | undefined> => {
const config = await configService.getConfig();
@@ -62,18 +73,31 @@ export const ensureAppAccessTokenIsValidOrRefresh = async (
appAccessToken: undefined,
appRefreshToken: undefined,
});
return undefined;
}
} catch {
// Non-JSON error response (e.g. proxy 502) — fall through to credential exchange
// Non-JSON error response (e.g. proxy 502) — fall through to the other token sources
}
}
if (credentials) {
if (tokenSources?.fetchTokenPair) {
const tokenPair = await tokenSources.fetchTokenPair();
if (tokenPair) {
await configService.setConfig({
appAccessToken: tokenPair.accessToken,
...(tokenPair.refreshToken
? { appRefreshToken: tokenPair.refreshToken }
: {}),
});
return tokenPair.accessToken;
}
}
if (tokenSources?.credentials) {
const result = await exchangeCredentialsForTokens(
configService,
credentials,
tokenSources.credentials,
);
return result.accessToken;
@@ -8,7 +8,7 @@ export const ensureAppRegistration = async (
app: { name: string; universalIdentifier: string },
): Promise<{
clientId: string;
clientSecret: string;
clientSecret?: string;
isNewRegistration: boolean;
}> => {
const createResult = await apiService.createApplicationRegistration({
@@ -68,18 +68,12 @@ export const ensureAppRegistration = async (
appRefreshToken: undefined,
});
const rotateResult =
await apiService.rotateApplicationRegistrationClientSecret(registration.id);
if (!rotateResult.success || !rotateResult.data) {
throw new Error(
`Failed to rotate client secret for registration ${registration.id}`,
);
}
// The registration may be a catalog-synced npm app owned by another (or no)
// workspace, so rotating its shared client secret is neither allowed nor
// desirable. Dev mode mints workspace-scoped app tokens via the
// generateApplicationToken mutation instead.
return {
clientId: registration.oAuthClientId,
clientSecret: rotateResult.data.clientSecret,
isNewRegistration: false,
};
};
@@ -1,4 +1,6 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { buildAppTokenPairFetcher } from '@/cli/utilities/auth/build-app-token-pair-fetcher';
import { type AppTokenSources } from '@/cli/utilities/auth/ensure-app-access-token-is-valid-or-refresh';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
@@ -227,13 +229,31 @@ export class DevModeOrchestrator {
if (objectsOrFieldsChanged) {
await this.generateApiClientStep.execute({
appPath: this.state.appPath,
credentials: this.registerAppStep.registrationCredentials,
tokenSources: this.buildAppTokenSources(),
});
this.skipTypecheck = false;
}
}
private buildAppTokenSources(): AppTokenSources {
const credentials = this.registerAppStep.registrationCredentials;
const applicationId =
this.state.steps.resolveApplication.output.applicationId;
return {
credentials: credentials?.clientSecret
? {
clientId: credentials.clientId,
clientSecret: credentials.clientSecret,
}
: undefined,
fetchTokenPair: applicationId
? buildAppTokenPairFetcher(this.apiService, applicationId)
: undefined,
};
}
private async initializePipeline(manifest: Manifest): Promise<boolean> {
await this.registerAppStep.execute({ manifest });
@@ -1,4 +1,5 @@
import { ensureAppAccessTokenIsValidOrRefresh } from '@/cli/utilities/auth';
import { type AppTokenSources } from '@/cli/utilities/auth/ensure-app-access-token-is-valid-or-refresh';
import { type ClientService } from '@/cli/utilities/client/client-service';
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
@@ -28,7 +29,7 @@ export class GenerateApiClientOrchestratorStep {
async execute(input: {
appPath: string;
credentials?: { clientId: string; clientSecret: string };
tokenSources?: AppTokenSources;
}): Promise<void> {
const step = this.state.steps.generateApiClient;
@@ -38,7 +39,7 @@ export class GenerateApiClientOrchestratorStep {
try {
const appAccessToken = await ensureAppAccessTokenIsValidOrRefresh(
this.configService,
input.credentials,
input.tokenSources,
);
await this.clientService.generateCoreClient({
@@ -11,7 +11,7 @@ export class RegisterAppOrchestratorStep {
private notify: () => void;
registrationCredentials:
| { clientId: string; clientSecret: string }
| { clientId: string; clientSecret?: string }
| undefined;
constructor({