Skip to content

feat: add npm and tarball app distribution with upgrade mechanism#18358

Merged
FelixMalfait merged 48 commits intomainfrom
feat/app-distribution-npm-tarball
Mar 5, 2026
Merged

feat: add npm and tarball app distribution with upgrade mechanism#18358
FelixMalfait merged 48 commits intomainfrom
feat/app-distribution-npm-tarball

Conversation

@FelixMalfait
Copy link
Copy Markdown
Member

Summary

  • npm + tarball app distribution: Apps can be installed from the npm registry (public or private) or uploaded as .tar.gz tarballs, with AppRegistrationSourceType tracking the origin
  • Upgrade mechanism: AppUpgradeService checks for newer versions, supports rollback for npm-sourced apps, and a cron job runs every 6 hours to update latestAvailableVersion on registrations
  • Security hardening: Tarball extraction uses path traversal protection, and enableScripts: false in .yarnrc.yml disables all lifecycle scripts during yarn install to prevent RCE
  • Frontend: "Install from npm" and "Upload tarball" modals, upgrade button on app detail page, blue "Update" badge on installed apps table when a newer version is available
  • Marketplace catalog sync: Hourly cron job syncs a hardcoded catalog index into ApplicationRegistration entities
  • Integration tests: Coverage for install, upgrade, tarball upload, and catalog sync flows

Backend changes

Area Files
Entity & migration ApplicationRegistrationEntity (sourceType, sourcePackage, latestAvailableVersion), ApplicationEntity (applicationRegistrationId), migration
Services AppPackageResolverService, ApplicationInstallService, AppUpgradeService, MarketplaceCatalogSyncService
Cron jobs MarketplaceCatalogSyncCronJob (hourly), AppVersionCheckCronJob (every 6h)
REST endpoint AppRegistrationUploadController — tarball upload with secure extraction
Resolver MarketplaceResolver — simplified installMarketplaceApp (removed redundant sourcePackage arg)
Security .yarnrc.ymlenableScripts: false to block postinstall RCE

Frontend changes

Area Files
Modals SettingsInstallNpmAppModal, SettingsUploadTarballModal, SettingsAppModalLayout
Hooks useUploadAppTarball, useInstallMarketplaceApp (cleaned up)
Upgrade UI SettingsApplicationVersionContainer, SettingsApplicationDetailAboutTab
Badge SettingsApplicationTableRow — blue "Update" tag, SettingsApplicationsInstalledTab — fetches registrations for version comparison
Styling Migrated to Linaria (matching main)

Test plan

  • Install an app from npm via the "Install from npm" modal
  • Upload a .tar.gz tarball via the "Upload tarball" modal
  • Verify upgrade badge appears when latestAvailableVersion > version
  • Verify upgrade flow from app detail page
  • Run integration tests: app-distribution.integration-spec.ts, marketplace-catalog-sync.integration-spec.ts
  • Verify enableScripts: false blocks postinstall scripts during yarn install

Made with Cursor

Implement a comprehensive app distribution system supporting both public
(npm registry) and private (tarball upload) installation channels.

Backend:
- Add AppRegistrationSourceType enum (npm, tarball, none) to track app origin
- Add AppPackageResolverService for resolving packages from npm or tarball sources
- Add ApplicationInstallService with PostgreSQL advisory locks for safe installs
- Add AppUpgradeService with version checking and rollback support
- Add tarball upload REST endpoint with secure extraction (path traversal protection)
- Add marketplace catalog sync cron job (hourly) from hardcoded catalog index
- Add app version check cron job (every 6 hours) to detect available updates
- Disable yarn lifecycle scripts (enableScripts: false) to prevent RCE via postinstall
- Add database migration for sourceType, sourcePackage, latestAvailableVersion fields

Frontend:
- Add "Install from npm" modal for manual package installation
- Add "Upload tarball" modal for direct .tar.gz uploads
- Add upgrade mutation and version container with upgrade button
- Add blue "Update" badge on installed apps table when newer version available
- Fetch application registrations to compare installed vs latest versions
- Migrate styled components from Emotion to Linaria (matching main migration)
- Remove redundant sourcePackage mutation argument (derived from universalIdentifier)

