From 197058f3a8aa06cf844cc145c4c62e43304750e7 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 18:46:03 -0400 Subject: [PATCH 1/8] Add Zod-based querystring schema for bundle search route Replace hand-written JSON Schema with BundleSearchQuerySchema in query.ts, wire it into the /v1/bundles/search route via toJsonSchema(), and make the handler type-safe using Fastify generics instead of manual type casts. Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 33 +++++--------------------- apps/registry/src/schemas/query.ts | 13 ++++++++++ 2 files changed, 19 insertions(+), 27 deletions(-) create mode 100644 apps/registry/src/schemas/query.ts diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 30aa5b3..bb090f1 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -23,6 +23,7 @@ import { AnnounceRequestSchema, AnnounceResponseSchema, } from '../../schemas/generated/api-responses.js'; +import { BundleSearchQuerySchema, type BundleSearchQuery } from '../../schemas/query.js'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; @@ -133,38 +134,17 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { const { packages: packageRepo } = fastify.repositories; // GET /v1/bundles/search - Search bundles - fastify.get('/search', { + fastify.get<{ Querystring: BundleSearchQuery }>('/search', { schema: { tags: ['bundles'], description: 'Search for bundles', - querystring: { - type: 'object', - properties: { - q: { type: 'string', description: 'Search query' }, - type: { type: 'string', description: 'Filter by server type' }, - sort: { type: 'string', enum: ['downloads', 'recent', 'name'], default: 'downloads' }, - limit: { type: 'number', default: 20, maximum: 100 }, - offset: { type: 'number', default: 0 }, - }, - }, + querystring: toJsonSchema(BundleSearchQuerySchema), response: { 200: toJsonSchema(BundleSearchResponseSchema), }, }, handler: async (request) => { - const { - q, - type, - sort = 'downloads', - limit = 20, - offset = 0, - } = request.query as { - q?: string; - type?: string; - sort?: string; - limit?: number; - offset?: number; - }; + const { q, type, sort, limit, offset } = request.query; // Build filters const filters: Record = {}; @@ -179,9 +159,8 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { orderBy = { name: 'asc' }; } - // Clamp pagination values to safe ranges - const safeLimit = Math.max(1, Math.min(limit, 100)); - const safeOffset = Math.max(0, offset); + const safeLimit = limit; + const safeOffset = offset; // Search packages const startTime = Date.now(); diff --git a/apps/registry/src/schemas/query.ts b/apps/registry/src/schemas/query.ts new file mode 100644 index 0000000..a5973fa --- /dev/null +++ b/apps/registry/src/schemas/query.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const BundleSearchQuerySchema = z.object({ + q: z.optional(z.string()), + type: z.optional(z.enum(["node", "python", "binary"])), + sort: z.optional( + z.enum(["downloads", "recent", "name"]).default("downloads"), + ), + limit: z.optional(z.number().min(1).max(100).default(20)), + offset: z.optional(z.number().min(0).default(0)), +}); + +export type BundleSearchQuery = z.infer; From 1f14242ef20a9411ed205d2e1314417d2a943434 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 21:42:08 -0400 Subject: [PATCH 2/8] Refactor bundle search route to use Zod querystring schema - Add BundleSearchQuerySchema in schemas/query.ts with proper enum constraints for type (node/python/binary) and sort, plus min/max validation for limit and offset - Replace hand-written JSON Schema with toJsonSchema(BundleSearchQuerySchema) - Make handler type-safe via Fastify generics, removing manual type cast - Use PackageSearchFilters instead of Record for filters - Replace if/else sort chain with explicit sort map lookup - Remove runtime clamping (now enforced at schema validation level) - Update test to verify 400 rejection instead of silent clamping Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 2296 +++++++++++++----------- apps/registry/src/schemas/query.ts | 8 +- apps/registry/tests/bundles.test.ts | 13 +- 3 files changed, 1266 insertions(+), 1051 deletions(-) diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index bb090f1..c24d76b 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,93 +1,109 @@ -import type { FastifyPluginAsync } from 'fastify'; -import { createHash, randomUUID } from 'crypto'; -import { createWriteStream, createReadStream, promises as fs } from 'fs'; -import { tmpdir } from 'os'; -import path from 'path'; -import { config } from '../../config.js'; -import { runInTransaction } from '../../db/index.js'; +import { createHash, randomUUID } from "crypto"; +import type { FastifyPluginAsync } from "fastify"; +import { createWriteStream, createReadStream, promises as fs } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { config } from "../../config.js"; +import { runInTransaction } from "../../db/index.js"; +import type { PackageSearchFilters } from "../../db/types.js"; import { - BadRequestError, - NotFoundError, - UnauthorizedError, - handleError, -} from '../../errors/index.js'; -import { toJsonSchema } from '../../lib/zod-schema.js'; -import { verifyGitHubOIDC, buildProvenance, type ProvenanceRecord } from '../../lib/oidc.js'; + BadRequestError, + NotFoundError, + UnauthorizedError, + handleError, +} from "../../errors/index.js"; import { - BundleSearchResponseSchema, - BundleDetailSchema, - VersionsResponseSchema, - VersionDetailSchema, - DownloadInfoSchema, - MCPBIndexSchema, - AnnounceRequestSchema, - AnnounceResponseSchema, -} from '../../schemas/generated/api-responses.js'; -import { BundleSearchQuerySchema, type BundleSearchQuery } from '../../schemas/query.js'; -import { generateBadge } from '../../utils/badge.js'; -import { notifyDiscordAnnounce } from '../../utils/discord.js'; -import { triggerSecurityScan } from '../../services/scanner.js'; + buildProvenance, + type ProvenanceRecord, + verifyGitHubOIDC, +} from "../../lib/oidc.js"; +import { toJsonSchema } from "../../lib/zod-schema.js"; +import { + BundleSearchResponseSchema, + BundleDetailSchema, + VersionsResponseSchema, + VersionDetailSchema, + DownloadInfoSchema, + MCPBIndexSchema, + AnnounceRequestSchema, + AnnounceResponseSchema, +} from "../../schemas/generated/api-responses.js"; +import { + BundleSearchQuerySchema, + type BundleSearchQuery, +} from "../../schemas/query.js"; +import { triggerSecurityScan } from "../../services/scanner.js"; +import { generateBadge } from "../../utils/badge.js"; +import { notifyDiscordAnnounce } from "../../utils/discord.js"; // GitHub release asset type interface GitHubReleaseAsset { - name: string; - url: string; - browser_download_url: string; - size: number; - content_type: string; + name: string; + url: string; + browser_download_url: string; + size: number; + content_type: string; } /** * Get platform string for storage (e.g., "linux-x64") */ function getPlatformString(os: string, arch: string): string { - if (os === 'any' && arch === 'any') { - return ''; // Universal bundle, no platform suffix - } - return `${os}-${arch}`; + if (os === "any" && arch === "any") { + return ""; // Universal bundle, no platform suffix + } + return `${os}-${arch}`; } // Package name validation (scoped only for v1 API) const SCOPED_REGEX = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9-]{0,213}$/; function isValidScopedPackageName(name: string): boolean { - return SCOPED_REGEX.test(name); + return SCOPED_REGEX.test(name); } -function parsePackageName(name: string): { scope: string; packageName: string } | null { - if (!name.startsWith('@')) return null; - const parts = name.split('/'); - if (parts.length !== 2 || !parts[0] || !parts[1]) return null; - return { - scope: parts[0].substring(1), // Remove @ - packageName: parts[1], - }; +function parsePackageName( + name: string, +): { scope: string; packageName: string } | null { + if (!name.startsWith("@")) return null; + const parts = name.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + return { + scope: parts[0].substring(1), // Remove @ + packageName: parts[1], + }; } /** * Helper to extract provenance summary for API responses */ -function getProvenanceSummary(version: { publishMethod: string | null; provenance: unknown }) { - if (version.publishMethod !== 'oidc' || !version.provenance) { - return null; - } - const p = version.provenance as ProvenanceRecord; - return { - schema_version: p.schema_version, - provider: p.provider, - repository: p.repository, - sha: p.sha, - }; +function getProvenanceSummary(version: { + publishMethod: string | null; + provenance: unknown; +}) { + if (version.publishMethod !== "oidc" || !version.provenance) { + return null; + } + const p = version.provenance as ProvenanceRecord; + return { + schema_version: p.schema_version, + provider: p.provider, + repository: p.repository, + sha: p.sha, + }; } /** * Helper to extract full provenance for detailed API responses */ -function getProvenanceFull(version: { publishMethod: string | null; provenance: unknown }) { - if (version.publishMethod !== 'oidc' || !version.provenance) { - return null; - } - return version.provenance as ProvenanceRecord; +function getProvenanceFull(version: { + publishMethod: string | null; + provenance: unknown; +}) { + if (version.publishMethod !== "oidc" || !version.provenance) { + return null; + } + return version.provenance as ProvenanceRecord; } /** @@ -96,22 +112,22 @@ function getProvenanceFull(version: { publishMethod: string | null; provenance: * and filenames that don't end with .mcpb. */ function validateArtifactFilename(filename: string): string | null { - if (!filename || filename.length === 0) { - return 'Filename must not be empty.'; - } - if (filename.length > 255) { - return 'Filename must be at most 255 characters.'; - } - if (/[/\\]/.test(filename)) { - return 'Filename must not contain path separators (/ or \\).'; - } - if (filename.includes('..')) { - return 'Filename must not contain "..".'; - } - if (!filename.endsWith('.mcpb')) { - return 'Filename must end with .mcpb.'; - } - return null; + if (!filename || filename.length === 0) { + return "Filename must not be empty."; + } + if (filename.length > 255) { + return "Filename must be at most 255 characters."; + } + if (/[/\\]/.test(filename)) { + return "Filename must not contain path separators (/ or \\)."; + } + if (filename.includes("..")) { + return 'Filename must not contain "..".'; + } + if (!filename.endsWith(".mcpb")) { + return "Filename must end with .mcpb."; + } + return null; } /** @@ -131,964 +147,1170 @@ function validateArtifactFilename(filename: string): string | null { * - POST /announce - Announce a new bundle version */ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { - const { packages: packageRepo } = fastify.repositories; - - // GET /v1/bundles/search - Search bundles - fastify.get<{ Querystring: BundleSearchQuery }>('/search', { - schema: { - tags: ['bundles'], - description: 'Search for bundles', - querystring: toJsonSchema(BundleSearchQuerySchema), - response: { - 200: toJsonSchema(BundleSearchResponseSchema), - }, - }, - handler: async (request) => { - const { q, type, sort, limit, offset } = request.query; - - // Build filters - const filters: Record = {}; - if (q) filters['query'] = q; - if (type) filters['serverType'] = type; - - // Build sort options - let orderBy: Record = { totalDownloads: 'desc' }; - if (sort === 'recent') { - orderBy = { createdAt: 'desc' }; - } else if (sort === 'name') { - orderBy = { name: 'asc' }; - } - - const safeLimit = limit; - const safeOffset = offset; - - // Search packages - const startTime = Date.now(); - const { packages, total } = await packageRepo.search( - filters, - { - skip: safeOffset, - take: safeLimit, - orderBy, - } - ); - - fastify.log.info({ - op: 'search', - query: q ?? null, - type: type ?? null, - sort, - results: total, - ms: Date.now() - startTime, - }, `search: q="${q ?? '*'}" returned ${total} results`); - - // Get package versions with tools info and certification - const bundles = await Promise.all( - packages.map(async (pkg) => { - const latestVersion = await packageRepo.findVersionWithLatestScan(pkg.id, pkg.latestVersion); - const manifest = (latestVersion?.manifest ?? {}) as Record; - const scan = latestVersion?.securityScans[0]; - - return { - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - author: pkg.authorName ? { name: pkg.authorName } : null, - latest_version: pkg.latestVersion, - icon: pkg.iconUrl, - server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], - downloads: Number(pkg.totalDownloads), - published_at: latestVersion?.publishedAt ?? pkg.createdAt, - verified: pkg.verified, - provenance: latestVersion ? getProvenanceSummary(latestVersion) : null, - certification_level: scan?.certificationLevel ?? null, - }; - }) - ); - - return { - bundles, - total, - pagination: { - limit, - offset, - has_more: offset + bundles.length < total, - }, - }; - }, - }); - - // GET /v1/bundles/@:scope/:package - Get bundle details - fastify.get('/@:scope/:package', { - schema: { - tags: ['bundles'], - description: 'Get detailed bundle information', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - }, - required: ['scope', 'package'], - }, - response: { - 200: toJsonSchema(BundleDetailSchema), - }, - }, - handler: async (request) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } - - // Get all versions - const versions = await packageRepo.getVersions(pkg.id); - - // Get latest version manifest with security scan - const latestVersion = await packageRepo.findVersionWithLatestScan(pkg.id, pkg.latestVersion); - const manifest = (latestVersion?.manifest ?? {}) as Record; - const scan = latestVersion?.securityScans[0]; - - // Build certification object from scan - const CERT_LEVEL_LABELS: Record = { 1: 'L1 Basic', 2: 'L2 Verified', 3: 'L3 Hardened', 4: 'L4 Certified' }; - const certLevel = scan?.certificationLevel ?? null; - const certification = certLevel != null ? { - level: certLevel, - level_name: CERT_LEVEL_LABELS[certLevel] ?? null, - controls_passed: scan?.controlsPassed ?? null, - controls_failed: scan?.controlsFailed ?? null, - controls_total: scan?.controlsTotal ?? null, - } : null; - - return { - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - author: pkg.authorName ? { name: pkg.authorName } : null, - latest_version: pkg.latestVersion, - icon: pkg.iconUrl, - server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], - downloads: Number(pkg.totalDownloads), - published_at: pkg.createdAt, - verified: pkg.verified, - homepage: pkg.homepage, - license: pkg.license, - provenance: latestVersion ? getProvenanceFull(latestVersion) : null, - certification_level: certLevel, - certification, - versions: versions.map((v) => ({ - version: v.version, - published_at: v.publishedAt, - downloads: Number(v.downloadCount), - })), - }; - }, - }); - - // GET /v1/bundles/@:scope/:package/badge.svg - Get SVG badge for package - fastify.get('/@:scope/:package/badge.svg', { - schema: { - tags: ['bundles'], - description: 'Get an SVG badge for a bundle. Shows version for uncertified packages, or certification level for certified ones.', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - }, - required: ['scope', 'package'], - }, - response: { - 200: { - type: 'string', - description: 'SVG badge image', - }, - }, - }, - handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } - - // Check for certification level from latest scan - let certLevel: number | null = null; - const latestVersion = await fastify.prisma.packageVersion.findFirst({ - where: { - packageId: pkg.id, - version: pkg.latestVersion, - }, - include: { - securityScans: { - where: { status: 'completed' }, - orderBy: { startedAt: 'desc' }, - take: 1, - }, - }, - }); - - const scan = latestVersion?.securityScans[0]; - if (scan?.certificationLevel) { - certLevel = scan.certificationLevel; - } - - const svg = generateBadge(pkg.latestVersion, certLevel); - - return reply - .header('Content-Type', 'image/svg+xml') - .header('Cache-Control', 'max-age=300, s-maxage=3600') - .send(svg); - }, - }); - - // GET /v1/bundles/@:scope/:package/index.json - Get multi-platform distribution index - fastify.get('/@:scope/:package/index.json', { - schema: { - tags: ['bundles'], - description: 'Get multi-platform distribution index for a bundle (MCPB Index spec)', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - }, - required: ['scope', 'package'], - }, - response: { - 200: toJsonSchema(MCPBIndexSchema), - }, - }, - handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } - - // Get latest version with artifacts - const latestVersion = await packageRepo.findVersionWithArtifacts(pkg.id, pkg.latestVersion); - if (!latestVersion) { - throw new NotFoundError('No versions found'); - } - - // Build bundles array from artifacts - const bundleArtifacts = await Promise.all( - latestVersion.artifacts.map(async (artifact) => { - const url = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); - - return { - mimeType: artifact.mimeType, - digest: artifact.digest, - size: Number(artifact.sizeBytes), - platform: { os: artifact.os, arch: artifact.arch }, - urls: [url, artifact.sourceUrl].filter(Boolean), - }; - }) - ); - - // Build conformant MCPB index.json - const index = { - index_version: '0.1', - mimeType: 'application/vnd.mcp.bundle.index.v0.1+json', - name: pkg.name, - version: pkg.latestVersion, - description: pkg.description, - bundles: bundleArtifacts, - annotations: { - ...(latestVersion.releaseUrl && { 'dev.mpak.release.url': latestVersion.releaseUrl }), - ...(latestVersion.provenanceRepository && { 'dev.mpak.provenance.repository': latestVersion.provenanceRepository }), - ...(latestVersion.provenanceSha && { 'dev.mpak.provenance.sha': latestVersion.provenanceSha }), - ...(latestVersion.publishMethod && { 'dev.mpak.provenance.provider': latestVersion.publishMethod === 'oidc' ? 'github_oidc' : latestVersion.publishMethod }), - }, - }; - - reply.header('Content-Type', 'application/vnd.mcp.bundle.index.v0.1+json'); - return index; - }, - }); - - // GET /v1/bundles/@:scope/:package/versions - List versions - fastify.get('/@:scope/:package/versions', { - schema: { - tags: ['bundles'], - description: 'List all versions of a bundle', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - }, - required: ['scope', 'package'], - }, - response: { - 200: toJsonSchema(VersionsResponseSchema), - }, - }, - handler: async (request) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } - - // Get all versions with artifacts - const versions = await packageRepo.getVersionsWithArtifacts(pkg.id); - - return { - name: pkg.name, - latest: pkg.latestVersion, - versions: versions.map((v) => ({ - version: v.version, - artifacts_count: v.artifacts.length, - platforms: v.artifacts.map((a) => ({ os: a.os, arch: a.arch })), - published_at: v.publishedAt, - downloads: Number(v.downloadCount), - publish_method: v.publishMethod, - provenance: getProvenanceSummary(v), - })), - }; - }, - }); - - // GET /v1/bundles/@:scope/:package/versions/:version - Get specific version info - fastify.get('/@:scope/:package/versions/:version', { - schema: { - tags: ['bundles'], - description: 'Get information about a specific version', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - version: { type: 'string' }, - }, - required: ['scope', 'package', 'version'], - }, - response: { - 200: toJsonSchema(VersionDetailSchema), - }, - }, - handler: async (request) => { - const { scope, package: packageName, version } = request.params as { - scope: string; - package: string; - version: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } - - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); - - if (!packageVersion) { - throw new NotFoundError('Version not found'); - } - - // Build artifacts array with download URLs - const artifacts = await Promise.all( - packageVersion.artifacts.map(async (a) => { - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(a.storagePath); - - return { - platform: { os: a.os, arch: a.arch }, - digest: a.digest, - size: Number(a.sizeBytes), - download_url: downloadUrl, - source_url: a.sourceUrl || undefined, - }; - }) - ); - - return { - name: pkg.name, - version: packageVersion.version, - published_at: packageVersion.publishedAt, - downloads: Number(packageVersion.downloadCount), - artifacts, - manifest: packageVersion.manifest, - release: packageVersion.releaseUrl ? { - tag: packageVersion.releaseTag, - url: packageVersion.releaseUrl, - } : undefined, - publish_method: packageVersion.publishMethod, - provenance: getProvenanceFull(packageVersion), - }; - }, - }); - - // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle - fastify.get('/@:scope/:package/versions/:version/download', { - schema: { - tags: ['bundles'], - description: 'Download a specific version of a bundle', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - version: { type: 'string' }, - }, - required: ['scope', 'package', 'version'], - }, - querystring: { - type: 'object', - properties: { - os: { type: 'string', description: 'Target OS (darwin, linux, win32, any)' }, - arch: { type: 'string', description: 'Target arch (x64, arm64, any)' }, - }, - }, - response: { - 200: toJsonSchema(DownloadInfoSchema), - 302: { type: 'null', description: 'Redirect to download URL' }, - }, - }, - handler: async (request, reply) => { - const { scope, package: packageName, version: versionParam } = request.params as { - scope: string; - package: string; - version: string; - }; - const { os: queryOs, arch: queryArch } = request.query as { os?: string; arch?: string }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } - - // Resolve "latest" to actual version - const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; - - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); - - if (!packageVersion) { - throw new NotFoundError('Version not found'); - } - - // Find the appropriate artifact - let artifact = packageVersion.artifacts[0]; // Default to first - - if (queryOs || queryArch) { - // Look for exact match - const match = packageVersion.artifacts.find( - (a) => a.os === queryOs && a.arch === queryArch - ); - if (match) { - artifact = match; - } else { - // Look for universal fallback - const universal = packageVersion.artifacts.find( - (a) => a.os === 'any' && a.arch === 'any' - ); - if (universal) { - artifact = universal; - } - } - } - - if (!artifact) { - throw new NotFoundError('No artifact found for this version'); - } - - // Log download - const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; - fastify.log.info({ - op: 'download', - pkg: name, - version, - platform, - }, `download: ${name}@${version} (${platform})`); - - // Increment download counts atomically in a single transaction - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, version, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update download counts') - ); - - // Check if client wants JSON response (CLI/API) or redirect (browser) - const acceptHeader = request.headers.accept ?? ''; - const wantsJson = acceptHeader.includes('application/json'); - - // Generate signed download URL using the actual storage path - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); - - if (wantsJson) { - // CLI/API mode: Return JSON with download URL and metadata - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900)); - - return { - url: downloadUrl, - bundle: { - name, - version, - platform: { os: artifact.os, arch: artifact.arch }, - sha256: artifact.digest.replace('sha256:', ''), - size: Number(artifact.sizeBytes), - }, - expires_at: expiresAt.toISOString(), - }; - } else { - // Browser mode: Redirect to download URL - if (downloadUrl.startsWith('/')) { - // Local storage - serve file directly - const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); - - return reply - .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) - .send(fileBuffer); - } else { - // S3/CloudFront - redirect to signed URL - return reply.code(302).redirect(downloadUrl); - } - } - }, - }); - - // POST /v1/bundles/announce - Announce a single artifact (OIDC only, idempotent per-artifact) - fastify.post('/announce', { - schema: { - tags: ['bundles'], - description: 'Announce a single artifact for a bundle version from a GitHub release (OIDC only). Idempotent - can be called multiple times for different artifacts of the same version.', - body: toJsonSchema(AnnounceRequestSchema), - response: { - 200: toJsonSchema(AnnounceResponseSchema), - }, - }, - handler: async (request, reply) => { - try { - // Extract OIDC token from Authorization header - const authHeader = request.headers.authorization; - if (!authHeader?.startsWith('Bearer ')) { - throw new UnauthorizedError('Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.'); - } - - const token = authHeader.substring(7); - const announceStart = Date.now(); - - // Verify the OIDC token - let claims; - try { - claims = await verifyGitHubOIDC(token); - } catch (error) { - const message = error instanceof Error ? error.message : 'Token verification failed'; - fastify.log.warn({ op: 'announce', error: message }, `announce: OIDC verification failed`); - throw new UnauthorizedError(`Invalid OIDC token: ${message}`); - } - - // Extract body - const { - name, - version, - manifest, - release_tag, - prerelease = false, - artifact: artifactInfo, - } = request.body as { - name: string; - version: string; - manifest: Record; - release_tag: string; - prerelease?: boolean; - artifact: { - filename: string; - os: string; - arch: string; - sha256: string; - size: number; - }; - }; - - // Validate artifact platform values - const VALID_OS = ['darwin', 'linux', 'win32', 'any']; - const VALID_ARCH = ['x64', 'arm64', 'any']; - if (!VALID_OS.includes(artifactInfo.os)) { - throw new BadRequestError( - `Invalid artifact os: "${artifactInfo.os}". Must be one of: ${VALID_OS.join(', ')}` - ); - } - if (!VALID_ARCH.includes(artifactInfo.arch)) { - throw new BadRequestError( - `Invalid artifact arch: "${artifactInfo.arch}". Must be one of: ${VALID_ARCH.join(', ')}` - ); - } - // Validate artifact filename (path traversal, extension, length) - const filenameError = validateArtifactFilename(artifactInfo.filename); - if (filenameError) { - throw new BadRequestError( - `Invalid artifact filename: "${artifactInfo.filename}". ${filenameError}` - ); - } - - // Validate package name - if (!isValidScopedPackageName(name)) { - throw new BadRequestError( - `Invalid package name: "${name}". Must be scoped (@scope/name) with lowercase alphanumeric characters and hyphens.` - ); - } - - const parsed = parsePackageName(name); - if (!parsed) { - throw new BadRequestError('Invalid package name format'); - } - - // Security: Verify the package name scope matches the repository owner - const repoOwnerLower = claims.repository_owner.toLowerCase(); - const scopeLower = parsed.scope.toLowerCase(); - - if (scopeLower !== repoOwnerLower) { - fastify.log.warn({ - op: 'announce', - pkg: name, - version, - repo: claims.repository, - error: 'scope_mismatch', - }, `announce: scope mismatch @${parsed.scope} != ${claims.repository_owner}`); - throw new UnauthorizedError( - `Scope mismatch: Package scope "@${parsed.scope}" does not match repository owner "${claims.repository_owner}". ` + - `OIDC publishing requires the package scope to match the GitHub organization or user.` - ); - } - - fastify.log.info({ - op: 'announce', - pkg: name, - version, - repo: claims.repository, - tag: release_tag, - prerelease, - artifact: artifactInfo.filename, - platform: `${artifactInfo.os}-${artifactInfo.arch}`, - }, `announce: starting ${name}@${version} artifact ${artifactInfo.filename}`); - - // Extract server_type from manifest - const serverObj = manifest['server'] as Record | undefined; - const serverType = (serverObj?.['type'] as string) ?? (manifest['server_type'] as string); - if (!serverType) { - throw new BadRequestError('Manifest must contain server type (server.type or server_type)'); - } - - // Build provenance record - const provenance = buildProvenance(claims); - - // Fetch release from GitHub API to get the specific artifact - const releaseApiUrl = `https://api.github.com/repos/${claims.repository}/releases/tags/${release_tag}`; - fastify.log.info(`Fetching release from ${releaseApiUrl}`); - - const releaseResponse = await fetch(releaseApiUrl, { - headers: { - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'mpak-registry/1.0', - }, - }); - - if (!releaseResponse.ok) { - throw new BadRequestError(`Failed to fetch release ${release_tag}: ${releaseResponse.statusText}`); - } - - const release = await releaseResponse.json() as { - tag_name: string; - html_url: string; - assets: GitHubReleaseAsset[]; - }; - - // Check for server.json in the release assets (for MCP Registry discovery) - let serverJson: Record | null = null; - const serverJsonAsset = release.assets.find((a: GitHubReleaseAsset) => a.name === 'server.json'); - if (serverJsonAsset) { - try { - fastify.log.info(`Fetching server.json from release ${release_tag}`); - const sjResponse = await fetch(serverJsonAsset.browser_download_url); - if (sjResponse.ok) { - const sjData = await sjResponse.json() as Record; - // Strip packages[] before storing (the registry populates it dynamically at serve time) - delete sjData['packages']; - serverJson = sjData; - fastify.log.info(`Loaded server.json for MCP Registry discovery`); - } - } catch (sjError) { - fastify.log.warn({ err: sjError }, 'Failed to fetch server.json from release, continuing without it'); - } - } - - // Find the specific artifact by filename - const asset = release.assets.find((a: GitHubReleaseAsset) => a.name === artifactInfo.filename); - if (!asset) { - throw new BadRequestError(`Artifact "${artifactInfo.filename}" not found in release ${release_tag}`); - } - - // Download artifact to temp file while computing hash (memory-efficient streaming) - const tempPath = path.join(tmpdir(), `mcpb-${randomUUID()}`); - const platformStr = getPlatformString(artifactInfo.os, artifactInfo.arch); - let storagePath: string; - let computedSha256: string; - - try { - fastify.log.info(`Downloading artifact: ${asset.name}`); - const assetResponse = await fetch(asset.browser_download_url); - if (!assetResponse.ok || !assetResponse.body) { - throw new BadRequestError(`Failed to download ${asset.name}: ${assetResponse.statusText}`); - } - - // Stream to temp file while computing hash - const hash = createHash('sha256'); - let bytesWritten = 0; - const writeStream = createWriteStream(tempPath); - - // Convert web ReadableStream to async iterable - const reader = assetResponse.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - hash.update(value); - bytesWritten += value.length; - writeStream.write(value); - } - } finally { - reader.releaseLock(); - } - - await new Promise((resolve, reject) => { - writeStream.end(); - writeStream.on('finish', resolve); - writeStream.on('error', reject); - }); - - // Verify size - if (bytesWritten !== artifactInfo.size) { - throw new BadRequestError( - `Size mismatch for ${asset.name}: declared ${artifactInfo.size} bytes, got ${bytesWritten} bytes` - ); - } - - // Verify hash - computedSha256 = hash.digest('hex'); - if (computedSha256 !== artifactInfo.sha256) { - throw new BadRequestError( - `SHA256 mismatch for ${asset.name}: declared ${artifactInfo.sha256}, computed ${computedSha256}` - ); - } - - // Stream verified file to storage - const uploadStream = createReadStream(tempPath); - const result = await fastify.storage.saveBundleFromStream( - parsed.scope, - parsed.packageName, - version, - uploadStream, - computedSha256, - bytesWritten, - platformStr || undefined - ); - storagePath = result.path; - - fastify.log.info(`Stored ${asset.name} -> ${storagePath} (${artifactInfo.os}-${artifactInfo.arch})`); - } finally { - // Always clean up temp file - await fs.unlink(tempPath).catch(() => {}); - } - - // Track whether we created or updated - let status: 'created' | 'updated' = 'created'; - let totalArtifacts = 0; - let oldStoragePath: string | null = null; - let versionId: string | null = null; - - // Use transaction to upsert package, version, and artifact - try { - const txResult = await runInTransaction(async (tx) => { - // Find or create package (handles race conditions atomically) - const { package: existingPackage, created: packageCreated } = await packageRepo.upsertPackage({ - name, - displayName: (manifest['display_name'] as string) ?? undefined, - description: (manifest['description'] as string) ?? undefined, - authorName: (manifest['author'] as Record)?.['name'] as string ?? undefined, - authorEmail: (manifest['author'] as Record)?.['email'] as string ?? undefined, - authorUrl: (manifest['author'] as Record)?.['url'] as string ?? undefined, - homepage: (manifest['homepage'] as string) ?? undefined, - license: (manifest['license'] as string) ?? undefined, - iconUrl: (manifest['icon'] as string) ?? undefined, - serverType, - verified: false, - latestVersion: version, - githubRepo: claims.repository, - }, tx); - - const packageId = existingPackage.id; - let versionCreated = packageCreated; // New package means new version - - // Fetch README only if this might be a new version - let readme: string | null = null; - const existingVersion = await packageRepo.findVersion(packageId, version, tx); - - if (!existingVersion || !existingVersion.readme) { - // Fetch README.md from the repository at the release tag - try { - const readmeUrl = `https://api.github.com/repos/${claims.repository}/contents/README.md?ref=${release_tag}`; - fastify.log.info(`Fetching README from ${readmeUrl}`); - - const readmeResponse = await fetch(readmeUrl, { - headers: { - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'mpak-registry/1.0', - }, - }); - - if (readmeResponse.ok) { - const readmeData = await readmeResponse.json() as { content?: string; encoding?: string }; - if (readmeData.content && readmeData.encoding === 'base64') { - readme = Buffer.from(readmeData.content, 'base64').toString('utf-8'); - fastify.log.info(`Fetched README.md (${readme.length} chars)`); - } - } - } catch (readmeError) { - fastify.log.warn({ err: readmeError }, 'Failed to fetch README.md, continuing without it'); - } - } - - // Upsert version - const { version: packageVersion, created } = await packageRepo.upsertVersion(packageId, { - packageId, - version, - manifest, - prerelease, - publishedBy: null, - publishedByEmail: null, - releaseTag: release_tag, - releaseUrl: release.html_url, - readme: readme ?? undefined, - publishMethod: 'oidc', - provenanceRepository: provenance.repository, - provenanceSha: provenance.sha, - provenance, - serverJson: serverJson ?? undefined, - }, tx); - - versionCreated = created; - - // Update latestVersion only when version is first created - if (versionCreated) { - if (!prerelease) { - await packageRepo.updateLatestVersion(packageId, version, tx); - } else { - // Check if current latest is a prerelease - if so, update to newer prerelease - const currentLatest = await packageRepo.findVersion(packageId, existingPackage.latestVersion, tx); - if (currentLatest?.prerelease) { - await packageRepo.updateLatestVersion(packageId, version, tx); - } - } - } - - // Upsert artifact - const artifactResult = await packageRepo.upsertArtifact({ - versionId: packageVersion.id, - os: artifactInfo.os, - arch: artifactInfo.arch, - digest: `sha256:${computedSha256}`, - sizeBytes: BigInt(artifactInfo.size), - storagePath, - sourceUrl: asset.browser_download_url, - }, tx); - - status = artifactResult.created ? 'created' : 'updated'; - oldStoragePath = artifactResult.oldStoragePath; - - // Count total artifacts for this version - totalArtifacts = await packageRepo.countVersionArtifacts(packageVersion.id, tx); - - return { versionId: packageVersion.id }; - }); - - versionId = txResult.versionId; - } catch (error) { - // Transaction failed - clean up uploaded file - try { - await fastify.storage.deleteBundle(storagePath); - fastify.log.info(`Cleaned up after transaction failure: ${storagePath}`); - } catch (cleanupError) { - fastify.log.error({ err: cleanupError, path: storagePath }, 'Failed to cleanup uploaded file'); - } - throw error; - } - - // Clean up old storage path if artifact was updated with different path - if (oldStoragePath) { - try { - await fastify.storage.deleteBundle(oldStoragePath); - fastify.log.info(`Cleaned up old artifact: ${oldStoragePath}`); - } catch (cleanupError) { - fastify.log.warn({ err: cleanupError, path: oldStoragePath }, 'Failed to cleanup old artifact file'); - } - } - - fastify.log.info({ - op: 'announce', - pkg: name, - version, - repo: claims.repository, - artifact: artifactInfo.filename, - platform: `${artifactInfo.os}-${artifactInfo.arch}`, - status, - totalArtifacts, - ms: Date.now() - announceStart, - }, `announce: ${status} ${name}@${version} artifact ${artifactInfo.filename} (${totalArtifacts} total, ${Date.now() - announceStart}ms)`); - - // Non-blocking Discord notification for new or updated bundles - notifyDiscordAnnounce({ name, version, type: 'bundle', repo: claims.repository }); - - // Non-blocking security scan trigger - if (config.scanner.enabled && versionId) { - triggerSecurityScan(fastify.prisma, { - versionId, - bundleStoragePath: storagePath, - packageName: name, - version, - }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to trigger security scan')); - } - - return { - package: name, - version, - artifact: { - os: artifactInfo.os, - arch: artifactInfo.arch, - filename: artifactInfo.filename, - }, - total_artifacts: totalArtifacts, - status, - }; - } catch (error) { - fastify.log.error({ op: 'announce', error: error instanceof Error ? error.message : 'unknown' }, `announce: failed`); - return handleError(error, request, reply); - } - }, - }); + const { packages: packageRepo } = fastify.repositories; + + // GET /v1/bundles/search - Search bundles + fastify.get<{ Querystring: BundleSearchQuery }>("/search", { + schema: { + tags: ["bundles"], + description: "Search for bundles", + querystring: toJsonSchema(BundleSearchQuerySchema), + response: { + 200: toJsonSchema(BundleSearchResponseSchema), + }, + }, + handler: async (request) => { + const { q, type, sort, limit, offset } = request.query; + + const filters: PackageSearchFilters = { + ...(q && { query: q }), + ...(type && { serverType: type }), + }; + + const sortMap: Record> = { + downloads: { totalDownloads: "desc" }, + recent: { createdAt: "desc" }, + name: { name: "asc" }, + }; + const orderBy = sortMap[sort]; + + // Search packages + const startTime = Date.now(); + const { packages, total } = await packageRepo.search(filters, { + skip: offset, + take: limit, + orderBy, + }); + + fastify.log.info( + { + op: "search", + query: q ?? null, + type: type ?? null, + sort, + results: total, + ms: Date.now() - startTime, + }, + `search: q="${q ?? "*"}" returned ${total} results`, + ); + + // Get package versions with tools info and certification + const bundles = await Promise.all( + packages.map(async (pkg) => { + const latestVersion = await packageRepo.findVersionWithLatestScan( + pkg.id, + pkg.latestVersion, + ); + const manifest = (latestVersion?.manifest ?? {}) as Record< + string, + unknown + >; + const scan = latestVersion?.securityScans[0]; + + return { + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + author: pkg.authorName ? { name: pkg.authorName } : null, + latest_version: pkg.latestVersion, + icon: pkg.iconUrl, + server_type: pkg.serverType, + tools: (manifest["tools"] as unknown[]) ?? [], + downloads: Number(pkg.totalDownloads), + published_at: latestVersion?.publishedAt ?? pkg.createdAt, + verified: pkg.verified, + provenance: latestVersion + ? getProvenanceSummary(latestVersion) + : null, + certification_level: scan?.certificationLevel ?? null, + }; + }), + ); + + return { + bundles, + total, + pagination: { + limit, + offset, + has_more: offset + bundles.length < total, + }, + }; + }, + }); + + // GET /v1/bundles/@:scope/:package - Get bundle details + fastify.get("/@:scope/:package", { + schema: { + tags: ["bundles"], + description: "Get detailed bundle information", + params: { + type: "object", + properties: { + scope: { type: "string" }, + package: { type: "string" }, + }, + required: ["scope", "package"], + }, + response: { + 200: toJsonSchema(BundleDetailSchema), + }, + }, + handler: async (request) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError("Bundle not found"); + } + + // Get all versions + const versions = await packageRepo.getVersions(pkg.id); + + // Get latest version manifest with security scan + const latestVersion = await packageRepo.findVersionWithLatestScan( + pkg.id, + pkg.latestVersion, + ); + const manifest = (latestVersion?.manifest ?? {}) as Record< + string, + unknown + >; + const scan = latestVersion?.securityScans[0]; + + // Build certification object from scan + const CERT_LEVEL_LABELS: Record = { + 1: "L1 Basic", + 2: "L2 Verified", + 3: "L3 Hardened", + 4: "L4 Certified", + }; + const certLevel = scan?.certificationLevel ?? null; + const certification = + certLevel != null + ? { + level: certLevel, + level_name: CERT_LEVEL_LABELS[certLevel] ?? null, + controls_passed: scan?.controlsPassed ?? null, + controls_failed: scan?.controlsFailed ?? null, + controls_total: scan?.controlsTotal ?? null, + } + : null; + + return { + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + author: pkg.authorName ? { name: pkg.authorName } : null, + latest_version: pkg.latestVersion, + icon: pkg.iconUrl, + server_type: pkg.serverType, + tools: (manifest["tools"] as unknown[]) ?? [], + downloads: Number(pkg.totalDownloads), + published_at: pkg.createdAt, + verified: pkg.verified, + homepage: pkg.homepage, + license: pkg.license, + provenance: latestVersion ? getProvenanceFull(latestVersion) : null, + certification_level: certLevel, + certification, + versions: versions.map((v) => ({ + version: v.version, + published_at: v.publishedAt, + downloads: Number(v.downloadCount), + })), + }; + }, + }); + + // GET /v1/bundles/@:scope/:package/badge.svg - Get SVG badge for package + fastify.get("/@:scope/:package/badge.svg", { + schema: { + tags: ["bundles"], + description: + "Get an SVG badge for a bundle. Shows version for uncertified packages, or certification level for certified ones.", + params: { + type: "object", + properties: { + scope: { type: "string" }, + package: { type: "string" }, + }, + required: ["scope", "package"], + }, + response: { + 200: { + type: "string", + description: "SVG badge image", + }, + }, + }, + handler: async (request, reply) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError("Bundle not found"); + } + + // Check for certification level from latest scan + let certLevel: number | null = null; + const latestVersion = await fastify.prisma.packageVersion.findFirst({ + where: { + packageId: pkg.id, + version: pkg.latestVersion, + }, + include: { + securityScans: { + where: { status: "completed" }, + orderBy: { startedAt: "desc" }, + take: 1, + }, + }, + }); + + const scan = latestVersion?.securityScans[0]; + if (scan?.certificationLevel) { + certLevel = scan.certificationLevel; + } + + const svg = generateBadge(pkg.latestVersion, certLevel); + + return reply + .header("Content-Type", "image/svg+xml") + .header("Cache-Control", "max-age=300, s-maxage=3600") + .send(svg); + }, + }); + + // GET /v1/bundles/@:scope/:package/index.json - Get multi-platform distribution index + fastify.get("/@:scope/:package/index.json", { + schema: { + tags: ["bundles"], + description: + "Get multi-platform distribution index for a bundle (MCPB Index spec)", + params: { + type: "object", + properties: { + scope: { type: "string" }, + package: { type: "string" }, + }, + required: ["scope", "package"], + }, + response: { + 200: toJsonSchema(MCPBIndexSchema), + }, + }, + handler: async (request, reply) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError("Bundle not found"); + } + + // Get latest version with artifacts + const latestVersion = await packageRepo.findVersionWithArtifacts( + pkg.id, + pkg.latestVersion, + ); + if (!latestVersion) { + throw new NotFoundError("No versions found"); + } + + // Build bundles array from artifacts + const bundleArtifacts = await Promise.all( + latestVersion.artifacts.map(async (artifact) => { + const url = await fastify.storage.getSignedDownloadUrlFromPath( + artifact.storagePath, + ); + + return { + mimeType: artifact.mimeType, + digest: artifact.digest, + size: Number(artifact.sizeBytes), + platform: { os: artifact.os, arch: artifact.arch }, + urls: [url, artifact.sourceUrl].filter(Boolean), + }; + }), + ); + + // Build conformant MCPB index.json + const index = { + index_version: "0.1", + mimeType: "application/vnd.mcp.bundle.index.v0.1+json", + name: pkg.name, + version: pkg.latestVersion, + description: pkg.description, + bundles: bundleArtifacts, + annotations: { + ...(latestVersion.releaseUrl && { + "dev.mpak.release.url": latestVersion.releaseUrl, + }), + ...(latestVersion.provenanceRepository && { + "dev.mpak.provenance.repository": + latestVersion.provenanceRepository, + }), + ...(latestVersion.provenanceSha && { + "dev.mpak.provenance.sha": latestVersion.provenanceSha, + }), + ...(latestVersion.publishMethod && { + "dev.mpak.provenance.provider": + latestVersion.publishMethod === "oidc" + ? "github_oidc" + : latestVersion.publishMethod, + }), + }, + }; + + reply.header( + "Content-Type", + "application/vnd.mcp.bundle.index.v0.1+json", + ); + return index; + }, + }); + + // GET /v1/bundles/@:scope/:package/versions - List versions + fastify.get("/@:scope/:package/versions", { + schema: { + tags: ["bundles"], + description: "List all versions of a bundle", + params: { + type: "object", + properties: { + scope: { type: "string" }, + package: { type: "string" }, + }, + required: ["scope", "package"], + }, + response: { + 200: toJsonSchema(VersionsResponseSchema), + }, + }, + handler: async (request) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError("Bundle not found"); + } + + // Get all versions with artifacts + const versions = await packageRepo.getVersionsWithArtifacts(pkg.id); + + return { + name: pkg.name, + latest: pkg.latestVersion, + versions: versions.map((v) => ({ + version: v.version, + artifacts_count: v.artifacts.length, + platforms: v.artifacts.map((a) => ({ os: a.os, arch: a.arch })), + published_at: v.publishedAt, + downloads: Number(v.downloadCount), + publish_method: v.publishMethod, + provenance: getProvenanceSummary(v), + })), + }; + }, + }); + + // GET /v1/bundles/@:scope/:package/versions/:version - Get specific version info + fastify.get("/@:scope/:package/versions/:version", { + schema: { + tags: ["bundles"], + description: "Get information about a specific version", + params: { + type: "object", + properties: { + scope: { type: "string" }, + package: { type: "string" }, + version: { type: "string" }, + }, + required: ["scope", "package", "version"], + }, + response: { + 200: toJsonSchema(VersionDetailSchema), + }, + }, + handler: async (request) => { + const { + scope, + package: packageName, + version, + } = request.params as { + scope: string; + package: string; + version: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError("Bundle not found"); + } + + const packageVersion = await packageRepo.findVersionWithArtifacts( + pkg.id, + version, + ); + + if (!packageVersion) { + throw new NotFoundError("Version not found"); + } + + // Build artifacts array with download URLs + const artifacts = await Promise.all( + packageVersion.artifacts.map(async (a) => { + const downloadUrl = + await fastify.storage.getSignedDownloadUrlFromPath(a.storagePath); + + return { + platform: { os: a.os, arch: a.arch }, + digest: a.digest, + size: Number(a.sizeBytes), + download_url: downloadUrl, + source_url: a.sourceUrl || undefined, + }; + }), + ); + + return { + name: pkg.name, + version: packageVersion.version, + published_at: packageVersion.publishedAt, + downloads: Number(packageVersion.downloadCount), + artifacts, + manifest: packageVersion.manifest, + release: packageVersion.releaseUrl + ? { + tag: packageVersion.releaseTag, + url: packageVersion.releaseUrl, + } + : undefined, + publish_method: packageVersion.publishMethod, + provenance: getProvenanceFull(packageVersion), + }; + }, + }); + + // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle + fastify.get("/@:scope/:package/versions/:version/download", { + schema: { + tags: ["bundles"], + description: "Download a specific version of a bundle", + params: { + type: "object", + properties: { + scope: { type: "string" }, + package: { type: "string" }, + version: { type: "string" }, + }, + required: ["scope", "package", "version"], + }, + querystring: { + type: "object", + properties: { + os: { + type: "string", + description: "Target OS (darwin, linux, win32, any)", + }, + arch: { + type: "string", + description: "Target arch (x64, arm64, any)", + }, + }, + }, + response: { + 200: toJsonSchema(DownloadInfoSchema), + 302: { type: "null", description: "Redirect to download URL" }, + }, + }, + handler: async (request, reply) => { + const { + scope, + package: packageName, + version: versionParam, + } = request.params as { + scope: string; + package: string; + version: string; + }; + const { os: queryOs, arch: queryArch } = request.query as { + os?: string; + arch?: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError("Bundle not found"); + } + + // Resolve "latest" to actual version + const version = + versionParam === "latest" ? pkg.latestVersion : versionParam; + + const packageVersion = await packageRepo.findVersionWithArtifacts( + pkg.id, + version, + ); + + if (!packageVersion) { + throw new NotFoundError("Version not found"); + } + + // Find the appropriate artifact + let artifact = packageVersion.artifacts[0]; // Default to first + + if (queryOs || queryArch) { + // Look for exact match + const match = packageVersion.artifacts.find( + (a) => a.os === queryOs && a.arch === queryArch, + ); + if (match) { + artifact = match; + } else { + // Look for universal fallback + const universal = packageVersion.artifacts.find( + (a) => a.os === "any" && a.arch === "any", + ); + if (universal) { + artifact = universal; + } + } + } + + if (!artifact) { + throw new NotFoundError("No artifact found for this version"); + } + + // Log download + const platform = + artifact.os === "any" ? "universal" : `${artifact.os}-${artifact.arch}`; + fastify.log.info( + { + op: "download", + pkg: name, + version, + platform, + }, + `download: ${name}@${version} (${platform})`, + ); + + // Increment download counts atomically in a single transaction + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, version, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => + fastify.log.error({ err }, "Failed to update download counts"), + ); + + // Check if client wants JSON response (CLI/API) or redirect (browser) + const acceptHeader = request.headers.accept ?? ""; + const wantsJson = acceptHeader.includes("application/json"); + + // Generate signed download URL using the actual storage path + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath( + artifact.storagePath, + ); + + if (wantsJson) { + // CLI/API mode: Return JSON with download URL and metadata + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); + + return { + url: downloadUrl, + bundle: { + name, + version, + platform: { os: artifact.os, arch: artifact.arch }, + sha256: artifact.digest.replace("sha256:", ""), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; + } else { + // Browser mode: Redirect to download URL + if (downloadUrl.startsWith("/")) { + // Local storage - serve file directly + const fileBuffer = await fastify.storage.getBundle( + artifact.storagePath, + ); + + return reply + .header("Content-Type", "application/octet-stream") + .header( + "Content-Disposition", + `attachment; filename="${packageName}-${version}.mcpb"`, + ) + .send(fileBuffer); + } else { + // S3/CloudFront - redirect to signed URL + return reply.code(302).redirect(downloadUrl); + } + } + }, + }); + + // POST /v1/bundles/announce - Announce a single artifact (OIDC only, idempotent per-artifact) + fastify.post("/announce", { + schema: { + tags: ["bundles"], + description: + "Announce a single artifact for a bundle version from a GitHub release (OIDC only). Idempotent - can be called multiple times for different artifacts of the same version.", + body: toJsonSchema(AnnounceRequestSchema), + response: { + 200: toJsonSchema(AnnounceResponseSchema), + }, + }, + handler: async (request, reply) => { + try { + // Extract OIDC token from Authorization header + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + throw new UnauthorizedError( + "Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.", + ); + } + + const token = authHeader.substring(7); + const announceStart = Date.now(); + + // Verify the OIDC token + let claims; + try { + claims = await verifyGitHubOIDC(token); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Token verification failed"; + fastify.log.warn( + { op: "announce", error: message }, + `announce: OIDC verification failed`, + ); + throw new UnauthorizedError(`Invalid OIDC token: ${message}`); + } + + // Extract body + const { + name, + version, + manifest, + release_tag, + prerelease = false, + artifact: artifactInfo, + } = request.body as { + name: string; + version: string; + manifest: Record; + release_tag: string; + prerelease?: boolean; + artifact: { + filename: string; + os: string; + arch: string; + sha256: string; + size: number; + }; + }; + + // Validate artifact platform values + const VALID_OS = ["darwin", "linux", "win32", "any"]; + const VALID_ARCH = ["x64", "arm64", "any"]; + if (!VALID_OS.includes(artifactInfo.os)) { + throw new BadRequestError( + `Invalid artifact os: "${artifactInfo.os}". Must be one of: ${VALID_OS.join(", ")}`, + ); + } + if (!VALID_ARCH.includes(artifactInfo.arch)) { + throw new BadRequestError( + `Invalid artifact arch: "${artifactInfo.arch}". Must be one of: ${VALID_ARCH.join(", ")}`, + ); + } + // Validate artifact filename (path traversal, extension, length) + const filenameError = validateArtifactFilename(artifactInfo.filename); + if (filenameError) { + throw new BadRequestError( + `Invalid artifact filename: "${artifactInfo.filename}". ${filenameError}`, + ); + } + + // Validate package name + if (!isValidScopedPackageName(name)) { + throw new BadRequestError( + `Invalid package name: "${name}". Must be scoped (@scope/name) with lowercase alphanumeric characters and hyphens.`, + ); + } + + const parsed = parsePackageName(name); + if (!parsed) { + throw new BadRequestError("Invalid package name format"); + } + + // Security: Verify the package name scope matches the repository owner + const repoOwnerLower = claims.repository_owner.toLowerCase(); + const scopeLower = parsed.scope.toLowerCase(); + + if (scopeLower !== repoOwnerLower) { + fastify.log.warn( + { + op: "announce", + pkg: name, + version, + repo: claims.repository, + error: "scope_mismatch", + }, + `announce: scope mismatch @${parsed.scope} != ${claims.repository_owner}`, + ); + throw new UnauthorizedError( + `Scope mismatch: Package scope "@${parsed.scope}" does not match repository owner "${claims.repository_owner}". ` + + `OIDC publishing requires the package scope to match the GitHub organization or user.`, + ); + } + + fastify.log.info( + { + op: "announce", + pkg: name, + version, + repo: claims.repository, + tag: release_tag, + prerelease, + artifact: artifactInfo.filename, + platform: `${artifactInfo.os}-${artifactInfo.arch}`, + }, + `announce: starting ${name}@${version} artifact ${artifactInfo.filename}`, + ); + + // Extract server_type from manifest + const serverObj = manifest["server"] as + | Record + | undefined; + const serverType = + (serverObj?.["type"] as string) ?? + (manifest["server_type"] as string); + if (!serverType) { + throw new BadRequestError( + "Manifest must contain server type (server.type or server_type)", + ); + } + + // Build provenance record + const provenance = buildProvenance(claims); + + // Fetch release from GitHub API to get the specific artifact + const releaseApiUrl = `https://api.github.com/repos/${claims.repository}/releases/tags/${release_tag}`; + fastify.log.info(`Fetching release from ${releaseApiUrl}`); + + const releaseResponse = await fetch(releaseApiUrl, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "mpak-registry/1.0", + }, + }); + + if (!releaseResponse.ok) { + throw new BadRequestError( + `Failed to fetch release ${release_tag}: ${releaseResponse.statusText}`, + ); + } + + const release = (await releaseResponse.json()) as { + tag_name: string; + html_url: string; + assets: GitHubReleaseAsset[]; + }; + + // Check for server.json in the release assets (for MCP Registry discovery) + let serverJson: Record | null = null; + const serverJsonAsset = release.assets.find( + (a: GitHubReleaseAsset) => a.name === "server.json", + ); + if (serverJsonAsset) { + try { + fastify.log.info( + `Fetching server.json from release ${release_tag}`, + ); + const sjResponse = await fetch( + serverJsonAsset.browser_download_url, + ); + if (sjResponse.ok) { + const sjData = (await sjResponse.json()) as Record< + string, + unknown + >; + // Strip packages[] before storing (the registry populates it dynamically at serve time) + delete sjData["packages"]; + serverJson = sjData; + fastify.log.info(`Loaded server.json for MCP Registry discovery`); + } + } catch (sjError) { + fastify.log.warn( + { err: sjError }, + "Failed to fetch server.json from release, continuing without it", + ); + } + } + + // Find the specific artifact by filename + const asset = release.assets.find( + (a: GitHubReleaseAsset) => a.name === artifactInfo.filename, + ); + if (!asset) { + throw new BadRequestError( + `Artifact "${artifactInfo.filename}" not found in release ${release_tag}`, + ); + } + + // Download artifact to temp file while computing hash (memory-efficient streaming) + const tempPath = path.join(tmpdir(), `mcpb-${randomUUID()}`); + const platformStr = getPlatformString( + artifactInfo.os, + artifactInfo.arch, + ); + let storagePath: string; + let computedSha256: string; + + try { + fastify.log.info(`Downloading artifact: ${asset.name}`); + const assetResponse = await fetch(asset.browser_download_url); + if (!assetResponse.ok || !assetResponse.body) { + throw new BadRequestError( + `Failed to download ${asset.name}: ${assetResponse.statusText}`, + ); + } + + // Stream to temp file while computing hash + const hash = createHash("sha256"); + let bytesWritten = 0; + const writeStream = createWriteStream(tempPath); + + // Convert web ReadableStream to async iterable + const reader = assetResponse.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + bytesWritten += value.length; + writeStream.write(value); + } + } finally { + reader.releaseLock(); + } + + await new Promise((resolve, reject) => { + writeStream.end(); + writeStream.on("finish", resolve); + writeStream.on("error", reject); + }); + + // Verify size + if (bytesWritten !== artifactInfo.size) { + throw new BadRequestError( + `Size mismatch for ${asset.name}: declared ${artifactInfo.size} bytes, got ${bytesWritten} bytes`, + ); + } + + // Verify hash + computedSha256 = hash.digest("hex"); + if (computedSha256 !== artifactInfo.sha256) { + throw new BadRequestError( + `SHA256 mismatch for ${asset.name}: declared ${artifactInfo.sha256}, computed ${computedSha256}`, + ); + } + + // Stream verified file to storage + const uploadStream = createReadStream(tempPath); + const result = await fastify.storage.saveBundleFromStream( + parsed.scope, + parsed.packageName, + version, + uploadStream, + computedSha256, + bytesWritten, + platformStr || undefined, + ); + storagePath = result.path; + + fastify.log.info( + `Stored ${asset.name} -> ${storagePath} (${artifactInfo.os}-${artifactInfo.arch})`, + ); + } finally { + // Always clean up temp file + await fs.unlink(tempPath).catch(() => {}); + } + + // Track whether we created or updated + let status: "created" | "updated" = "created"; + let totalArtifacts = 0; + let oldStoragePath: string | null = null; + let versionId: string | null = null; + + // Use transaction to upsert package, version, and artifact + try { + const txResult = await runInTransaction(async (tx) => { + // Find or create package (handles race conditions atomically) + const { package: existingPackage, created: packageCreated } = + await packageRepo.upsertPackage( + { + name, + displayName: + (manifest["display_name"] as string) ?? undefined, + description: (manifest["description"] as string) ?? undefined, + authorName: + ((manifest["author"] as Record)?.[ + "name" + ] as string) ?? undefined, + authorEmail: + ((manifest["author"] as Record)?.[ + "email" + ] as string) ?? undefined, + authorUrl: + ((manifest["author"] as Record)?.[ + "url" + ] as string) ?? undefined, + homepage: (manifest["homepage"] as string) ?? undefined, + license: (manifest["license"] as string) ?? undefined, + iconUrl: (manifest["icon"] as string) ?? undefined, + serverType, + verified: false, + latestVersion: version, + githubRepo: claims.repository, + }, + tx, + ); + + const packageId = existingPackage.id; + let versionCreated = packageCreated; // New package means new version + + // Fetch README only if this might be a new version + let readme: string | null = null; + const existingVersion = await packageRepo.findVersion( + packageId, + version, + tx, + ); + + if (!existingVersion || !existingVersion.readme) { + // Fetch README.md from the repository at the release tag + try { + const readmeUrl = `https://api.github.com/repos/${claims.repository}/contents/README.md?ref=${release_tag}`; + fastify.log.info(`Fetching README from ${readmeUrl}`); + + const readmeResponse = await fetch(readmeUrl, { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "mpak-registry/1.0", + }, + }); + + if (readmeResponse.ok) { + const readmeData = (await readmeResponse.json()) as { + content?: string; + encoding?: string; + }; + if (readmeData.content && readmeData.encoding === "base64") { + readme = Buffer.from(readmeData.content, "base64").toString( + "utf-8", + ); + fastify.log.info( + `Fetched README.md (${readme.length} chars)`, + ); + } + } + } catch (readmeError) { + fastify.log.warn( + { err: readmeError }, + "Failed to fetch README.md, continuing without it", + ); + } + } + + // Upsert version + const { version: packageVersion, created } = + await packageRepo.upsertVersion( + packageId, + { + packageId, + version, + manifest, + prerelease, + publishedBy: null, + publishedByEmail: null, + releaseTag: release_tag, + releaseUrl: release.html_url, + readme: readme ?? undefined, + publishMethod: "oidc", + provenanceRepository: provenance.repository, + provenanceSha: provenance.sha, + provenance, + serverJson: serverJson ?? undefined, + }, + tx, + ); + + versionCreated = created; + + // Update latestVersion only when version is first created + if (versionCreated) { + if (!prerelease) { + await packageRepo.updateLatestVersion(packageId, version, tx); + } else { + // Check if current latest is a prerelease - if so, update to newer prerelease + const currentLatest = await packageRepo.findVersion( + packageId, + existingPackage.latestVersion, + tx, + ); + if (currentLatest?.prerelease) { + await packageRepo.updateLatestVersion(packageId, version, tx); + } + } + } + + // Upsert artifact + const artifactResult = await packageRepo.upsertArtifact( + { + versionId: packageVersion.id, + os: artifactInfo.os, + arch: artifactInfo.arch, + digest: `sha256:${computedSha256}`, + sizeBytes: BigInt(artifactInfo.size), + storagePath, + sourceUrl: asset.browser_download_url, + }, + tx, + ); + + status = artifactResult.created ? "created" : "updated"; + oldStoragePath = artifactResult.oldStoragePath; + + // Count total artifacts for this version + totalArtifacts = await packageRepo.countVersionArtifacts( + packageVersion.id, + tx, + ); + + return { versionId: packageVersion.id }; + }); + + versionId = txResult.versionId; + } catch (error) { + // Transaction failed - clean up uploaded file + try { + await fastify.storage.deleteBundle(storagePath); + fastify.log.info( + `Cleaned up after transaction failure: ${storagePath}`, + ); + } catch (cleanupError) { + fastify.log.error( + { err: cleanupError, path: storagePath }, + "Failed to cleanup uploaded file", + ); + } + throw error; + } + + // Clean up old storage path if artifact was updated with different path + if (oldStoragePath) { + try { + await fastify.storage.deleteBundle(oldStoragePath); + fastify.log.info(`Cleaned up old artifact: ${oldStoragePath}`); + } catch (cleanupError) { + fastify.log.warn( + { err: cleanupError, path: oldStoragePath }, + "Failed to cleanup old artifact file", + ); + } + } + + fastify.log.info( + { + op: "announce", + pkg: name, + version, + repo: claims.repository, + artifact: artifactInfo.filename, + platform: `${artifactInfo.os}-${artifactInfo.arch}`, + status, + totalArtifacts, + ms: Date.now() - announceStart, + }, + `announce: ${status} ${name}@${version} artifact ${artifactInfo.filename} (${totalArtifacts} total, ${Date.now() - announceStart}ms)`, + ); + + // Non-blocking Discord notification for new or updated bundles + notifyDiscordAnnounce({ + name, + version, + type: "bundle", + repo: claims.repository, + }); + + // Non-blocking security scan trigger + if (config.scanner.enabled && versionId) { + triggerSecurityScan(fastify.prisma, { + versionId, + bundleStoragePath: storagePath, + packageName: name, + version, + }).catch((err: unknown) => + fastify.log.error({ err }, "Failed to trigger security scan"), + ); + } + + return { + package: name, + version, + artifact: { + os: artifactInfo.os, + arch: artifactInfo.arch, + filename: artifactInfo.filename, + }, + total_artifacts: totalArtifacts, + status, + }; + } catch (error) { + fastify.log.error( + { + op: "announce", + error: error instanceof Error ? error.message : "unknown", + }, + `announce: failed`, + ); + return handleError(error, request, reply); + } + }, + }); }; diff --git a/apps/registry/src/schemas/query.ts b/apps/registry/src/schemas/query.ts index a5973fa..69cbe52 100644 --- a/apps/registry/src/schemas/query.ts +++ b/apps/registry/src/schemas/query.ts @@ -3,11 +3,9 @@ import { z } from "zod"; export const BundleSearchQuerySchema = z.object({ q: z.optional(z.string()), type: z.optional(z.enum(["node", "python", "binary"])), - sort: z.optional( - z.enum(["downloads", "recent", "name"]).default("downloads"), - ), - limit: z.optional(z.number().min(1).max(100).default(20)), - offset: z.optional(z.number().min(0).default(0)), + sort: z.enum(["downloads", "recent", "name"]).optional().default("downloads"), + limit: z.number().min(1).max(100).optional().default(20), + offset: z.number().min(0).optional().default(0), }); export type BundleSearchQuery = z.infer; diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index a2b02c9..d0b9071 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -143,16 +143,11 @@ describe('Bundle Routes', () => { expect(body.total).toBe(0); }); - it('clamps pagination limits to safe ranges', async () => { - packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); - - // limit=0 should be clamped to 1, offset=-5 should be clamped to 0 - await app.inject({ method: 'GET', url: '/search?q=x&limit=0&offset=-5' }); + it('rejects invalid pagination values', async () => { + const res = await app.inject({ method: 'GET', url: '/search?q=x&limit=0&offset=-5' }); - expect(packageRepo.search).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ take: 1, skip: 0 }), - ); + expect(res.statusCode).toBe(400); + expect(packageRepo.search).not.toHaveBeenCalled(); }); it('supports sort parameter', async () => { From b9ac6305191ddbb3f882c512d441151d24959ad4 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Tue, 10 Mar 2026 21:56:09 -0400 Subject: [PATCH 3/8] Apply Prettier formatting to changed files Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 2424 ++++++++++++------------ apps/registry/src/schemas/query.ts | 12 +- 2 files changed, 1171 insertions(+), 1265 deletions(-) diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index c24d76b..578b8d9 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,109 +1,94 @@ -import { createHash, randomUUID } from "crypto"; -import type { FastifyPluginAsync } from "fastify"; -import { createWriteStream, createReadStream, promises as fs } from "fs"; -import { tmpdir } from "os"; -import path from "path"; -import { config } from "../../config.js"; -import { runInTransaction } from "../../db/index.js"; -import type { PackageSearchFilters } from "../../db/types.js"; +import { createHash, randomUUID } from 'crypto'; +import type { FastifyPluginAsync } from 'fastify'; +import { createWriteStream, createReadStream, promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { config } from '../../config.js'; +import { runInTransaction } from '../../db/index.js'; +import type { PackageSearchFilters } from '../../db/types.js'; import { - BadRequestError, - NotFoundError, - UnauthorizedError, - handleError, -} from "../../errors/index.js"; + BadRequestError, + NotFoundError, + UnauthorizedError, + handleError, +} from '../../errors/index.js'; +import { buildProvenance, type ProvenanceRecord, verifyGitHubOIDC } from '../../lib/oidc.js'; +import { toJsonSchema } from '../../lib/zod-schema.js'; import { - buildProvenance, - type ProvenanceRecord, - verifyGitHubOIDC, -} from "../../lib/oidc.js"; -import { toJsonSchema } from "../../lib/zod-schema.js"; -import { - BundleSearchResponseSchema, - BundleDetailSchema, - VersionsResponseSchema, - VersionDetailSchema, - DownloadInfoSchema, - MCPBIndexSchema, - AnnounceRequestSchema, - AnnounceResponseSchema, -} from "../../schemas/generated/api-responses.js"; -import { - BundleSearchQuerySchema, - type BundleSearchQuery, -} from "../../schemas/query.js"; -import { triggerSecurityScan } from "../../services/scanner.js"; -import { generateBadge } from "../../utils/badge.js"; -import { notifyDiscordAnnounce } from "../../utils/discord.js"; + BundleSearchResponseSchema, + BundleDetailSchema, + VersionsResponseSchema, + VersionDetailSchema, + DownloadInfoSchema, + MCPBIndexSchema, + AnnounceRequestSchema, + AnnounceResponseSchema, +} from '../../schemas/generated/api-responses.js'; +import { BundleSearchQuerySchema, type BundleSearchQuery } from '../../schemas/query.js'; +import { triggerSecurityScan } from '../../services/scanner.js'; +import { generateBadge } from '../../utils/badge.js'; +import { notifyDiscordAnnounce } from '../../utils/discord.js'; // GitHub release asset type interface GitHubReleaseAsset { - name: string; - url: string; - browser_download_url: string; - size: number; - content_type: string; + name: string; + url: string; + browser_download_url: string; + size: number; + content_type: string; } /** * Get platform string for storage (e.g., "linux-x64") */ function getPlatformString(os: string, arch: string): string { - if (os === "any" && arch === "any") { - return ""; // Universal bundle, no platform suffix - } - return `${os}-${arch}`; + if (os === 'any' && arch === 'any') { + return ''; // Universal bundle, no platform suffix + } + return `${os}-${arch}`; } // Package name validation (scoped only for v1 API) const SCOPED_REGEX = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9-]{0,213}$/; function isValidScopedPackageName(name: string): boolean { - return SCOPED_REGEX.test(name); + return SCOPED_REGEX.test(name); } -function parsePackageName( - name: string, -): { scope: string; packageName: string } | null { - if (!name.startsWith("@")) return null; - const parts = name.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) return null; - return { - scope: parts[0].substring(1), // Remove @ - packageName: parts[1], - }; +function parsePackageName(name: string): { scope: string; packageName: string } | null { + if (!name.startsWith('@')) return null; + const parts = name.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; + return { + scope: parts[0].substring(1), // Remove @ + packageName: parts[1], + }; } /** * Helper to extract provenance summary for API responses */ -function getProvenanceSummary(version: { - publishMethod: string | null; - provenance: unknown; -}) { - if (version.publishMethod !== "oidc" || !version.provenance) { - return null; - } - const p = version.provenance as ProvenanceRecord; - return { - schema_version: p.schema_version, - provider: p.provider, - repository: p.repository, - sha: p.sha, - }; +function getProvenanceSummary(version: { publishMethod: string | null; provenance: unknown }) { + if (version.publishMethod !== 'oidc' || !version.provenance) { + return null; + } + const p = version.provenance as ProvenanceRecord; + return { + schema_version: p.schema_version, + provider: p.provider, + repository: p.repository, + sha: p.sha, + }; } /** * Helper to extract full provenance for detailed API responses */ -function getProvenanceFull(version: { - publishMethod: string | null; - provenance: unknown; -}) { - if (version.publishMethod !== "oidc" || !version.provenance) { - return null; - } - return version.provenance as ProvenanceRecord; +function getProvenanceFull(version: { publishMethod: string | null; provenance: unknown }) { + if (version.publishMethod !== 'oidc' || !version.provenance) { + return null; + } + return version.provenance as ProvenanceRecord; } /** @@ -112,22 +97,22 @@ function getProvenanceFull(version: { * and filenames that don't end with .mcpb. */ function validateArtifactFilename(filename: string): string | null { - if (!filename || filename.length === 0) { - return "Filename must not be empty."; - } - if (filename.length > 255) { - return "Filename must be at most 255 characters."; - } - if (/[/\\]/.test(filename)) { - return "Filename must not contain path separators (/ or \\)."; - } - if (filename.includes("..")) { - return 'Filename must not contain "..".'; - } - if (!filename.endsWith(".mcpb")) { - return "Filename must end with .mcpb."; - } - return null; + if (!filename || filename.length === 0) { + return 'Filename must not be empty.'; + } + if (filename.length > 255) { + return 'Filename must be at most 255 characters.'; + } + if (/[/\\]/.test(filename)) { + return 'Filename must not contain path separators (/ or \\).'; + } + if (filename.includes('..')) { + return 'Filename must not contain "..".'; + } + if (!filename.endsWith('.mcpb')) { + return 'Filename must end with .mcpb.'; + } + return null; } /** @@ -147,1170 +132,1091 @@ function validateArtifactFilename(filename: string): string | null { * - POST /announce - Announce a new bundle version */ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { - const { packages: packageRepo } = fastify.repositories; - - // GET /v1/bundles/search - Search bundles - fastify.get<{ Querystring: BundleSearchQuery }>("/search", { - schema: { - tags: ["bundles"], - description: "Search for bundles", - querystring: toJsonSchema(BundleSearchQuerySchema), - response: { - 200: toJsonSchema(BundleSearchResponseSchema), - }, - }, - handler: async (request) => { - const { q, type, sort, limit, offset } = request.query; - - const filters: PackageSearchFilters = { - ...(q && { query: q }), - ...(type && { serverType: type }), - }; - - const sortMap: Record> = { - downloads: { totalDownloads: "desc" }, - recent: { createdAt: "desc" }, - name: { name: "asc" }, - }; - const orderBy = sortMap[sort]; - - // Search packages - const startTime = Date.now(); - const { packages, total } = await packageRepo.search(filters, { - skip: offset, - take: limit, - orderBy, - }); - - fastify.log.info( - { - op: "search", - query: q ?? null, - type: type ?? null, - sort, - results: total, - ms: Date.now() - startTime, - }, - `search: q="${q ?? "*"}" returned ${total} results`, - ); - - // Get package versions with tools info and certification - const bundles = await Promise.all( - packages.map(async (pkg) => { - const latestVersion = await packageRepo.findVersionWithLatestScan( - pkg.id, - pkg.latestVersion, - ); - const manifest = (latestVersion?.manifest ?? {}) as Record< - string, - unknown - >; - const scan = latestVersion?.securityScans[0]; - - return { - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - author: pkg.authorName ? { name: pkg.authorName } : null, - latest_version: pkg.latestVersion, - icon: pkg.iconUrl, - server_type: pkg.serverType, - tools: (manifest["tools"] as unknown[]) ?? [], - downloads: Number(pkg.totalDownloads), - published_at: latestVersion?.publishedAt ?? pkg.createdAt, - verified: pkg.verified, - provenance: latestVersion - ? getProvenanceSummary(latestVersion) - : null, - certification_level: scan?.certificationLevel ?? null, - }; - }), - ); - - return { - bundles, - total, - pagination: { - limit, - offset, - has_more: offset + bundles.length < total, - }, - }; - }, - }); - - // GET /v1/bundles/@:scope/:package - Get bundle details - fastify.get("/@:scope/:package", { - schema: { - tags: ["bundles"], - description: "Get detailed bundle information", - params: { - type: "object", - properties: { - scope: { type: "string" }, - package: { type: "string" }, - }, - required: ["scope", "package"], - }, - response: { - 200: toJsonSchema(BundleDetailSchema), - }, - }, - handler: async (request) => { - const { scope, package: packageName } = request.params as { - scope: string; - package: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError("Bundle not found"); - } - - // Get all versions - const versions = await packageRepo.getVersions(pkg.id); - - // Get latest version manifest with security scan - const latestVersion = await packageRepo.findVersionWithLatestScan( - pkg.id, - pkg.latestVersion, - ); - const manifest = (latestVersion?.manifest ?? {}) as Record< - string, - unknown - >; - const scan = latestVersion?.securityScans[0]; - - // Build certification object from scan - const CERT_LEVEL_LABELS: Record = { - 1: "L1 Basic", - 2: "L2 Verified", - 3: "L3 Hardened", - 4: "L4 Certified", - }; - const certLevel = scan?.certificationLevel ?? null; - const certification = - certLevel != null - ? { - level: certLevel, - level_name: CERT_LEVEL_LABELS[certLevel] ?? null, - controls_passed: scan?.controlsPassed ?? null, - controls_failed: scan?.controlsFailed ?? null, - controls_total: scan?.controlsTotal ?? null, - } - : null; - - return { - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - author: pkg.authorName ? { name: pkg.authorName } : null, - latest_version: pkg.latestVersion, - icon: pkg.iconUrl, - server_type: pkg.serverType, - tools: (manifest["tools"] as unknown[]) ?? [], - downloads: Number(pkg.totalDownloads), - published_at: pkg.createdAt, - verified: pkg.verified, - homepage: pkg.homepage, - license: pkg.license, - provenance: latestVersion ? getProvenanceFull(latestVersion) : null, - certification_level: certLevel, - certification, - versions: versions.map((v) => ({ - version: v.version, - published_at: v.publishedAt, - downloads: Number(v.downloadCount), - })), - }; - }, - }); - - // GET /v1/bundles/@:scope/:package/badge.svg - Get SVG badge for package - fastify.get("/@:scope/:package/badge.svg", { - schema: { - tags: ["bundles"], - description: - "Get an SVG badge for a bundle. Shows version for uncertified packages, or certification level for certified ones.", - params: { - type: "object", - properties: { - scope: { type: "string" }, - package: { type: "string" }, - }, - required: ["scope", "package"], - }, - response: { - 200: { - type: "string", - description: "SVG badge image", - }, - }, - }, - handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { - scope: string; - package: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError("Bundle not found"); - } - - // Check for certification level from latest scan - let certLevel: number | null = null; - const latestVersion = await fastify.prisma.packageVersion.findFirst({ - where: { - packageId: pkg.id, - version: pkg.latestVersion, - }, - include: { - securityScans: { - where: { status: "completed" }, - orderBy: { startedAt: "desc" }, - take: 1, - }, - }, - }); - - const scan = latestVersion?.securityScans[0]; - if (scan?.certificationLevel) { - certLevel = scan.certificationLevel; - } - - const svg = generateBadge(pkg.latestVersion, certLevel); - - return reply - .header("Content-Type", "image/svg+xml") - .header("Cache-Control", "max-age=300, s-maxage=3600") - .send(svg); - }, - }); - - // GET /v1/bundles/@:scope/:package/index.json - Get multi-platform distribution index - fastify.get("/@:scope/:package/index.json", { - schema: { - tags: ["bundles"], - description: - "Get multi-platform distribution index for a bundle (MCPB Index spec)", - params: { - type: "object", - properties: { - scope: { type: "string" }, - package: { type: "string" }, - }, - required: ["scope", "package"], - }, - response: { - 200: toJsonSchema(MCPBIndexSchema), - }, - }, - handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { - scope: string; - package: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError("Bundle not found"); - } - - // Get latest version with artifacts - const latestVersion = await packageRepo.findVersionWithArtifacts( - pkg.id, - pkg.latestVersion, - ); - if (!latestVersion) { - throw new NotFoundError("No versions found"); - } - - // Build bundles array from artifacts - const bundleArtifacts = await Promise.all( - latestVersion.artifacts.map(async (artifact) => { - const url = await fastify.storage.getSignedDownloadUrlFromPath( - artifact.storagePath, - ); - - return { - mimeType: artifact.mimeType, - digest: artifact.digest, - size: Number(artifact.sizeBytes), - platform: { os: artifact.os, arch: artifact.arch }, - urls: [url, artifact.sourceUrl].filter(Boolean), - }; - }), - ); - - // Build conformant MCPB index.json - const index = { - index_version: "0.1", - mimeType: "application/vnd.mcp.bundle.index.v0.1+json", - name: pkg.name, - version: pkg.latestVersion, - description: pkg.description, - bundles: bundleArtifacts, - annotations: { - ...(latestVersion.releaseUrl && { - "dev.mpak.release.url": latestVersion.releaseUrl, - }), - ...(latestVersion.provenanceRepository && { - "dev.mpak.provenance.repository": - latestVersion.provenanceRepository, - }), - ...(latestVersion.provenanceSha && { - "dev.mpak.provenance.sha": latestVersion.provenanceSha, - }), - ...(latestVersion.publishMethod && { - "dev.mpak.provenance.provider": - latestVersion.publishMethod === "oidc" - ? "github_oidc" - : latestVersion.publishMethod, - }), - }, - }; - - reply.header( - "Content-Type", - "application/vnd.mcp.bundle.index.v0.1+json", - ); - return index; - }, - }); - - // GET /v1/bundles/@:scope/:package/versions - List versions - fastify.get("/@:scope/:package/versions", { - schema: { - tags: ["bundles"], - description: "List all versions of a bundle", - params: { - type: "object", - properties: { - scope: { type: "string" }, - package: { type: "string" }, - }, - required: ["scope", "package"], - }, - response: { - 200: toJsonSchema(VersionsResponseSchema), - }, - }, - handler: async (request) => { - const { scope, package: packageName } = request.params as { - scope: string; - package: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError("Bundle not found"); - } - - // Get all versions with artifacts - const versions = await packageRepo.getVersionsWithArtifacts(pkg.id); - - return { - name: pkg.name, - latest: pkg.latestVersion, - versions: versions.map((v) => ({ - version: v.version, - artifacts_count: v.artifacts.length, - platforms: v.artifacts.map((a) => ({ os: a.os, arch: a.arch })), - published_at: v.publishedAt, - downloads: Number(v.downloadCount), - publish_method: v.publishMethod, - provenance: getProvenanceSummary(v), - })), - }; - }, - }); - - // GET /v1/bundles/@:scope/:package/versions/:version - Get specific version info - fastify.get("/@:scope/:package/versions/:version", { - schema: { - tags: ["bundles"], - description: "Get information about a specific version", - params: { - type: "object", - properties: { - scope: { type: "string" }, - package: { type: "string" }, - version: { type: "string" }, - }, - required: ["scope", "package", "version"], - }, - response: { - 200: toJsonSchema(VersionDetailSchema), - }, - }, - handler: async (request) => { - const { - scope, - package: packageName, - version, - } = request.params as { - scope: string; - package: string; - version: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError("Bundle not found"); - } - - const packageVersion = await packageRepo.findVersionWithArtifacts( - pkg.id, - version, - ); - - if (!packageVersion) { - throw new NotFoundError("Version not found"); - } - - // Build artifacts array with download URLs - const artifacts = await Promise.all( - packageVersion.artifacts.map(async (a) => { - const downloadUrl = - await fastify.storage.getSignedDownloadUrlFromPath(a.storagePath); - - return { - platform: { os: a.os, arch: a.arch }, - digest: a.digest, - size: Number(a.sizeBytes), - download_url: downloadUrl, - source_url: a.sourceUrl || undefined, - }; - }), - ); - - return { - name: pkg.name, - version: packageVersion.version, - published_at: packageVersion.publishedAt, - downloads: Number(packageVersion.downloadCount), - artifacts, - manifest: packageVersion.manifest, - release: packageVersion.releaseUrl - ? { - tag: packageVersion.releaseTag, - url: packageVersion.releaseUrl, - } - : undefined, - publish_method: packageVersion.publishMethod, - provenance: getProvenanceFull(packageVersion), - }; - }, - }); - - // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle - fastify.get("/@:scope/:package/versions/:version/download", { - schema: { - tags: ["bundles"], - description: "Download a specific version of a bundle", - params: { - type: "object", - properties: { - scope: { type: "string" }, - package: { type: "string" }, - version: { type: "string" }, - }, - required: ["scope", "package", "version"], - }, - querystring: { - type: "object", - properties: { - os: { - type: "string", - description: "Target OS (darwin, linux, win32, any)", - }, - arch: { - type: "string", - description: "Target arch (x64, arm64, any)", - }, - }, - }, - response: { - 200: toJsonSchema(DownloadInfoSchema), - 302: { type: "null", description: "Redirect to download URL" }, - }, - }, - handler: async (request, reply) => { - const { - scope, - package: packageName, - version: versionParam, - } = request.params as { - scope: string; - package: string; - version: string; - }; - const { os: queryOs, arch: queryArch } = request.query as { - os?: string; - arch?: string; - }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError("Bundle not found"); - } - - // Resolve "latest" to actual version - const version = - versionParam === "latest" ? pkg.latestVersion : versionParam; - - const packageVersion = await packageRepo.findVersionWithArtifacts( - pkg.id, - version, - ); - - if (!packageVersion) { - throw new NotFoundError("Version not found"); - } - - // Find the appropriate artifact - let artifact = packageVersion.artifacts[0]; // Default to first - - if (queryOs || queryArch) { - // Look for exact match - const match = packageVersion.artifacts.find( - (a) => a.os === queryOs && a.arch === queryArch, - ); - if (match) { - artifact = match; - } else { - // Look for universal fallback - const universal = packageVersion.artifacts.find( - (a) => a.os === "any" && a.arch === "any", - ); - if (universal) { - artifact = universal; - } - } - } - - if (!artifact) { - throw new NotFoundError("No artifact found for this version"); - } - - // Log download - const platform = - artifact.os === "any" ? "universal" : `${artifact.os}-${artifact.arch}`; - fastify.log.info( - { - op: "download", - pkg: name, - version, - platform, - }, - `download: ${name}@${version} (${platform})`, - ); - - // Increment download counts atomically in a single transaction - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, version, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => - fastify.log.error({ err }, "Failed to update download counts"), - ); - - // Check if client wants JSON response (CLI/API) or redirect (browser) - const acceptHeader = request.headers.accept ?? ""; - const wantsJson = acceptHeader.includes("application/json"); - - // Generate signed download URL using the actual storage path - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath( - artifact.storagePath, - ); - - if (wantsJson) { - // CLI/API mode: Return JSON with download URL and metadata - const expiresAt = new Date(); - expiresAt.setSeconds( - expiresAt.getSeconds() + - (config.storage.cloudfront.urlExpirationSeconds || 900), - ); - - return { - url: downloadUrl, - bundle: { - name, - version, - platform: { os: artifact.os, arch: artifact.arch }, - sha256: artifact.digest.replace("sha256:", ""), - size: Number(artifact.sizeBytes), - }, - expires_at: expiresAt.toISOString(), - }; - } else { - // Browser mode: Redirect to download URL - if (downloadUrl.startsWith("/")) { - // Local storage - serve file directly - const fileBuffer = await fastify.storage.getBundle( - artifact.storagePath, - ); - - return reply - .header("Content-Type", "application/octet-stream") - .header( - "Content-Disposition", - `attachment; filename="${packageName}-${version}.mcpb"`, - ) - .send(fileBuffer); - } else { - // S3/CloudFront - redirect to signed URL - return reply.code(302).redirect(downloadUrl); - } - } - }, - }); - - // POST /v1/bundles/announce - Announce a single artifact (OIDC only, idempotent per-artifact) - fastify.post("/announce", { - schema: { - tags: ["bundles"], - description: - "Announce a single artifact for a bundle version from a GitHub release (OIDC only). Idempotent - can be called multiple times for different artifacts of the same version.", - body: toJsonSchema(AnnounceRequestSchema), - response: { - 200: toJsonSchema(AnnounceResponseSchema), - }, - }, - handler: async (request, reply) => { - try { - // Extract OIDC token from Authorization header - const authHeader = request.headers.authorization; - if (!authHeader?.startsWith("Bearer ")) { - throw new UnauthorizedError( - "Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.", - ); - } - - const token = authHeader.substring(7); - const announceStart = Date.now(); - - // Verify the OIDC token - let claims; - try { - claims = await verifyGitHubOIDC(token); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "Token verification failed"; - fastify.log.warn( - { op: "announce", error: message }, - `announce: OIDC verification failed`, - ); - throw new UnauthorizedError(`Invalid OIDC token: ${message}`); - } - - // Extract body - const { - name, - version, - manifest, - release_tag, - prerelease = false, - artifact: artifactInfo, - } = request.body as { - name: string; - version: string; - manifest: Record; - release_tag: string; - prerelease?: boolean; - artifact: { - filename: string; - os: string; - arch: string; - sha256: string; - size: number; - }; - }; - - // Validate artifact platform values - const VALID_OS = ["darwin", "linux", "win32", "any"]; - const VALID_ARCH = ["x64", "arm64", "any"]; - if (!VALID_OS.includes(artifactInfo.os)) { - throw new BadRequestError( - `Invalid artifact os: "${artifactInfo.os}". Must be one of: ${VALID_OS.join(", ")}`, - ); - } - if (!VALID_ARCH.includes(artifactInfo.arch)) { - throw new BadRequestError( - `Invalid artifact arch: "${artifactInfo.arch}". Must be one of: ${VALID_ARCH.join(", ")}`, - ); - } - // Validate artifact filename (path traversal, extension, length) - const filenameError = validateArtifactFilename(artifactInfo.filename); - if (filenameError) { - throw new BadRequestError( - `Invalid artifact filename: "${artifactInfo.filename}". ${filenameError}`, - ); - } - - // Validate package name - if (!isValidScopedPackageName(name)) { - throw new BadRequestError( - `Invalid package name: "${name}". Must be scoped (@scope/name) with lowercase alphanumeric characters and hyphens.`, - ); - } - - const parsed = parsePackageName(name); - if (!parsed) { - throw new BadRequestError("Invalid package name format"); - } - - // Security: Verify the package name scope matches the repository owner - const repoOwnerLower = claims.repository_owner.toLowerCase(); - const scopeLower = parsed.scope.toLowerCase(); - - if (scopeLower !== repoOwnerLower) { - fastify.log.warn( - { - op: "announce", - pkg: name, - version, - repo: claims.repository, - error: "scope_mismatch", - }, - `announce: scope mismatch @${parsed.scope} != ${claims.repository_owner}`, - ); - throw new UnauthorizedError( - `Scope mismatch: Package scope "@${parsed.scope}" does not match repository owner "${claims.repository_owner}". ` + - `OIDC publishing requires the package scope to match the GitHub organization or user.`, - ); - } - - fastify.log.info( - { - op: "announce", - pkg: name, - version, - repo: claims.repository, - tag: release_tag, - prerelease, - artifact: artifactInfo.filename, - platform: `${artifactInfo.os}-${artifactInfo.arch}`, - }, - `announce: starting ${name}@${version} artifact ${artifactInfo.filename}`, - ); - - // Extract server_type from manifest - const serverObj = manifest["server"] as - | Record - | undefined; - const serverType = - (serverObj?.["type"] as string) ?? - (manifest["server_type"] as string); - if (!serverType) { - throw new BadRequestError( - "Manifest must contain server type (server.type or server_type)", - ); - } - - // Build provenance record - const provenance = buildProvenance(claims); - - // Fetch release from GitHub API to get the specific artifact - const releaseApiUrl = `https://api.github.com/repos/${claims.repository}/releases/tags/${release_tag}`; - fastify.log.info(`Fetching release from ${releaseApiUrl}`); - - const releaseResponse = await fetch(releaseApiUrl, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "mpak-registry/1.0", - }, - }); - - if (!releaseResponse.ok) { - throw new BadRequestError( - `Failed to fetch release ${release_tag}: ${releaseResponse.statusText}`, - ); - } - - const release = (await releaseResponse.json()) as { - tag_name: string; - html_url: string; - assets: GitHubReleaseAsset[]; - }; - - // Check for server.json in the release assets (for MCP Registry discovery) - let serverJson: Record | null = null; - const serverJsonAsset = release.assets.find( - (a: GitHubReleaseAsset) => a.name === "server.json", - ); - if (serverJsonAsset) { - try { - fastify.log.info( - `Fetching server.json from release ${release_tag}`, - ); - const sjResponse = await fetch( - serverJsonAsset.browser_download_url, - ); - if (sjResponse.ok) { - const sjData = (await sjResponse.json()) as Record< - string, - unknown - >; - // Strip packages[] before storing (the registry populates it dynamically at serve time) - delete sjData["packages"]; - serverJson = sjData; - fastify.log.info(`Loaded server.json for MCP Registry discovery`); - } - } catch (sjError) { - fastify.log.warn( - { err: sjError }, - "Failed to fetch server.json from release, continuing without it", - ); - } - } - - // Find the specific artifact by filename - const asset = release.assets.find( - (a: GitHubReleaseAsset) => a.name === artifactInfo.filename, - ); - if (!asset) { - throw new BadRequestError( - `Artifact "${artifactInfo.filename}" not found in release ${release_tag}`, - ); - } - - // Download artifact to temp file while computing hash (memory-efficient streaming) - const tempPath = path.join(tmpdir(), `mcpb-${randomUUID()}`); - const platformStr = getPlatformString( - artifactInfo.os, - artifactInfo.arch, - ); - let storagePath: string; - let computedSha256: string; - - try { - fastify.log.info(`Downloading artifact: ${asset.name}`); - const assetResponse = await fetch(asset.browser_download_url); - if (!assetResponse.ok || !assetResponse.body) { - throw new BadRequestError( - `Failed to download ${asset.name}: ${assetResponse.statusText}`, - ); - } - - // Stream to temp file while computing hash - const hash = createHash("sha256"); - let bytesWritten = 0; - const writeStream = createWriteStream(tempPath); - - // Convert web ReadableStream to async iterable - const reader = assetResponse.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - hash.update(value); - bytesWritten += value.length; - writeStream.write(value); - } - } finally { - reader.releaseLock(); - } - - await new Promise((resolve, reject) => { - writeStream.end(); - writeStream.on("finish", resolve); - writeStream.on("error", reject); - }); - - // Verify size - if (bytesWritten !== artifactInfo.size) { - throw new BadRequestError( - `Size mismatch for ${asset.name}: declared ${artifactInfo.size} bytes, got ${bytesWritten} bytes`, - ); - } - - // Verify hash - computedSha256 = hash.digest("hex"); - if (computedSha256 !== artifactInfo.sha256) { - throw new BadRequestError( - `SHA256 mismatch for ${asset.name}: declared ${artifactInfo.sha256}, computed ${computedSha256}`, - ); - } - - // Stream verified file to storage - const uploadStream = createReadStream(tempPath); - const result = await fastify.storage.saveBundleFromStream( - parsed.scope, - parsed.packageName, - version, - uploadStream, - computedSha256, - bytesWritten, - platformStr || undefined, - ); - storagePath = result.path; - - fastify.log.info( - `Stored ${asset.name} -> ${storagePath} (${artifactInfo.os}-${artifactInfo.arch})`, - ); - } finally { - // Always clean up temp file - await fs.unlink(tempPath).catch(() => {}); - } - - // Track whether we created or updated - let status: "created" | "updated" = "created"; - let totalArtifacts = 0; - let oldStoragePath: string | null = null; - let versionId: string | null = null; - - // Use transaction to upsert package, version, and artifact - try { - const txResult = await runInTransaction(async (tx) => { - // Find or create package (handles race conditions atomically) - const { package: existingPackage, created: packageCreated } = - await packageRepo.upsertPackage( - { - name, - displayName: - (manifest["display_name"] as string) ?? undefined, - description: (manifest["description"] as string) ?? undefined, - authorName: - ((manifest["author"] as Record)?.[ - "name" - ] as string) ?? undefined, - authorEmail: - ((manifest["author"] as Record)?.[ - "email" - ] as string) ?? undefined, - authorUrl: - ((manifest["author"] as Record)?.[ - "url" - ] as string) ?? undefined, - homepage: (manifest["homepage"] as string) ?? undefined, - license: (manifest["license"] as string) ?? undefined, - iconUrl: (manifest["icon"] as string) ?? undefined, - serverType, - verified: false, - latestVersion: version, - githubRepo: claims.repository, - }, - tx, - ); - - const packageId = existingPackage.id; - let versionCreated = packageCreated; // New package means new version - - // Fetch README only if this might be a new version - let readme: string | null = null; - const existingVersion = await packageRepo.findVersion( - packageId, - version, - tx, - ); - - if (!existingVersion || !existingVersion.readme) { - // Fetch README.md from the repository at the release tag - try { - const readmeUrl = `https://api.github.com/repos/${claims.repository}/contents/README.md?ref=${release_tag}`; - fastify.log.info(`Fetching README from ${readmeUrl}`); - - const readmeResponse = await fetch(readmeUrl, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "mpak-registry/1.0", - }, - }); - - if (readmeResponse.ok) { - const readmeData = (await readmeResponse.json()) as { - content?: string; - encoding?: string; - }; - if (readmeData.content && readmeData.encoding === "base64") { - readme = Buffer.from(readmeData.content, "base64").toString( - "utf-8", - ); - fastify.log.info( - `Fetched README.md (${readme.length} chars)`, - ); - } - } - } catch (readmeError) { - fastify.log.warn( - { err: readmeError }, - "Failed to fetch README.md, continuing without it", - ); - } - } - - // Upsert version - const { version: packageVersion, created } = - await packageRepo.upsertVersion( - packageId, - { - packageId, - version, - manifest, - prerelease, - publishedBy: null, - publishedByEmail: null, - releaseTag: release_tag, - releaseUrl: release.html_url, - readme: readme ?? undefined, - publishMethod: "oidc", - provenanceRepository: provenance.repository, - provenanceSha: provenance.sha, - provenance, - serverJson: serverJson ?? undefined, - }, - tx, - ); - - versionCreated = created; - - // Update latestVersion only when version is first created - if (versionCreated) { - if (!prerelease) { - await packageRepo.updateLatestVersion(packageId, version, tx); - } else { - // Check if current latest is a prerelease - if so, update to newer prerelease - const currentLatest = await packageRepo.findVersion( - packageId, - existingPackage.latestVersion, - tx, - ); - if (currentLatest?.prerelease) { - await packageRepo.updateLatestVersion(packageId, version, tx); - } - } - } - - // Upsert artifact - const artifactResult = await packageRepo.upsertArtifact( - { - versionId: packageVersion.id, - os: artifactInfo.os, - arch: artifactInfo.arch, - digest: `sha256:${computedSha256}`, - sizeBytes: BigInt(artifactInfo.size), - storagePath, - sourceUrl: asset.browser_download_url, - }, - tx, - ); - - status = artifactResult.created ? "created" : "updated"; - oldStoragePath = artifactResult.oldStoragePath; - - // Count total artifacts for this version - totalArtifacts = await packageRepo.countVersionArtifacts( - packageVersion.id, - tx, - ); - - return { versionId: packageVersion.id }; - }); - - versionId = txResult.versionId; - } catch (error) { - // Transaction failed - clean up uploaded file - try { - await fastify.storage.deleteBundle(storagePath); - fastify.log.info( - `Cleaned up after transaction failure: ${storagePath}`, - ); - } catch (cleanupError) { - fastify.log.error( - { err: cleanupError, path: storagePath }, - "Failed to cleanup uploaded file", - ); - } - throw error; - } - - // Clean up old storage path if artifact was updated with different path - if (oldStoragePath) { - try { - await fastify.storage.deleteBundle(oldStoragePath); - fastify.log.info(`Cleaned up old artifact: ${oldStoragePath}`); - } catch (cleanupError) { - fastify.log.warn( - { err: cleanupError, path: oldStoragePath }, - "Failed to cleanup old artifact file", - ); - } - } - - fastify.log.info( - { - op: "announce", - pkg: name, - version, - repo: claims.repository, - artifact: artifactInfo.filename, - platform: `${artifactInfo.os}-${artifactInfo.arch}`, - status, - totalArtifacts, - ms: Date.now() - announceStart, - }, - `announce: ${status} ${name}@${version} artifact ${artifactInfo.filename} (${totalArtifacts} total, ${Date.now() - announceStart}ms)`, - ); - - // Non-blocking Discord notification for new or updated bundles - notifyDiscordAnnounce({ - name, - version, - type: "bundle", - repo: claims.repository, - }); - - // Non-blocking security scan trigger - if (config.scanner.enabled && versionId) { - triggerSecurityScan(fastify.prisma, { - versionId, - bundleStoragePath: storagePath, - packageName: name, - version, - }).catch((err: unknown) => - fastify.log.error({ err }, "Failed to trigger security scan"), - ); - } - - return { - package: name, - version, - artifact: { - os: artifactInfo.os, - arch: artifactInfo.arch, - filename: artifactInfo.filename, - }, - total_artifacts: totalArtifacts, - status, - }; - } catch (error) { - fastify.log.error( - { - op: "announce", - error: error instanceof Error ? error.message : "unknown", - }, - `announce: failed`, - ); - return handleError(error, request, reply); - } - }, - }); + const { packages: packageRepo } = fastify.repositories; + + // GET /v1/bundles/search - Search bundles + fastify.get<{ Querystring: BundleSearchQuery }>('/search', { + schema: { + tags: ['bundles'], + description: 'Search for bundles', + querystring: toJsonSchema(BundleSearchQuerySchema), + response: { + 200: toJsonSchema(BundleSearchResponseSchema), + }, + }, + handler: async (request) => { + const { q, type, sort, limit, offset } = request.query; + + const filters: PackageSearchFilters = { + ...(q && { query: q }), + ...(type && { serverType: type }), + }; + + const sortMap: Record> = { + downloads: { totalDownloads: 'desc' }, + recent: { createdAt: 'desc' }, + name: { name: 'asc' }, + }; + const orderBy = sortMap[sort]; + + // Search packages + const startTime = Date.now(); + const { packages, total } = await packageRepo.search(filters, { + skip: offset, + take: limit, + orderBy, + }); + + fastify.log.info( + { + op: 'search', + query: q ?? null, + type: type ?? null, + sort, + results: total, + ms: Date.now() - startTime, + }, + `search: q="${q ?? '*'}" returned ${total} results`, + ); + + // Get package versions with tools info and certification + const bundles = await Promise.all( + packages.map(async (pkg) => { + const latestVersion = await packageRepo.findVersionWithLatestScan( + pkg.id, + pkg.latestVersion, + ); + const manifest = (latestVersion?.manifest ?? {}) as Record; + const scan = latestVersion?.securityScans[0]; + + return { + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + author: pkg.authorName ? { name: pkg.authorName } : null, + latest_version: pkg.latestVersion, + icon: pkg.iconUrl, + server_type: pkg.serverType, + tools: (manifest['tools'] as unknown[]) ?? [], + downloads: Number(pkg.totalDownloads), + published_at: latestVersion?.publishedAt ?? pkg.createdAt, + verified: pkg.verified, + provenance: latestVersion ? getProvenanceSummary(latestVersion) : null, + certification_level: scan?.certificationLevel ?? null, + }; + }), + ); + + return { + bundles, + total, + pagination: { + limit, + offset, + has_more: offset + bundles.length < total, + }, + }; + }, + }); + + // GET /v1/bundles/@:scope/:package - Get bundle details + fastify.get('/@:scope/:package', { + schema: { + tags: ['bundles'], + description: 'Get detailed bundle information', + params: { + type: 'object', + properties: { + scope: { type: 'string' }, + package: { type: 'string' }, + }, + required: ['scope', 'package'], + }, + response: { + 200: toJsonSchema(BundleDetailSchema), + }, + }, + handler: async (request) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } + + // Get all versions + const versions = await packageRepo.getVersions(pkg.id); + + // Get latest version manifest with security scan + const latestVersion = await packageRepo.findVersionWithLatestScan(pkg.id, pkg.latestVersion); + const manifest = (latestVersion?.manifest ?? {}) as Record; + const scan = latestVersion?.securityScans[0]; + + // Build certification object from scan + const CERT_LEVEL_LABELS: Record = { + 1: 'L1 Basic', + 2: 'L2 Verified', + 3: 'L3 Hardened', + 4: 'L4 Certified', + }; + const certLevel = scan?.certificationLevel ?? null; + const certification = + certLevel != null + ? { + level: certLevel, + level_name: CERT_LEVEL_LABELS[certLevel] ?? null, + controls_passed: scan?.controlsPassed ?? null, + controls_failed: scan?.controlsFailed ?? null, + controls_total: scan?.controlsTotal ?? null, + } + : null; + + return { + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + author: pkg.authorName ? { name: pkg.authorName } : null, + latest_version: pkg.latestVersion, + icon: pkg.iconUrl, + server_type: pkg.serverType, + tools: (manifest['tools'] as unknown[]) ?? [], + downloads: Number(pkg.totalDownloads), + published_at: pkg.createdAt, + verified: pkg.verified, + homepage: pkg.homepage, + license: pkg.license, + provenance: latestVersion ? getProvenanceFull(latestVersion) : null, + certification_level: certLevel, + certification, + versions: versions.map((v) => ({ + version: v.version, + published_at: v.publishedAt, + downloads: Number(v.downloadCount), + })), + }; + }, + }); + + // GET /v1/bundles/@:scope/:package/badge.svg - Get SVG badge for package + fastify.get('/@:scope/:package/badge.svg', { + schema: { + tags: ['bundles'], + description: + 'Get an SVG badge for a bundle. Shows version for uncertified packages, or certification level for certified ones.', + params: { + type: 'object', + properties: { + scope: { type: 'string' }, + package: { type: 'string' }, + }, + required: ['scope', 'package'], + }, + response: { + 200: { + type: 'string', + description: 'SVG badge image', + }, + }, + }, + handler: async (request, reply) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } + + // Check for certification level from latest scan + let certLevel: number | null = null; + const latestVersion = await fastify.prisma.packageVersion.findFirst({ + where: { + packageId: pkg.id, + version: pkg.latestVersion, + }, + include: { + securityScans: { + where: { status: 'completed' }, + orderBy: { startedAt: 'desc' }, + take: 1, + }, + }, + }); + + const scan = latestVersion?.securityScans[0]; + if (scan?.certificationLevel) { + certLevel = scan.certificationLevel; + } + + const svg = generateBadge(pkg.latestVersion, certLevel); + + return reply + .header('Content-Type', 'image/svg+xml') + .header('Cache-Control', 'max-age=300, s-maxage=3600') + .send(svg); + }, + }); + + // GET /v1/bundles/@:scope/:package/index.json - Get multi-platform distribution index + fastify.get('/@:scope/:package/index.json', { + schema: { + tags: ['bundles'], + description: 'Get multi-platform distribution index for a bundle (MCPB Index spec)', + params: { + type: 'object', + properties: { + scope: { type: 'string' }, + package: { type: 'string' }, + }, + required: ['scope', 'package'], + }, + response: { + 200: toJsonSchema(MCPBIndexSchema), + }, + }, + handler: async (request, reply) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } + + // Get latest version with artifacts + const latestVersion = await packageRepo.findVersionWithArtifacts(pkg.id, pkg.latestVersion); + if (!latestVersion) { + throw new NotFoundError('No versions found'); + } + + // Build bundles array from artifacts + const bundleArtifacts = await Promise.all( + latestVersion.artifacts.map(async (artifact) => { + const url = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); + + return { + mimeType: artifact.mimeType, + digest: artifact.digest, + size: Number(artifact.sizeBytes), + platform: { os: artifact.os, arch: artifact.arch }, + urls: [url, artifact.sourceUrl].filter(Boolean), + }; + }), + ); + + // Build conformant MCPB index.json + const index = { + index_version: '0.1', + mimeType: 'application/vnd.mcp.bundle.index.v0.1+json', + name: pkg.name, + version: pkg.latestVersion, + description: pkg.description, + bundles: bundleArtifacts, + annotations: { + ...(latestVersion.releaseUrl && { + 'dev.mpak.release.url': latestVersion.releaseUrl, + }), + ...(latestVersion.provenanceRepository && { + 'dev.mpak.provenance.repository': latestVersion.provenanceRepository, + }), + ...(latestVersion.provenanceSha && { + 'dev.mpak.provenance.sha': latestVersion.provenanceSha, + }), + ...(latestVersion.publishMethod && { + 'dev.mpak.provenance.provider': + latestVersion.publishMethod === 'oidc' ? 'github_oidc' : latestVersion.publishMethod, + }), + }, + }; + + reply.header('Content-Type', 'application/vnd.mcp.bundle.index.v0.1+json'); + return index; + }, + }); + + // GET /v1/bundles/@:scope/:package/versions - List versions + fastify.get('/@:scope/:package/versions', { + schema: { + tags: ['bundles'], + description: 'List all versions of a bundle', + params: { + type: 'object', + properties: { + scope: { type: 'string' }, + package: { type: 'string' }, + }, + required: ['scope', 'package'], + }, + response: { + 200: toJsonSchema(VersionsResponseSchema), + }, + }, + handler: async (request) => { + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } + + // Get all versions with artifacts + const versions = await packageRepo.getVersionsWithArtifacts(pkg.id); + + return { + name: pkg.name, + latest: pkg.latestVersion, + versions: versions.map((v) => ({ + version: v.version, + artifacts_count: v.artifacts.length, + platforms: v.artifacts.map((a) => ({ os: a.os, arch: a.arch })), + published_at: v.publishedAt, + downloads: Number(v.downloadCount), + publish_method: v.publishMethod, + provenance: getProvenanceSummary(v), + })), + }; + }, + }); + + // GET /v1/bundles/@:scope/:package/versions/:version - Get specific version info + fastify.get('/@:scope/:package/versions/:version', { + schema: { + tags: ['bundles'], + description: 'Get information about a specific version', + params: { + type: 'object', + properties: { + scope: { type: 'string' }, + package: { type: 'string' }, + version: { type: 'string' }, + }, + required: ['scope', 'package', 'version'], + }, + response: { + 200: toJsonSchema(VersionDetailSchema), + }, + }, + handler: async (request) => { + const { + scope, + package: packageName, + version, + } = request.params as { + scope: string; + package: string; + version: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } + + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + + if (!packageVersion) { + throw new NotFoundError('Version not found'); + } + + // Build artifacts array with download URLs + const artifacts = await Promise.all( + packageVersion.artifacts.map(async (a) => { + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(a.storagePath); + + return { + platform: { os: a.os, arch: a.arch }, + digest: a.digest, + size: Number(a.sizeBytes), + download_url: downloadUrl, + source_url: a.sourceUrl || undefined, + }; + }), + ); + + return { + name: pkg.name, + version: packageVersion.version, + published_at: packageVersion.publishedAt, + downloads: Number(packageVersion.downloadCount), + artifacts, + manifest: packageVersion.manifest, + release: packageVersion.releaseUrl + ? { + tag: packageVersion.releaseTag, + url: packageVersion.releaseUrl, + } + : undefined, + publish_method: packageVersion.publishMethod, + provenance: getProvenanceFull(packageVersion), + }; + }, + }); + + // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle + fastify.get('/@:scope/:package/versions/:version/download', { + schema: { + tags: ['bundles'], + description: 'Download a specific version of a bundle', + params: { + type: 'object', + properties: { + scope: { type: 'string' }, + package: { type: 'string' }, + version: { type: 'string' }, + }, + required: ['scope', 'package', 'version'], + }, + querystring: { + type: 'object', + properties: { + os: { + type: 'string', + description: 'Target OS (darwin, linux, win32, any)', + }, + arch: { + type: 'string', + description: 'Target arch (x64, arm64, any)', + }, + }, + }, + response: { + 200: toJsonSchema(DownloadInfoSchema), + 302: { type: 'null', description: 'Redirect to download URL' }, + }, + }, + handler: async (request, reply) => { + const { + scope, + package: packageName, + version: versionParam, + } = request.params as { + scope: string; + package: string; + version: string; + }; + const { os: queryOs, arch: queryArch } = request.query as { + os?: string; + arch?: string; + }; + const name = `@${scope}/${packageName}`; + + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } + + // Resolve "latest" to actual version + const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; + + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + + if (!packageVersion) { + throw new NotFoundError('Version not found'); + } + + // Find the appropriate artifact + let artifact = packageVersion.artifacts[0]; // Default to first + + if (queryOs || queryArch) { + // Look for exact match + const match = packageVersion.artifacts.find( + (a) => a.os === queryOs && a.arch === queryArch, + ); + if (match) { + artifact = match; + } else { + // Look for universal fallback + const universal = packageVersion.artifacts.find( + (a) => a.os === 'any' && a.arch === 'any', + ); + if (universal) { + artifact = universal; + } + } + } + + if (!artifact) { + throw new NotFoundError('No artifact found for this version'); + } + + // Log download + const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; + fastify.log.info( + { + op: 'download', + pkg: name, + version, + platform, + }, + `download: ${name}@${version} (${platform})`, + ); + + // Increment download counts atomically in a single transaction + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, version, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to update download counts')); + + // Check if client wants JSON response (CLI/API) or redirect (browser) + const acceptHeader = request.headers.accept ?? ''; + const wantsJson = acceptHeader.includes('application/json'); + + // Generate signed download URL using the actual storage path + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); + + if (wantsJson) { + // CLI/API mode: Return JSON with download URL and metadata + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); + + return { + url: downloadUrl, + bundle: { + name, + version, + platform: { os: artifact.os, arch: artifact.arch }, + sha256: artifact.digest.replace('sha256:', ''), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; + } else { + // Browser mode: Redirect to download URL + if (downloadUrl.startsWith('/')) { + // Local storage - serve file directly + const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); + + return reply + .header('Content-Type', 'application/octet-stream') + .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) + .send(fileBuffer); + } else { + // S3/CloudFront - redirect to signed URL + return reply.code(302).redirect(downloadUrl); + } + } + }, + }); + + // POST /v1/bundles/announce - Announce a single artifact (OIDC only, idempotent per-artifact) + fastify.post('/announce', { + schema: { + tags: ['bundles'], + description: + 'Announce a single artifact for a bundle version from a GitHub release (OIDC only). Idempotent - can be called multiple times for different artifacts of the same version.', + body: toJsonSchema(AnnounceRequestSchema), + response: { + 200: toJsonSchema(AnnounceResponseSchema), + }, + }, + handler: async (request, reply) => { + try { + // Extract OIDC token from Authorization header + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError( + 'Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.', + ); + } + + const token = authHeader.substring(7); + const announceStart = Date.now(); + + // Verify the OIDC token + let claims; + try { + claims = await verifyGitHubOIDC(token); + } catch (error) { + const message = error instanceof Error ? error.message : 'Token verification failed'; + fastify.log.warn( + { op: 'announce', error: message }, + `announce: OIDC verification failed`, + ); + throw new UnauthorizedError(`Invalid OIDC token: ${message}`); + } + + // Extract body + const { + name, + version, + manifest, + release_tag, + prerelease = false, + artifact: artifactInfo, + } = request.body as { + name: string; + version: string; + manifest: Record; + release_tag: string; + prerelease?: boolean; + artifact: { + filename: string; + os: string; + arch: string; + sha256: string; + size: number; + }; + }; + + // Validate artifact platform values + const VALID_OS = ['darwin', 'linux', 'win32', 'any']; + const VALID_ARCH = ['x64', 'arm64', 'any']; + if (!VALID_OS.includes(artifactInfo.os)) { + throw new BadRequestError( + `Invalid artifact os: "${artifactInfo.os}". Must be one of: ${VALID_OS.join(', ')}`, + ); + } + if (!VALID_ARCH.includes(artifactInfo.arch)) { + throw new BadRequestError( + `Invalid artifact arch: "${artifactInfo.arch}". Must be one of: ${VALID_ARCH.join(', ')}`, + ); + } + // Validate artifact filename (path traversal, extension, length) + const filenameError = validateArtifactFilename(artifactInfo.filename); + if (filenameError) { + throw new BadRequestError( + `Invalid artifact filename: "${artifactInfo.filename}". ${filenameError}`, + ); + } + + // Validate package name + if (!isValidScopedPackageName(name)) { + throw new BadRequestError( + `Invalid package name: "${name}". Must be scoped (@scope/name) with lowercase alphanumeric characters and hyphens.`, + ); + } + + const parsed = parsePackageName(name); + if (!parsed) { + throw new BadRequestError('Invalid package name format'); + } + + // Security: Verify the package name scope matches the repository owner + const repoOwnerLower = claims.repository_owner.toLowerCase(); + const scopeLower = parsed.scope.toLowerCase(); + + if (scopeLower !== repoOwnerLower) { + fastify.log.warn( + { + op: 'announce', + pkg: name, + version, + repo: claims.repository, + error: 'scope_mismatch', + }, + `announce: scope mismatch @${parsed.scope} != ${claims.repository_owner}`, + ); + throw new UnauthorizedError( + `Scope mismatch: Package scope "@${parsed.scope}" does not match repository owner "${claims.repository_owner}". ` + + `OIDC publishing requires the package scope to match the GitHub organization or user.`, + ); + } + + fastify.log.info( + { + op: 'announce', + pkg: name, + version, + repo: claims.repository, + tag: release_tag, + prerelease, + artifact: artifactInfo.filename, + platform: `${artifactInfo.os}-${artifactInfo.arch}`, + }, + `announce: starting ${name}@${version} artifact ${artifactInfo.filename}`, + ); + + // Extract server_type from manifest + const serverObj = manifest['server'] as Record | undefined; + const serverType = (serverObj?.['type'] as string) ?? (manifest['server_type'] as string); + if (!serverType) { + throw new BadRequestError( + 'Manifest must contain server type (server.type or server_type)', + ); + } + + // Build provenance record + const provenance = buildProvenance(claims); + + // Fetch release from GitHub API to get the specific artifact + const releaseApiUrl = `https://api.github.com/repos/${claims.repository}/releases/tags/${release_tag}`; + fastify.log.info(`Fetching release from ${releaseApiUrl}`); + + const releaseResponse = await fetch(releaseApiUrl, { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'mpak-registry/1.0', + }, + }); + + if (!releaseResponse.ok) { + throw new BadRequestError( + `Failed to fetch release ${release_tag}: ${releaseResponse.statusText}`, + ); + } + + const release = (await releaseResponse.json()) as { + tag_name: string; + html_url: string; + assets: GitHubReleaseAsset[]; + }; + + // Check for server.json in the release assets (for MCP Registry discovery) + let serverJson: Record | null = null; + const serverJsonAsset = release.assets.find( + (a: GitHubReleaseAsset) => a.name === 'server.json', + ); + if (serverJsonAsset) { + try { + fastify.log.info(`Fetching server.json from release ${release_tag}`); + const sjResponse = await fetch(serverJsonAsset.browser_download_url); + if (sjResponse.ok) { + const sjData = (await sjResponse.json()) as Record; + // Strip packages[] before storing (the registry populates it dynamically at serve time) + delete sjData['packages']; + serverJson = sjData; + fastify.log.info(`Loaded server.json for MCP Registry discovery`); + } + } catch (sjError) { + fastify.log.warn( + { err: sjError }, + 'Failed to fetch server.json from release, continuing without it', + ); + } + } + + // Find the specific artifact by filename + const asset = release.assets.find( + (a: GitHubReleaseAsset) => a.name === artifactInfo.filename, + ); + if (!asset) { + throw new BadRequestError( + `Artifact "${artifactInfo.filename}" not found in release ${release_tag}`, + ); + } + + // Download artifact to temp file while computing hash (memory-efficient streaming) + const tempPath = path.join(tmpdir(), `mcpb-${randomUUID()}`); + const platformStr = getPlatformString(artifactInfo.os, artifactInfo.arch); + let storagePath: string; + let computedSha256: string; + + try { + fastify.log.info(`Downloading artifact: ${asset.name}`); + const assetResponse = await fetch(asset.browser_download_url); + if (!assetResponse.ok || !assetResponse.body) { + throw new BadRequestError( + `Failed to download ${asset.name}: ${assetResponse.statusText}`, + ); + } + + // Stream to temp file while computing hash + const hash = createHash('sha256'); + let bytesWritten = 0; + const writeStream = createWriteStream(tempPath); + + // Convert web ReadableStream to async iterable + const reader = assetResponse.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + bytesWritten += value.length; + writeStream.write(value); + } + } finally { + reader.releaseLock(); + } + + await new Promise((resolve, reject) => { + writeStream.end(); + writeStream.on('finish', resolve); + writeStream.on('error', reject); + }); + + // Verify size + if (bytesWritten !== artifactInfo.size) { + throw new BadRequestError( + `Size mismatch for ${asset.name}: declared ${artifactInfo.size} bytes, got ${bytesWritten} bytes`, + ); + } + + // Verify hash + computedSha256 = hash.digest('hex'); + if (computedSha256 !== artifactInfo.sha256) { + throw new BadRequestError( + `SHA256 mismatch for ${asset.name}: declared ${artifactInfo.sha256}, computed ${computedSha256}`, + ); + } + + // Stream verified file to storage + const uploadStream = createReadStream(tempPath); + const result = await fastify.storage.saveBundleFromStream( + parsed.scope, + parsed.packageName, + version, + uploadStream, + computedSha256, + bytesWritten, + platformStr || undefined, + ); + storagePath = result.path; + + fastify.log.info( + `Stored ${asset.name} -> ${storagePath} (${artifactInfo.os}-${artifactInfo.arch})`, + ); + } finally { + // Always clean up temp file + await fs.unlink(tempPath).catch(() => {}); + } + + // Track whether we created or updated + let status: 'created' | 'updated' = 'created'; + let totalArtifacts = 0; + let oldStoragePath: string | null = null; + let versionId: string | null = null; + + // Use transaction to upsert package, version, and artifact + try { + const txResult = await runInTransaction(async (tx) => { + // Find or create package (handles race conditions atomically) + const { package: existingPackage, created: packageCreated } = + await packageRepo.upsertPackage( + { + name, + displayName: (manifest['display_name'] as string) ?? undefined, + description: (manifest['description'] as string) ?? undefined, + authorName: + ((manifest['author'] as Record)?.['name'] as string) ?? + undefined, + authorEmail: + ((manifest['author'] as Record)?.['email'] as string) ?? + undefined, + authorUrl: + ((manifest['author'] as Record)?.['url'] as string) ?? + undefined, + homepage: (manifest['homepage'] as string) ?? undefined, + license: (manifest['license'] as string) ?? undefined, + iconUrl: (manifest['icon'] as string) ?? undefined, + serverType, + verified: false, + latestVersion: version, + githubRepo: claims.repository, + }, + tx, + ); + + const packageId = existingPackage.id; + let versionCreated = packageCreated; // New package means new version + + // Fetch README only if this might be a new version + let readme: string | null = null; + const existingVersion = await packageRepo.findVersion(packageId, version, tx); + + if (!existingVersion || !existingVersion.readme) { + // Fetch README.md from the repository at the release tag + try { + const readmeUrl = `https://api.github.com/repos/${claims.repository}/contents/README.md?ref=${release_tag}`; + fastify.log.info(`Fetching README from ${readmeUrl}`); + + const readmeResponse = await fetch(readmeUrl, { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'mpak-registry/1.0', + }, + }); + + if (readmeResponse.ok) { + const readmeData = (await readmeResponse.json()) as { + content?: string; + encoding?: string; + }; + if (readmeData.content && readmeData.encoding === 'base64') { + readme = Buffer.from(readmeData.content, 'base64').toString('utf-8'); + fastify.log.info(`Fetched README.md (${readme.length} chars)`); + } + } + } catch (readmeError) { + fastify.log.warn( + { err: readmeError }, + 'Failed to fetch README.md, continuing without it', + ); + } + } + + // Upsert version + const { version: packageVersion, created } = await packageRepo.upsertVersion( + packageId, + { + packageId, + version, + manifest, + prerelease, + publishedBy: null, + publishedByEmail: null, + releaseTag: release_tag, + releaseUrl: release.html_url, + readme: readme ?? undefined, + publishMethod: 'oidc', + provenanceRepository: provenance.repository, + provenanceSha: provenance.sha, + provenance, + serverJson: serverJson ?? undefined, + }, + tx, + ); + + versionCreated = created; + + // Update latestVersion only when version is first created + if (versionCreated) { + if (!prerelease) { + await packageRepo.updateLatestVersion(packageId, version, tx); + } else { + // Check if current latest is a prerelease - if so, update to newer prerelease + const currentLatest = await packageRepo.findVersion( + packageId, + existingPackage.latestVersion, + tx, + ); + if (currentLatest?.prerelease) { + await packageRepo.updateLatestVersion(packageId, version, tx); + } + } + } + + // Upsert artifact + const artifactResult = await packageRepo.upsertArtifact( + { + versionId: packageVersion.id, + os: artifactInfo.os, + arch: artifactInfo.arch, + digest: `sha256:${computedSha256}`, + sizeBytes: BigInt(artifactInfo.size), + storagePath, + sourceUrl: asset.browser_download_url, + }, + tx, + ); + + status = artifactResult.created ? 'created' : 'updated'; + oldStoragePath = artifactResult.oldStoragePath; + + // Count total artifacts for this version + totalArtifacts = await packageRepo.countVersionArtifacts(packageVersion.id, tx); + + return { versionId: packageVersion.id }; + }); + + versionId = txResult.versionId; + } catch (error) { + // Transaction failed - clean up uploaded file + try { + await fastify.storage.deleteBundle(storagePath); + fastify.log.info(`Cleaned up after transaction failure: ${storagePath}`); + } catch (cleanupError) { + fastify.log.error( + { err: cleanupError, path: storagePath }, + 'Failed to cleanup uploaded file', + ); + } + throw error; + } + + // Clean up old storage path if artifact was updated with different path + if (oldStoragePath) { + try { + await fastify.storage.deleteBundle(oldStoragePath); + fastify.log.info(`Cleaned up old artifact: ${oldStoragePath}`); + } catch (cleanupError) { + fastify.log.warn( + { err: cleanupError, path: oldStoragePath }, + 'Failed to cleanup old artifact file', + ); + } + } + + fastify.log.info( + { + op: 'announce', + pkg: name, + version, + repo: claims.repository, + artifact: artifactInfo.filename, + platform: `${artifactInfo.os}-${artifactInfo.arch}`, + status, + totalArtifacts, + ms: Date.now() - announceStart, + }, + `announce: ${status} ${name}@${version} artifact ${artifactInfo.filename} (${totalArtifacts} total, ${Date.now() - announceStart}ms)`, + ); + + // Non-blocking Discord notification for new or updated bundles + notifyDiscordAnnounce({ + name, + version, + type: 'bundle', + repo: claims.repository, + }); + + // Non-blocking security scan trigger + if (config.scanner.enabled && versionId) { + triggerSecurityScan(fastify.prisma, { + versionId, + bundleStoragePath: storagePath, + packageName: name, + version, + }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to trigger security scan')); + } + + return { + package: name, + version, + artifact: { + os: artifactInfo.os, + arch: artifactInfo.arch, + filename: artifactInfo.filename, + }, + total_artifacts: totalArtifacts, + status, + }; + } catch (error) { + fastify.log.error( + { + op: 'announce', + error: error instanceof Error ? error.message : 'unknown', + }, + `announce: failed`, + ); + return handleError(error, request, reply); + } + }, + }); }; diff --git a/apps/registry/src/schemas/query.ts b/apps/registry/src/schemas/query.ts index 69cbe52..3774b17 100644 --- a/apps/registry/src/schemas/query.ts +++ b/apps/registry/src/schemas/query.ts @@ -1,11 +1,11 @@ -import { z } from "zod"; +import { z } from 'zod'; export const BundleSearchQuerySchema = z.object({ - q: z.optional(z.string()), - type: z.optional(z.enum(["node", "python", "binary"])), - sort: z.enum(["downloads", "recent", "name"]).optional().default("downloads"), - limit: z.number().min(1).max(100).optional().default(20), - offset: z.number().min(0).optional().default(0), + q: z.optional(z.string()), + type: z.optional(z.enum(['node', 'python', 'binary'])), + sort: z.enum(['downloads', 'recent', 'name']).optional().default('downloads'), + limit: z.number().min(1).max(100).optional().default(20), + offset: z.number().min(0).optional().default(0), }); export type BundleSearchQuery = z.infer; From ee5d40f4368c031bd1bd292238d116c5a5b797ff Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 12 Mar 2026 11:04:23 -0400 Subject: [PATCH 4/8] Add package seed data with full version history for echo and nationalparks Seed the local dev database with realistic bundle data matching production. Includes all 9 echo versions (with prereleases) and 6 nationalparks versions, with real manifests, provenance SHAs, download counts, and timestamps. Co-Authored-By: Claude Opus 4.6 --- apps/registry/prisma/seed.ts | 318 ++++++++++++++++++++++++++++++++++- 1 file changed, 317 insertions(+), 1 deletion(-) diff --git a/apps/registry/prisma/seed.ts b/apps/registry/prisma/seed.ts index fe69af5..1088fd6 100644 --- a/apps/registry/prisma/seed.ts +++ b/apps/registry/prisma/seed.ts @@ -366,6 +366,263 @@ The skill follows a structured thinking process: }, ]; +// --------------------------------------------------------------------------- +// Seed data: Packages (bundles) +// --------------------------------------------------------------------------- + +interface SeedPackageVersion { + version: string; + prerelease?: boolean; + downloads: number; + manifest: object; + publishedAt: string; + publishMethod: string; + provenanceRepository: string; + provenanceSha: string; + releaseTag?: string; + releaseUrl?: string; +} + +interface SeedPackage { + name: string; + description: string; + authorName: string; + serverType: string; + license?: string; + githubRepo?: string; + versions: SeedPackageVersion[]; +} + +const echoManifest = (version: string) => ({ + name: '@nimblebraininc/echo', + version, + description: 'Echo server for testing and debugging MCP connections', + manifest_version: '0.3', + author: { name: 'NimbleBrain Inc' }, + server: { + type: 'python', + mcp_config: { command: 'python', args: ['-m', 'mcp_echo.server'] }, + entry_point: 'mcp_echo.server', + }, +}); + +const nationalparksManifest = (version: string) => ({ + name: '@nimblebraininc/nationalparks', + version, + description: 'MCP server for National Parks Service API', + manifest_version: '0.3', + author: { name: 'NimbleBrain Inc' }, + server: { + type: 'node', + mcp_config: { + command: 'node', + args: ['${__dirname}/build/index.js'], + env: { NPS_API_KEY: '${user_config.api_key}' }, + }, + entry_point: 'build/index.js', + }, + user_config: { + api_key: { + type: 'string', + title: 'NPS API Key', + required: true, + sensitive: true, + description: 'Your NPS API key from https://www.nps.gov/subjects/developer/get-started.htm', + }, + }, +}); + +const PACKAGES: SeedPackage[] = [ + { + name: '@nimblebraininc/echo', + description: 'Echo server for testing and debugging MCP connections', + authorName: 'NimbleBrain Inc', + serverType: 'python', + license: 'Apache-2.0', + githubRepo: 'NimbleBrainInc/mcp-echo', + versions: [ + { + version: '0.1.0', + downloads: 103, + publishedAt: '2025-12-31T19:46:28.468Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'e3406ec72697feaba4da26f18f356ac9aae8a31f', + releaseTag: 'v0.1.0', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.0', + manifest: echoManifest('0.1.0'), + }, + { + version: '0.1.1-beta.1', + prerelease: true, + downloads: 208, + publishedAt: '2026-01-02T22:00:46.118Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '1b0be7da2ff7f6f88e738e8897b8b2e602816935', + releaseTag: 'v0.1.1-beta.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.1-beta.1', + manifest: echoManifest('0.1.1-beta.1'), + }, + { + version: '0.1.1', + downloads: 336, + publishedAt: '2026-01-02T22:27:05.591Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'df13c722759cd066aa97ce6a9921cab52dbf5c58', + releaseTag: 'v0.1.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.1', + manifest: echoManifest('0.1.1'), + }, + { + version: '0.1.2', + downloads: 124, + publishedAt: '2026-01-04T19:22:00.731Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '01a67c0c69847783c53fb428b898ebd64d439a4a', + releaseTag: 'v0.1.2', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.2', + manifest: echoManifest('0.1.2'), + }, + { + version: '0.1.3', + downloads: 226, + publishedAt: '2026-01-04T19:48:38.421Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'ea9ea341fdd7085e5ced55c8748010efa07ef492', + releaseTag: 'v0.1.3', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.3', + manifest: echoManifest('0.1.3'), + }, + { + version: '0.1.4-rc.1', + prerelease: true, + downloads: 0, + publishedAt: '2026-02-09T18:47:48.584Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'facc63fda3553268eee4da38ceb7758dc7d47607', + releaseTag: 'v0.1.4-rc.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4-rc.1', + manifest: echoManifest('0.1.4-rc.1'), + }, + { + version: '0.1.4-rc.4', + prerelease: true, + downloads: 0, + publishedAt: '2026-02-09T19:21:38.476Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'facc63fda3553268eee4da38ceb7758dc7d47607', + releaseTag: 'v0.1.4-rc.4', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4-rc.4', + manifest: echoManifest('0.1.4-rc.4'), + }, + { + version: '0.1.4', + downloads: 2, + publishedAt: '2026-02-11T03:40:32.500Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '638181a3357e89fcf8f77234667459df97d61d89', + releaseTag: 'v0.1.4', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4', + manifest: echoManifest('0.1.4'), + }, + { + version: '0.1.5', + downloads: 101, + publishedAt: '2026-02-11T08:11:50.559Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '640aa8ef2dd3843f834292015b3562349ebcbf00', + releaseTag: 'v0.1.5', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.5', + manifest: echoManifest('0.1.5'), + }, + ], + }, + { + name: '@nimblebraininc/nationalparks', + description: 'MCP server for National Parks Service API', + authorName: 'NimbleBrain Inc', + serverType: 'node', + license: 'Apache-2.0', + githubRepo: 'NimbleBrainInc/mcp-server-nationalparks', + versions: [ + { + version: '0.1.1', + downloads: 255, + publishedAt: '2026-01-05T05:52:56.802Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: '528a517c72167f6e2903a40a67b233a3c2bb641a', + releaseTag: 'v0.1.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.1', + manifest: nationalparksManifest('0.1.1'), + }, + { + version: '0.1.2', + downloads: 56, + publishedAt: '2026-01-05T06:12:53.512Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: 'acd8c36aa4bea46ce69c8b6f3225a23ce8b83e19', + releaseTag: 'v0.1.2', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.2', + manifest: nationalparksManifest('0.1.2'), + }, + { + version: '0.1.3', + downloads: 67, + publishedAt: '2026-01-05T07:01:29.791Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: 'd4d7e54b40a4f96aa79778239da71b5c635f4377', + releaseTag: 'v0.1.3', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.3', + manifest: nationalparksManifest('0.1.3'), + }, + { + version: '0.1.4', + downloads: 171, + publishedAt: '2026-01-05T07:04:34.396Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: '4897a961ace1e34760fd5aff15496ff520ca7ce7', + releaseTag: 'v0.1.4', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.4', + manifest: nationalparksManifest('0.1.4'), + }, + { + version: '0.1.5', + downloads: 174, + publishedAt: '2026-01-05T07:08:30.892Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: '5e6f1f3f5512b837a14f564d6c182e6a370d7a66', + releaseTag: 'v0.1.5', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.5', + manifest: nationalparksManifest('0.1.5'), + }, + { + version: '0.2.0', + downloads: 2, + publishedAt: '2026-02-12T23:33:07.687Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: 'b4566b1298b2617aedd1a2cc7d23b1576fe96e5d', + releaseTag: 'v0.2.0', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.2.0', + manifest: nationalparksManifest('0.2.0'), + }, + ], + }, +]; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -466,7 +723,66 @@ async function seed() { } } - console.log(`\nSeeded ${SKILLS.length} skills successfully.`); + console.log(`\nSeeded ${SKILLS.length} skills successfully.\n`); + + // Seed packages (bundles) + for (const p of PACKAGES) { + const totalDownloads = p.versions.reduce((sum, v) => sum + v.downloads, 0); + const latestVersion = p.versions[p.versions.length - 1]!.version; + + const pkg = await prisma.package.upsert({ + where: { name: p.name }, + create: { + name: p.name, + description: p.description, + authorName: p.authorName, + serverType: p.serverType, + license: p.license ?? null, + githubRepo: p.githubRepo ?? null, + latestVersion, + totalDownloads: BigInt(totalDownloads), + }, + update: { + description: p.description, + authorName: p.authorName, + serverType: p.serverType, + license: p.license ?? null, + latestVersion, + totalDownloads: BigInt(totalDownloads), + }, + }); + + console.log(` Package: ${p.name} (${pkg.id})`); + + for (const v of p.versions) { + await prisma.packageVersion.upsert({ + where: { + packageId_version: { packageId: pkg.id, version: v.version }, + }, + create: { + packageId: pkg.id, + version: v.version, + manifest: v.manifest, + prerelease: v.prerelease ?? false, + downloadCount: BigInt(v.downloads), + publishMethod: v.publishMethod, + provenanceRepository: v.provenanceRepository, + provenanceSha: v.provenanceSha, + releaseTag: v.releaseTag ?? null, + releaseUrl: v.releaseUrl ?? null, + publishedAt: new Date(v.publishedAt), + }, + update: { + manifest: v.manifest, + downloadCount: BigInt(v.downloads), + }, + }); + + console.log(` v${v.version} (${v.downloads} downloads)`); + } + } + + console.log(`\nSeeded ${PACKAGES.length} packages successfully.`); } // --------------------------------------------------------------------------- From c20345203b2fd0b9afae7cd8d56444f1eeb676db Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 12 Mar 2026 13:08:06 -0400 Subject: [PATCH 5/8] Add BundleSearchParamsSchema to shared schemas and use in registry Define BundleSearchParamsSchema in packages/schemas as the single source of truth for bundle search query validation (q max 200 chars, type, sort defaulting to downloads, limit 1-100, offset min 0). Copy to registry generated schemas and update bundles route to import from there instead of the ad-hoc query.ts, which is now deleted. Also fix type mismatches in the search handler: explicitly type tools as PackageTool[], coerce verified to boolean, and convert provenance schema_version from number to string to match the API response schema. Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 37 +++++++++++-------- .../registry/src/schemas/generated/package.ts | 31 +++++++++++++--- apps/registry/src/schemas/query.ts | 11 ------ packages/schemas/src/package.ts | 18 +++++++-- 4 files changed, 61 insertions(+), 36 deletions(-) delete mode 100644 apps/registry/src/schemas/query.ts diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 578b8d9..451b625 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'crypto'; import type { FastifyPluginAsync } from 'fastify'; -import { createWriteStream, createReadStream, promises as fs } from 'fs'; +import { createReadStream, createWriteStream, promises as fs } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; import { config } from '../../config.js'; @@ -8,23 +8,28 @@ import { runInTransaction } from '../../db/index.js'; import type { PackageSearchFilters } from '../../db/types.js'; import { BadRequestError, + handleError, NotFoundError, UnauthorizedError, - handleError, } from '../../errors/index.js'; import { buildProvenance, type ProvenanceRecord, verifyGitHubOIDC } from '../../lib/oidc.js'; import { toJsonSchema } from '../../lib/zod-schema.js'; import { - BundleSearchResponseSchema, + AnnounceRequestSchema, + AnnounceResponseSchema, BundleDetailSchema, - VersionsResponseSchema, - VersionDetailSchema, + type BundleSearchResponse, + BundleSearchResponseSchema, + type PackageTool, DownloadInfoSchema, MCPBIndexSchema, - AnnounceRequestSchema, - AnnounceResponseSchema, + VersionDetailSchema, + VersionsResponseSchema, } from '../../schemas/generated/api-responses.js'; -import { BundleSearchQuerySchema, type BundleSearchQuery } from '../../schemas/query.js'; +import { + type BundleSearchParams, + BundleSearchParamsSchema, +} from '../../schemas/generated/package.js'; import { triggerSecurityScan } from '../../services/scanner.js'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; @@ -74,7 +79,7 @@ function getProvenanceSummary(version: { publishMethod: string | null; provenanc } const p = version.provenance as ProvenanceRecord; return { - schema_version: p.schema_version, + schema_version: String(p.schema_version), provider: p.provider, repository: p.repository, sha: p.sha, @@ -135,11 +140,11 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { const { packages: packageRepo } = fastify.repositories; // GET /v1/bundles/search - Search bundles - fastify.get<{ Querystring: BundleSearchQuery }>('/search', { + fastify.get<{ Querystring: BundleSearchParams }>('/search', { schema: { tags: ['bundles'], description: 'Search for bundles', - querystring: toJsonSchema(BundleSearchQuerySchema), + querystring: toJsonSchema(BundleSearchParamsSchema), response: { 200: toJsonSchema(BundleSearchResponseSchema), }, @@ -197,17 +202,17 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { latest_version: pkg.latestVersion, icon: pkg.iconUrl, server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], + tools: (manifest['tools'] as PackageTool[]) ?? [], downloads: Number(pkg.totalDownloads), - published_at: latestVersion?.publishedAt ?? pkg.createdAt, - verified: pkg.verified, + published_at: latestVersion?.publishedAt ?? (pkg.createdAt as Date), + verified: pkg.verified || false, provenance: latestVersion ? getProvenanceSummary(latestVersion) : null, certification_level: scan?.certificationLevel ?? null, }; }), ); - return { + const response: BundleSearchResponse = { bundles, total, pagination: { @@ -216,6 +221,8 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { has_more: offset + bundles.length < total, }, }; + + return response; }, }); diff --git a/apps/registry/src/schemas/generated/package.ts b/apps/registry/src/schemas/generated/package.ts index f162d13..c7fde0a 100644 --- a/apps/registry/src/schemas/generated/package.ts +++ b/apps/registry/src/schemas/generated/package.ts @@ -1,16 +1,22 @@ import { z } from 'zod'; -// Server type enum +// ============================================================================= +// Enums & Search Params +// ============================================================================= + +/** Server runtime type */ export const ServerTypeSchema = z.enum(['node', 'python', 'binary']); -// Platform enum +/** Supported operating system platforms */ export const PlatformSchema = z.enum(['darwin', 'win32', 'linux']); -// Sort options +/** Sort options for package listings */ export const PackageSortSchema = z.enum(['downloads', 'recent', 'name']); -// Package search params schema -// Query params from HTTP are always strings, but we parse them to proper types +/** + * Package search query parameters. + * HTTP query params arrive as strings, so limit/offset accept both. + */ export const PackageSearchParamsSchema = z.object({ q: z.string().optional(), type: ServerTypeSchema.optional(), @@ -22,8 +28,21 @@ export const PackageSearchParamsSchema = z.object({ offset: z.union([z.string(), z.number()]).optional(), }); -// Export TypeScript types +/** Bundle search query parameters. */ +export const BundleSearchParamsSchema = z.object({ + q: z.string().max(200).optional(), + type: ServerTypeSchema.optional(), + sort: PackageSortSchema.optional().default('downloads'), + limit: z.number().min(1).max(100).optional().default(20), + offset: z.number().min(0).optional().default(0), +}); + +// ============================================================================= +// TypeScript Types +// ============================================================================= + export type ServerType = z.infer; export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; +export type BundleSearchParams = z.infer; diff --git a/apps/registry/src/schemas/query.ts b/apps/registry/src/schemas/query.ts deleted file mode 100644 index 3774b17..0000000 --- a/apps/registry/src/schemas/query.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const BundleSearchQuerySchema = z.object({ - q: z.optional(z.string()), - type: z.optional(z.enum(['node', 'python', 'binary'])), - sort: z.enum(['downloads', 'recent', 'name']).optional().default('downloads'), - limit: z.number().min(1).max(100).optional().default(20), - offset: z.number().min(0).optional().default(0), -}); - -export type BundleSearchQuery = z.infer; diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index c03f3c0..c7fde0a 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -1,17 +1,17 @@ -import { z } from "zod"; +import { z } from 'zod'; // ============================================================================= // Enums & Search Params // ============================================================================= /** Server runtime type */ -export const ServerTypeSchema = z.enum(["node", "python", "binary"]); +export const ServerTypeSchema = z.enum(['node', 'python', 'binary']); /** Supported operating system platforms */ -export const PlatformSchema = z.enum(["darwin", "win32", "linux"]); +export const PlatformSchema = z.enum(['darwin', 'win32', 'linux']); /** Sort options for package listings */ -export const PackageSortSchema = z.enum(["downloads", "recent", "name"]); +export const PackageSortSchema = z.enum(['downloads', 'recent', 'name']); /** * Package search query parameters. @@ -28,6 +28,15 @@ export const PackageSearchParamsSchema = z.object({ offset: z.union([z.string(), z.number()]).optional(), }); +/** Bundle search query parameters. */ +export const BundleSearchParamsSchema = z.object({ + q: z.string().max(200).optional(), + type: ServerTypeSchema.optional(), + sort: PackageSortSchema.optional().default('downloads'), + limit: z.number().min(1).max(100).optional().default(20), + offset: z.number().min(0).optional().default(0), +}); + // ============================================================================= // TypeScript Types // ============================================================================= @@ -36,3 +45,4 @@ export type ServerType = z.infer; export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; +export type BundleSearchParams = z.infer; From da84d32f4ec9feb6eb23173bb5da39e3aafc37c8 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 12 Mar 2026 13:32:40 -0400 Subject: [PATCH 6/8] Add Zod schema validation tests for bundle search route Co-Authored-By: Claude Opus 4.6 --- apps/registry/tests/bundles.test.ts | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index d0b9071..117cbda 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -160,6 +160,68 @@ describe('Bundle Routes', () => { expect.objectContaining({ orderBy: { name: 'asc' } }), ); }); + + it('applies defaults when no params provided', async () => { + packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); + + const res = await app.inject({ method: 'GET', url: '/search' }); + + expect(res.statusCode).toBe(200); + expect(packageRepo.search).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + skip: 0, + take: 20, + orderBy: { totalDownloads: 'desc' }, + }), + ); + }); + + it('passes type filter to search', async () => { + packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); + + const res = await app.inject({ method: 'GET', url: '/search?type=node' }); + + expect(res.statusCode).toBe(200); + expect(packageRepo.search).toHaveBeenCalledWith( + expect.objectContaining({ serverType: 'node' }), + expect.any(Object), + ); + }); + + it('rejects invalid type and sort enum values', async () => { + const typeRes = await app.inject({ method: 'GET', url: '/search?type=invalid' }); + const sortRes = await app.inject({ method: 'GET', url: '/search?sort=bogus' }); + + expect(typeRes.statusCode).toBe(400); + expect(sortRes.statusCode).toBe(400); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('rejects q longer than 200 characters', async () => { + const res = await app.inject({ method: 'GET', url: `/search?q=${'a'.repeat(201)}` }); + + expect(res.statusCode).toBe(400); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('rejects limit above 100', async () => { + const res = await app.inject({ method: 'GET', url: '/search?limit=101' }); + + expect(res.statusCode).toBe(400); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('sets has_more when total exceeds returned results', async () => { + packageRepo.search.mockResolvedValue({ packages: [mockPackage], total: 50 }); + packageRepo.findVersionWithLatestScan.mockResolvedValue(mockVersionWithScans); + + const res = await app.inject({ method: 'GET', url: '/search?limit=1&offset=0' }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.pagination).toEqual({ limit: 1, offset: 0, has_more: true }); + }); }); // ========================================================================= From 7feb0a789bbe26f2759c1ef98c238527c5b3ba3b Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 12 Mar 2026 14:56:54 -0400 Subject: [PATCH 7/8] Add Zod schema validation for bundle download route params and querystring Replace inline JSON Schema definitions in the download route with Zod schemas, matching the pattern established for the search route: - Add BundleDownloadParamsSchema to validate os/arch query params against known enum values (darwin/linux/win32, x64/arm64), rejecting invalid platform values at the Fastify validation layer (422) - Add BundleVersionPathParamsSchema for scope/package/version path params - Wire both schemas via Fastify generics for type-safe request access without manual `as` casts - Register the production error handler in tests so validation errors return 422 (matching prod behavior) instead of raw Fastify 400s - Add tests for invalid os/arch rejection and search schema validation (defaults, type filter, enum/boundary validation, pagination) Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 242 ++++++++---------- apps/registry/src/schemas/bundles.ts | 10 + .../registry/src/schemas/generated/package.ts | 7 + apps/registry/tests/bundles.test.ts | 23 +- packages/schemas/src/package.ts | 7 + 5 files changed, 155 insertions(+), 134 deletions(-) create mode 100644 apps/registry/src/schemas/bundles.ts diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 451b625..4c67cdc 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -14,19 +14,25 @@ import { } from '../../errors/index.js'; import { buildProvenance, type ProvenanceRecord, verifyGitHubOIDC } from '../../lib/oidc.js'; import { toJsonSchema } from '../../lib/zod-schema.js'; +import { + type BundleVersionPathParams, + BundleVersionPathParamsSchema, +} from '../../schemas/bundles.js'; import { AnnounceRequestSchema, AnnounceResponseSchema, BundleDetailSchema, type BundleSearchResponse, BundleSearchResponseSchema, - type PackageTool, DownloadInfoSchema, MCPBIndexSchema, + type PackageTool, VersionDetailSchema, VersionsResponseSchema, } from '../../schemas/generated/api-responses.js'; import { + type BundleDownloadParams, + BundleDownloadParamsSchema, type BundleSearchParams, BundleSearchParamsSchema, } from '../../schemas/generated/package.js'; @@ -580,154 +586,132 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }); // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle - fastify.get('/@:scope/:package/versions/:version/download', { - schema: { - tags: ['bundles'], - description: 'Download a specific version of a bundle', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - version: { type: 'string' }, - }, - required: ['scope', 'package', 'version'], - }, - querystring: { - type: 'object', - properties: { - os: { - type: 'string', - description: 'Target OS (darwin, linux, win32, any)', - }, - arch: { - type: 'string', - description: 'Target arch (x64, arm64, any)', - }, + fastify.get<{ Params: BundleVersionPathParams; Querystring: BundleDownloadParams }>( + '/@:scope/:package/versions/:version/download', + { + schema: { + tags: ['bundles'], + description: 'Download a specific version of a bundle', + params: toJsonSchema(BundleVersionPathParamsSchema), + querystring: toJsonSchema(BundleDownloadParamsSchema), + response: { + 200: toJsonSchema(DownloadInfoSchema), + 302: { type: 'null', description: 'Redirect to download URL' }, }, }, - response: { - 200: toJsonSchema(DownloadInfoSchema), - 302: { type: 'null', description: 'Redirect to download URL' }, - }, - }, - handler: async (request, reply) => { - const { - scope, - package: packageName, - version: versionParam, - } = request.params as { - scope: string; - package: string; - version: string; - }; - const { os: queryOs, arch: queryArch } = request.query as { - os?: string; - arch?: string; - }; - const name = `@${scope}/${packageName}`; + handler: async (request, reply) => { + // Destructure query and path params + const { scope, package: packageName, version: versionParam } = request.params; + const { os: queryOs, arch: queryArch } = request.query; + const name = `@${scope}/${packageName}`; - const pkg = await packageRepo.findByName(name); + const pkg = await packageRepo.findByName(name); - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } - // Resolve "latest" to actual version - const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; + // Resolve "latest" to actual version + const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); - if (!packageVersion) { - throw new NotFoundError('Version not found'); - } + if (!packageVersion) { + throw new NotFoundError('Version not found'); + } - // Find the appropriate artifact - let artifact = packageVersion.artifacts[0]; // Default to first + // Find the appropriate artifact + let artifact = packageVersion.artifacts[0]; // Default to first - if (queryOs || queryArch) { - // Look for exact match - const match = packageVersion.artifacts.find( - (a) => a.os === queryOs && a.arch === queryArch, - ); - if (match) { - artifact = match; - } else { - // Look for universal fallback - const universal = packageVersion.artifacts.find( - (a) => a.os === 'any' && a.arch === 'any', + if (queryOs || queryArch) { + // Look for exact match + const match = packageVersion.artifacts.find( + (a) => a.os === queryOs && a.arch === queryArch, ); - if (universal) { - artifact = universal; + if (match) { + artifact = match; + } else { + // Look for universal fallback + const universal = packageVersion.artifacts.find( + (a) => a.os === 'any' && a.arch === 'any', + ); + if (universal) { + artifact = universal; + } } } - } - if (!artifact) { - throw new NotFoundError('No artifact found for this version'); - } + if (!artifact) { + throw new NotFoundError('No artifact found for this version'); + } - // Log download - const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; - fastify.log.info( - { - op: 'download', - pkg: name, - version, - platform, - }, - `download: ${name}@${version} (${platform})`, - ); + // Log download + const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; + fastify.log.info( + { + op: 'download', + pkg: name, + version, + platform, + }, + `download: ${name}@${version} (${platform})`, + ); + + // Increment download counts atomically in a single transaction + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, version, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to update download counts')); + + // Check if client wants JSON response (CLI/API) or redirect (browser) + const acceptHeader = request.headers.accept ?? ''; + const wantsJson = acceptHeader.includes('application/json'); - // Increment download counts atomically in a single transaction - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, version, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to update download counts')); - - // Check if client wants JSON response (CLI/API) or redirect (browser) - const acceptHeader = request.headers.accept ?? ''; - const wantsJson = acceptHeader.includes('application/json'); - - // Generate signed download URL using the actual storage path - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); - - if (wantsJson) { - // CLI/API mode: Return JSON with download URL and metadata - const expiresAt = new Date(); - expiresAt.setSeconds( - expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + // Generate signed download URL using the actual storage path + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath( + artifact.storagePath, ); - return { - url: downloadUrl, - bundle: { - name, - version, - platform: { os: artifact.os, arch: artifact.arch }, - sha256: artifact.digest.replace('sha256:', ''), - size: Number(artifact.sizeBytes), - }, - expires_at: expiresAt.toISOString(), - }; - } else { - // Browser mode: Redirect to download URL - if (downloadUrl.startsWith('/')) { - // Local storage - serve file directly - const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); - - return reply - .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) - .send(fileBuffer); + if (wantsJson) { + // CLI/API mode: Return JSON with download URL and metadata + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); + + return { + url: downloadUrl, + bundle: { + name, + version, + platform: { os: artifact.os, arch: artifact.arch }, + sha256: artifact.digest.replace('sha256:', ''), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; } else { - // S3/CloudFront - redirect to signed URL - return reply.code(302).redirect(downloadUrl); + // Browser mode: Redirect to download URL + if (downloadUrl.startsWith('/')) { + // Local storage - serve file directly + const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); + + return reply + .header('Content-Type', 'application/octet-stream') + .header( + 'Content-Disposition', + `attachment; filename="${packageName}-${version}.mcpb"`, + ) + .send(fileBuffer); + } else { + // S3/CloudFront - redirect to signed URL + return reply.code(302).redirect(downloadUrl); + } } - } + }, }, - }); + ); // POST /v1/bundles/announce - Announce a single artifact (OIDC only, idempotent per-artifact) fastify.post('/announce', { diff --git a/apps/registry/src/schemas/bundles.ts b/apps/registry/src/schemas/bundles.ts new file mode 100644 index 0000000..5f1b638 --- /dev/null +++ b/apps/registry/src/schemas/bundles.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +/** Path params for routes like /@:scope/:package/versions/:version/download */ +export const BundleVersionPathParamsSchema = z.object({ + scope: z.string(), + package: z.string(), + version: z.string(), +}); + +export type BundleVersionPathParams = z.infer; diff --git a/apps/registry/src/schemas/generated/package.ts b/apps/registry/src/schemas/generated/package.ts index c7fde0a..e5a3f29 100644 --- a/apps/registry/src/schemas/generated/package.ts +++ b/apps/registry/src/schemas/generated/package.ts @@ -28,6 +28,12 @@ export const PackageSearchParamsSchema = z.object({ offset: z.union([z.string(), z.number()]).optional(), }); +/** Bundle download query parameters. */ +export const BundleDownloadParamsSchema = z.object({ + os: z.enum(['darwin', 'linux', 'win32']).describe('Target OS (darwin, linux, win32)').optional(), + arch: z.enum(['x64', 'arm64']).describe('Target arch (x64, arm64)').optional(), +}); + /** Bundle search query parameters. */ export const BundleSearchParamsSchema = z.object({ q: z.string().max(200).optional(), @@ -45,4 +51,5 @@ export type ServerType = z.infer; export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; +export type BundleDownloadParams = z.infer; export type BundleSearchParams = z.infer; diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index 117cbda..d107e77 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -71,6 +71,7 @@ import { mockVersionWithScans, } from './helpers.js'; import { verifyGitHubOIDC } from '../src/lib/oidc.js'; +import { errorHandler } from '../src/errors/middleware.js'; // --------------------------------------------------------------------------- // Test setup @@ -90,6 +91,7 @@ describe('Bundle Routes', () => { app = Fastify({ logger: false }); app.setReplySerializer((payload) => JSON.stringify(payload)); await app.register(sensible); + app.setErrorHandler(errorHandler); // Decorate with mocks app.decorate('repositories', { @@ -146,7 +148,7 @@ describe('Bundle Routes', () => { it('rejects invalid pagination values', async () => { const res = await app.inject({ method: 'GET', url: '/search?q=x&limit=0&offset=-5' }); - expect(res.statusCode).toBe(400); + expect(res.statusCode).toBe(422); expect(packageRepo.search).not.toHaveBeenCalled(); }); @@ -193,22 +195,22 @@ describe('Bundle Routes', () => { const typeRes = await app.inject({ method: 'GET', url: '/search?type=invalid' }); const sortRes = await app.inject({ method: 'GET', url: '/search?sort=bogus' }); - expect(typeRes.statusCode).toBe(400); - expect(sortRes.statusCode).toBe(400); + expect(typeRes.statusCode).toBe(422); + expect(sortRes.statusCode).toBe(422); expect(packageRepo.search).not.toHaveBeenCalled(); }); it('rejects q longer than 200 characters', async () => { const res = await app.inject({ method: 'GET', url: `/search?q=${'a'.repeat(201)}` }); - expect(res.statusCode).toBe(400); + expect(res.statusCode).toBe(422); expect(packageRepo.search).not.toHaveBeenCalled(); }); it('rejects limit above 100', async () => { const res = await app.inject({ method: 'GET', url: '/search?limit=101' }); - expect(res.statusCode).toBe(400); + expect(res.statusCode).toBe(422); expect(packageRepo.search).not.toHaveBeenCalled(); }); @@ -363,6 +365,17 @@ describe('Bundle Routes', () => { expect(res.statusCode).toBe(404); }); + it('rejects invalid os and arch query params', async () => { + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?os=foo&arch=bar', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(422); + expect(packageRepo.findByName).not.toHaveBeenCalled(); + }); + it('returns 404 for unknown bundle', async () => { packageRepo.findByName.mockResolvedValue(null); diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index c7fde0a..e5a3f29 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -28,6 +28,12 @@ export const PackageSearchParamsSchema = z.object({ offset: z.union([z.string(), z.number()]).optional(), }); +/** Bundle download query parameters. */ +export const BundleDownloadParamsSchema = z.object({ + os: z.enum(['darwin', 'linux', 'win32']).describe('Target OS (darwin, linux, win32)').optional(), + arch: z.enum(['x64', 'arm64']).describe('Target arch (x64, arm64)').optional(), +}); + /** Bundle search query parameters. */ export const BundleSearchParamsSchema = z.object({ q: z.string().max(200).optional(), @@ -45,4 +51,5 @@ export type ServerType = z.infer; export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; +export type BundleDownloadParams = z.infer; export type BundleSearchParams = z.infer; From c87e8c5980eaff6b43bd3f61838cf6272f660f45 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Thu, 12 Mar 2026 15:57:03 -0400 Subject: [PATCH 8/8] Fix download endpoint serving wrong artifact when platform params don't match When os/arch query params were provided but didn't match any artifact, the download route silently fell back to artifacts[0] instead of returning a 404. This meant requests like ?os=foo&arch=bar would succeed and return an unrelated platform's artifact. Clear the artifact to undefined when neither an exact platform match nor a universal (any/any) fallback is found, so the existing !artifact guard correctly returns 404. Fixes #28 Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 209 ++++++++++++------------- apps/registry/tests/bundles.test.ts | 14 ++ 2 files changed, 117 insertions(+), 106 deletions(-) diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 4c67cdc..27caeac 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,3 +1,4 @@ +import type { Artifact } from '@prisma/client'; import { createHash, randomUUID } from 'crypto'; import type { FastifyPluginAsync } from 'fastify'; import { createReadStream, createWriteStream, promises as fs } from 'fs'; @@ -586,132 +587,128 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }); // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle - fastify.get<{ Params: BundleVersionPathParams; Querystring: BundleDownloadParams }>( - '/@:scope/:package/versions/:version/download', - { - schema: { - tags: ['bundles'], - description: 'Download a specific version of a bundle', - params: toJsonSchema(BundleVersionPathParamsSchema), - querystring: toJsonSchema(BundleDownloadParamsSchema), - response: { - 200: toJsonSchema(DownloadInfoSchema), - 302: { type: 'null', description: 'Redirect to download URL' }, - }, + fastify.get<{ + Params: BundleVersionPathParams; + Querystring: BundleDownloadParams; + }>('/@:scope/:package/versions/:version/download', { + schema: { + tags: ['bundles'], + description: 'Download a specific version of a bundle', + params: toJsonSchema(BundleVersionPathParamsSchema), + querystring: toJsonSchema(BundleDownloadParamsSchema), + response: { + 200: toJsonSchema(DownloadInfoSchema), + 302: { type: 'null', description: 'Redirect to download URL' }, }, - handler: async (request, reply) => { - // Destructure query and path params - const { scope, package: packageName, version: versionParam } = request.params; - const { os: queryOs, arch: queryArch } = request.query; - const name = `@${scope}/${packageName}`; + }, + handler: async (request, reply) => { + const { scope, package: packageName, version: versionParam } = request.params; + const { os: queryOs, arch: queryArch } = request.query; + const name = `@${scope}/${packageName}`; - const pkg = await packageRepo.findByName(name); + const pkg = await packageRepo.findByName(name); - if (!pkg) { - throw new NotFoundError('Bundle not found'); - } + if (!pkg) { + throw new NotFoundError('Bundle not found'); + } - // Resolve "latest" to actual version - const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; + // Resolve "latest" to actual version + const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); - if (!packageVersion) { - throw new NotFoundError('Version not found'); - } + if (!packageVersion) { + throw new NotFoundError('Version not found'); + } - // Find the appropriate artifact - let artifact = packageVersion.artifacts[0]; // Default to first + // Find the appropriate artifact + let artifact: Artifact | undefined = packageVersion.artifacts[0]; // Default to first - if (queryOs || queryArch) { - // Look for exact match - const match = packageVersion.artifacts.find( - (a) => a.os === queryOs && a.arch === queryArch, + if (queryOs || queryArch) { + // Look for exact match + const match = packageVersion.artifacts.find( + (a) => a.os === queryOs && a.arch === queryArch, + ); + if (match) { + artifact = match; + } else { + // Look for universal fallback + const universal = packageVersion.artifacts.find( + (a) => a.os === 'any' && a.arch === 'any', ); - if (match) { - artifact = match; + if (universal) { + artifact = universal; } else { - // Look for universal fallback - const universal = packageVersion.artifacts.find( - (a) => a.os === 'any' && a.arch === 'any', - ); - if (universal) { - artifact = universal; - } + artifact = undefined; } } + } - if (!artifact) { - throw new NotFoundError('No artifact found for this version'); - } - - // Log download - const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; - fastify.log.info( - { - op: 'download', - pkg: name, - version, - platform, - }, - `download: ${name}@${version} (${platform})`, - ); - - // Increment download counts atomically in a single transaction - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, version, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to update download counts')); + if (!artifact) { + throw new NotFoundError('No artifact found for this version'); + } - // Check if client wants JSON response (CLI/API) or redirect (browser) - const acceptHeader = request.headers.accept ?? ''; - const wantsJson = acceptHeader.includes('application/json'); + // Log download + const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; + fastify.log.info( + { + op: 'download', + pkg: name, + version, + platform, + }, + `download: ${name}@${version} (${platform})`, + ); - // Generate signed download URL using the actual storage path - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath( - artifact.storagePath, + // Increment download counts atomically in a single transaction + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, version, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to update download counts')); + + // Check if client wants JSON response (CLI/API) or redirect (browser) + const acceptHeader = request.headers.accept ?? ''; + const wantsJson = acceptHeader.includes('application/json'); + + // Generate signed download URL using the actual storage path + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); + + if (wantsJson) { + // CLI/API mode: Return JSON with download URL and metadata + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), ); - if (wantsJson) { - // CLI/API mode: Return JSON with download URL and metadata - const expiresAt = new Date(); - expiresAt.setSeconds( - expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), - ); - - return { - url: downloadUrl, - bundle: { - name, - version, - platform: { os: artifact.os, arch: artifact.arch }, - sha256: artifact.digest.replace('sha256:', ''), - size: Number(artifact.sizeBytes), - }, - expires_at: expiresAt.toISOString(), - }; + return { + url: downloadUrl, + bundle: { + name, + version, + platform: { os: artifact.os, arch: artifact.arch }, + sha256: artifact.digest.replace('sha256:', ''), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; + } else { + // Browser mode: Redirect to download URL + if (downloadUrl.startsWith('/')) { + // Local storage - serve file directly + const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); + + return reply + .header('Content-Type', 'application/octet-stream') + .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) + .send(fileBuffer); } else { - // Browser mode: Redirect to download URL - if (downloadUrl.startsWith('/')) { - // Local storage - serve file directly - const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); - - return reply - .header('Content-Type', 'application/octet-stream') - .header( - 'Content-Disposition', - `attachment; filename="${packageName}-${version}.mcpb"`, - ) - .send(fileBuffer); - } else { - // S3/CloudFront - redirect to signed URL - return reply.code(302).redirect(downloadUrl); - } + // S3/CloudFront - redirect to signed URL + return reply.code(302).redirect(downloadUrl); } - }, + } }, - ); + }); // POST /v1/bundles/announce - Announce a single artifact (OIDC only, idempotent per-artifact) fastify.post('/announce', { diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index d107e77..001d717 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -387,6 +387,20 @@ describe('Bundle Routes', () => { expect(res.statusCode).toBe(404); }); + + it('returns 404 when platform params do not match any artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + // mockArtifact is linux/x64 — requesting darwin/arm64 should 404 + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?os=darwin&arch=arm64', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(404); + }); }); // =========================================================================