feat(app): allow non-admin developers to claim and list marketplace apps (#22621)

## Context

Follow-up to #22609. Lets a non-admin developer claim ownership of a
public Twenty app they published to npm, then request a marketplace
listing that a server admin reviews. Marketplace state is per-instance
for now.

## Claiming

- Developer tab gets a **Claim an application** section: look up an
unclaimed npm app by package name or universal identifier.
- Ownership is proven with GitHub OAuth against the package's npm
provenance (trusted publishing): the connected account must own the
GitHub account or organization the package was published from.
- Errors from the GitHub callback come back as a code and are shown
inline with a link to the relevant documentation.
- The old one-click claim stays admin-only.
- A **Sync catalog** button triggers a catalog refresh instead of
waiting for the hourly cron.
- Gated behind the `IS_APP_CLAIMING_ENABLED` feature flag.

## Listing requests

- Catalog-synced apps are created **unlisted**; a data migration unlists
previously auto-listed unclaimed npm apps (owned or vetted rows are left
untouched).
- Owners request a listing from the Distribution tab (logo + description
required); a server admin approves or rejects it from a **Listing
requests** section in the Admin Panel.

## Screenshots

<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/788d4362-97c4-4e42-810c-ef1f11517bec"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/d6246190-c82a-4f64-87be-3bb668527645"/>
<img width="1512" height="828" alt="image"
src="https://github.com/user-attachments/assets/21a8dad4-610b-4d1f-8948-b9acab40d373"/>
<img width="1512" height="829" alt="image"
src="https://github.com/user-attachments/assets/58246130-41f7-451e-ae7f-57bd21d04bb6"/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
martmull
2026-07-20 17:43:40 +02:00
committed by GitHub
parent 1b5e974629
commit 8d84a0b9f3
48 changed files with 2497 additions and 467 deletions
@@ -50,8 +50,10 @@ import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/se
import { ApplicationRegistrationVariableDTO } from 'src/engine/core-modules/application/application-registration-variable/dtos/application-registration-variable.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { ApplicationRegistrationClaimService } from 'src/engine/core-modules/application/application-registration/application-registration-claim.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AdminApplicationRegistrationClaimDTO } from 'src/engine/core-modules/application/application-registration/dtos/admin-application-registration-claim.dto';
import { ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { FindApplicationRegistrationInstalledWorkspacesInput } from 'src/engine/core-modules/application/application-registration/dtos/find-application-registration-installed-workspaces.input';
@@ -135,6 +137,7 @@ export class AdminPanelResolver {
private readonly adminPanelHealthService: AdminPanelHealthService,
private readonly adminPanelSigningKeyService: AdminPanelSigningKeyService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationClaimService: ApplicationRegistrationClaimService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private adminPanelQueueService: AdminPanelQueueService,
private featureFlagService: FeatureFlagService,
@@ -514,6 +517,16 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => [AdminApplicationRegistrationClaimDTO])
async findAdminApplicationRegistrationClaims(
@Args('applicationRegistrationId') applicationRegistrationId: string,
): Promise<AdminApplicationRegistrationClaimDTO[]> {
return this.applicationRegistrationClaimService.findClaimsForRegistration(
applicationRegistrationId,
);
}
@UseGuards(AdminPanelGuard)
@Mutation(() => ApplicationRegistrationEntity)
async updateAdminApplicationRegistration(
@@ -0,0 +1,400 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import axios from 'axios';
import { ApplicationRegistrationClaimService } from 'src/engine/core-modules/application/application-registration/application-registration-claim.service';
import { type 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 { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { type ApplicationRegistrationGithubClaimStateJwtPayload } from 'src/engine/core-modules/auth/types/application-registration-github-claim-state-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const WORKSPACE_ID = 'workspace-1';
const REGISTRATION_ID = 'registration-1';
const CONFIG_VALUES: Record<string, string> = {
APP_CLAIM_GITHUB_CLIENT_ID: 'github-client-id',
APP_CLAIM_GITHUB_CLIENT_SECRET: 'github-client-secret',
APP_REGISTRY_URL: 'https://registry.npmjs.org',
SERVER_URL: 'https://server.example.com',
};
const buildRegistration = (
overrides: Partial<ApplicationRegistrationEntity> = {},
): ApplicationRegistrationEntity =>
({
id: REGISTRATION_ID,
universalIdentifier: 'universal-identifier-1',
ownerWorkspaceId: null,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: '@acme/my-twenty-app',
latestAvailableVersion: '1.2.3',
...overrides,
}) as ApplicationRegistrationEntity;
const buildAttestationsResponse = (repository: string) => ({
attestations: [
{
predicateType: 'https://slsa.dev/provenance/v1',
bundle: {
dsseEnvelope: {
payload: Buffer.from(
JSON.stringify({
predicate: {
buildDefinition: {
externalParameters: { workflow: { repository } },
},
},
}),
).toString('base64'),
},
},
},
],
});
const STATE_PAYLOAD: ApplicationRegistrationGithubClaimStateJwtPayload = {
sub: REGISTRATION_ID,
type: JwtTokenTypeEnum.APPLICATION_REGISTRATION_GITHUB_CLAIM_STATE,
applicationRegistrationId: REGISTRATION_ID,
workspaceId: WORKSPACE_ID,
userId: 'user-1',
};
describe('ApplicationRegistrationClaimService', () => {
let service: ApplicationRegistrationClaimService;
let applicationRegistrationService: jest.Mocked<ApplicationRegistrationService>;
let jwtWrapperService: jest.Mocked<JwtWrapperService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationRegistrationClaimService,
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: ApplicationRegistrationService,
useValue: {
findOneByIdGlobal: jest.fn(),
claimOwnership: jest.fn(),
},
},
{
provide: JwtWrapperService,
useValue: {
signAsyncOrThrow: jest.fn().mockResolvedValue('signed-state'),
verifyJwtToken: jest.fn(),
decode: jest.fn().mockReturnValue(STATE_PAYLOAD),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn((key: string) => CONFIG_VALUES[key]),
},
},
],
}).compile();
service = module.get(ApplicationRegistrationClaimService);
applicationRegistrationService = module.get(ApplicationRegistrationService);
jwtWrapperService = module.get(JwtWrapperService);
mockedAxios.isAxiosError.mockReturnValue(false);
});
afterEach(() => jest.clearAllMocks());
const expectException = async (
promise: Promise<unknown>,
code: ApplicationRegistrationExceptionCode,
) => {
await expect(promise).rejects.toThrow(ApplicationRegistrationException);
await promise.catch((error) => expect(error.code).toBe(code));
};
describe('buildGithubAuthorizationUrl', () => {
it('rejects an already-owned registration', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration({ ownerWorkspaceId: 'other-workspace' }),
);
await expectException(
service.buildGithubAuthorizationUrl({
applicationRegistrationId: REGISTRATION_ID,
workspaceId: WORKSPACE_ID,
userId: null,
}),
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_ALREADY_OWNED,
);
});
it('rejects a non-npm registration', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration({
sourceType: ApplicationRegistrationSourceType.LOCAL,
sourcePackage: null,
}),
);
await expectException(
service.buildGithubAuthorizationUrl({
applicationRegistrationId: REGISTRATION_ID,
workspaceId: WORKSPACE_ID,
userId: null,
}),
ApplicationRegistrationExceptionCode.CLAIM_NOT_SUPPORTED,
);
});
it('throws when the GitHub OAuth app is not configured', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
const configService = { APP_CLAIM_GITHUB_CLIENT_ID: '' };
(
service as unknown as {
twentyConfigService: { get: jest.Mock };
}
).twentyConfigService.get = jest.fn(
(key: string) =>
({ ...CONFIG_VALUES, ...configService })[key] as string,
);
await expectException(
service.buildGithubAuthorizationUrl({
applicationRegistrationId: REGISTRATION_ID,
workspaceId: WORKSPACE_ID,
userId: null,
}),
ApplicationRegistrationExceptionCode.CLAIM_NOT_CONFIGURED,
);
});
it('returns the GitHub authorization url with a signed state', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
const url = new URL(
await service.buildGithubAuthorizationUrl({
applicationRegistrationId: REGISTRATION_ID,
workspaceId: WORKSPACE_ID,
userId: 'user-1',
}),
);
expect(url.origin + url.pathname).toBe(
'https://github.com/login/oauth/authorize',
);
expect(url.searchParams.get('client_id')).toBe('github-client-id');
expect(url.searchParams.get('scope')).toBe('read:org');
expect(url.searchParams.get('state')).toBe('signed-state');
expect(url.searchParams.get('prompt')).toBe('select_account');
expect(url.searchParams.get('redirect_uri')).toBe(
'https://server.example.com/application-registration-claim/github/callback',
);
expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith(
STATE_PAYLOAD,
expect.objectContaining({ expiresIn: '15m' }),
);
});
});
describe('completeGithubClaim', () => {
it('throws when no provenance attestation exists', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
const notFoundError = Object.assign(new Error('Request failed'), {
response: { status: 404 },
});
mockedAxios.get.mockRejectedValueOnce(notFoundError);
mockedAxios.isAxiosError.mockReturnValue(true);
await expectException(
service.completeGithubClaim({
statePayload: STATE_PAYLOAD,
code: 'oauth-code',
}),
ApplicationRegistrationExceptionCode.PROVENANCE_NOT_FOUND,
);
});
it('reports a registry outage without blaming the provenance', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
mockedAxios.get.mockRejectedValueOnce(new Error('ETIMEDOUT'));
await expectException(
service.completeGithubClaim({
statePayload: STATE_PAYLOAD,
code: 'oauth-code',
}),
ApplicationRegistrationExceptionCode.PROVENANCE_CHECK_UNAVAILABLE,
);
});
it('claims when the connected user is the publishing account', async () => {
const claimed = buildRegistration({ ownerWorkspaceId: WORKSPACE_ID });
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
applicationRegistrationService.claimOwnership.mockResolvedValue(claimed);
mockedAxios.get.mockImplementation(async (url: string) => {
if (url.includes('/attestations/')) {
return {
data: buildAttestationsResponse('https://github.com/acme/my-app'),
};
}
if (url.endsWith('/user')) {
return { data: { login: 'Acme' } };
}
throw new Error(`Unexpected GET ${url}`);
});
mockedAxios.post.mockResolvedValue({
data: { access_token: 'github-token' },
});
const result = await service.completeGithubClaim({
statePayload: STATE_PAYLOAD,
code: 'oauth-code',
});
expect(
applicationRegistrationService.claimOwnership,
).toHaveBeenCalledWith({
applicationRegistrationId: REGISTRATION_ID,
claimingWorkspaceId: WORKSPACE_ID,
});
expect(result).toBe(claimed);
});
it('claims when the connected user owns the publishing organization', async () => {
const claimed = buildRegistration({ ownerWorkspaceId: WORKSPACE_ID });
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
applicationRegistrationService.claimOwnership.mockResolvedValue(claimed);
mockedAxios.get.mockImplementation(async (url: string) => {
if (url.includes('/attestations/')) {
return {
data: buildAttestationsResponse('https://github.com/acme/my-app'),
};
}
if (url.endsWith('/user')) {
return { data: { login: 'someone-else' } };
}
if (url.includes('/user/memberships/orgs/acme')) {
return { data: { state: 'active', role: 'admin' } };
}
throw new Error(`Unexpected GET ${url}`);
});
mockedAxios.post.mockResolvedValue({
data: { access_token: 'github-token' },
});
const result = await service.completeGithubClaim({
statePayload: STATE_PAYLOAD,
code: 'oauth-code',
});
expect(result).toBe(claimed);
});
it('rejects a member who is not an owner of the publishing organization', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
mockedAxios.get.mockImplementation(async (url: string) => {
if (url.includes('/attestations/')) {
return {
data: buildAttestationsResponse('https://github.com/acme/my-app'),
};
}
if (url.endsWith('/user')) {
return { data: { login: 'someone-else' } };
}
if (url.includes('/user/memberships/orgs/acme')) {
return { data: { state: 'active', role: 'member' } };
}
throw new Error(`Unexpected GET ${url}`);
});
mockedAxios.post.mockResolvedValue({
data: { access_token: 'github-token' },
});
await expectException(
service.completeGithubClaim({
statePayload: STATE_PAYLOAD,
code: 'oauth-code',
}),
ApplicationRegistrationExceptionCode.GITHUB_ORG_OWNERSHIP_REQUIRED,
);
expect(
applicationRegistrationService.claimOwnership,
).not.toHaveBeenCalled();
});
it('fails when the GitHub code exchange fails', async () => {
applicationRegistrationService.findOneByIdGlobal.mockResolvedValue(
buildRegistration(),
);
mockedAxios.get.mockResolvedValue({
data: buildAttestationsResponse('https://github.com/acme/my-app'),
});
mockedAxios.post.mockRejectedValue(new Error('bad code'));
await expectException(
service.completeGithubClaim({
statePayload: STATE_PAYLOAD,
code: 'oauth-code',
}),
ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED,
);
});
});
describe('verifyClaimState', () => {
it('rejects a state token of the wrong type', async () => {
jwtWrapperService.decode.mockReturnValue({
...STATE_PAYLOAD,
type: 'SOMETHING_ELSE',
} as never);
await expectException(
service.verifyClaimState('state-token'),
ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED,
);
});
it('returns the payload of a valid state token', async () => {
const payload = await service.verifyClaimState('state-token');
expect(payload).toEqual(STATE_PAYLOAD);
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
'state-token',
);
});
});
});
@@ -0,0 +1,105 @@
import { Controller, Get, Query, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { ApplicationRegistrationClaimService } from 'src/engine/core-modules/application/application-registration/application-registration-claim.service';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { CustomException } from 'src/utils/custom-exception';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
// GitHub OAuth callback of the trusted-publishers claim flow. Auth context
// travels in the signed state token, not in a session, hence the public
// endpoint.
@Controller('application-registration-claim')
export class ApplicationRegistrationClaimController {
constructor(
private readonly applicationRegistrationClaimService: ApplicationRegistrationClaimService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly guardRedirectService: GuardRedirectService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Get('github/callback')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async githubCallback(
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Query('error') oauthError: string | undefined,
@Res() res: Response,
) {
let workspace: WorkspaceEntity | null = null;
try {
const statePayload =
await this.applicationRegistrationClaimService.verifyClaimState(
state ?? '',
);
workspace =
await this.applicationRegistrationClaimService.findWorkspaceById(
statePayload.workspaceId,
);
if (oauthError !== undefined || code === undefined) {
throw new ApplicationRegistrationException(
'GitHub authorization was denied',
ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED,
);
}
await this.applicationRegistrationClaimService.completeGithubClaim({
statePayload,
code,
});
if (workspace === null) {
throw new Error('Workspace not found');
}
const url = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getSettingsPath(SettingsPath.Applications),
});
url.hash = 'developer';
return res.redirect(url.toString());
} catch (error) {
const claimErrorCode =
error instanceof CustomException ? error.code : 'CLAIM_FAILED';
if (workspace !== null) {
const url = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getSettingsPath(SettingsPath.Applications),
});
url.searchParams.set('claimErrorCode', claimErrorCode);
url.hash = 'developer';
return res.redirect(url.toString());
}
return res.redirect(
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
error,
workspace: {
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
},
pathname: getSettingsPath(SettingsPath.Applications),
}),
);
}
}
}
@@ -0,0 +1,450 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import axios from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { z } from 'zod';
import { type 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 AdminApplicationRegistrationClaimDTO } from 'src/engine/core-modules/application/application-registration/dtos/admin-application-registration-claim.dto';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { type ApplicationRegistrationGithubClaimStateJwtPayload } from 'src/engine/core-modules/auth/types/application-registration-github-claim-state-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const GITHUB_CLAIM_STATE_EXPIRES_IN = '15m';
const attestationsResponseSchema = z.object({
attestations: z.array(
z.object({
predicateType: z.string(),
bundle: z.object({
dsseEnvelope: z.object({
payload: z.string(),
}),
}),
}),
),
});
const provenancePayloadSchema = z.object({
predicate: z
.object({
buildDefinition: z
.object({
externalParameters: z
.object({
workflow: z.object({ repository: z.string() }).partial(),
})
.partial()
.optional(),
})
.partial()
.optional(),
invocation: z
.object({
configSource: z.object({ uri: z.string() }).partial().optional(),
})
.partial()
.optional(),
})
.partial(),
});
@Injectable()
export class ApplicationRegistrationClaimService {
private readonly logger = new Logger(
ApplicationRegistrationClaimService.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async buildGithubAuthorizationUrl(params: {
applicationRegistrationId: string;
workspaceId: string;
userId: string | null;
}): Promise<string> {
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
params.applicationRegistrationId,
);
this.assertClaimable(registration);
const clientId = this.twentyConfigService.get('APP_CLAIM_GITHUB_CLIENT_ID');
if (!isNonEmptyString(clientId)) {
throw new ApplicationRegistrationException(
'GitHub OAuth app is not configured (APP_CLAIM_GITHUB_CLIENT_ID)',
ApplicationRegistrationExceptionCode.CLAIM_NOT_CONFIGURED,
);
}
const statePayload: ApplicationRegistrationGithubClaimStateJwtPayload = {
sub: registration.id,
type: JwtTokenTypeEnum.APPLICATION_REGISTRATION_GITHUB_CLAIM_STATE,
applicationRegistrationId: registration.id,
workspaceId: params.workspaceId,
userId: params.userId,
};
const state = await this.jwtWrapperService.signAsyncOrThrow(statePayload, {
expiresIn: GITHUB_CLAIM_STATE_EXPIRES_IN,
});
const authorizationUrl = new URL(
'https://github.com/login/oauth/authorize',
);
authorizationUrl.searchParams.set('client_id', clientId);
authorizationUrl.searchParams.set('scope', 'read:org');
authorizationUrl.searchParams.set('redirect_uri', this.buildCallbackUrl());
authorizationUrl.searchParams.set('state', state);
// Always show the account picker: without it GitHub silently reuses the
// previous authorization, leaving no way to retry with another account.
authorizationUrl.searchParams.set('prompt', 'select_account');
return authorizationUrl.toString();
}
async verifyClaimState(
state: string,
): Promise<ApplicationRegistrationGithubClaimStateJwtPayload> {
await this.jwtWrapperService.verifyJwtToken(state);
const payload =
this.jwtWrapperService.decode<ApplicationRegistrationGithubClaimStateJwtPayload>(
state,
);
if (
payload.type !==
JwtTokenTypeEnum.APPLICATION_REGISTRATION_GITHUB_CLAIM_STATE
) {
throw new ApplicationRegistrationException(
'Invalid claim state token',
ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED,
);
}
return payload;
}
async completeGithubClaim(params: {
statePayload: ApplicationRegistrationGithubClaimStateJwtPayload;
code: string;
}): Promise<ApplicationRegistrationEntity> {
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
params.statePayload.applicationRegistrationId,
);
const sourcePackage = this.assertClaimable(registration);
const publisherLogin = await this.fetchProvenancePublisherLogin({
packageName: sourcePackage,
version: registration.latestAvailableVersion,
});
const accessToken = await this.exchangeGithubCode(params.code);
await this.assertGithubOwnership({ accessToken, publisherLogin });
return this.applicationRegistrationService.claimOwnership({
applicationRegistrationId: registration.id,
claimingWorkspaceId: params.statePayload.workspaceId,
});
}
async findWorkspaceById(
workspaceId: string,
): Promise<WorkspaceEntity | null> {
return this.workspaceRepository.findOne({ where: { id: workspaceId } });
}
async findClaimsForRegistration(
applicationRegistrationId: string,
): Promise<AdminApplicationRegistrationClaimDTO[]> {
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
if (!isDefined(registration.ownerWorkspaceId)) {
return [];
}
const ownerWorkspace = await this.workspaceRepository.findOne({
where: { id: registration.ownerWorkspaceId },
});
return [
{
workspaceId: registration.ownerWorkspaceId,
workspaceDisplayName: ownerWorkspace?.displayName ?? null,
},
];
}
private assertClaimable(registration: ApplicationRegistrationEntity): string {
if (isDefined(registration.ownerWorkspaceId)) {
throw new ApplicationRegistrationException(
'Application registration is already owned by a workspace',
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_ALREADY_OWNED,
);
}
if (
registration.sourceType !== ApplicationRegistrationSourceType.NPM ||
!isDefined(registration.sourcePackage)
) {
throw new ApplicationRegistrationException(
'Only npm-sourced applications can be claimed',
ApplicationRegistrationExceptionCode.CLAIM_NOT_SUPPORTED,
);
}
return registration.sourcePackage;
}
private buildCallbackUrl(): string {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return `${serverUrl}/application-registration-claim/github/callback`;
}
private async fetchProvenancePublisherLogin(params: {
packageName: string;
version: string | null;
}): Promise<string> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
const version =
params.version ??
(await this.fetchLatestVersion(registryUrl, params.packageName));
// Scoped packages need their slash percent-encoded for the registry path.
const encodedName = params.packageName.replace(/\//g, '%2F');
let data: unknown;
try {
const response = await axios.get(
`${registryUrl}/-/npm/v1/attestations/${encodedName}@${version}`,
{
headers: { 'User-Agent': 'Twenty-Marketplace' },
timeout: 10_000,
},
);
data = response.data;
} catch (error) {
if (
axios.isAxiosError(error) &&
isDefined(error.response) &&
error.response.status === 404
) {
throw new ApplicationRegistrationException(
`No provenance attestation found for ${params.packageName}@${version}`,
ApplicationRegistrationExceptionCode.PROVENANCE_NOT_FOUND,
);
}
this.logger.warn(
`Failed to fetch attestations for ${params.packageName}@${version}: ${error instanceof Error ? error.message : String(error)}`,
);
throw new ApplicationRegistrationException(
`Could not reach the package registry to verify provenance for ${params.packageName}`,
ApplicationRegistrationExceptionCode.PROVENANCE_CHECK_UNAVAILABLE,
);
}
const repositoryUrl = this.extractProvenanceRepositoryUrl(data);
const match = repositoryUrl?.match(
/(?:^|\/\/|@)github\.com[/:]([^/]+)\/[^/@]+/i,
);
if (!isDefined(match)) {
throw new ApplicationRegistrationException(
`No GitHub source repository found in the provenance of ${params.packageName}@${version}`,
ApplicationRegistrationExceptionCode.PROVENANCE_NOT_FOUND,
);
}
return match[1];
}
private extractProvenanceRepositoryUrl(data: unknown): string | null {
const parsed = attestationsResponseSchema.safeParse(data);
if (!parsed.success) {
return null;
}
for (const attestation of parsed.data.attestations) {
if (!attestation.predicateType.includes('slsa.dev/provenance')) {
continue;
}
try {
const payload = provenancePayloadSchema.parse(
JSON.parse(
Buffer.from(
attestation.bundle.dsseEnvelope.payload,
'base64',
).toString('utf-8'),
),
);
const repository =
payload.predicate.buildDefinition?.externalParameters?.workflow
?.repository ?? payload.predicate.invocation?.configSource?.uri;
if (isNonEmptyString(repository)) {
return repository;
}
} catch {
continue;
}
}
return null;
}
private async fetchLatestVersion(
registryUrl: string,
packageName: string,
): Promise<string> {
const encodedName = packageName.replace(/\//g, '%2F');
try {
const { data } = await axios.get(`${registryUrl}/${encodedName}/latest`, {
headers: { 'User-Agent': 'Twenty-Marketplace' },
timeout: 10_000,
});
const version = z.object({ version: z.string() }).parse(data).version;
return version;
} catch {
throw new ApplicationRegistrationException(
`Could not resolve the latest published version of ${packageName}`,
ApplicationRegistrationExceptionCode.PROVENANCE_CHECK_UNAVAILABLE,
);
}
}
private async exchangeGithubCode(code: string): Promise<string> {
const clientId = this.twentyConfigService.get('APP_CLAIM_GITHUB_CLIENT_ID');
const clientSecret = this.twentyConfigService.get(
'APP_CLAIM_GITHUB_CLIENT_SECRET',
);
if (!isNonEmptyString(clientId) || !isNonEmptyString(clientSecret)) {
throw new ApplicationRegistrationException(
'GitHub OAuth app is not configured',
ApplicationRegistrationExceptionCode.CLAIM_NOT_CONFIGURED,
);
}
try {
const { data } = await axios.post(
'https://github.com/login/oauth/access_token',
{ client_id: clientId, client_secret: clientSecret, code },
{
headers: { Accept: 'application/json' },
timeout: 10_000,
},
);
const accessToken = z
.object({ access_token: z.string() })
.parse(data).access_token;
return accessToken;
} catch (error) {
this.logger.warn(
`GitHub code exchange failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw new ApplicationRegistrationException(
'GitHub authentication failed',
ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED,
);
}
}
private async assertGithubOwnership(params: {
accessToken: string;
publisherLogin: string;
}): Promise<void> {
const headers = {
Authorization: `Bearer ${params.accessToken}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'Twenty-Marketplace',
};
let viewerLogin: string;
try {
const { data } = await axios.get('https://api.github.com/user', {
headers,
timeout: 10_000,
});
viewerLogin = z.object({ login: z.string() }).parse(data).login;
} catch (error) {
this.logger.warn(
`GitHub user lookup failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw new ApplicationRegistrationException(
'GitHub authentication failed',
ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED,
);
}
if (viewerLogin.toLowerCase() === params.publisherLogin.toLowerCase()) {
return;
}
try {
const { data } = await axios.get(
`https://api.github.com/user/memberships/orgs/${params.publisherLogin}`,
{ headers, timeout: 10_000 },
);
const membership = z
.object({ state: z.string(), role: z.string() })
.parse(data);
if (membership.state === 'active' && membership.role === 'admin') {
return;
}
} catch {
// 404 means the user is not a member of the organization; fall through
// to the ownership error below.
}
throw new ApplicationRegistrationException(
`The connected GitHub account is not an owner of ${params.publisherLogin}`,
ApplicationRegistrationExceptionCode.GITHUB_ORG_OWNERSHIP_REQUIRED,
);
}
}
@@ -16,6 +16,13 @@ export enum ApplicationRegistrationExceptionCode {
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
APPLICATION_REGISTRATION_ALREADY_OWNED = 'APPLICATION_REGISTRATION_ALREADY_OWNED',
CLAIM_NOT_SUPPORTED = 'CLAIM_NOT_SUPPORTED',
CLAIM_NOT_CONFIGURED = 'CLAIM_NOT_CONFIGURED',
PROVENANCE_NOT_FOUND = 'PROVENANCE_NOT_FOUND',
PROVENANCE_CHECK_UNAVAILABLE = 'PROVENANCE_CHECK_UNAVAILABLE',
GITHUB_AUTH_FAILED = 'GITHUB_AUTH_FAILED',
GITHUB_ORG_OWNERSHIP_REQUIRED = 'GITHUB_ORG_OWNERSHIP_REQUIRED',
}
const getExceptionUserFriendlyMessage = (
@@ -44,6 +51,20 @@ const getExceptionUserFriendlyMessage = (
return msg`The app manifest declares an invalid server version requirement.`;
case ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION:
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_ALREADY_OWNED:
return msg`This application is already owned by a workspace.`;
case ApplicationRegistrationExceptionCode.CLAIM_NOT_SUPPORTED:
return msg`Only applications published to npm can be claimed this way.`;
case ApplicationRegistrationExceptionCode.CLAIM_NOT_CONFIGURED:
return msg`Claiming is not configured on this server. Ask an administrator to configure the GitHub OAuth app.`;
case ApplicationRegistrationExceptionCode.PROVENANCE_NOT_FOUND:
return msg`No provenance attestation was found for the published package. Publish it with npm trusted publishing from GitHub Actions, then try again.`;
case ApplicationRegistrationExceptionCode.PROVENANCE_CHECK_UNAVAILABLE:
return msg`The package registry could not be reached to verify the package provenance. Try again later.`;
case ApplicationRegistrationExceptionCode.GITHUB_AUTH_FAILED:
return msg`GitHub authentication failed. Try connecting your GitHub account again.`;
case ApplicationRegistrationExceptionCode.GITHUB_ORG_OWNERSHIP_REQUIRED:
return msg`Your GitHub account does not own the organization that publishes this package. If you are an owner, make sure you granted this app access to the organization on GitHub's authorization screen (Organization access section).`;
default:
assertUnreachable(code);
}
@@ -4,6 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { ApplicationRegistrationAssetUrlService } from 'src/engine/core-modules/application/application-registration/application-registration-asset-url.service';
import { ApplicationRegistrationAssetService } from 'src/engine/core-modules/application/application-registration/application-registration-asset.service';
import { ApplicationRegistrationClaimController } from 'src/engine/core-modules/application/application-registration/application-registration-claim.controller';
import { ApplicationRegistrationClaimService } from 'src/engine/core-modules/application/application-registration/application-registration-claim.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';
@@ -15,9 +17,12 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
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 { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -36,15 +41,20 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
CacheLockModule,
CoreEntityCacheModule,
DomainServerConfigModule,
WorkspaceDomainsModule,
FeatureFlagModule,
GuardRedirectModule,
JwtModule,
PermissionsModule,
FileStorageModule,
FileUrlModule,
MetricsModule,
WorkspaceCacheStorageModule,
],
controllers: [ApplicationRegistrationClaimController],
providers: [
ApplicationRegistrationService,
ApplicationRegistrationClaimService,
ApplicationRegistrationResolver,
ApplicationRegistrationSummaryResolver,
ApplicationTarballService,
@@ -53,6 +63,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
],
exports: [
ApplicationRegistrationService,
ApplicationRegistrationClaimService,
ApplicationRegistrationVariableModule,
ApplicationRegistrationAssetService,
ApplicationRegistrationAssetUrlService,
@@ -27,8 +27,12 @@ import { ApplicationRegistrationAssetUrlService } from 'src/engine/core-modules/
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationRegistrationClaimService } from 'src/engine/core-modules/application/application-registration/application-registration-claim.service';
import { ApplicationRegistrationClaimInput } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-claim.input';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { ClaimApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/claim-application-registration-ownership.input';
import { ClaimableApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/claimable-application-registration.dto';
import { FindClaimableApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/find-claimable-application-registration.input';
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 { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
@@ -44,6 +48,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
@@ -66,6 +71,7 @@ import {
export class ApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationClaimService: ApplicationRegistrationClaimService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly applicationRegistrationAssetUrlService: ApplicationRegistrationAssetUrlService,
@@ -317,6 +323,25 @@ export class ApplicationRegistrationResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@Query(() => ClaimableApplicationRegistrationDTO, { nullable: true })
async findClaimableApplicationRegistration(
@Args()
{
sourcePackage,
universalIdentifier,
}: FindClaimableApplicationRegistrationInput,
): Promise<ClaimableApplicationRegistrationDTO | null> {
return this.applicationRegistrationService.findClaimable({
sourcePackage,
universalIdentifier,
});
}
@UseGuards(
WorkspaceAuthGuard,
AdminPanelGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@Mutation(() => ApplicationRegistrationEntity)
async claimApplicationRegistrationOwnership(
@Args()
@@ -329,6 +354,25 @@ export class ApplicationRegistrationResolver {
});
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@Query(() => String)
async githubClaimAuthorizationUrl(
@Args() { applicationRegistrationId }: ApplicationRegistrationClaimInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
): Promise<string> {
return this.applicationRegistrationClaimService.buildGithubAuthorizationUrl(
{
applicationRegistrationId,
workspaceId,
userId: user?.id ?? null,
},
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as bcrypt from 'bcrypt';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
@@ -24,6 +25,7 @@ import {
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { type ApplicationRegistrationInstalledWorkspacesDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-installed-workspaces.dto';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type ClaimableApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/claimable-application-registration.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PaginatedApplicationRegistrationsDTO } from 'src/engine/core-modules/application/application-registration/dtos/paginated-application-registrations.dto';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
@@ -914,6 +916,49 @@ export class ApplicationRegistrationService {
};
}
async findClaimable(params: {
sourcePackage?: string;
universalIdentifier?: string;
}): Promise<ClaimableApplicationRegistrationDTO | null> {
const hasPackage = isNonEmptyString(params.sourcePackage);
const hasUniversalIdentifier = isNonEmptyString(params.universalIdentifier);
if (hasPackage === hasUniversalIdentifier) {
throw new ApplicationRegistrationException(
'Provide exactly one of sourcePackage or universalIdentifier',
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
const where: FindOptionsWhere<ApplicationRegistrationEntity> = {
sourceType: ApplicationRegistrationSourceType.NPM,
...(hasPackage
? { sourcePackage: params.sourcePackage }
: { universalIdentifier: params.universalIdentifier }),
};
const registration = await this.applicationRegistrationRepository.findOne({
select: APPLICATION_REGISTRATION_WITHOUT_MANIFEST_SELECT,
where,
});
if (!isDefined(registration)) {
return null;
}
return {
id: registration.id,
universalIdentifier: registration.universalIdentifier,
name: registration.name,
sourcePackage: registration.sourcePackage,
logoUrl:
this.applicationRegistrationAssetUrlService.buildLogoUrl(registration),
description: registration.description,
author: registration.author,
isOwned: isDefined(registration.ownerWorkspaceId),
};
}
async claimOwnership(params: {
applicationRegistrationId: string;
claimingWorkspaceId: string;
@@ -926,7 +971,7 @@ export class ApplicationRegistrationService {
if (isDefined(registration.ownerWorkspaceId)) {
throw new ApplicationRegistrationException(
'Application registration is already owned by a workspace',
ApplicationRegistrationExceptionCode.INVALID_INPUT,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_ALREADY_OWNED,
);
}
@@ -940,7 +985,7 @@ export class ApplicationRegistrationService {
if (updateResult.affected === 0) {
throw new ApplicationRegistrationException(
'Application registration is already owned by a workspace',
ApplicationRegistrationExceptionCode.INVALID_INPUT,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_ALREADY_OWNED,
);
}
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('AdminApplicationRegistrationClaim')
export class AdminApplicationRegistrationClaimDTO {
@Field(() => String)
workspaceId: string;
@Field(() => String, { nullable: true })
workspaceDisplayName: string | null;
}
@@ -0,0 +1,10 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsUUID } from 'class-validator';
@ArgsType()
export class ApplicationRegistrationClaimInput {
@Field(() => String)
@IsUUID()
applicationRegistrationId: string;
}
@@ -0,0 +1,28 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('ClaimableApplicationRegistration')
export class ClaimableApplicationRegistrationDTO {
@Field(() => String)
id: string;
@Field(() => String)
universalIdentifier: string;
@Field(() => String)
name: string;
@Field(() => String, { nullable: true })
sourcePackage: string | null;
@Field(() => String, { nullable: true })
logoUrl: string | null;
@Field(() => String, { nullable: true })
description: string | null;
@Field(() => String, { nullable: true })
author: string | null;
@Field(() => Boolean)
isOwned: boolean;
}
@@ -0,0 +1,16 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsOptional, IsString, IsUUID } from 'class-validator';
@ArgsType()
export class FindClaimableApplicationRegistrationInput {
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
sourcePackage?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID()
universalIdentifier?: string;
}
@@ -0,0 +1,10 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApplicationRegistrationGithubClaimStateJwtPayload =
CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_REGISTRATION_GITHUB_CLAIM_STATE;
applicationRegistrationId: string;
workspaceId: string;
userId: string | null;
};
@@ -3,6 +3,7 @@ import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/a
import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type';
import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type';
import { type ApplicationRegistrationGithubClaimStateJwtPayload } from 'src/engine/core-modules/auth/types/application-registration-github-claim-state-jwt-payload.type';
import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type';
import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type';
import { type FileUploadTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type';
@@ -26,5 +27,6 @@ export type JwtPayload =
| FileTokenJwtPayloadLegacy
| FileUploadTokenJwtPayload
| AppOAuthStateJwtPayload
| ApplicationRegistrationGithubClaimStateJwtPayload
| ApprovedAccessDomainJwtPayload
| PlaygroundTokenJwtPayload;
@@ -11,6 +11,7 @@ export enum JwtTokenTypeEnum {
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
APP_OAUTH_STATE = 'APP_OAUTH_STATE',
APPLICATION_REGISTRATION_GITHUB_CLAIM_STATE = 'APPLICATION_REGISTRATION_GITHUB_CLAIM_STATE',
APPROVED_ACCESS_DOMAIN = 'APPROVED_ACCESS_DOMAIN',
PLAYGROUND = 'PLAYGROUND',
}
@@ -2066,6 +2066,27 @@ export class ConfigVariables {
@IsOptional()
APP_REGISTRY_CDN_URL: string = 'https://unpkg.com';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'Client ID of the GitHub OAuth app used to verify app ownership when claiming a marketplace application',
type: ConfigVariableType.STRING,
})
@IsString()
@IsOptional()
APP_CLAIM_GITHUB_CLIENT_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
isSensitive: true,
description:
'Client secret of the GitHub OAuth app used to verify app ownership when claiming a marketplace application',
type: ConfigVariableType.STRING,
})
@IsString()
@IsOptional()
APP_CLAIM_GITHUB_CLIENT_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
isSensitive: true,