Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
+30
-6
@@ -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 });
|
||||
|
||||
|
||||
+3
-2
@@ -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({
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export class RegisterAppOrchestratorStep {
|
||||
private notify: () => void;
|
||||
|
||||
registrationCredentials:
|
||||
| { clientId: string; clientSecret: string }
|
||||
| { clientId: string; clientSecret?: string }
|
||||
| undefined;
|
||||
|
||||
constructor({
|
||||
|
||||
Reference in New Issue
Block a user