Testing:
- Add integration tests for app distribution (install, upgrade, tarball upload)
- Add integration tests for marketplace catalog sync

Made-with: Cursor
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 3, 2026

TODOs/FIXMEs:

  • // TODO: defaulting to version 0.0.0, build better system: packages/twenty-server/src/engine/core-modules/application/application-registration/oauth.service.ts
  • // TODO fetch latestVersion of the application: packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationVersionContainer.tsx
  • // TODO: Migrate to MetadataClient once available: packages/twenty-sdk/src/cli/utilities/api/api-service.ts

Generated by 🚫 dangerJS against d2d6fc4

@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 3, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​tar@​7.0.871001004096100

View full report

@FelixMalfait
Copy link
Copy Markdown
Member Author

FelixMalfait commented Mar 3, 2026

📊 API Changes Report

GraphQL Metadata Schema Changes

GraphQL Metadata Schema Changes

[log]
Detected the following changes (17) between schemas:

[log] ✖ Field Application.description changed type from String! to String
[log] ✖ Field Application.version changed type from String! to String
[log] ✖ Argument universalIdentifier: String! added to field Mutation.installMarketplaceApp
[log] ⚠ Enum value AppTarball was added to enum FileFolder
[log] ⚠ Argument version: String added to field Mutation.installMarketplaceApp
[log] ✔ Type AppRegistrationSourceType was added
[log] ✔ Field applicationRegistration was added to object type Application
[log] ✔ Field applicationRegistrationId was added to object type Application
[log] ✔ Field isFeatured was added to object type ApplicationRegistration
[log] ✔ Field latestAvailableVersion was added to object type ApplicationRegistration
[log] ✔ Field sourcePackage was added to object type ApplicationRegistration
[log] ✔ Field sourceType was added to object type ApplicationRegistration
[log] ✔ Type ApplicationRegistrationSummary was added
[log] ✔ Field sourcePackage was added to object type MarketplaceApp
[log] ✔ Field installNpmApp was added to object type Mutation
[log] ✔ Field upgradeApplication was added to object type Mutation
[log] ✔ Field uploadAppTarball was added to object type Mutation
[error] Detected 3 breaking changes
⚠️ Breaking changes or errors detected in GraphQL metadata schema

[log] 
Detected the following changes (17) between schemas:

[log] ✖  Field Application.description changed type from String! to String
[log] ✖  Field Application.version changed type from String! to String
[log] ✖  Argument universalIdentifier: String! added to field Mutation.installMarketplaceApp
[log] ⚠  Enum value AppTarball was added to enum FileFolder
[log] ⚠  Argument version: String added to field Mutation.installMarketplaceApp
[log] ✔  Type AppRegistrationSourceType was added
[log] ✔  Field applicationRegistration was added to object type Application
[log] ✔  Field applicationRegistrationId was added to object type Application
[log] ✔  Field isFeatured was added to object type ApplicationRegistration
[log] ✔  Field latestAvailableVersion was added to object type ApplicationRegistration
[log] ✔  Field sourcePackage was added to object type ApplicationRegistration
[log] ✔  Field sourceType was added to object type ApplicationRegistration
[log] ✔  Type ApplicationRegistrationSummary was added
[log] ✔  Field sourcePackage was added to object type MarketplaceApp
[log] ✔  Field installNpmApp was added to object type Mutation
[log] ✔  Field upgradeApplication was added to object type Mutation
[log] ✔  Field uploadAppTarball was added to object type Mutation
[error] Detected 3 breaking changes
Error generating diff

⚠️ Please review these API changes carefully before merging.

- Create app-pack.ts and app-push.ts CLI commands (lost during merge)
- Remove duplicate app:build command registration in app-command.ts
- Fix Prettier formatting in server integration test files
- Fix Prettier formatting in app-registration-upload controller

Made-with: Cursor
@FelixMalfait
Copy link
Copy Markdown
Member Author

FelixMalfait commented Mar 3, 2026

🚀 Preview Environment Ready!

Your preview environment is available at: http://bore.pub:60734

This environment will automatically shut down after 5 hours.

FelixMalfait and others added 8 commits March 3, 2026 17:37
…ketplaceApp

