Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
823b823
chore: merge release v3.81.0 back to main [skip ci]
github-actions[bot] Jun 12, 2026
aad9701
feat(framework-editor): show requirement context in edit dialog + hig…
tofikwest Jun 12, 2026
0483f3c
fix(framework-editor): flip Linked Controls panel up when clipped at …
tofikwest Jun 12, 2026
079ed73
Merge branch 'main' into tofik/frame-7-edit-requirement-dialog-context
tofikwest Jun 12, 2026
b6e8240
fix(framework-editor): detect framework name/description-only changes…
tofikwest Jun 12, 2026
cc03bc2
feat(framework-editor): add dark mode
tofikwest Jun 12, 2026
0653950
fix(analytics): fall back to server-evaluated feature flags when post…
tofikwest Jun 12, 2026
7661c6d
Merge pull request #3127 from trycompai/tofik/frame-9-publish-detects…
tofikwest Jun 12, 2026
3d12388
Merge branch 'main' into tofik/frame-8-linked-controls-dropdown-clip
tofikwest Jun 12, 2026
d242db2
Merge pull request #3126 from trycompai/tofik/frame-8-linked-controls…
tofikwest Jun 12, 2026
f04e60d
Merge branch 'main' into tofik/frame-7-edit-requirement-dialog-context
tofikwest Jun 12, 2026
b3dcbcc
Merge pull request #3124 from trycompai/tofik/frame-7-edit-requiremen…
tofikwest Jun 12, 2026
9ef22dc
feat(framework-editor): resizable + size-remembering description editor
tofikwest Jun 12, 2026
3f77ccd
Merge pull request #3125 from trycompai/tofik/frame-3-resizable-edito…
tofikwest Jun 12, 2026
bb7064b
feat(framework-editor): add save-as-draft and save-and-commit buttons
tofikwest Jun 12, 2026
094cae0
Merge pull request #3129 from trycompai/tofik/frame-4-save-draft-and-…
tofikwest Jun 12, 2026
e77c0eb
Merge branch 'main' into tofik/frame-5-framework-editor-dark-mode
tofikwest Jun 12, 2026
e5ef5fb
Merge pull request #3128 from trycompai/tofik/frame-5-framework-edito…
tofikwest Jun 12, 2026
022f2b9
Merge branch 'main' into tofik/fix-timeline-flag-server-fallback
tofikwest Jun 12, 2026
a7d30f3
feat(app): add expand-to-read for long requirement descriptions
tofikwest Jun 12, 2026
11d41cd
Merge pull request #3131 from trycompai/tofik/fix-timeline-flag-serve…
tofikwest Jun 12, 2026
2988a8e
Merge branch 'main' into tofik/app-requirements-description-expand
tofikwest Jun 12, 2026
36afe80
Merge pull request #3132 from trycompai/tofik/app-requirements-descri…
tofikwest Jun 12, 2026
ade4bfa
fix(cloud-security): run AWS integration checks on our server (schedu…
tofikwest Jun 12, 2026
918d95a
Merge branch 'main' into tofik/aws-checks-on-server
tofikwest Jun 12, 2026
aa23647
fix(cloud-security): address cubic review on AWS-on-server checks
tofikwest Jun 12, 2026
577bc05
fix(cloud-security): re-throw non-AWS errors in the per-check catch
tofikwest Jun 12, 2026
91ca27b
fix(cloud-security): exempt internal check-run endpoint from throttle…
tofikwest Jun 12, 2026
a7399d8
Merge pull request #3133 from trycompai/tofik/aws-checks-on-server
tofikwest Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ describe('diffManifests', () => {
expect(diff.requirementMapEdges.removed).toContainEqual({ controlTemplateId: 'c1', requirementTemplateId: 'r1' });
});

it('reports no framework-metadata change for identical manifests', () => {
const m = emptyManifest();
expect(diffManifests(m, m).framework.changed).toBe(false);
});

it('detects a framework name change (FRAME-9)', () => {
const from = emptyManifest();
const to = { ...emptyManifest(), framework: { ...from.framework, name: 'New Name' } };
const diff = diffManifests(from, to);
expect(diff.framework.changed).toBe(true);
expect(diff.framework.name).toEqual({ from: 'n', to: 'New Name' });
expect(diff.framework.description).toBeUndefined();
});

it('detects a framework description change (FRAME-9)', () => {
const from = { ...emptyManifest(), framework: { id: 'f', name: 'n', catalogVersion: '1', description: 'old' } };
const to = { ...emptyManifest(), framework: { id: 'f', name: 'n', catalogVersion: '1', description: 'new' } };
const diff = diffManifests(from, to);
expect(diff.framework.changed).toBe(true);
expect(diff.framework.description).toEqual({ from: 'old', to: 'new' });
expect(diff.framework.name).toBeUndefined();
});

it('drops phantom edges that reference entities missing from the manifest', () => {
// Older snapshots sometimes stored cross-framework requirement IDs in
// control.requirementIds. Those IDs are not in manifest.requirements, so
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/frameworks/framework-versioning/framework-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,19 @@ export interface ControlDocumentTypeEdge {
formType: string;
}

/**
* Changes to the framework's own metadata (name / description). These don't
* live in any entity list, so without this the diff treats a name- or
* description-only edit as "no changes" and the Publish button stays disabled.
*/
export interface FrameworkMetaDiff {
changed: boolean;
name?: { from: string; to: string };
description?: { from: string | null; to: string | null };
}

export interface ManifestDiff {
framework: FrameworkMetaDiff;
controls: EntityDiff<ManifestControl>;
requirements: EntityDiff<ManifestRequirement>;
policies: EntityDiff<ManifestPolicy>;
Expand Down Expand Up @@ -85,6 +97,7 @@ export function diffManifests(fromRaw: FrameworkManifest, toRaw: FrameworkManife
const from = sanitizeManifestEdges(fromRaw);
const to = sanitizeManifestEdges(toRaw);
return {
framework: diffFrameworkMeta(from.framework, to.framework),
controls: diffEntities(from.controls, to.controls, controlEqual),
requirements: diffEntities(from.requirements, to.requirements, requirementEqual),
policies: diffEntities(from.policies, to.policies, policyEqual),
Expand Down Expand Up @@ -168,6 +181,23 @@ function edgesFromControls<E>(
return controls.flatMap(extract);
}

function diffFrameworkMeta(
from: FrameworkManifest['framework'],
to: FrameworkManifest['framework'],
): FrameworkMetaDiff {
const nameChanged = from.name !== to.name;
const fromDescription = from.description ?? null;
const toDescription = to.description ?? null;
const descriptionChanged = fromDescription !== toDescription;
return {
changed: nameChanged || descriptionChanged,
...(nameChanged ? { name: { from: from.name, to: to.name } } : {}),
...(descriptionChanged
? { description: { from: fromDescription, to: toDescription } }
: {}),
};
}

function controlEqual(a: ManifestControl, b: ManifestControl): boolean {
return a.name === b.name && a.description === b.description && (a.controlFamily ?? null) === (b.controlFamily ?? null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InternalChecksController } from './internal-checks.controller';
import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
import { PermissionGuard } from '../../auth/permission.guard';
import { ServiceTokenOnlyGuard } from '../../auth/service-token-only.guard';
import { ConnectionCheckRunnerService } from '../services/connection-check-runner.service';

jest.mock('@db', () => ({ db: {} }));
jest.mock('../../auth/auth.server', () => ({
auth: { api: { getSession: jest.fn() } },
}));
jest.mock('@trycompai/auth', () => ({
statement: { integration: ['create', 'read', 'update', 'delete'] },
BUILT_IN_ROLE_PERMISSIONS: {},
}));

describe('InternalChecksController', () => {
let controller: InternalChecksController;
const mockRunner = { runChecks: jest.fn() };
const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [InternalChecksController],
providers: [
{ provide: ConnectionCheckRunnerService, useValue: mockRunner },
],
})
.overrideGuard(HybridAuthGuard)
.useValue(mockGuard)
.overrideGuard(ServiceTokenOnlyGuard)
.useValue(mockGuard)
.overrideGuard(PermissionGuard)
.useValue(mockGuard)
.compile();

controller = module.get(InternalChecksController);
jest.clearAllMocks();
});

it('delegates to the runner with the connection, org and checkId', async () => {
const runResult = { results: [], totalFindings: 0, totalPassing: 0 };
mockRunner.runChecks.mockResolvedValue(runResult);

const result = await controller.runConnectionChecks('conn_1', 'org_1', {
checkId: 'aws-s3-public-access',
});

expect(mockRunner.runChecks).toHaveBeenCalledWith({
connectionId: 'conn_1',
organizationId: 'org_1',
checkId: 'aws-s3-public-access',
});
expect(result).toBe(runResult);
});

it('passes checkId undefined when omitted (run all)', async () => {
mockRunner.runChecks.mockResolvedValue({});
await controller.runConnectionChecks('conn_1', 'org_1', {});
expect(mockRunner.runChecks).toHaveBeenCalledWith({
connectionId: 'conn_1',
organizationId: 'org_1',
checkId: undefined,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import {
ApiBody,
ApiOperation,
ApiPropertyOptional,
ApiTags,
} from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { IsOptional, IsString } from 'class-validator';
import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
import { PermissionGuard } from '../../auth/permission.guard';
import { ServiceTokenOnlyGuard } from '../../auth/service-token-only.guard';
import { RequirePermission } from '../../auth/require-permission.decorator';
import { OrganizationId } from '../../auth/auth-context.decorator';
import {
ConnectionCheckRunnerService,
type RunAllChecksResult,
} from '../services/connection-check-runner.service';

// Internal payload. Service-token only — never called by the UI/customers.
class RunConnectionChecksOnServerDto {
@ApiPropertyOptional({
description:
"Run a single check. Omit to run all of the connection's checks.",
})
@IsOptional()
@IsString()
checkId?: string;
}

/**
* Internal, service-token-only endpoint that runs a connection's checks ON OUR
* SERVER and returns the raw result (no persistence). Used exclusively by the
* AWS Trigger tasks so AWS S3 calls egress our VPC instead of Trigger.dev's
* (whose endpoint policy blocks our cross-account reads). All other providers
* keep executing inside Trigger.dev unchanged.
*/
@Controller({ path: 'integrations/internal', version: '1' })
@ApiTags('Integrations')
export class InternalChecksController {
constructor(private readonly runner: ConnectionCheckRunnerService) {}

@Post('run-connection-checks/:connectionId')
// Called by the AWS Trigger tasks in bursts (the 6 AM schedule fans out across
// every AWS connection/check). Exempt from the global rate limiter so the burst
// doesn't hit 429s and re-fail the very checks this path exists to fix.
@SkipThrottle()
@UseGuards(HybridAuthGuard, ServiceTokenOnlyGuard, PermissionGuard)
@RequirePermission('integration', 'update')
@ApiOperation({
summary: "Run a connection's checks on the API server (internal only)",
})
@ApiBody({ type: RunConnectionChecksOnServerDto })
async runConnectionChecks(
@Param('connectionId') connectionId: string,
@OrganizationId() organizationId: string,
@Body() body: RunConnectionChecksOnServerDto,
): Promise<RunAllChecksResult> {
return this.runner.runChecks({
connectionId,
organizationId,
checkId: body.checkId,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ConnectionsController } from './controllers/connections.controller';
import { AdminIntegrationsController } from './controllers/admin-integrations.controller';
import { DynamicIntegrationsController } from './controllers/dynamic-integrations.controller';
import { ChecksController } from './controllers/checks.controller';
import { InternalChecksController } from './controllers/internal-checks.controller';
import { VariablesController } from './controllers/variables.controller';
import { TaskIntegrationsController } from './controllers/task-integrations.controller';
import { WebhookController } from './controllers/webhook.controller';
Expand All @@ -20,6 +21,7 @@ import { ConnectionAuthTeardownService } from './services/connection-auth-teardo
import { OAuthTokenRevocationService } from './services/oauth-token-revocation.service';
import { DynamicManifestLoaderService } from './services/dynamic-manifest-loader.service';
import { TaskIntegrationChecksService } from './services/task-integration-checks.service';
import { ConnectionCheckRunnerService } from './services/connection-check-runner.service';
import { ProviderRepository } from './repositories/provider.repository';
import { ConnectionRepository } from './repositories/connection.repository';
import { CredentialRepository } from './repositories/credential.repository';
Expand All @@ -42,6 +44,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service
AdminIntegrationsController,
DynamicIntegrationsController,
ChecksController,
InternalChecksController,
VariablesController,
TaskIntegrationsController,
WebhookController,
Expand All @@ -58,6 +61,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service
ConnectionAuthTeardownService,
DynamicManifestLoaderService,
TaskIntegrationChecksService,
ConnectionCheckRunnerService,
IntegrationSyncLoggerService,
GenericEmployeeSyncService,
GenericDeviceSyncService,
Expand Down
Loading
Loading