Replace JWT claim system with npm maintainer email matching and provenance metadata
- Remove ApplicationNpmClaimService, NpmClaimTokenDTO, NPM_CLAIM JWT type, and all claim-related mutations/methods across server, SDK, and frontend - Add provenanceRepositoryUrl, isProvenanceVerified, provenanceVerifiedAt fields to ApplicationRegistrationEntity with core migration - Create ApplicationNpmRegistrationService with email-based ownership verification and lightweight npm provenance attestation fetching - Add registerNpmPackage GraphQL mutation with workspace auth guards - Enforce twenty-app- prefix for all npm package names in validation and marketplace search filtering - Create .github/actions/publish-twenty-app composite action for building and publishing apps with --provenance - Scaffold publish.yml GitHub workflow in create-twenty-app - Add app:register CLI command and public operation in SDK - Update frontend empty state to show app:register instructions - Enrich MarketplaceCatalogSyncService to fetch and store provenance metadata during npm app discovery - Add unit tests for registration service and package name validation Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
name: Publish Twenty App
|
||||
description: Build and publish a Twenty app to npm with provenance
|
||||
|
||||
inputs:
|
||||
npm-token:
|
||||
description: npm token with publish permissions
|
||||
required: true
|
||||
npm-tag:
|
||||
description: npm dist-tag
|
||||
required: false
|
||||
default: latest
|
||||
app-path:
|
||||
description: Path to the app directory
|
||||
required: false
|
||||
default: '.'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install and build
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: |
|
||||
yarn install --immutable
|
||||
npx twenty app:build
|
||||
|
||||
- name: Publish with provenance
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}/.twenty/output
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ inputs.npm-token }}
|
||||
run: npm publish --provenance --access public --tag ${{ inputs.npm-tag }}
|
||||
@@ -66,7 +66,7 @@ export class CreateAppCommand {
|
||||
name: 'name',
|
||||
message: 'Application name:',
|
||||
when: () => !directory,
|
||||
default: 'my-awesome-app',
|
||||
default: 'twenty-app-my-awesome-app',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
|
||||
@@ -36,6 +36,8 @@ export const copyBaseApplicationProject = async ({
|
||||
|
||||
await createYarnLock(appDirectory);
|
||||
|
||||
await createPublishWorkflow(appDirectory);
|
||||
|
||||
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
|
||||
|
||||
await fs.ensureDir(sourceFolderPath);
|
||||
@@ -141,6 +143,33 @@ const createYarnLock = async (appDirectory: string) => {
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
|
||||
};
|
||||
|
||||
const createPublishWorkflow = async (appDirectory: string) => {
|
||||
const workflowDir = join(appDirectory, '.github', 'workflows');
|
||||
|
||||
await fs.ensureDir(workflowDir);
|
||||
|
||||
const workflowContent = `name: Publish
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: twentyhq/twenty/.github/actions/publish-twenty-app@main
|
||||
with:
|
||||
npm-token: \${{ secrets.NPM_TOKEN }}
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(workflowDir, 'publish.yml'), workflowContent);
|
||||
};
|
||||
const createGitignore = async (appDirectory: string) => {
|
||||
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
@@ -583,10 +612,18 @@ const createPackageJson = async ({
|
||||
devDependencies['vite-tsconfig-paths'] = '^4.2.1';
|
||||
}
|
||||
|
||||
const normalizedName = appName.startsWith('twenty-app-')
|
||||
? appName
|
||||
: `twenty-app-${appName}`;
|
||||
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
name: normalizedName,
|
||||
version: '0.1.0',
|
||||
license: 'MIT',
|
||||
keywords: ['twenty-app'],
|
||||
publishConfig: {
|
||||
access: 'public' as const,
|
||||
},
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
|
||||
@@ -357,6 +357,7 @@ export type ApplicationRegistration = {
|
||||
id: Scalars['UUID'];
|
||||
isFeatured: Scalars['Boolean'];
|
||||
isListed: Scalars['Boolean'];
|
||||
isProvenanceVerified: Scalars['Boolean'];
|
||||
latestAvailableVersion?: Maybe<Scalars['String']>;
|
||||
logoUrl?: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
@@ -364,6 +365,7 @@ export type ApplicationRegistration = {
|
||||
oAuthRedirectUris: Array<Scalars['String']>;
|
||||
oAuthScopes: Array<Scalars['String']>;
|
||||
ownerWorkspaceId?: Maybe<Scalars['UUID']>;
|
||||
provenanceRepositoryUrl?: Maybe<Scalars['String']>;
|
||||
sourcePackage?: Maybe<Scalars['String']>;
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
termsUrl?: Maybe<Scalars['String']>;
|
||||
@@ -2535,6 +2537,7 @@ export type Mutation = {
|
||||
verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginToken;
|
||||
verifyEmailAndGetWorkspaceAgnosticToken: AvailableWorkspacesAndAccessTokens;
|
||||
verifyEmailingDomain: EmailingDomain;
|
||||
registerNpmPackage: ApplicationRegistration;
|
||||
verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethod;
|
||||
};
|
||||
|
||||
@@ -3478,6 +3481,11 @@ export type MutationVerifyEmailingDomainArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationRegisterNpmPackageArgs = {
|
||||
packageName: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs = {
|
||||
otp: Scalars['String'];
|
||||
};
|
||||
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useApolloClient, useMutation } from '@apollo/client';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
|
||||
import { INSTALL_APPLICATION } from '~/modules/marketplace/graphql/mutations/installApplication';
|
||||
import { useUploadAppTarball } from '~/modules/marketplace/hooks/useUploadAppTarball';
|
||||
import {
|
||||
StyledAppModal,
|
||||
StyledAppModalButton,
|
||||
StyledAppModalSection,
|
||||
StyledAppModalTitle,
|
||||
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
|
||||
|
||||
export const UPLOAD_TARBALL_MODAL_ID = 'upload-tarball-modal';
|
||||
|
||||
const StyledFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
export const SettingsUploadTarballModal = () => {
|
||||
const { t: tFn } = useLingui();
|
||||
const { closeModal } = useModal();
|
||||
const { upload, isUploading } = useUploadAppTarball();
|
||||
const [installApplication] = useMutation(INSTALL_APPLICATION);
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const apolloClient = useApolloClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!isDefined(file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadResult = await upload(file);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registrationId = uploadResult.registrationId;
|
||||
|
||||
if (isDefined(registrationId)) {
|
||||
try {
|
||||
await installApplication({
|
||||
variables: { appRegistrationId: registrationId },
|
||||
});
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Application installed successfully.`,
|
||||
});
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Tarball uploaded but installation failed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await apolloClient.refetchQueries({
|
||||
include: [FIND_MANY_APPLICATION_REGISTRATIONS],
|
||||
});
|
||||
closeModal(UPLOAD_TARBALL_MODAL_ID);
|
||||
} finally {
|
||||
if (isDefined(fileInputRef.current)) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
closeModal(UPLOAD_TARBALL_MODAL_ID);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledAppModal
|
||||
modalId={UPLOAD_TARBALL_MODAL_ID}
|
||||
isClosable={true}
|
||||
padding="large"
|
||||
dataGloballyPreventClickOutside
|
||||
>
|
||||
<StyledAppModalTitle>
|
||||
<H1Title
|
||||
title={tFn`Upload tarball`}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
</StyledAppModalTitle>
|
||||
<StyledAppModalSection
|
||||
alignment={SectionAlignment.Center}
|
||||
fontColor={SectionFontColor.Primary}
|
||||
>
|
||||
{tFn`Select a .tar.gz application package to upload and install.`}
|
||||
</StyledAppModalSection>
|
||||
|
||||
<StyledFileInput
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<StyledAppModalButton
|
||||
onClick={handleCancel}
|
||||
variant="secondary"
|
||||
title={tFn`Cancel`}
|
||||
fullWidth
|
||||
/>
|
||||
<StyledAppModalButton
|
||||
onClick={handleSelectFile}
|
||||
variant="secondary"
|
||||
accent="blue"
|
||||
title={tFn`Choose file`}
|
||||
disabled={isUploading}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledAppModal>
|
||||
);
|
||||
};
|
||||
+72
-26
@@ -2,7 +2,6 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
|
||||
import { FIND_MANY_APPLICATION_REGISTRATIONS } from '@/settings/application-registrations/graphql/queries/findManyApplicationRegistrations';
|
||||
import { SettingsListCard } from '@/settings/components/SettingsListCard';
|
||||
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -17,26 +16,27 @@ import {
|
||||
IconChevronRight,
|
||||
IconCopy,
|
||||
IconFileInfo,
|
||||
IconUpload,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
import {
|
||||
SettingsUploadTarballModal,
|
||||
UPLOAD_TARBALL_MODAL_ID,
|
||||
} from '~/pages/settings/applications/components/SettingsUploadTarballModal';
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
margin: ${themeCssVariables.spacing[2]} 0;
|
||||
`;
|
||||
|
||||
const StyledButtonGroupContainer = styled.div`
|
||||
const StyledPublishMethodLabel = styled.div`
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
margin-top: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledEmptyStateContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
type ApplicationRegistration = {
|
||||
@@ -50,7 +50,6 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
@@ -59,23 +58,64 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
const registrations: ApplicationRegistration[] =
|
||||
data?.findManyApplicationRegistrations ?? [];
|
||||
|
||||
const commands = [
|
||||
const createCommands = [
|
||||
// oxlint-disable-next-line lingui/no-unlocalized-strings
|
||||
'npx create-twenty-app@latest my-twenty-app',
|
||||
// oxlint-disable-next-line lingui/no-unlocalized-strings
|
||||
'cd my-twenty-app',
|
||||
];
|
||||
|
||||
const copyButton = (
|
||||
const createCopyButton = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
|
||||
copyToClipboard(
|
||||
createCommands.join('\n'),
|
||||
t`Commands copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
ariaLabel={t`Copy commands`}
|
||||
Icon={IconCopy}
|
||||
/>
|
||||
);
|
||||
|
||||
const publishNpmCommands = [
|
||||
// oxlint-disable-next-line lingui/no-unlocalized-strings
|
||||
'npx twenty app:publish',
|
||||
// oxlint-disable-next-line lingui/no-unlocalized-strings
|
||||
'npx twenty app:register <package-name>',
|
||||
];
|
||||
|
||||
const publishNpmCopyButton = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
copyToClipboard(
|
||||
publishNpmCommands.join('\n'),
|
||||
t`Command copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
ariaLabel={t`Copy command`}
|
||||
Icon={IconCopy}
|
||||
/>
|
||||
);
|
||||
|
||||
const publishServerCommands = [
|
||||
// oxlint-disable-next-line lingui/no-unlocalized-strings
|
||||
'npx twenty app:publish --server <server-url>',
|
||||
];
|
||||
|
||||
const publishServerCopyButton = (
|
||||
<Button
|
||||
onClick={() => {
|
||||
copyToClipboard(
|
||||
publishServerCommands.join('\n'),
|
||||
t`Command copied to clipboard`,
|
||||
);
|
||||
}}
|
||||
ariaLabel={t`Copy command`}
|
||||
Icon={IconCopy}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
@@ -83,7 +123,7 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
title={t`Create an application`}
|
||||
description={t`You can either create a private app or share it to others`}
|
||||
/>
|
||||
<CommandBlock commands={commands} button={copyButton} />
|
||||
<CommandBlock commands={createCommands} button={createCopyButton} />
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
Icon={IconFileInfo}
|
||||
@@ -105,7 +145,7 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
title={t`My Apps`}
|
||||
description={t`Apps you've created, registered, or published`}
|
||||
/>
|
||||
{registrations.length > 0 && (
|
||||
{registrations.length > 0 ? (
|
||||
<SettingsListCard
|
||||
items={registrations}
|
||||
getItemLabel={(registration) => registration.name}
|
||||
@@ -125,19 +165,25 @@ export const SettingsApplicationsDeveloperTab = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<StyledEmptyStateContainer>
|
||||
<StyledPublishMethodLabel>
|
||||
{t`Publish to npm`}
|
||||
</StyledPublishMethodLabel>
|
||||
<CommandBlock
|
||||
commands={publishNpmCommands}
|
||||
button={publishNpmCopyButton}
|
||||
/>
|
||||
<StyledPublishMethodLabel>
|
||||
{t`Or push directly to your server`}
|
||||
</StyledPublishMethodLabel>
|
||||
<CommandBlock
|
||||
commands={publishServerCommands}
|
||||
button={publishServerCopyButton}
|
||||
/>
|
||||
</StyledEmptyStateContainer>
|
||||
)}
|
||||
</Section>
|
||||
<StyledButtonGroupContainer>
|
||||
<Button
|
||||
Icon={IconUpload}
|
||||
title={t`Upload tarball`}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => openModal(UPLOAD_TARBALL_MODAL_ID)}
|
||||
/>
|
||||
</StyledButtonGroupContainer>
|
||||
|
||||
<SettingsUploadTarballModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,12 @@ import { formatPath } from '@/cli/utilities/file/file-path';
|
||||
import chalk from 'chalk';
|
||||
import type { Command } from 'commander';
|
||||
import { AppBuildCommand } from './app/app-build';
|
||||
import { AppClaimCommand } from './app/app-claim';
|
||||
import { AppGenerateClientCommand } from './app/app-generate-client';
|
||||
import { AppDevCommand } from './app/app-dev';
|
||||
import { AppPublishCommand } from './app/app-publish';
|
||||
import { AppRegisterCommand } from './app/app-register';
|
||||
import { AppTypecheckCommand } from './app/app-typecheck';
|
||||
import { AppUninstallCommand } from './app/app-uninstall';
|
||||
import { AppVerifyClaimCommand } from './app/app-verify-claim';
|
||||
import { AuthListCommand } from './auth/auth-list';
|
||||
import { AuthLoginCommand } from './auth/auth-login';
|
||||
import { AuthLogoutCommand } from './auth/auth-logout';
|
||||
@@ -66,13 +65,12 @@ export const registerCommands = (program: Command): void => {
|
||||
|
||||
// App commands
|
||||
const buildCommand = new AppBuildCommand();
|
||||
const claimCommand = new AppClaimCommand();
|
||||
const generateClientCommand = new AppGenerateClientCommand();
|
||||
const devCommand = new AppDevCommand();
|
||||
const publishCommand = new AppPublishCommand();
|
||||
const registerCommand = new AppRegisterCommand();
|
||||
const typecheckCommand = new AppTypecheckCommand();
|
||||
const uninstallCommand = new AppUninstallCommand();
|
||||
const verifyClaimCommand = new AppVerifyClaimCommand();
|
||||
const addCommand = new EntityAddCommand();
|
||||
const logsCommand = new LogicFunctionLogsCommand();
|
||||
const executeCommand = new LogicFunctionExecuteCommand();
|
||||
@@ -127,6 +125,15 @@ export const registerCommands = (program: Command): void => {
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('app:register [packageName]')
|
||||
.description(
|
||||
'Register ownership of an npm package (verifies your email is in npm maintainers)',
|
||||
)
|
||||
.action(async (packageName?: string) => {
|
||||
await registerCommand.execute({ packageName });
|
||||
});
|
||||
|
||||
program
|
||||
.command('app:typecheck [appPath]')
|
||||
.description('Run TypeScript type checking on the application')
|
||||
@@ -152,27 +159,6 @@ export const registerCommands = (program: Command): void => {
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('app:claim <packageName> [appPath]')
|
||||
.description(
|
||||
'Generate a claim token for an npm package and write it to the app directory',
|
||||
)
|
||||
.action(async (packageName: string, appPath?: string) => {
|
||||
await claimCommand.execute({
|
||||
packageName,
|
||||
appPath: formatPath(appPath),
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command('app:verify-claim <packageName>')
|
||||
.description(
|
||||
'Verify npm package ownership by checking the claim token in the published package',
|
||||
)
|
||||
.action(async (packageName: string) => {
|
||||
await verifyClaimCommand.execute({ packageName });
|
||||
});
|
||||
|
||||
program
|
||||
.command('entity:add [entityType]')
|
||||
.option('--path <path>', 'Path in which the entity should be created.')
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { appClaim } from '@/cli/public-operations/app-claim';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type AppClaimCommandOptions = {
|
||||
packageName: string;
|
||||
appPath?: string;
|
||||
};
|
||||
|
||||
export class AppClaimCommand {
|
||||
async execute(options: AppClaimCommandOptions): Promise<void> {
|
||||
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
|
||||
|
||||
console.log(
|
||||
chalk.blue(
|
||||
`Claiming npm package "${options.packageName}" for this workspace...`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(`App path: ${appPath}`));
|
||||
console.log('');
|
||||
|
||||
const result = await appClaim({
|
||||
packageName: options.packageName,
|
||||
appPath,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`✓ Claim file written to ${result.data.claimFilePath}`),
|
||||
);
|
||||
console.log('');
|
||||
console.log(chalk.yellow('Next steps:'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' 1. Build and publish your app to npm (the claim file will be included)',
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` 2. Run \`twenty app:verify-claim ${options.packageName}\` to verify ownership`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { appRegister } from '@/cli/public-operations/app-register';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export class AppRegisterCommand {
|
||||
async execute({
|
||||
packageName,
|
||||
appPath = CURRENT_EXECUTION_DIRECTORY,
|
||||
}: {
|
||||
packageName?: string;
|
||||
appPath?: string;
|
||||
}): Promise<void> {
|
||||
console.log(chalk.blue('Registering npm package ownership...'));
|
||||
|
||||
if (packageName) {
|
||||
console.log(chalk.gray(`Package: ${packageName}`));
|
||||
} else {
|
||||
console.log(
|
||||
chalk.gray(`Reading package name from ${appPath}/package.json`),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
const result = await appRegister({ packageName, appPath });
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red('Registration failed:'), result.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green('Package registered successfully!'));
|
||||
console.log(chalk.gray(` Name: ${result.data.name}`));
|
||||
console.log(
|
||||
chalk.gray(` ID: ${result.data.universalIdentifier}`),
|
||||
);
|
||||
|
||||
if (result.data.isProvenanceVerified) {
|
||||
console.log(
|
||||
chalk.green(' Provenance: Verified'),
|
||||
);
|
||||
|
||||
if (result.data.provenanceRepositoryUrl) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
` Repository: ${result.data.provenanceRepositoryUrl}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Provenance: Not found. Publish with --provenance for a verified badge.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { appVerifyClaim } from '@/cli/public-operations/app-verify-claim';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type AppVerifyClaimCommandOptions = {
|
||||
packageName: string;
|
||||
};
|
||||
|
||||
export class AppVerifyClaimCommand {
|
||||
async execute(options: AppVerifyClaimCommandOptions): Promise<void> {
|
||||
console.log(
|
||||
chalk.blue(`Verifying npm claim for "${options.packageName}"...`),
|
||||
);
|
||||
console.log('');
|
||||
|
||||
const result = await appVerifyClaim({
|
||||
packageName: options.packageName,
|
||||
onProgress: (message) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red(result.error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`✓ Ownership verified for "${options.packageName}"`),
|
||||
);
|
||||
console.log(chalk.gray(` Registration: ${result.data.registrationId}`));
|
||||
console.log(chalk.gray(` Name: ${result.data.name}`));
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import { APP_ERROR_CODES, type CommandResult } from './types';
|
||||
|
||||
const CLAIM_FILE_NAME = 'twenty-claim.jwt';
|
||||
|
||||
type AppClaimOptions = {
|
||||
packageName: string;
|
||||
appPath: string;
|
||||
onProgress?: (message: string) => void;
|
||||
};
|
||||
|
||||
type AppClaimResult = {
|
||||
claimFilePath: string;
|
||||
};
|
||||
|
||||
const innerAppClaim = async (
|
||||
options: AppClaimOptions,
|
||||
): Promise<CommandResult<AppClaimResult>> => {
|
||||
const { packageName, appPath, onProgress } = options;
|
||||
|
||||
onProgress?.(`Generating claim token for "${packageName}"...`);
|
||||
|
||||
const apiService = new ApiService();
|
||||
|
||||
const result = await apiService.generateNpmClaimToken(packageName);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage =
|
||||
result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error ?? 'Unknown error');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.CLAIM_FAILED,
|
||||
message: `Failed to generate claim token: ${errorMessage}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const claimFilePath = path.join(appPath, CLAIM_FILE_NAME);
|
||||
|
||||
onProgress?.(`Writing claim file to ${claimFilePath}...`);
|
||||
fs.writeFileSync(claimFilePath, result.data.token, 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { claimFilePath },
|
||||
};
|
||||
};
|
||||
|
||||
export const appClaim = (
|
||||
options: AppClaimOptions,
|
||||
): Promise<CommandResult<AppClaimResult>> =>
|
||||
runSafe(() => innerAppClaim(options), APP_ERROR_CODES.CLAIM_FAILED);
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { ConfigService } from '@/cli/utilities/config/config-service';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import { APP_ERROR_CODES, type CommandResult } from './types';
|
||||
|
||||
export type AppRegisterOptions = {
|
||||
packageName?: string;
|
||||
appPath?: string;
|
||||
workspace?: string;
|
||||
};
|
||||
|
||||
export type AppRegisterResult = {
|
||||
id: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
isProvenanceVerified: boolean;
|
||||
provenanceRepositoryUrl: string | null;
|
||||
};
|
||||
|
||||
const readPackageNameFromDir = (appPath: string): string | undefined => {
|
||||
try {
|
||||
const packageJsonPath = path.join(appPath, 'package.json');
|
||||
const content = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
const packageJson = JSON.parse(content) as { name?: string };
|
||||
|
||||
return packageJson.name;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const innerAppRegister = async (
|
||||
options: AppRegisterOptions,
|
||||
): Promise<CommandResult<AppRegisterResult>> => {
|
||||
if (options.workspace) {
|
||||
ConfigService.setActiveWorkspace(options.workspace);
|
||||
}
|
||||
|
||||
let packageName = options.packageName;
|
||||
|
||||
if (!packageName && options.appPath) {
|
||||
packageName = readPackageNameFromDir(options.appPath);
|
||||
}
|
||||
|
||||
if (!packageName) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.REGISTER_FAILED,
|
||||
message:
|
||||
'Package name is required. Provide it as an argument or run from an app directory with a package.json.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apiService = new ApiService();
|
||||
const result = await apiService.registerNpmPackage(packageName);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage =
|
||||
result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error ?? 'Unknown error');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.REGISTER_FAILED,
|
||||
message: errorMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
};
|
||||
|
||||
export const appRegister = (
|
||||
options: AppRegisterOptions,
|
||||
): Promise<CommandResult<AppRegisterResult>> =>
|
||||
runSafe(() => innerAppRegister(options), APP_ERROR_CODES.REGISTER_FAILED);
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { runSafe } from '@/cli/utilities/run-safe';
|
||||
import { APP_ERROR_CODES, type CommandResult } from './types';
|
||||
|
||||
type AppVerifyClaimOptions = {
|
||||
packageName: string;
|
||||
onProgress?: (message: string) => void;
|
||||
};
|
||||
|
||||
type AppVerifyClaimResult = {
|
||||
registrationId: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const innerAppVerifyClaim = async (
|
||||
options: AppVerifyClaimOptions,
|
||||
): Promise<CommandResult<AppVerifyClaimResult>> => {
|
||||
const { packageName, onProgress } = options;
|
||||
|
||||
onProgress?.(`Verifying claim for "${packageName}" on npm...`);
|
||||
|
||||
const apiService = new ApiService();
|
||||
|
||||
const result = await apiService.verifyNpmPackageClaim(packageName);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage =
|
||||
result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error ?? 'Unknown error');
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: APP_ERROR_CODES.CLAIM_FAILED,
|
||||
message: `Claim verification failed: ${errorMessage}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
};
|
||||
|
||||
export const appVerifyClaim = (
|
||||
options: AppVerifyClaimOptions,
|
||||
): Promise<CommandResult<AppVerifyClaimResult>> =>
|
||||
runSafe(() => innerAppVerifyClaim(options), APP_ERROR_CODES.CLAIM_FAILED);
|
||||
@@ -22,7 +22,7 @@ export const APP_ERROR_CODES = {
|
||||
UNINSTALL_FAILED: 'UNINSTALL_FAILED',
|
||||
SYNC_FAILED: 'SYNC_FAILED',
|
||||
TYPECHECK_FAILED: 'TYPECHECK_FAILED',
|
||||
CLAIM_FAILED: 'CLAIM_FAILED',
|
||||
REGISTER_FAILED: 'REGISTER_FAILED',
|
||||
} as const;
|
||||
|
||||
export const FUNCTION_ERROR_CODES = {
|
||||
|
||||
@@ -810,14 +810,24 @@ export class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async generateNpmClaimToken(
|
||||
packageName: string,
|
||||
): Promise<ApiResponse<{ token: string }>> {
|
||||
async registerNpmPackage(packageName: string): Promise<
|
||||
ApiResponse<{
|
||||
id: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
isProvenanceVerified: boolean;
|
||||
provenanceRepositoryUrl: string | null;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation GenerateNpmClaimToken($packageName: String!) {
|
||||
generateNpmClaimToken(packageName: $packageName) {
|
||||
token
|
||||
mutation RegisterNpmPackage($packageName: String!) {
|
||||
registerNpmPackage(packageName: $packageName) {
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
isProvenanceVerified
|
||||
provenanceRepositoryUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -841,71 +851,13 @@ export class ApiService {
|
||||
success: false,
|
||||
error:
|
||||
response.data.errors[0]?.message ||
|
||||
'Failed to generate claim token',
|
||||
'Failed to register npm package',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.generateNpmClaimToken,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async verifyNpmPackageClaim(packageName: string): Promise<
|
||||
ApiResponse<{
|
||||
registrationId: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation VerifyNpmPackageClaim($packageName: String!) {
|
||||
verifyNpmPackageClaim(packageName: $packageName) {
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.client.post(
|
||||
'/metadata',
|
||||
{
|
||||
query: mutation,
|
||||
variables: { packageName },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.errors) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
response.data.errors[0]?.message || 'Failed to verify npm claim',
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.data.data.verifyNpmPackageClaim;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
registrationId: data.id,
|
||||
universalIdentifier: data.universalIdentifier,
|
||||
name: data.name,
|
||||
},
|
||||
data: response.data.data.registerNpmPackage,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -125,15 +125,6 @@ export const buildApplication = async (
|
||||
collectFileBuilt,
|
||||
});
|
||||
|
||||
await copyStaticFiles({
|
||||
appPath: options.appPath,
|
||||
fileFolder: FileFolder.Source,
|
||||
filePaths: ['twenty-claim.jwt'].filter((filePath) =>
|
||||
pathExistsSync(join(options.appPath, filePath)),
|
||||
),
|
||||
collectFileBuilt,
|
||||
});
|
||||
|
||||
return { builtFileInfos };
|
||||
};
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddApplicationRegistrationProvenanceFields1772877016000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddApplicationRegistrationProvenanceFields1772877016000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD COLUMN "provenanceRepositoryUrl" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD COLUMN "isProvenanceVerified" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" ADD COLUMN "provenanceVerifiedAt" timestamptz`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "provenanceVerifiedAt"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "isProvenanceVerified"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "provenanceRepositoryUrl"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
|
||||
@@ -12,6 +13,7 @@ export class MarketplaceCatalogSyncService {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly applicationNpmRegistrationService: ApplicationNpmRegistrationService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
@@ -62,6 +64,24 @@ export class MarketplaceCatalogSyncService {
|
||||
}
|
||||
|
||||
try {
|
||||
let isProvenanceVerified = false;
|
||||
let provenanceRepositoryUrl: string | null = null;
|
||||
let provenanceVerifiedAt: Date | null = null;
|
||||
|
||||
if (app.version) {
|
||||
const provenance =
|
||||
await this.applicationNpmRegistrationService.fetchProvenanceMetadata(
|
||||
app.sourcePackage ?? app.name,
|
||||
app.version,
|
||||
);
|
||||
|
||||
if (provenance?.hasProvenance) {
|
||||
isProvenanceVerified = true;
|
||||
provenanceRepositoryUrl = provenance.repositoryUrl;
|
||||
provenanceVerifiedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier: app.id,
|
||||
name: app.name,
|
||||
@@ -77,6 +97,9 @@ export class MarketplaceCatalogSyncService {
|
||||
isFeatured: false,
|
||||
marketplaceDisplayData: null,
|
||||
ownerWorkspaceId: null,
|
||||
isProvenanceVerified,
|
||||
provenanceRepositoryUrl,
|
||||
provenanceVerifiedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
|
||||
+12
@@ -53,6 +53,11 @@ export class MarketplaceService {
|
||||
return parsed.data.objects
|
||||
.map((result) => {
|
||||
const { name, version, description, author, links } = result.package;
|
||||
|
||||
if (!this.hasValidAppPrefix(name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const twentyKeyword = (result.package.keywords ?? []).find(
|
||||
(keyword) => keyword.startsWith('twenty-uid:'),
|
||||
);
|
||||
@@ -92,4 +97,11 @@ export class MarketplaceService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private hasValidAppPrefix(packageName: string): boolean {
|
||||
return (
|
||||
packageName.startsWith('twenty-app-') ||
|
||||
/^@[^/]+\/twenty-app-/.test(packageName)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/application-package/utils/assert-valid-npm-package-name.util';
|
||||
|
||||
describe('assertValidNpmPackageName', () => {
|
||||
it('should accept valid twenty-app- prefixed names', () => {
|
||||
expect(() => assertValidNpmPackageName('twenty-app-my-cool-app')).not.toThrow();
|
||||
expect(() => assertValidNpmPackageName('twenty-app-hello')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept scoped packages with twenty-app- prefix', () => {
|
||||
expect(() =>
|
||||
assertValidNpmPackageName('@myorg/twenty-app-my-app'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject names without twenty-app- prefix', () => {
|
||||
expect(() => assertValidNpmPackageName('my-cool-app')).toThrow(
|
||||
ApplicationException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject scoped packages without twenty-app- prefix', () => {
|
||||
expect(() =>
|
||||
assertValidNpmPackageName('@myorg/my-cool-app'),
|
||||
).toThrow(ApplicationException);
|
||||
});
|
||||
|
||||
it('should reject invalid npm package names', () => {
|
||||
expect(() => assertValidNpmPackageName('Twenty-App-Test')).toThrow(
|
||||
ApplicationException,
|
||||
);
|
||||
expect(() => assertValidNpmPackageName('twenty-app-../evil')).toThrow(
|
||||
ApplicationException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject empty or malformed names', () => {
|
||||
expect(() => assertValidNpmPackageName('')).toThrow(ApplicationException);
|
||||
});
|
||||
});
|
||||
+13
@@ -8,6 +8,9 @@ import {
|
||||
const NPM_PACKAGE_NAME_REGEX =
|
||||
/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
||||
|
||||
const TWENTY_APP_PREFIX = 'twenty-app-';
|
||||
const TWENTY_APP_SCOPED_PREFIX = /^@[^/]+\/twenty-app-/;
|
||||
|
||||
export const assertValidNpmPackageName = (name: string): void => {
|
||||
if (!NPM_PACKAGE_NAME_REGEX.test(name) || name.includes('..')) {
|
||||
throw new ApplicationException(
|
||||
@@ -15,4 +18,14 @@ export const assertValidNpmPackageName = (name: string): void => {
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!name.startsWith(TWENTY_APP_PREFIX) &&
|
||||
!TWENTY_APP_SCOPED_PREFIX.test(name)
|
||||
) {
|
||||
throw new ApplicationException(
|
||||
'npm package name must start with "twenty-app-" (or "@scope/twenty-app-")',
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
const createMockUser = (
|
||||
overrides: Partial<UserEntity> = {},
|
||||
): UserEntity =>
|
||||
({
|
||||
id: 'user-123',
|
||||
email: 'dev@example.com',
|
||||
isEmailVerified: true,
|
||||
...overrides,
|
||||
}) as UserEntity;
|
||||
|
||||
describe('ApplicationNpmRegistrationService', () => {
|
||||
let service: ApplicationNpmRegistrationService;
|
||||
let applicationRegistrationService: jest.Mocked<ApplicationRegistrationService>;
|
||||
let applicationPackageFetcherService: jest.Mocked<ApplicationPackageFetcherService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApplicationNpmRegistrationService,
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'APP_REGISTRY_URL') {
|
||||
return 'https://registry.npmjs.org';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationPackageFetcherService,
|
||||
useValue: {
|
||||
resolveNpmPackage: jest.fn(),
|
||||
cleanupExtractedDir: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationRegistrationService,
|
||||
useValue: {
|
||||
findOneByUniversalIdentifier: jest.fn(),
|
||||
upsertFromNpmRegistration: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ApplicationNpmRegistrationService);
|
||||
applicationRegistrationService = module.get(
|
||||
ApplicationRegistrationService,
|
||||
);
|
||||
applicationPackageFetcherService = module.get(
|
||||
ApplicationPackageFetcherService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('registerNpmPackage', () => {
|
||||
it('should reject unverified email', async () => {
|
||||
const user = createMockUser({ isEmailVerified: false });
|
||||
|
||||
await expect(
|
||||
service.registerNpmPackage('twenty-app-test', user, 'workspace-1'),
|
||||
).rejects.toThrow(ApplicationRegistrationException);
|
||||
|
||||
await expect(
|
||||
service.registerNpmPackage('twenty-app-test', user, 'workspace-1'),
|
||||
).rejects.toMatchObject({
|
||||
code: ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject packages without twenty-app- prefix', async () => {
|
||||
const user = createMockUser();
|
||||
|
||||
await expect(
|
||||
service.registerNpmPackage('some-other-package', user, 'workspace-1'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject when user email is not in maintainers', async () => {
|
||||
const user = createMockUser({ email: 'notamaintainer@example.com' });
|
||||
|
||||
jest.spyOn(service, 'fetchPackument').mockResolvedValueOnce({
|
||||
name: 'twenty-app-test',
|
||||
'dist-tags': { latest: '1.0.0' },
|
||||
maintainers: [{ name: 'other', email: 'other@example.com' }],
|
||||
versions: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.registerNpmPackage(
|
||||
'twenty-app-test',
|
||||
user,
|
||||
'workspace-1',
|
||||
),
|
||||
).rejects.toThrow(ApplicationRegistrationException);
|
||||
});
|
||||
|
||||
it('should register successfully when email matches a maintainer', async () => {
|
||||
const user = createMockUser({ email: 'Dev@Example.com' });
|
||||
|
||||
const mockRegistration = {
|
||||
id: 'reg-1',
|
||||
universalIdentifier: 'uid-123',
|
||||
name: 'Test App',
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'fetchPackument').mockResolvedValueOnce({
|
||||
name: 'twenty-app-test',
|
||||
'dist-tags': { latest: '1.0.0' },
|
||||
maintainers: [{ name: 'dev', email: 'dev@example.com' }],
|
||||
versions: {},
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(service, 'fetchProvenanceMetadata')
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
applicationPackageFetcherService.resolveNpmPackage.mockResolvedValueOnce({
|
||||
extractedDir: '/tmp/test',
|
||||
cleanupDir: '/tmp/test-parent',
|
||||
manifest: {
|
||||
application: {
|
||||
universalIdentifier: 'uid-123',
|
||||
displayName: 'Test App',
|
||||
description: 'A test app',
|
||||
author: 'Dev',
|
||||
},
|
||||
} as any,
|
||||
packageJson: { name: 'twenty-app-test', version: '1.0.0' },
|
||||
});
|
||||
|
||||
applicationRegistrationService.upsertFromNpmRegistration.mockResolvedValueOnce(
|
||||
mockRegistration as any,
|
||||
);
|
||||
|
||||
const result = await service.registerNpmPackage(
|
||||
'twenty-app-test',
|
||||
user,
|
||||
'workspace-1',
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockRegistration);
|
||||
|
||||
expect(
|
||||
applicationRegistrationService.upsertFromNpmRegistration,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
universalIdentifier: 'uid-123',
|
||||
packageName: 'twenty-app-test',
|
||||
ownerWorkspaceId: 'workspace-1',
|
||||
createdByUserId: 'user-123',
|
||||
isProvenanceVerified: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
applicationPackageFetcherService.cleanupExtractedDir,
|
||||
).toHaveBeenCalledWith('/tmp/test-parent');
|
||||
});
|
||||
|
||||
it('should store provenance metadata when available', async () => {
|
||||
const user = createMockUser();
|
||||
|
||||
jest.spyOn(service, 'fetchPackument').mockResolvedValueOnce({
|
||||
name: 'twenty-app-test',
|
||||
'dist-tags': { latest: '1.0.0' },
|
||||
maintainers: [{ name: 'dev', email: 'dev@example.com' }],
|
||||
versions: {},
|
||||
});
|
||||
|
||||
jest.spyOn(service, 'fetchProvenanceMetadata').mockResolvedValueOnce({
|
||||
repositoryUrl: 'https://github.com/user/twenty-app-test',
|
||||
hasProvenance: true,
|
||||
});
|
||||
|
||||
applicationPackageFetcherService.resolveNpmPackage.mockResolvedValueOnce({
|
||||
extractedDir: '/tmp/test',
|
||||
cleanupDir: '/tmp/test-parent',
|
||||
manifest: {
|
||||
application: {
|
||||
universalIdentifier: 'uid-123',
|
||||
displayName: 'Test App',
|
||||
},
|
||||
} as any,
|
||||
packageJson: { name: 'twenty-app-test', version: '1.0.0' },
|
||||
});
|
||||
|
||||
applicationRegistrationService.upsertFromNpmRegistration.mockResolvedValueOnce(
|
||||
{} as any,
|
||||
);
|
||||
|
||||
await service.registerNpmPackage(
|
||||
'twenty-app-test',
|
||||
user,
|
||||
'workspace-1',
|
||||
);
|
||||
|
||||
expect(
|
||||
applicationRegistrationService.upsertFromNpmRegistration,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isProvenanceVerified: true,
|
||||
provenanceRepositoryUrl:
|
||||
'https://github.com/user/twenty-app-test',
|
||||
provenanceVerifiedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import {
|
||||
type NpmClaimTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
const CLAIM_FILE_NAME = 'twenty-claim.jwt';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationNpmClaimService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
|
||||
) {}
|
||||
|
||||
generateClaimToken(packageName: string, workspaceId: string): string {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.NPM_CLAIM,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const payload: NpmClaimTokenJwtPayload = {
|
||||
sub: packageName,
|
||||
type: JwtTokenTypeEnum.NPM_CLAIM,
|
||||
workspaceId,
|
||||
packageName,
|
||||
};
|
||||
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
expiresIn: '10y',
|
||||
});
|
||||
}
|
||||
|
||||
private verifyClaimToken(token: string): NpmClaimTokenJwtPayload {
|
||||
const decoded = this.jwtWrapperService.decode<NpmClaimTokenJwtPayload>(
|
||||
token,
|
||||
{ json: true },
|
||||
);
|
||||
|
||||
if (!isDefined(decoded) || decoded.type !== JwtTokenTypeEnum.NPM_CLAIM) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'Invalid claim token: not an NPM_CLAIM token',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(decoded.workspaceId)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'Invalid claim token: missing workspaceId',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.NPM_CLAIM,
|
||||
decoded.workspaceId,
|
||||
);
|
||||
|
||||
return this.jwtWrapperService.verify<NpmClaimTokenJwtPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyAndClaimNpmPackage(
|
||||
packageName: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const resolvedPackage =
|
||||
await this.applicationPackageFetcherService.resolveNpmPackage(
|
||||
packageName,
|
||||
);
|
||||
|
||||
try {
|
||||
const claimFilePath = join(resolvedPackage.extractedDir, CLAIM_FILE_NAME);
|
||||
|
||||
let claimToken: string;
|
||||
|
||||
try {
|
||||
claimToken = (await fs.readFile(claimFilePath, 'utf-8')).trim();
|
||||
} catch {
|
||||
throw new ApplicationRegistrationException(
|
||||
`No ${CLAIM_FILE_NAME} file found in npm package "${packageName}". Run \`twenty app:claim\` first.`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.verifyClaimToken(claimToken);
|
||||
|
||||
if (payload.packageName !== packageName) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Claim token package name "${payload.packageName}" does not match requested package "${packageName}"`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = resolvedPackage.manifest;
|
||||
const universalIdentifier = manifest.application.universalIdentifier;
|
||||
|
||||
const existing =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (isDefined(existing)) {
|
||||
if (
|
||||
isDefined(existing.ownerWorkspaceId) &&
|
||||
existing.ownerWorkspaceId !== payload.workspaceId
|
||||
) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'This application is already claimed by another workspace',
|
||||
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationRegistrationService.claimForNpm({
|
||||
existingRegistration: existing,
|
||||
packageName,
|
||||
ownerWorkspaceId: payload.workspaceId,
|
||||
manifest,
|
||||
version: resolvedPackage.packageJson.version as string | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return this.applicationRegistrationService.createFromNpmClaim({
|
||||
packageName,
|
||||
universalIdentifier,
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
author: manifest.application.author,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
version: resolvedPackage.packageJson.version as string | undefined,
|
||||
ownerWorkspaceId: payload.workspaceId,
|
||||
});
|
||||
} finally {
|
||||
await this.applicationPackageFetcherService.cleanupExtractedDir(
|
||||
resolvedPackage.cleanupDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/application-package/utils/assert-valid-npm-package-name.util';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
type NpmPackument = {
|
||||
name: string;
|
||||
'dist-tags': Record<string, string>;
|
||||
maintainers: Array<{ name: string; email: string }>;
|
||||
versions: Record<
|
||||
string,
|
||||
{ dist?: { tarball?: string; integrity?: string } }
|
||||
>;
|
||||
};
|
||||
|
||||
export type ProvenanceMetadata = {
|
||||
repositoryUrl: string | null;
|
||||
hasProvenance: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationNpmRegistrationService {
|
||||
private readonly logger = new Logger(
|
||||
ApplicationNpmRegistrationService.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
) {}
|
||||
|
||||
async fetchPackument(packageName: string): Promise<NpmPackument> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Twenty-NpmRegistration',
|
||||
};
|
||||
|
||||
const authToken = this.twentyConfigService.get('APP_REGISTRY_TOKEN');
|
||||
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const { data } = await axios.get<NpmPackument>(
|
||||
`${registryUrl}/${encodeURIComponent(packageName).replace('%40', '@')}`,
|
||||
{ headers, timeout: 15_000 },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async fetchProvenanceMetadata(
|
||||
packageName: string,
|
||||
version: string,
|
||||
): Promise<ProvenanceMetadata | null> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${registryUrl}/-/npm/v1/attestations/${encodeURIComponent(packageName)}@${version}`,
|
||||
{
|
||||
headers: { 'User-Agent': 'Twenty-Provenance' },
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const repositoryUrl = this.extractRepositoryUrl(data);
|
||||
|
||||
return { repositoryUrl, hasProvenance: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async registerNpmPackage(
|
||||
packageName: string,
|
||||
user: UserEntity,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
if (!user.isEmailVerified) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'Email must be verified to register npm packages',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
assertValidNpmPackageName(packageName);
|
||||
|
||||
const packument = await this.fetchPackument(packageName);
|
||||
|
||||
const userEmailLower = user.email.toLowerCase();
|
||||
const isMaintainer = packument.maintainers.some(
|
||||
(maintainer) => maintainer.email.toLowerCase() === userEmailLower,
|
||||
);
|
||||
|
||||
if (!isMaintainer) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Your verified email (${user.email}) is not listed as a maintainer of ${packageName}`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const latestVersion = packument['dist-tags']?.latest;
|
||||
|
||||
let provenanceMetadata: ProvenanceMetadata | null = null;
|
||||
|
||||
if (latestVersion) {
|
||||
provenanceMetadata = await this.fetchProvenanceMetadata(
|
||||
packageName,
|
||||
latestVersion,
|
||||
);
|
||||
}
|
||||
|
||||
const resolved =
|
||||
await this.applicationPackageFetcherService.resolveNpmPackage(
|
||||
packageName,
|
||||
);
|
||||
|
||||
try {
|
||||
const { manifest } = resolved;
|
||||
|
||||
return await this.applicationRegistrationService.upsertFromNpmRegistration(
|
||||
{
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
packageName,
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description ?? null,
|
||||
author: manifest.application.author ?? null,
|
||||
ownerWorkspaceId: workspaceId,
|
||||
createdByUserId: user.id,
|
||||
latestAvailableVersion: latestVersion ?? null,
|
||||
isProvenanceVerified:
|
||||
provenanceMetadata?.hasProvenance ?? false,
|
||||
provenanceRepositoryUrl:
|
||||
provenanceMetadata?.repositoryUrl ?? null,
|
||||
provenanceVerifiedAt: provenanceMetadata?.hasProvenance
|
||||
? new Date()
|
||||
: null,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await this.applicationPackageFetcherService.cleanupExtractedDir(
|
||||
resolved.cleanupDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private extractRepositoryUrl(attestationData: unknown): string | null {
|
||||
try {
|
||||
const data = attestationData as {
|
||||
attestations?: Array<{
|
||||
predicateType?: string;
|
||||
bundle?: {
|
||||
dsseEnvelope?: {
|
||||
payload?: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!data?.attestations?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const attestation of data.attestations) {
|
||||
if (
|
||||
attestation.predicateType !== 'https://slsa.dev/provenance/v1'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = attestation.bundle?.dsseEnvelope?.payload;
|
||||
|
||||
if (!payload) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payload, 'base64').toString('utf-8'),
|
||||
);
|
||||
|
||||
const resolvedDependencies =
|
||||
decoded?.predicate?.buildDefinition?.resolvedDependencies;
|
||||
|
||||
if (Array.isArray(resolvedDependencies)) {
|
||||
for (const dep of resolvedDependencies) {
|
||||
if (
|
||||
typeof dep.uri === 'string' &&
|
||||
dep.uri.startsWith('git+https://')
|
||||
) {
|
||||
return dep.uri.replace(/^git\+/, '').replace(/@[^@]+$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to extract repository URL from attestation: ${error}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -147,6 +147,17 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
marketplaceDisplayData: MarketplaceDisplayData | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
provenanceRepositoryUrl: string | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isProvenanceVerified: boolean;
|
||||
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
provenanceVerifiedAt: Date | null;
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationRegistrationVariableEntity,
|
||||
(variable) => variable.applicationRegistration,
|
||||
|
||||
+3
-4
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationNpmClaimService } from 'src/engine/core-modules/application/application-registration/application-npm-claim.service';
|
||||
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
@@ -14,7 +14,6 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
@@ -32,17 +31,17 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
PermissionsModule,
|
||||
FileStorageModule,
|
||||
FileUrlModule,
|
||||
JwtModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationResolver,
|
||||
ApplicationTarballService,
|
||||
ApplicationNpmClaimService,
|
||||
ApplicationNpmRegistrationService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationNpmRegistrationService,
|
||||
ApplicationRegistrationVariableModule,
|
||||
],
|
||||
})
|
||||
|
||||
+8
-24
@@ -13,7 +13,7 @@ import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/a
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
|
||||
import { ApplicationNpmClaimService } from 'src/engine/core-modules/application/application-registration/application-npm-claim.service';
|
||||
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { NpmClaimTokenDTO } from 'src/engine/core-modules/application/application-registration/dtos/npm-claim-token.dto';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
|
||||
import { TransferApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/transfer-application-registration-ownership.input';
|
||||
@@ -64,7 +63,7 @@ export class ApplicationRegistrationResolver {
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationTarballService: ApplicationTarballService,
|
||||
private readonly applicationNpmClaimService: ApplicationNpmClaimService,
|
||||
private readonly applicationNpmRegistrationService: ApplicationNpmRegistrationService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
) {}
|
||||
|
||||
@@ -346,25 +345,6 @@ export class ApplicationRegistrationResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => NpmClaimTokenDTO)
|
||||
async generateNpmClaimToken(
|
||||
@Args('packageName') packageName: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<NpmClaimTokenDTO> {
|
||||
const token = this.applicationNpmClaimService.generateClaimToken(
|
||||
packageName,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
@@ -372,11 +352,15 @@ export class ApplicationRegistrationResolver {
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async verifyNpmPackageClaim(
|
||||
async registerNpmPackage(
|
||||
@Args('packageName') packageName: string,
|
||||
@AuthUser() user: UserEntity,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationNpmClaimService.verifyAndClaimNpmPackage(
|
||||
return this.applicationNpmRegistrationService.registerNpmPackage(
|
||||
packageName,
|
||||
user,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+56
-41
@@ -280,7 +280,15 @@ export class ApplicationRegistrationService {
|
||||
| 'isFeatured'
|
||||
| 'marketplaceDisplayData'
|
||||
| 'ownerWorkspaceId'
|
||||
>,
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
ApplicationRegistrationEntity,
|
||||
| 'isProvenanceVerified'
|
||||
| 'provenanceRepositoryUrl'
|
||||
| 'provenanceVerifiedAt'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const existing = await this.findOneByUniversalIdentifier(
|
||||
params.universalIdentifier,
|
||||
@@ -299,6 +307,11 @@ export class ApplicationRegistrationService {
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
...(isDefined(params.isProvenanceVerified) && {
|
||||
isProvenanceVerified: params.isProvenanceVerified,
|
||||
provenanceRepositoryUrl: params.provenanceRepositoryUrl ?? null,
|
||||
provenanceVerifiedAt: params.provenanceVerifiedAt ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -322,67 +335,69 @@ export class ApplicationRegistrationService {
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
isProvenanceVerified: params.isProvenanceVerified ?? false,
|
||||
provenanceRepositoryUrl: params.provenanceRepositoryUrl ?? null,
|
||||
provenanceVerifiedAt: params.provenanceVerifiedAt ?? null,
|
||||
});
|
||||
|
||||
await this.applicationRegistrationRepository.save(registration);
|
||||
}
|
||||
|
||||
async claimForNpm(params: {
|
||||
existingRegistration: ApplicationRegistrationEntity;
|
||||
async upsertFromNpmRegistration(params: {
|
||||
universalIdentifier: string;
|
||||
packageName: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
ownerWorkspaceId: string;
|
||||
manifest: import('twenty-shared/application').Manifest;
|
||||
version?: string;
|
||||
createdByUserId: string;
|
||||
latestAvailableVersion: string | null;
|
||||
isProvenanceVerified: boolean;
|
||||
provenanceRepositoryUrl: string | null;
|
||||
provenanceVerifiedAt: Date | null;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
const { existingRegistration, packageName, ownerWorkspaceId, manifest } =
|
||||
params;
|
||||
|
||||
await this.applicationRegistrationRepository.update(
|
||||
existingRegistration.id,
|
||||
{
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: packageName,
|
||||
ownerWorkspaceId,
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description ?? null,
|
||||
author: manifest.application.author ?? null,
|
||||
latestAvailableVersion: params.version ?? null,
|
||||
},
|
||||
const existing = await this.findOneByUniversalIdentifier(
|
||||
params.universalIdentifier,
|
||||
);
|
||||
|
||||
return this.applicationRegistrationRepository.findOneOrFail({
|
||||
where: { id: existingRegistration.id },
|
||||
});
|
||||
}
|
||||
if (isDefined(existing)) {
|
||||
await this.applicationRegistrationRepository.save({
|
||||
...existing,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: params.packageName,
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
createdByUserId: params.createdByUserId,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isProvenanceVerified: params.isProvenanceVerified,
|
||||
provenanceRepositoryUrl: params.provenanceRepositoryUrl,
|
||||
provenanceVerifiedAt: params.provenanceVerifiedAt,
|
||||
});
|
||||
|
||||
return this.applicationRegistrationRepository.findOneOrFail({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
}
|
||||
|
||||
async createFromNpmClaim(params: {
|
||||
packageName: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
version?: string;
|
||||
ownerWorkspaceId: string;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = this.applicationRegistrationRepository.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
description: params.description ?? null,
|
||||
author: params.author ?? null,
|
||||
logoUrl: params.logoUrl ?? null,
|
||||
websiteUrl: params.websiteUrl ?? null,
|
||||
termsUrl: params.termsUrl ?? null,
|
||||
latestAvailableVersion: params.version ?? null,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: params.packageName,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isListed: true,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
createdByUserId: params.createdByUserId,
|
||||
isProvenanceVerified: params.isProvenanceVerified,
|
||||
provenanceRepositoryUrl: params.provenanceRepositoryUrl,
|
||||
provenanceVerifiedAt: params.provenanceVerifiedAt,
|
||||
});
|
||||
|
||||
return this.applicationRegistrationRepository.save(registration);
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('NpmClaimToken')
|
||||
export class NpmClaimTokenDTO {
|
||||
@Field()
|
||||
token: string;
|
||||
}
|
||||
@@ -48,7 +48,6 @@ export enum JwtTokenTypeEnum {
|
||||
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
|
||||
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
|
||||
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
|
||||
NPM_CLAIM = 'NPM_CLAIM',
|
||||
}
|
||||
|
||||
type CommonPropertiesJwtPayload = {
|
||||
@@ -142,12 +141,6 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.POSTGRES_PROXY;
|
||||
};
|
||||
|
||||
export type NpmClaimTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.NPM_CLAIM;
|
||||
workspaceId: string;
|
||||
packageName: string;
|
||||
};
|
||||
|
||||
export type JwtPayload =
|
||||
| AccessTokenJwtPayload
|
||||
| ApiKeyTokenJwtPayload
|
||||
@@ -159,5 +152,4 @@ export type JwtPayload =
|
||||
| RefreshTokenJwtPayload
|
||||
| FileTokenJwtPayload
|
||||
| FileTokenJwtPayloadLegacy
|
||||
| PostgresProxyTokenJwtPayload
|
||||
| NpmClaimTokenJwtPayload;
|
||||
| PostgresProxyTokenJwtPayload;
|
||||
|
||||
Reference in New Issue
Block a user