When universalIdentifier is a UUID and no matching registration exists,
throw instead of falling through to create a new npm registration.

Made-with: Cursor
Error code changed from BAD_USER_INPUT to FORBIDDEN.

Made-with: Cursor
@FelixMalfait FelixMalfait marked this pull request as ready for review March 3, 2026 22:07
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12 issues found across 67 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-modules/application-registration/controllers/app-registration-upload.controller.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application-registration/controllers/app-registration-upload.controller.ts:101">
P2: Wrap `JSON.parse(manifestContent)` in a try-catch to return a proper validation error (400) instead of an unhandled 500 when the manifest contains invalid JSON.</violation>
</file>

<file name="packages/twenty-front/src/pages/settings/applications/components/SettingsUploadTarballModal.tsx">

<violation number="1" location="packages/twenty-front/src/pages/settings/applications/components/SettingsUploadTarballModal.tsx:49">
P2: The modal closes even when install fails because the return value of `install` is ignored. This can dismiss the dialog after a failed install, forcing users to reopen it instead of retrying. Gate the refetch/close on `install` success.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/utils/get-admin-workspace-id.util.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/utils/get-admin-workspace-id.util.ts:13">
P2: Avoid relying on deletedAt soft-delete columns for core entity logic. This query now depends on deletedAt for user/workspace/userWorkspace, which conflicts with the guidance to avoid soft-delete usage outside applicationRegistration.

