diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ccb8410374..9582e978b3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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'; @@ -128,6 +129,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- TimelinesModule, OffboardingChecklistModule, McpModule, + McpDownloadModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts index af87d5b1e9..246472ee26 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync.service.spec.ts @@ -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) => cb(tx)), __tx: tx, }, @@ -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; @@ -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(); + }); +}); diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts b/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts index 0011c1dbdf..2a5b9685cf 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync.service.ts @@ -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'); diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 50e1a95281..2230f4a089 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -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! }, @@ -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([ @@ -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, diff --git a/apps/api/src/mcp-download/mcp-download.controller.ts b/apps/api/src/mcp-download/mcp-download.controller.ts new file mode 100644 index 0000000000..44c2670192 --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.controller.ts @@ -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 { + const url = await this.mcpDownloadService.resolveDownloadUrl(target); + res.redirect(302, url); + } +} diff --git a/apps/api/src/mcp-download/mcp-download.module.ts b/apps/api/src/mcp-download/mcp-download.module.ts new file mode 100644 index 0000000000..9a8c694371 --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.module.ts @@ -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 {} diff --git a/apps/api/src/mcp-download/mcp-download.service.spec.ts b/apps/api/src/mcp-download/mcp-download.service.spec.ts new file mode 100644 index 0000000000..bb06949cdb --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.service.spec.ts @@ -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, Parameters>; + + 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); + }); +}); diff --git a/apps/api/src/mcp-download/mcp-download.service.ts b/apps/api/src/mcp-download/mcp-download.service.ts new file mode 100644 index 0000000000..81d6e7d70d --- /dev/null +++ b/apps/api/src/mcp-download/mcp-download.service.ts @@ -0,0 +1,162 @@ +import { + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; + +/** + * Stable, human-friendly download target -> the (version-less) GitHub release + * asset name. The MCP server's release assets keep the same names across + * versions, so we can resolve them by name on whatever the latest release is. + */ +const ASSET_BY_TARGET: Record = { + 'claude-desktop': 'mcp-server.mcpb', + 'macos-arm64': 'mcp-server-bun-darwin-arm64', + 'linux-x64': 'mcp-server-bun-linux-x64-modern', + 'windows-x64': 'mcp-server-bun-windows-x64-modern.exe', +}; + +// The MCP server ships its own release stream (tag `apps/mcp-server/vX.Y.Z`), +// interleaved with the much more frequent product releases (`vX.Y.Z`) — the repo +// cuts several product releases a day, so the latest MCP release can sit well +// past the first page. We page through newest-first and stop at the first MCP +// release (which, because the list is newest-first, is the latest one). +const GITHUB_RELEASES_URL = + 'https://api.github.com/repos/trycompai/comp/releases'; +const RELEASES_PER_PAGE = 100; +// Bounds the work (and the GitHub API budget) while covering many months of +// product-release velocity, so a slow MCP release cadence can't make downloads +// look unavailable. +const MAX_RELEASE_PAGES = 5; +const MCP_TAG_PREFIX = 'apps/mcp-server/'; + +// Bound each GitHub call so a stalled upstream can't tie up the endpoint. +const GITHUB_TIMEOUT_MS = 5_000; + +// Cache the resolved release so a burst of downloads can't exhaust GitHub's +// unauthenticated API budget (60 req/hr/IP). 10 min keeps us at a handful of +// API calls per hour regardless of download volume. +const CACHE_TTL_MS = 10 * 60 * 1000; + +interface ResolvedRelease { + tag: string; + /** asset filename -> browser_download_url */ + assets: Record; + fetchedAt: number; +} + +interface GitHubRelease { + tag_name: string; + draft: boolean; + assets: Array<{ name: string; browser_download_url: string }>; +} + +@Injectable() +export class McpDownloadService { + private readonly logger = new Logger(McpDownloadService.name); + private cache: ResolvedRelease | null = null; + + static readonly TARGETS = Object.keys(ASSET_BY_TARGET); + + /** + * Resolve a stable download target to the current GitHub CDN URL for that + * asset on the latest MCP release. Throws NotFoundException for unknown + * targets or missing assets. + */ + async resolveDownloadUrl(target: string): Promise { + const assetName = ASSET_BY_TARGET[target]; + if (!assetName) { + throw new NotFoundException( + `Unknown download target '${target}'. Valid targets: ${McpDownloadService.TARGETS.join( + ', ', + )}.`, + ); + } + + const release = await this.getLatestMcpRelease(); + const url = release.assets[assetName]; + if (!url) { + throw new NotFoundException( + `Asset '${assetName}' is not present on the latest MCP release (${release.tag}).`, + ); + } + return url; + } + + private async getLatestMcpRelease(): Promise { + if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { + return this.cache; + } + + try { + this.cache = await this.fetchLatestMcpRelease(); + return this.cache; + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown error'; + // Prefer a slightly stale answer over a hard failure if GitHub blips. + if (this.cache) { + this.logger.warn( + `GitHub lookup failed; serving stale MCP release ${this.cache.tag}: ${message}`, + ); + return this.cache; + } + this.logger.error(`Failed to resolve latest MCP release: ${message}`); + throw new ServiceUnavailableException( + 'Could not resolve the latest MCP server release. Please try again shortly.', + ); + } + } + + private async fetchLatestMcpRelease(): Promise { + for (let page = 1; page <= MAX_RELEASE_PAGES; page++) { + const releases = await this.fetchReleasesPage(page); + + // Releases come back newest-first, so the first MCP-tagged, non-draft + // release on any page is the latest one. + const latest = releases.find( + (release) => + !release.draft && release.tag_name.startsWith(MCP_TAG_PREFIX), + ); + if (latest) { + const assets: Record = {}; + for (const asset of latest.assets) { + assets[asset.name] = asset.browser_download_url; + } + return { tag: latest.tag_name, assets, fetchedAt: Date.now() }; + } + + // A short page means we've reached the end of the release history. + if (releases.length < RELEASES_PER_PAGE) break; + } + + throw new Error( + `No release with tag prefix '${MCP_TAG_PREFIX}' in the latest ${ + MAX_RELEASE_PAGES * RELEASES_PER_PAGE + } releases.`, + ); + } + + private async fetchReleasesPage(page: number): Promise { + const url = `${GITHUB_RELEASES_URL}?per_page=${RELEASES_PER_PAGE}&page=${page}`; + const response = await fetch(url, { + // Abort a stalled GitHub call instead of hanging the endpoint. + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + headers: { + // GitHub rejects API requests without a User-Agent. + 'User-Agent': 'comp-ai-mcp-download', + Accept: 'application/vnd.github+json', + // Optional: lifts the rate limit to 5000/hr if a token is configured. + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }), + }, + }); + + if (!response.ok) { + throw new Error(`GitHub releases API returned ${response.status}`); + } + + return (await response.json()) as GitHubRelease[]; + } +}