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:
Félix Malfait
2026-03-07 11:02:47 +01:00
parent ae8a0713b0
commit d4ce4414e2
30 changed files with 971 additions and 694 deletions
@@ -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'];
};
@@ -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>
);
};
@@ -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 };
};
@@ -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"`,
);
}
}
@@ -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(
@@ -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)
);
}
}
@@ -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);
});
});
@@ -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,
);
}
};
@@ -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),
}),
);
});
});
});
@@ -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,
);
}
}
}
@@ -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;
}
}
}
@@ -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,
@@ -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,
],
})
@@ -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,
);
}
}
@@ -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);
@@ -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;