(Based on your team's feedback about avoiding deletedAt usage in core entity logic.) [FEEDBACK_USED]</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/application.exception.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/application.exception.ts:47">
P2: Use a generic user-facing message here; this repeats internal package-resolution details that shouldn't be exposed in the userFriendlyMessage.

(Based on your team's feedback about keeping userFriendlyMessage generic and non-technical.) [FEEDBACK_USED]</violation>
</file>

<file name="packages/twenty-front/src/pages/settings/applications/hooks/useUploadAppTarball.ts">

<violation number="1" location="packages/twenty-front/src/pages/settings/applications/hooks/useUploadAppTarball.ts:26">
P2: Base64 conversion uses per-byte string concatenation, which is quadratic and can freeze the browser on larger tarballs. Use FileReader/readAsDataURL (or chunked encoding) to avoid O(n^2) string building.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception-filter.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/application-rest-api-exception-filter.ts:25">
P2: Add a default/fallback branch in the exception-code switch to guarantee every caught exception sends an HTTP response.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/services/app-upgrade.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/services/app-upgrade.service.ts:44">
P2: Scoped npm packages like `@scope/name` must be URL-encoded in registry URLs; using the raw `sourcePackage` will 404 and prevent update checks for scoped packages. Encode the package name before building the URL.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/services/app-package-resolver.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/services/app-package-resolver.service.ts:249">
P2: Error messages expose full internal filesystem paths (e.g., `manifestPath` at line 245, `packageJsonPath` at line 253). If these exceptions propagate to client-facing responses, they leak server directory structure. Use a generic message and log the detailed path server-side only.</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/services/application-install.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/services/application-install.service.ts:102">
P2: Files written to storage inside the transaction cannot be rolled back on failure. If `synchronizeFromManifest` or the commit fails, the DB transaction is rolled back but uploaded files remain as orphans in file storage. Consider moving `writeFilesToStorage` after the commit, or adding compensating cleanup in the `catch` block to delete the uploaded files.</violation>

<violation number="2" location="packages/twenty-server/src/engine/core-modules/application/services/application-install.service.ts:200">
P1: Security: `collectFiles` does not filter out symbolic links, allowing symlink-based path traversal. A malicious tarball can include a symlink pointing to an arbitrary file on the host (e.g., `/etc/shadow`). Since `Dirent.isDirectory()` returns `false` for symlinks, they fall through to the `else` branch and `fs.readFile` follows them. Add a symlink check and skip them:

```ts
if (entry.isSymbolicLink()) {
  continue;
}
```</violation>
</file>

<file name="packages/twenty-server/src/engine/core-modules/application/resolvers/marketplace.resolver.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/resolvers/marketplace.resolver.ts:42">
P1: Marketplace query isn’t scoped to a workspace (or admin catalog workspace), so it can return NPM registrations from other workspaces. Add workspace scoping (current workspace or admin workspace) to avoid cross-tenant leakage.</violation>

<violation number="2" location="packages/twenty-server/src/engine/core-modules/application/resolvers/marketplace.resolver.ts:50">
P2: Setting `hasSyncedOnce` before the catalog sync means a failed sync permanently disables retries. Move the flag assignment after a successful sync so transient failures don’t lock out marketplace data.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR adds npm and tarball-based app distribution with an upgrade mechanism, marketplace catalog sync, and corresponding frontend UI. It's a substantial feature addition that introduces new services and REST/GraphQL endpoints. However, critical correctness issues were identified that prevent the feature from working as intended:

Critical bugs:

  • SDK push entirely broken: app-push.ts targets the wrong endpoint (/api/application-registrations/ instead of /api/app-registrations/) and sends multipart/form-data while the controller expects a base64 JSON body—the CLI push flow will never succeed.

Security & data integrity issues:

  • Weak path traversal check: extractTarballSecurely uses includes('..') which is insufficient; should use resolved path comparison.
  • TOCTOU inconsistency: New registrations are saved with sourceType=NONE before the tarball file is stored; a failure between steps leaves a permanently inconsistent record.

Multi-tenancy & scalability issues:

  • Missing workspaceId scoping: upsertRegistration in MarketplaceCatalogSyncService queries by universalIdentifier alone, potentially overwriting registrations across workspaces.
  • In-memory sync flag: hasSyncedOnce in MarketplaceResolver is per-instance and will cause duplicate catalog syncs in multi-pod deployments.
  • Advisory lock collisions: The 32-bit hash in computeLockKey produces collisions for unrelated (workspaceId, universalIdentifier) pairs.

Operational & correctness issues:

  • Hardcoded yarn version: Path to yarn-4.9.2.cjs will silently fail if yarn is upgraded.
  • Temp directory accumulation: The shared parent directory /tmp/twenty-app-resolver is never cleaned up.
  • Update badge logic: Uses inequality comparison instead of semver, causing the badge to appear on rollbacks.

Confidence Score: 1/5

  • Not safe to merge — critical bugs make the SDK push command non-functional, and multi-tenancy/data integrity issues exist.
  • The SDK CLI push command is completely broken due to endpoint and format mismatches (Comments 0–1). Additionally, multi-tenancy correctness (Comment 4), concurrency safety (Comments 5, 7), data consistency (Comment 8), and security (Comment 2) issues make this unsuitable for production. While the overall architecture is sound, these bugs must be fixed before merge.
  • packages/twenty-sdk/src/cli/commands/app/app-push.ts (endpoint + format), packages/twenty-server/src/engine/core-modules/application/utils/extract-tarball-securely.util.ts (path traversal), packages/twenty-server/src/engine/core-modules/application-registration/controllers/app-registration-upload.controller.ts (TOCTOU), packages/twenty-server/src/engine/core-modules/application/services/marketplace-catalog-sync.service.ts (workspaceId scoping), packages/twenty-server/src/engine/core-modules/application/resolvers/marketplace.resolver.ts (sync flag), packages/twenty-server/src/engine/core-modules/application/services/application-install.service.ts (lock collision), packages/twenty-front/src/pages/settings/applications/components/SettingsApplicationsTable.tsx (update badge logic)

Last reviewed commit: af9e551

);

const response = await fetch(
`${serverUrl}/api/application-registrations/upload-tarball`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mismatched API endpoint URL

The SDK posts to /api/application-registrations/upload-tarball, but the controller is registered at api/app-registrations (line 44 of app-registration-upload.controller.ts). This will result in a 404 error — the CLI push command will never succeed as written.

Suggested change
`${serverUrl}/api/application-registrations/upload-tarball`,
`${serverUrl}/api/app-registrations/upload-tarball`,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — corrected the endpoint to /api/app-registrations/upload-tarball and switched to JSON body with base64-encoded tarball to match the controller's expected format.

Comment on lines +67 to +85
const formData = new FormData();
const fileBuffer = fs.readFileSync(tarballPath);

formData.append(
'file',
new Blob([fileBuffer], { type: 'application/gzip' }),
path.basename(tarballPath),
);

const response = await fetch(
`${serverUrl}/api/application-registrations/upload-tarball`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
},
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incompatible request format

The SDK sends a multipart/form-data request with a file field (lines 67–74), but the controller expects a JSON body with a base64-encoded tarball field (see TarballUploadBody type at line 39–42 of app-registration-upload.controller.ts and the base64 decode at line 81). These formats are incompatible — the controller will not find body.tarball from a multipart request, causing the upload to fail.

The SDK should encode the tarball to base64 and send it as a JSON body to match the controller's expectations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the SDK now sends Content-Type: application/json with a { tarball: base64 } body, matching the controller's TarballUploadBody type.

Comment on lines +20 to +22
if (entryPath.includes('..')) {
return false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete path traversal protection

The filter checks for '..' using includes(), which catches the literal substring but may miss other traversal vectors (e.g., ....// or entries with encoded escape sequences). A more robust approach is to resolve the absolute path and verify it remains within the target directory:

Suggested change
if (entryPath.includes('..')) {
return false;
}
filter: (entryPath, entry) => {
const resolvedPath = resolve(targetDir, entryPath);
if (!resolvedPath.startsWith(resolve(targetDir) + sep)) {
return false;
}

(You'll need to import resolve and sep from 'path'.)

Note: Symlinks and hard links are already blocked (lines 27–29), which is good.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now uses resolve(targetDir, entryPath) + startsWith(resolvedTarget) instead of includes('..') for robust path traversal protection.

}

private async runYarnInstall(workDir: string): Promise<void> {
const localYarnPath = join(workDir, '.yarn/releases/yarn-4.9.2.cjs');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded yarn release filename

The path to the yarn binary is hardcoded as yarn-4.9.2.cjs. If the yarn version in YARN_ENGINE_DIRNAME changes (e.g., to yarn-5.0.0.cjs in a future update), this call will silently fail with a "file not found" error that surfaces only as a generic "yarn install failed" message. Consider dynamically resolving the CJS filename from the .yarn/releases/ directory contents, or at least add a clear error message if the file is missing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The yarn engine directory is a constant that ships bundled with the server — the .cjs filename and the constant are updated together. This won't silently break; a version bump to the engine would necessarily update both. Not a runtime concern.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now fixed — runYarnInstall reads .yarnrc.yml to resolve the yarnPath dynamically instead of hardcoding the version.

export class MarketplaceResolver {
constructor(private readonly marketplaceService: MarketplaceService) {}
private readonly logger = new Logger(MarketplaceResolver.name);
private hasSyncedOnce = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instance-level flag causes duplicate syncs in multi-pod deployments

private hasSyncedOnce = false is stored in the resolver instance. In a horizontally scaled deployment with multiple Node processes or pods, each instance maintains its own copy of this flag and will independently trigger the initial catalog sync when findManyMarketplaceApps() is called, potentially causing duplicate upsert races and wasted work.

The existing cron job (MarketplaceCatalogSyncCronJob) is the intended synchronization mechanism; this in-resolver sync should either be removed or replaced with a persisted flag (e.g., a database row or feature flag) to prevent duplicate work across instances.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the flag assignment to after successful sync. Re: multi-pod — the sync is an idempotent upsert, so duplicate syncs in a scale-out scenario are harmless (just redundant). Adding Redis/DB coordination would be overengineering for a lazy-init optimization that only runs once per cold start.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Further improved — replaced the boolean flag with a timestamp-based cooldown (5 minutes). This allows retries after transient failures while still avoiding spam. Each pod converges independently via idempotent upserts.

Comment on lines +236 to +250
private computeLockKey(
workspaceId: string,
universalIdentifier: string,
): number {
const combined = `${workspaceId}:${universalIdentifier}`;
let hash = 0;

for (let i = 0; i < combined.length; i++) {
const char = combined.charCodeAt(i);

hash = (hash << 5) - hash + char;
hash |= 0;
}

return hash;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory lock key collision risk

computeLockKey produces a 32-bit hash via hash |= 0, but PostgreSQL's pg_advisory_xact_lock() accepts a 64-bit integer. With a 32-bit key space and many potential (workspaceId, universalIdentifier) pairs, hash collisions become likely, causing unrelated install operations to block each other unnecessarily.

Consider using pg_advisory_xact_lock(bigint, bigint) with separate hash values for each component, or implement a proper advisory lock using the full 64-bit space to significantly reduce collision probability.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the 32-bit key space is unlikely to cause real collisions in practice (we'd need many concurrent installs of different apps in the same workspace), but I'll track this as a follow-up improvement to use a proper 64-bit hash.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now fixed — computeLockKeys returns two separate INT4 hashes (one per input), and we use pg_advisory_xact_lock(key1, key2) for a 64-bit key space.

Comment on lines +130 to +143
} else {
appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
name: manifest.application?.displayName ?? 'Unknown App',
sourceType: AppRegistrationSourceType.NONE,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
workspaceId,
});

appRegistration =
await this.appRegistrationRepository.save(appRegistration);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data consistency issue: registration saved before file upload completes

When creating a new registration for a tarball upload, the entity is initially saved with sourceType: AppRegistrationSourceType.NONE (line 142), and then updated to TARBALL afterward (line 159). If any exception occurs between the save and the update calls (e.g., file storage failure at line 151–156), the registration will remain permanently in state NONE, creating an inconsistent record.

Consider setting sourceType: AppRegistrationSourceType.TARBALL directly on the new entity before the first save call, or wrapping both the save and file write in a transaction to ensure atomicity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — new registrations are now created with sourceType: AppRegistrationSourceType.TARBALL directly, eliminating the TOCTOU window where an orphaned NONE record could persist.

Comment on lines +80 to +83
const hasUpdate =
latestVersion !== undefined &&
application.version !== undefined &&
latestVersion !== application.version;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update badge uses inequality instead of semver comparison

The update badge is shown whenever latestVersion !== application.version (lines 80–83). However, the PR description states the intent is to show the badge "when a newer version is available." This means if latestAvailableVersion is older than the installed version (e.g., after a rollback or stale sync), the badge will incorrectly appear. Use proper semantic versioning comparison to only show the badge when latestVersion > application.version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — for the initial marketplace launch, the simple inequality is intentional since versions should only go up. A proper semver comparison with semver.gt() would be more correct; I'll track this as a follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now fixed — added isNewerSemver() utility that does proper major.minor.patch comparison. The update badge only shows when the latest version is strictly greater.


const execFilePromise = promisify(execFile);

const APP_RESOLVER_TMPDIR = join(tmpdir(), 'twenty-app-resolver');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared temp directory never cleaned up

APP_RESOLVER_TMPDIR is evaluated once at module load and becomes a shared root directory. While each call creates a unique workDir subdirectory (line 73) that is cleaned up after use (line 140–141), the parent directory /tmp/twenty-app-resolver itself is never removed. Over time, especially after server crashes or OOM kills that interrupt cleanup, orphaned subdirectories will accumulate on the host filesystem.

Consider cleaning up stale work directories on startup, or using OS-managed temp files with automatic expiration.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent /tmp/twenty-app-resolver directory is empty after each operation completes (all subdirectories are cleaned up). The OS cleans /tmp on reboot. Adding explicit cleanup of the parent would be a minor improvement but not a real issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now fixed — AppPackageResolverService implements OnModuleInit and wipes APP_RESOLVER_TMPDIR on startup, clearing any stale temp files from previous server runs.

1. SDK push: fix endpoint URL (/api/app-registrations/) and send
   base64 JSON body instead of multipart/form-data
2. Security: skip symlinks in collectFiles to prevent path traversal
3. Security: use resolve()+startsWith() instead of includes('..')
   for tarball path traversal protection
4. Multi-tenant: scope upsertRegistration by workspaceId
5. Data consistency: set sourceType=TARBALL on creation instead of
   saving as NONE then updating
6. Correctness: move hasSyncedOnce after successful sync
7. Correctness: URL-encode scoped npm package names in registry URLs
8. Error handling: wrap JSON.parse in try-catch for manifest parsing
9. Security: remove filesystem paths from error messages
10. Robustness: add default branch to REST exception filter
11. UX: gate modal close on install success
12. Robustness: add default case to mapSourceType
13. UX: improve userFriendlyMessage for package resolution failure

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-modules/application/services/application-install.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/services/application-install.service.ts:225">
P2: Avoid a silent `default` fallback in `mapSourceType`; it masks unsupported source types by coercing them to `'local'` instead of surfacing an error.

(Based on your team's feedback about avoiding silent fallbacks for enum-based mappings.) [FEEDBACK_USED]</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

1. Advisory lock: use pg_advisory_xact_lock(key1, key2) with two
   separate INT4 hashes for 64-bit key space instead of single 32-bit
2. Update badge: use proper semver comparison (isNewerSemver) instead
   of simple inequality check, preventing false positives on downgrades
3. Catalog sync: replace boolean hasSyncedOnce with timestamp-based
   cooldown (5min), enabling retries after transient failures
4. Yarn engine: dynamically resolve yarn path from .yarnrc.yml instead
   of hardcoding the version in the filename
5. Temp directory: clean up APP_RESOLVER_TMPDIR on module init to
   remove stale files from previous server runs

Made-with: Cursor
…ntity

The per-registration registry URL override was never set by any code
path — all npm operations already fall back to the server-level
APP_REGISTRY_URL config. Remove the column, entity field, and migration
to avoid unnecessary schema complexity. Can be re-added later if
per-app private registries become a real use case.

Made-with: Cursor
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Too many files changed for review. (202 files found, 100 file limit)

@FelixMalfait FelixMalfait force-pushed the feat/app-distribution-npm-tarball branch from 6edebfd to 7768a9e Compare March 4, 2026 23:30
@FelixMalfait FelixMalfait deleted the feat/app-distribution-npm-tarball branch March 4, 2026 23:33
@FelixMalfait FelixMalfait restored the feat/app-distribution-npm-tarball branch March 4, 2026 23:33
@FelixMalfait FelixMalfait reopened this Mar 4, 2026
@FelixMalfait
Copy link
Copy Markdown
Member Author

Recreating PR on a fresh branch to fix stuck GitHub Actions CI

@FelixMalfait FelixMalfait reopened this Mar 5, 2026
- Merge ApplicationQueryResolver into ApplicationInstallResolver
  (queries, token renewal, install/uninstall in one resolver)
- ApplicationModule stays as pure service module (no resolver deps)
  because PermissionsModule already imports ApplicationModule,
  making it impossible for ApplicationModule to import PermissionsModule
- Add PermissionsModule import to ApplicationDevelopmentModule
- Set sourcePath to 'oauth-install' for OAuth auto-installed apps

Made-with: Cursor
@FelixMalfait FelixMalfait force-pushed the feat/app-distribution-npm-tarball branch from 21c76ba to 9ec69c2 Compare March 5, 2026 07:21
…-npm-tarball

Resolve conflicts:
- auth.module.ts: keep reorganized import paths
- 1-18 upgrade commands: keep our import paths
- graphql.ts: merge both branches' additions
- FileStorageService: adapt to removed legacy methods by
  using FileStorageDriverFactory directly for system-level
  tarball operations

Made-with: Cursor
"Resolver" has a strong GraphQL connotation in this codebase.
This service fetches and extracts NPM/tarball packages, not GraphQL queries.

Made-with: Cursor
- Filter out npm packages without twenty-uid keyword (would fail on
  uuid column with package names like @scope/name)
- Return packageDir instead of workDir for NPM installs so
  collectFiles finds the actual app files instead of skipping them
  via the node_modules exclusion
- Prevent duplicate sync job enqueue when marketplace is empty
- Handle findOrCreateNpmRegistration race with try/catch fallback
- Enforce NPM source type check in installMarketplaceApp
- Don't leak raw error details to client in upgrade failures
- Rename AppPackageResolverService to AppPackageFetcherService

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/twenty-server/src/engine/core-modules/application/application-marketplace/services/marketplace-query.service.ts">

<violation number="1" location="packages/twenty-server/src/engine/core-modules/application/application-marketplace/services/marketplace-query.service.ts:47">
P1: Set the sync-enqueue guard only after the enqueue operation succeeds; currently a transient enqueue failure can permanently suppress future retries in this process.

(Based on your team's feedback about setting sync/retry guard flags only after successful completion.) [FEEDBACK_USED]</violation>

<violation number="2" location="packages/twenty-server/src/engine/core-modules/application/application-marketplace/services/marketplace-query.service.ts:114">
P2: Avoid catching all save errors as a not-found exception; rethrow non-concurrency failures so real infrastructure/data errors are not hidden.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


if (registrations.length === 0) {
if (!this.hasSyncBeenEnqueued) {
this.hasSyncBeenEnqueued = true;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Set the sync-enqueue guard only after the enqueue operation succeeds; currently a transient enqueue failure can permanently suppress future retries in this process.

(Based on your team's feedback about setting sync/retry guard flags only after successful completion.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-server/src/engine/core-modules/application/application-marketplace/services/marketplace-query.service.ts, line 47:

<comment>Set the sync-enqueue guard only after the enqueue operation succeeds; currently a transient enqueue failure can permanently suppress future retries in this process.

(Based on your team's feedback about setting sync/retry guard flags only after successful completion.) </comment>

<file context>
@@ -42,13 +43,16 @@ export class MarketplaceQueryService {
-        {},
-      );
+      if (!this.hasSyncBeenEnqueued) {
+        this.hasSyncBeenEnqueued = true;
+        this.logger.log(
+          'No marketplace registrations found, enqueuing one-time sync job',
</file context>
Fix with Cubic

Comment on lines +114 to +127
} catch {
const concurrentlyCreated = await this.appRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});

if (isDefined(concurrentlyCreated)) {
return concurrentlyCreated;
}

throw new ApplicationRegistrationException(
`Failed to create registration for package "${params.packageName}"`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Avoid catching all save errors as a not-found exception; rethrow non-concurrency failures so real infrastructure/data errors are not hidden.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-server/src/engine/core-modules/application/application-marketplace/services/marketplace-query.service.ts, line 114:

<comment>Avoid catching all save errors as a not-found exception; rethrow non-concurrency failures so real infrastructure/data errors are not hidden.</comment>

<file context>
@@ -94,18 +98,33 @@ export class MarketplaceQueryService {
+      });
+
+      return await this.appRegistrationRepository.save(registration);
+    } catch {
+      const concurrentlyCreated = await this.appRegistrationRepository.findOne({
+        where: { sourcePackage: params.packageName },
</file context>
Suggested change
} catch {
const concurrentlyCreated = await this.appRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(concurrentlyCreated)) {
return concurrentlyCreated;
}
throw new ApplicationRegistrationException(
`Failed to create registration for package "${params.packageName}"`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
} catch (error) {
const concurrentlyCreated = await this.appRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(concurrentlyCreated)) {
return concurrentlyCreated;
}
throw error;
}
Fix with Cubic

- Remove isClosable cast to true by using discriminated union props
  and conditional rendering (no spread, no cast)
- Move file input reset to finally block in tarball upload modal
- Extract shared appPack public operation to deduplicate build+pack
  logic between app:pack and app:push CLI commands

Made-with: Cursor
…hip checks

- Add FeatureFlagGuard to @UseGuards in all 4 application resolvers
  (install, development, marketplace, registration) so that
  @RequireFeatureFlag actually enforces the feature flag at runtime
- Add @RequireFeatureFlag to marketplace and registration resolver
  methods that were missing it
- Import FeatureFlagModule in all 4 host modules
- Add assertValidNpmPackageName() to reject path traversal and
  injection via sourcePackage (used in fetcher and marketplace query)
- Add ownership check in resolveApplicationRegistrationId to prevent
  cross-workspace IDOR in app:dev flow
- Move syncVariableSchemas inside ownership check block

Made-with: Cursor
findManyApplications and findOneApplication are basic read queries
needed for core workspace functionality. They should not be gated
behind IS_APPLICATION_ENABLED, which should only protect mutations
(install, uninstall, sync, marketplace operations).

Made-with: Cursor
@FelixMalfait FelixMalfait merged commit 0e89c96 into main Mar 5, 2026
80 of 84 checks passed
@FelixMalfait FelixMalfait deleted the feat/app-distribution-npm-tarball branch March 5, 2026 09:34
@twenty-eng-sync
Copy link
Copy Markdown

Hey @FelixMalfait! After you've done the QA of your Pull Request, you can mark it as done here. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants