Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { AuditModule } from './audit/audit.module';
import { ControlsModule } from './controls/controls.module';
import { RolesModule } from './roles/roles.module';
import { McpModule } from './mcp/mcp.module';
import { McpDownloadModule } from './mcp-download/mcp-download.module';
import { EmailModule } from './email/email.module';
import { SecretsModule } from './secrets/secrets.module';
import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module';
Expand Down Expand Up @@ -128,6 +129,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding-
TimelinesModule,
OffboardingChecklistModule,
McpModule,
McpDownloadModule,
],
controllers: [AppController],
providers: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { FrameworkSyncService } from './framework-sync.service';
jest.mock('@db', () => {
const tx = {
frameworkInstance: { findUnique: jest.fn() },
frameworkVersion: { findUnique: jest.fn() },
frameworkVersion: { findUnique: jest.fn(), findFirst: jest.fn() },
};
return {
db: {
frameworkInstance: { findUnique: jest.fn() },
frameworkVersion: { findUnique: jest.fn() },
frameworkVersion: { findUnique: jest.fn(), findFirst: jest.fn() },
$transaction: jest.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
__tx: tx,
},
Expand All @@ -21,8 +21,20 @@ jest.mock('./org-advisory-lock', () => ({
lockOrganizationForSync: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('./framework-sync-apply', () => ({
applySync: jest.fn(),
}));

import { db } from '@db';
const tx = (db as unknown as { __tx: { frameworkInstance: { findUnique: jest.Mock }; frameworkVersion: { findUnique: jest.Mock } } }).__tx;
import { applySync } from './framework-sync-apply';
const tx = (
db as unknown as {
__tx: {
frameworkInstance: { findUnique: jest.Mock };
frameworkVersion: { findUnique: jest.Mock; findFirst: jest.Mock };
};
}
).__tx;

describe('FrameworkSyncService preconditions', () => {
let service: FrameworkSyncService;
Expand Down Expand Up @@ -62,3 +74,47 @@ describe('FrameworkSyncService preconditions', () => {
expect(db.$transaction).not.toHaveBeenCalled();
});
});

describe('FrameworkSyncService unpinned instances (FRAME-2)', () => {
let service: FrameworkSyncService;

beforeEach(async () => {
jest.clearAllMocks();
const mod = await Test.createTestingModule({ providers: [FrameworkSyncService] }).compile();
service = mod.get(FrameworkSyncService);
});

it('diffs from the earliest published version when the instance has no current version', async () => {
const unpinned = { id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: null };
(db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(unpinned);
tx.frameworkInstance.findUnique.mockResolvedValue(unpinned);
tx.frameworkVersion.findUnique.mockResolvedValue({ id: 'fvr_target', frameworkId: 'frk_soc2' });
tx.frameworkVersion.findFirst.mockResolvedValue({ id: 'fvr_earliest', frameworkId: 'frk_soc2' });
(applySync as jest.Mock).mockResolvedValue({ syncOperationId: 'sync_1' });

const result = await service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_target', memberId: 'mem_1' });

// Falls back to the earliest version as the "from" instead of throwing.
expect(tx.frameworkVersion.findFirst).toHaveBeenCalled();
expect(applySync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
currentVersion: expect.objectContaining({ id: 'fvr_earliest' }),
targetVersion: expect.objectContaining({ id: 'fvr_target' }),
}),
);
expect(result.kind).toBe('synced');
});

it('400s when an unpinned instance’s framework has no published version to diff from', async () => {
const unpinned = { id: 'frm_1', organizationId: 'org_1', frameworkId: 'frk_soc2', currentVersionId: null };
(db.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(unpinned);
tx.frameworkInstance.findUnique.mockResolvedValue(unpinned);
tx.frameworkVersion.findUnique.mockResolvedValue({ id: 'fvr_target', frameworkId: 'frk_soc2' });
tx.frameworkVersion.findFirst.mockResolvedValue(null);

await expect(service.sync({ organizationId: 'org_1', frameworkInstanceId: 'frm_1', targetVersionId: 'fvr_target', memberId: 'mem_1' }))
.rejects.toBeInstanceOf(BadRequestException);
expect(applySync).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,27 @@ export class FrameworkSyncService {
return { syncOperationId: null, instanceId: instance.id };
}

const [currentVersion, targetVersion] = await Promise.all([
const [pinnedVersion, targetVersion] = await Promise.all([
instance.currentVersionId
? tx.frameworkVersion.findUnique({ where: { id: instance.currentVersionId } })
: null,
tx.frameworkVersion.findUnique({ where: { id: params.targetVersionId } }),
]);
if (!targetVersion) throw new NotFoundException('Target version not found');

// Unpinned instances (no currentVersion) diff from the framework's
// earliest published version so they can still adopt updates. applySync
// sets currentVersionId at the end, healing the unpinned state.
const currentVersion =
pinnedVersion ??
(await tx.frameworkVersion.findFirst({
where: { frameworkId: instance.frameworkId! },
orderBy: { publishedAt: 'asc' },
}));
if (!currentVersion) {
throw new BadRequestException('Instance is not on any version; backfill v1.0.0 first');
throw new BadRequestException(
'Framework has no published version to sync from',
);
}
if (currentVersion.frameworkId !== instance.frameworkId) {
throw new BadRequestException('Version / framework mismatch');
Expand Down
25 changes: 18 additions & 7 deletions apps/api/src/frameworks/frameworks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,9 +1022,6 @@ export class FrameworksService {
if (!instance || instance.organizationId !== params.organizationId) {
throw new NotFoundException('Framework instance not found');
}
if (!instance.currentVersion) {
throw new BadRequestException('Instance is not on any version');
}

const latest = await db.frameworkVersion.findFirst({
where: { frameworkId: instance.frameworkId! },
Expand All @@ -1034,8 +1031,22 @@ export class FrameworksService {
throw new NotFoundException('No update available');
}

const fromManifest = instance.currentVersion
.manifest as unknown as FrameworkManifest;
// Instances that were never pinned to a version (e.g. created before
// versioning) have no currentVersion. Rather than dead-ending the update
// flow, diff from the framework's earliest published version so they can
// still adopt the latest — applying the sync pins currentVersionId, which
// heals the state for future updates.
const baseVersion =
instance.currentVersion ??
(await db.frameworkVersion.findFirst({
where: { frameworkId: instance.frameworkId! },
orderBy: { publishedAt: 'asc' },
}));
if (!baseVersion) {
throw new NotFoundException('No update available');
}

const fromManifest = baseVersion.manifest as unknown as FrameworkManifest;
const toManifest = latest.manifest as unknown as FrameworkManifest;
const templateControlIds = [
...new Set([
Expand Down Expand Up @@ -1109,8 +1120,8 @@ export class FrameworksService {
status: p.status,
})),
fromVersionLabel: {
id: instance.currentVersion.id,
version: instance.currentVersion.version,
id: baseVersion.id,
version: baseVersion.version,
},
toVersionLabel: { id: latest.id, version: latest.version },
releaseNotes: latest.releaseNotes,
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/mcp-download/mcp-download.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Controller, Get, Param, Res, VERSION_NEUTRAL } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import type { Response } from 'express';
import { Public } from '../auth/public.decorator';
import { McpDownloadService } from './mcp-download.service';

/**
* Stable, never-stale download links for the MCP server artifacts.
*
* The `.mcpb` and the platform binaries are published as assets on the MCP
* release stream (`apps/mcp-server/vX.Y.Z`), which is buried among the frequent
* product releases — so a version-pinned link in the docs rots every release and
* is hard for customers to find. These endpoints 302-redirect to the asset on
* whatever the latest MCP release is, so the docs can link here once and forget.
*
* Public + unversioned on purpose (customer-facing download URLs). Excluded from
* the OpenAPI spec so it never becomes an MCP tool or a docs entry.
*/
@ApiExcludeController()
@Controller({ path: 'mcp/download', version: VERSION_NEUTRAL })
export class McpDownloadController {
constructor(private readonly mcpDownloadService: McpDownloadService) {}

@Get(':target')
@Public()
async download(
@Param('target') target: string,
@Res() res: Response,
): Promise<void> {
const url = await this.mcpDownloadService.resolveDownloadUrl(target);
res.redirect(302, url);
}
}
9 changes: 9 additions & 0 deletions apps/api/src/mcp-download/mcp-download.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { McpDownloadController } from './mcp-download.controller';
import { McpDownloadService } from './mcp-download.service';

@Module({
controllers: [McpDownloadController],
providers: [McpDownloadService],
})
export class McpDownloadModule {}
176 changes: 176 additions & 0 deletions apps/api/src/mcp-download/mcp-download.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { McpDownloadService } from './mcp-download.service';

function releasesPayload() {
return [
// A product release with no MCP assets — must be skipped.
{ tag_name: 'v3.79.1', draft: false, assets: [] },
// The latest MCP release.
{
tag_name: 'apps/mcp-server/v0.2.0',
draft: false,
assets: [
{
name: 'mcp-server.mcpb',
browser_download_url: 'https://gh/dl/v0.2.0/mcp-server.mcpb',
},
{
name: 'mcp-server-bun-darwin-arm64',
browser_download_url:
'https://gh/dl/v0.2.0/mcp-server-bun-darwin-arm64',
},
],
},
// An older MCP release — must not win.
{
tag_name: 'apps/mcp-server/v0.1.0',
draft: false,
assets: [
{
name: 'mcp-server.mcpb',
browser_download_url: 'https://gh/dl/v0.1.0/mcp-server.mcpb',
},
],
},
];
}

describe('McpDownloadService', () => {
let service: McpDownloadService;
let fetchSpy: jest.SpyInstance<Promise<Response>, Parameters<typeof fetch>>;

beforeEach(() => {
service = new McpDownloadService();
fetchSpy = jest.spyOn(global, 'fetch');
});

afterEach(() => {
fetchSpy.mockRestore();
});

function mockReleasesOnce(payload: unknown, status = 200) {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
}),
);
}

it('resolves a target to the asset URL on the latest MCP release', async () => {
mockReleasesOnce(releasesPayload());

const url = await service.resolveDownloadUrl('claude-desktop');

// Picked v0.2.0, not the older v0.1.0, and ignored the product release.
expect(url).toBe('https://gh/dl/v0.2.0/mcp-server.mcpb');
});

it('maps each platform target to its stable asset', async () => {
mockReleasesOnce(releasesPayload());

const macos = await service.resolveDownloadUrl('macos-arm64');

expect(macos).toBe('https://gh/dl/v0.2.0/mcp-server-bun-darwin-arm64');
});

it('rejects an unknown target without calling GitHub', async () => {
await expect(service.resolveDownloadUrl('bogus')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('404s when the asset is missing on the latest release', async () => {
// Latest MCP release ships only the .mcpb, not the windows binary.
mockReleasesOnce([
{
tag_name: 'apps/mcp-server/v0.2.0',
draft: false,
assets: [
{
name: 'mcp-server.mcpb',
browser_download_url: 'https://gh/dl/v0.2.0/mcp-server.mcpb',
},
],
},
]);

await expect(
service.resolveDownloadUrl('windows-x64'),
).rejects.toBeInstanceOf(NotFoundException);
});

it('paginates beyond the first 100 releases to find the MCP release', async () => {
// Page 1: a full page of product releases, no MCP release.
const page1 = Array.from({ length: 100 }, (_, i) => ({
tag_name: `v3.${i}.0`,
draft: false,
assets: [],
}));
mockReleasesOnce(page1);
// Page 2: contains the MCP release.
mockReleasesOnce(releasesPayload());

const url = await service.resolveDownloadUrl('claude-desktop');

expect(url).toBe('https://gh/dl/v0.2.0/mcp-server.mcpb');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it('bounds each GitHub request with an abort signal (no unbounded hang)', async () => {
mockReleasesOnce(releasesPayload());

await service.resolveDownloadUrl('claude-desktop');

const init = fetchSpy.mock.calls[0][1];
expect(init?.signal).toBeInstanceOf(AbortSignal);
});

it('handles a GitHub timeout/abort without hanging', async () => {
const timeout = new Error('The operation timed out');
timeout.name = 'TimeoutError';
fetchSpy.mockRejectedValueOnce(timeout);

await expect(
service.resolveDownloadUrl('claude-desktop'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
});

it('caches the release lookup across requests', async () => {
mockReleasesOnce(releasesPayload());

await service.resolveDownloadUrl('claude-desktop');
await service.resolveDownloadUrl('macos-arm64');

expect(fetchSpy).toHaveBeenCalledTimes(1);
});

it('serves a stale cached release if a later GitHub lookup fails', async () => {
mockReleasesOnce(releasesPayload());
await service.resolveDownloadUrl('claude-desktop'); // primes the cache

// Force the cache to look expired, then make the refresh fail.
(service as unknown as { cache: { fetchedAt: number } }).cache.fetchedAt = 0;
fetchSpy.mockRejectedValueOnce(new Error('network down'));

const url = await service.resolveDownloadUrl('claude-desktop');
expect(url).toBe('https://gh/dl/v0.2.0/mcp-server.mcpb');
});

it('throws ServiceUnavailable when GitHub fails and there is no cache', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 503 }));

await expect(
service.resolveDownloadUrl('claude-desktop'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
});

it('throws when no MCP-tagged release exists in the page', async () => {
mockReleasesOnce([{ tag_name: 'v3.79.1', draft: false, assets: [] }]);

await expect(
service.resolveDownloadUrl('claude-desktop'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
Loading
Loading