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.`); } // --------------------------------------------------------------------------- diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 30aa5b3..27caeac 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,31 +1,45 @@ -import type { FastifyPluginAsync } from 'fastify'; +import type { Artifact } from '@prisma/client'; import { createHash, randomUUID } from 'crypto'; -import { createWriteStream, createReadStream, promises as fs } from 'fs'; +import type { FastifyPluginAsync } from 'fastify'; +import { createReadStream, createWriteStream, 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, + 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 { verifyGitHubOIDC, buildProvenance, type ProvenanceRecord } from '../../lib/oidc.js'; import { - BundleSearchResponseSchema, + type BundleVersionPathParams, + BundleVersionPathParamsSchema, +} from '../../schemas/bundles.js'; +import { + AnnounceRequestSchema, + AnnounceResponseSchema, BundleDetailSchema, - VersionsResponseSchema, - VersionDetailSchema, + type BundleSearchResponse, + BundleSearchResponseSchema, DownloadInfoSchema, MCPBIndexSchema, - AnnounceRequestSchema, - AnnounceResponseSchema, + type PackageTool, + VersionDetailSchema, + VersionsResponseSchema, } from '../../schemas/generated/api-responses.js'; +import { + type BundleDownloadParams, + BundleDownloadParamsSchema, + 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'; -import { triggerSecurityScan } from '../../services/scanner.js'; // GitHub release asset type interface GitHubReleaseAsset { @@ -72,7 +86,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, @@ -133,80 +147,57 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { const { packages: packageRepo } = fastify.repositories; // GET /v1/bundles/search - Search bundles - fastify.get('/search', { + fastify.get<{ Querystring: BundleSearchParams }>('/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(BundleSearchParamsSchema), 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 = {}; - 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 filters: PackageSearchFilters = { + ...(q && { query: q }), + ...(type && { serverType: type }), + }; - // Clamp pagination values to safe ranges - const safeLimit = Math.max(1, Math.min(limit, 100)); - const safeOffset = Math.max(0, offset); + 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, + const { packages, total } = await packageRepo.search(filters, { + skip: offset, + take: limit, + orderBy, + }); + + fastify.log.info( { - skip: safeOffset, - take: safeLimit, - orderBy, - } + op: 'search', + query: q ?? null, + type: type ?? null, + sort, + results: total, + ms: Date.now() - startTime, + }, + `search: q="${q ?? '*'}" returned ${total} results`, ); - 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 latestVersion = await packageRepo.findVersionWithLatestScan( + pkg.id, + pkg.latestVersion, + ); const manifest = (latestVersion?.manifest ?? {}) as Record; const scan = latestVersion?.securityScans[0]; @@ -218,17 +209,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: { @@ -237,6 +228,8 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { has_more: offset + bundles.length < total, }, }; + + return response; }, }); @@ -258,7 +251,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; const name = `@${scope}/${packageName}`; const pkg = await packageRepo.findByName(name); @@ -276,15 +272,23 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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 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; + 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, @@ -316,7 +320,8 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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.', + description: + 'Get an SVG badge for a bundle. Shows version for uncertified packages, or certification level for certified ones.', params: { type: 'object', properties: { @@ -333,7 +338,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; const name = `@${scope}/${packageName}`; const pkg = await packageRepo.findByName(name); @@ -390,7 +398,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; const name = `@${scope}/${packageName}`; const pkg = await packageRepo.findByName(name); @@ -417,7 +428,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { platform: { os: artifact.os, arch: artifact.arch }, urls: [url, artifact.sourceUrl].filter(Boolean), }; - }) + }), ); // Build conformant MCPB index.json @@ -429,10 +440,19 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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 }), + ...(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, + }), }, }; @@ -459,7 +479,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; const name = `@${scope}/${packageName}`; const pkg = await packageRepo.findByName(name); @@ -506,7 +529,11 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { scope, package: packageName, version } = request.params as { + const { + scope, + package: packageName, + version, + } = request.params as { scope: string; package: string; version: string; @@ -537,7 +564,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { download_url: downloadUrl, source_url: a.sourceUrl || undefined, }; - }) + }), ); return { @@ -547,10 +574,12 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { downloads: Number(packageVersion.downloadCount), artifacts, manifest: packageVersion.manifest, - release: packageVersion.releaseUrl ? { - tag: packageVersion.releaseTag, - url: packageVersion.releaseUrl, - } : undefined, + release: packageVersion.releaseUrl + ? { + tag: packageVersion.releaseTag, + url: packageVersion.releaseUrl, + } + : undefined, publish_method: packageVersion.publishMethod, provenance: getProvenanceFull(packageVersion), }; @@ -558,38 +587,23 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }); // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle - fastify.get('/@:scope/:package/versions/:version/download', { + fastify.get<{ + Params: BundleVersionPathParams; + Querystring: BundleDownloadParams; + }>('/@: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)' }, - }, - }, + params: toJsonSchema(BundleVersionPathParamsSchema), + querystring: toJsonSchema(BundleDownloadParamsSchema), 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 { scope, package: packageName, version: versionParam } = request.params; + const { os: queryOs, arch: queryArch } = request.query; const name = `@${scope}/${packageName}`; const pkg = await packageRepo.findByName(name); @@ -608,22 +622,24 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { } // Find the appropriate artifact - let artifact = packageVersion.artifacts[0]; // Default to first + 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 + (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' + (a) => a.os === 'any' && a.arch === 'any', ); if (universal) { artifact = universal; + } else { + artifact = undefined; } } } @@ -634,21 +650,22 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { // 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})`); + 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') - ); + }).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 ?? ''; @@ -660,7 +677,9 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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)); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); return { url: downloadUrl, @@ -695,7 +714,8 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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.', + 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), @@ -706,7 +726,9 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { // 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.'); + throw new UnauthorizedError( + 'Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.', + ); } const token = authHeader.substring(7); @@ -718,7 +740,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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`); + fastify.log.warn( + { op: 'announce', error: message }, + `announce: OIDC verification failed`, + ); throw new UnauthorizedError(`Invalid OIDC token: ${message}`); } @@ -750,26 +775,26 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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(', ')}` + `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(', ')}` + `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}` + `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.` + `Invalid package name: "${name}". Must be scoped (@scope/name) with lowercase alphanumeric characters and hyphens.`, ); } @@ -783,35 +808,43 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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}`); + 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.` + `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}`); + 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)'); + throw new BadRequestError( + 'Manifest must contain server type (server.type or server_type)', + ); } // Build provenance record @@ -823,17 +856,19 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { const releaseResponse = await fetch(releaseApiUrl, { headers: { - 'Accept': 'application/vnd.github+json', + 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}`); + throw new BadRequestError( + `Failed to fetch release ${release_tag}: ${releaseResponse.statusText}`, + ); } - const release = await releaseResponse.json() as { + const release = (await releaseResponse.json()) as { tag_name: string; html_url: string; assets: GitHubReleaseAsset[]; @@ -841,27 +876,36 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { // 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'); + 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; + 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'); + 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); + 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}`); + throw new BadRequestError( + `Artifact "${artifactInfo.filename}" not found in release ${release_tag}`, + ); } // Download artifact to temp file while computing hash (memory-efficient streaming) @@ -874,7 +918,9 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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}`); + throw new BadRequestError( + `Failed to download ${asset.name}: ${assetResponse.statusText}`, + ); } // Stream to temp file while computing hash @@ -905,7 +951,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { // Verify size if (bytesWritten !== artifactInfo.size) { throw new BadRequestError( - `Size mismatch for ${asset.name}: declared ${artifactInfo.size} bytes, got ${bytesWritten} bytes` + `Size mismatch for ${asset.name}: declared ${artifactInfo.size} bytes, got ${bytesWritten} bytes`, ); } @@ -913,7 +959,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { computedSha256 = hash.digest('hex'); if (computedSha256 !== artifactInfo.sha256) { throw new BadRequestError( - `SHA256 mismatch for ${asset.name}: declared ${artifactInfo.sha256}, computed ${computedSha256}` + `SHA256 mismatch for ${asset.name}: declared ${artifactInfo.sha256}, computed ${computedSha256}`, ); } @@ -926,11 +972,13 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { uploadStream, computedSha256, bytesWritten, - platformStr || undefined + platformStr || undefined, ); storagePath = result.path; - fastify.log.info(`Stored ${asset.name} -> ${storagePath} (${artifactInfo.os}-${artifactInfo.arch})`); + fastify.log.info( + `Stored ${asset.name} -> ${storagePath} (${artifactInfo.os}-${artifactInfo.arch})`, + ); } finally { // Always clean up temp file await fs.unlink(tempPath).catch(() => {}); @@ -946,21 +994,31 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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 { 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 @@ -977,41 +1035,51 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { const readmeResponse = await fetch(readmeUrl, { headers: { - 'Accept': 'application/vnd.github+json', + 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 }; + 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'); + fastify.log.warn( + { err: readmeError }, + 'Failed to fetch README.md, continuing without it', + ); } } // Upsert version - const { version: packageVersion, created } = await packageRepo.upsertVersion(packageId, { + const { version: packageVersion, created } = await packageRepo.upsertVersion( 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); + { + 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; @@ -1021,7 +1089,11 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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); + const currentLatest = await packageRepo.findVersion( + packageId, + existingPackage.latestVersion, + tx, + ); if (currentLatest?.prerelease) { await packageRepo.updateLatestVersion(packageId, version, tx); } @@ -1029,15 +1101,18 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { } // 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); + 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; @@ -1055,7 +1130,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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'); + fastify.log.error( + { err: cleanupError, path: storagePath }, + 'Failed to cleanup uploaded file', + ); } throw error; } @@ -1066,24 +1144,35 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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.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)`); + 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 }); + notifyDiscordAnnounce({ + name, + version, + type: 'bundle', + repo: claims.repository, + }); // Non-blocking security scan trigger if (config.scanner.enabled && versionId) { @@ -1107,7 +1196,13 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { status, }; } catch (error) { - fastify.log.error({ op: 'announce', error: error instanceof Error ? error.message : 'unknown' }, `announce: failed`); + 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/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 f162d13..e5a3f29 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,28 @@ export const PackageSearchParamsSchema = z.object({ offset: z.union([z.string(), z.number()]).optional(), }); -// Export TypeScript types +/** 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(), + 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 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 a2b02c9..001d717 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', { @@ -143,28 +145,85 @@ describe('Bundle Routes', () => { expect(body.total).toBe(0); }); - it('clamps pagination limits to safe ranges', async () => { + 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(422); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('supports sort parameter', 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' }); + await app.inject({ method: 'GET', url: '/search?q=x&sort=name' }); expect(packageRepo.search).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ take: 1, skip: 0 }), + expect.objectContaining({ orderBy: { name: 'asc' } }), ); }); - it('supports sort parameter', async () => { + it('applies defaults when no params provided', async () => { packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); - await app.inject({ method: 'GET', url: '/search?q=x&sort=name' }); + 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), - expect.objectContaining({ orderBy: { name: 'asc' } }), ); }); + + 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(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(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(422); + 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 }); + }); }); // ========================================================================= @@ -306,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); @@ -317,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); + }); }); // ========================================================================= diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index c03f3c0..e5a3f29 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,21 @@ 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(), + 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 +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;