diff --git a/apps/registry/package.json b/apps/registry/package.json index fb36acf..43b57ca 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -46,6 +46,7 @@ "jose": "^6.1.3", "pg": "^8.13.1", "prisma": "^7.2.0", + "@nimblebrain/mpak-schemas": "workspace:*", "semver": "^7.6.3", "zod": "^4.3.4" }, diff --git a/apps/registry/prisma/seed.ts b/apps/registry/prisma/seed.ts index fe69af5..cedcfac 100644 --- a/apps/registry/prisma/seed.ts +++ b/apps/registry/prisma/seed.ts @@ -17,6 +17,8 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import pg from 'pg'; import { createHash, randomUUID } from 'crypto'; +import { mkdir, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; // --------------------------------------------------------------------------- // Database setup (standalone, doesn't use the app's singleton) @@ -122,7 +124,10 @@ const SKILLS: SeedSkill[] = [ surfaces: ['claude-code'], author: { name: 'NimbleBrain', url: 'https://nimblebrain.ai' }, examples: [ - { prompt: 'Audit the docs in the docs/ folder against the current codebase', context: 'After a major refactor' }, + { + prompt: 'Audit the docs in the docs/ folder against the current codebase', + context: 'After a major refactor', + }, { prompt: 'Find all stale documentation that references removed APIs' }, ], }, @@ -182,8 +187,7 @@ The audit produces a structured report with: downloads: 45, frontmatter: { name: 'seo-optimizer', - description: - 'Analyzes and optimizes content for search engine visibility.', + description: 'Analyzes and optimizes content for search engine visibility.', metadata: { version: '1.0.0', category: 'writing', @@ -207,8 +211,7 @@ The audit produces a structured report with: downloads: 213, frontmatter: { name: 'seo-optimizer', - description: - 'Analyzes and optimizes content for search engine visibility.', + description: 'Analyzes and optimizes content for search engine visibility.', metadata: { version: '1.0.7', category: 'writing', @@ -225,7 +228,10 @@ The audit produces a structured report with: surfaces: ['claude-code', 'claude-ai'], author: { name: 'NimbleBrain', url: 'https://nimblebrain.ai' }, examples: [ - { prompt: 'Optimize this blog post for SEO', context: 'Before publishing a new article' }, + { + prompt: 'Optimize this blog post for SEO', + context: 'Before publishing a new article', + }, { prompt: 'Check the meta descriptions on our landing pages' }, ], }, @@ -328,7 +334,10 @@ The optimizer follows current SEO best practices: surfaces: ['claude-code', 'claude-ai'], author: { name: 'NimbleBrain', url: 'https://nimblebrain.ai' }, examples: [ - { prompt: 'Help me think through whether to pivot from B2C to B2B', context: 'Early-stage startup with declining consumer metrics' }, + { + prompt: 'Help me think through whether to pivot from B2C to B2B', + context: 'Early-stage startup with declining consumer metrics', + }, { prompt: 'Evaluate the tradeoffs of building vs buying our auth system' }, ], }, @@ -366,6 +375,292 @@ The skill follows a structured thinking process: }, ]; +// --------------------------------------------------------------------------- +// Seed data: Packages (bundles) +// --------------------------------------------------------------------------- + +interface SeedArtifact { + os: string; + arch: string; + sizeBytes: number; + sourceUrl: string; +} + +interface SeedPackageVersion { + version: string; + prerelease?: boolean; + downloads: number; + manifest: object; + publishedAt: string; + publishMethod: string; + provenanceRepository: string; + provenanceSha: string; + releaseTag?: string; + releaseUrl?: string; + artifacts?: SeedArtifact[]; +} + +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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.0', 14_200), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.1-beta.1', 14_350), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.1', 14_500), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.2', 14_600), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.3', 14_700), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.4-rc.1', 15_000), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.4-rc.4', 15_100), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.4', 15_200), + }, + { + 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'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.5', 15_400), + }, + ], + }, + { + 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'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.1', 82_000), + }, + { + 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'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.2', 83_000), + }, + { + 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'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.3', 83_500), + }, + { + 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'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.4', 84_000), + }, + { + 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'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.5', 84_500), + }, + { + 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'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.2.0', 86_000), + }, + ], + }, +]; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -375,6 +670,26 @@ function fakeDigest(input: string): string { return 'sha256:' + createHash('sha256').update(input).digest('hex'); } +/** Universal artifact for python/any-platform bundles */ +function universalArtifact(repo: string, version: string, sizeBytes: number): SeedArtifact[] { + return [{ + os: 'any', + arch: 'any', + sizeBytes, + sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${repo.split('/')[1]}-${version}.mcpb`, + }]; +} + +/** Multi-platform artifacts for node bundles */ +function multiPlatformArtifacts(repo: string, version: string, sizeBytes: number): SeedArtifact[] { + const name = repo.split('/')[1]; + return [ + { os: 'darwin', arch: 'arm64', sizeBytes, sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${name}-${version}-darwin-arm64.mcpb` }, + { os: 'darwin', arch: 'x64', sizeBytes: sizeBytes + 1024, sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${name}-${version}-darwin-x64.mcpb` }, + { os: 'linux', arch: 'x64', sizeBytes: sizeBytes + 2048, sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${name}-${version}-linux-x64.mcpb` }, + ]; +} + /** Generate a deterministic fake storage path */ function storagePath(scope: string, name: string, version: string): string { return `skills/${scope}/${name}/${version}/skill.bundle`; @@ -451,7 +766,10 @@ async function seed() { downloadCount: BigInt(v.downloads), publishMethod: 'oidc', provenanceRepository: s.githubRepo, - provenanceSha: createHash('sha256').update(`${s.name}@${v.version}-commit`).digest('hex').slice(0, 40), + provenanceSha: createHash('sha256') + .update(`${s.name}@${v.version}-commit`) + .digest('hex') + .slice(0, 40), releaseTag: `${skillName}/v${v.version}`, releaseUrl: `https://github.com/${s.githubRepo}/releases/tag/${skillName}%2Fv${v.version}`, }, @@ -466,7 +784,99 @@ 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) { + const pkgVersion = 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), + }, + }); + + // Upsert artifacts for this version + for (const a of v.artifacts ?? []) { + const digest = fakeDigest(`${p.name}@${v.version}-${a.os}-${a.arch}`); + const artifactPath = `${p.name}/${v.version}/${a.os}-${a.arch}.mcpb`; + + await prisma.artifact.upsert({ + where: { + versionId_os_arch: { versionId: pkgVersion.id, os: a.os, arch: a.arch }, + }, + create: { + versionId: pkgVersion.id, + os: a.os, + arch: a.arch, + digest, + sizeBytes: BigInt(a.sizeBytes), + storagePath: artifactPath, + sourceUrl: a.sourceUrl, + }, + update: { + digest, + sizeBytes: BigInt(a.sizeBytes), + sourceUrl: a.sourceUrl, + }, + }); + + // Create placeholder file on disk so local storage can serve it + const storagePath = process.env['STORAGE_PATH'] || './packages'; + const fullPath = join(storagePath, artifactPath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, `placeholder:${p.name}@${v.version}:${a.os}-${a.arch}`); + } + + const artifactCount = v.artifacts?.length ?? 0; + console.log(` v${v.version} (${v.downloads} downloads, ${artifactCount} artifacts)`); + } + } + + console.log(`\nSeeded ${PACKAGES.length} packages successfully.`); } // --------------------------------------------------------------------------- diff --git a/apps/registry/src/routes/auth.ts b/apps/registry/src/routes/auth.ts index 8242f13..15306d9 100644 --- a/apps/registry/src/routes/auth.ts +++ b/apps/registry/src/routes/auth.ts @@ -1,6 +1,6 @@ import type { FastifyPluginAsync } from 'fastify'; import { toJsonSchema } from '../lib/zod-schema.js'; -import { UserProfileSchema, type UserProfile } from '../schemas/generated/auth.js'; +import { UserProfileSchema, type UserProfile } from '@nimblebrain/mpak-schemas'; export const authRoutes: FastifyPluginAsync = async (fastify) => { // GET /app/auth/me - Get current authenticated user diff --git a/apps/registry/src/routes/packages.ts b/apps/registry/src/routes/packages.ts index 0d01b90..12c2a32 100644 --- a/apps/registry/src/routes/packages.ts +++ b/apps/registry/src/routes/packages.ts @@ -11,7 +11,7 @@ import { handleError, } from '../errors/index.js'; import { toJsonSchema } from '../lib/zod-schema.js'; -import type { PackageSearchParams } from '../schemas/generated/package.js'; +import type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; import { PublishResponseSchema, PackageSearchResponseSchema, @@ -21,7 +21,7 @@ import { ClaimResponseSchema, MyPackagesResponseSchema, UnclaimedPackagesResponseSchema, -} from '../schemas/generated/api-responses.js'; +} from '@nimblebrain/mpak-schemas'; import { generateMpakJsonExample } from '../schemas/mpak-schema.js'; import { extractScannerVersion } from '../utils/scanner-version.js'; import { fetchGitHubRepoStats, parseGitHubRepo, verifyPackageClaim } from '../services/github-verifier.js'; diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 30aa5b3..3f5ae9f 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 type { FastifyPluginAsync } from 'fastify'; import { createHash, randomUUID } from 'crypto'; import { createWriteStream, createReadStream, promises as fs } from 'fs'; @@ -22,7 +23,18 @@ import { MCPBIndexSchema, AnnounceRequestSchema, AnnounceResponseSchema, -} from '../../schemas/generated/api-responses.js'; + BundleSearchParamsSchema, + BundleDownloadParamsSchema, + type BundleSearchParams, + type BundleDownloadParams, + type BundleSearchResponse, + type PackageTool, +} from '@nimblebrain/mpak-schemas'; +import type { PackageSearchFilters } from '../../db/types.js'; +import { + BundleVersionPathParamsSchema, + type BundleVersionPathParams, +} from '../../schemas/bundles.js'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; @@ -53,6 +65,30 @@ function isValidScopedPackageName(name: string): boolean { return SCOPED_REGEX.test(name); } +/** + * Resolve the correct artifact given optional platform query params. + * + * - Neither os nor arch → return the any/any (universal) artifact, or null + * - Only one of os/arch → throws BadRequestError + * - Both os and arch → return exact match, or null + */ +function resolveArtifact( + artifacts: Artifact[], + os?: string, + arch?: string, +): Artifact | null { + if ((os && !arch) || (!os && arch)) { + throw new BadRequestError('Both os and arch are required when specifying platform'); + } + + if (os && arch) { + return artifacts.find((a) => a.os === os && a.arch === arch) ?? null; + } + + // No platform params: return universal artifact only + return artifacts.find((a) => a.os === 'any' && a.arch === 'any') ?? null; +} + function parsePackageName(name: string): { scope: string; packageName: string } | null { if (!name.startsWith('@')) return null; const parts = name.split('/'); @@ -72,7 +108,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,63 +169,38 @@ 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; + const filters: PackageSearchFilters = {}; + 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' }; - } - - // 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, { - skip: safeOffset, - take: safeLimit, + skip: offset, + take: limit, orderBy, } ); @@ -218,17 +229,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: Boolean(pkg.verified), provenance: latestVersion ? getProvenanceSummary(latestVersion) : null, certification_level: scan?.certificationLevel ?? null, }; }) ); - return { + const response: BundleSearchResponse = { bundles, total, pagination: { @@ -237,6 +248,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { has_more: offset + bundles.length < total, }, }; + return response; }, }); @@ -558,38 +570,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,28 +605,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { } // 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; - } - } - } + const artifact = resolveArtifact(packageVersion.artifacts, queryOs, queryArch); if (!artifact) { - throw new NotFoundError('No artifact found for this version'); + throw new NotFoundError('No artifact found for the requested platform'); } // Log download diff --git a/apps/registry/src/routes/v1/skills.ts b/apps/registry/src/routes/v1/skills.ts index d6aec1e..1e2f27b 100644 --- a/apps/registry/src/routes/v1/skills.ts +++ b/apps/registry/src/routes/v1/skills.ts @@ -19,7 +19,7 @@ import { SkillDownloadInfoSchema, SkillAnnounceRequestSchema, SkillAnnounceResponseSchema, -} from '../../schemas/generated/skill.js'; +} from '@nimblebrain/mpak-schemas'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { extractSkillContent } from '../../utils/skill-content.js'; 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/api-responses.ts b/apps/registry/src/schemas/generated/api-responses.ts deleted file mode 100644 index 748c3e0..0000000 --- a/apps/registry/src/schemas/generated/api-responses.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { z } from 'zod'; - -export const PackageAuthorSchema = z.object({ - name: z.string(), -}); - -export const PackageToolSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}); - -export const PackageGitHubSchema = z.object({ - repo: z.string(), - stars: z.number().nullable(), - forks: z.number().nullable(), - watchers: z.number().nullable(), - updated_at: z.string().nullable().optional(), -}); - -export const PackageSchema = z.object({ - name: z.string(), - display_name: z.string().nullable(), - description: z.string().nullable(), - author: PackageAuthorSchema.nullable(), - latest_version: z.string(), - icon: z.string().nullable(), - server_type: z.string(), - tools: z.array(PackageToolSchema), - downloads: z.number(), - published_at: z.union([z.string(), z.date()]), - verified: z.boolean(), - claimable: z.boolean().optional(), - claimed: z.boolean().optional(), - github: PackageGitHubSchema.nullable().optional(), - certification_level: z.number().nullable().optional(), -}); - -export const ArtifactSchema = z.object({ - os: z.string(), - arch: z.string(), - size_bytes: z.number(), - digest: z.string(), - downloads: z.number(), -}); - -export const ProvenanceSchema = z.object({ - publish_method: z.string().nullable(), - repository: z.string().nullable(), - sha: z.string().nullable(), -}); - -export const CertificationSchema = z.object({ - level: z.number().nullable(), - level_name: z.string().nullable(), - controls_passed: z.number().nullable(), - controls_failed: z.number().nullable(), - controls_total: z.number().nullable(), -}); - -export const SecurityScanSchema = z.object({ - status: z.enum(['pending', 'scanning', 'completed', 'failed']), - risk_score: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).nullable(), - scanned_at: z.union([z.string(), z.date()]).nullable(), - scanner_version: z.string().nullable().optional(), - certification: CertificationSchema.nullable().optional(), - summary: z.object({ - components: z.number(), - vulnerabilities: z.object({ - critical: z.number(), - high: z.number(), - medium: z.number(), - low: z.number(), - }), - secrets: z.number(), - malicious: z.number(), - code_issues: z.number(), - }).optional(), -}); - -export const PackageVersionSchema = z.object({ - version: z.string(), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - artifacts: z.array(ArtifactSchema).optional(), - readme: z.string().nullable().optional(), - provenance: ProvenanceSchema.nullable().optional(), - release_url: z.string().nullable().optional(), - prerelease: z.boolean().optional(), - manifest: z.record(z.string(), z.unknown()).nullable().optional(), - security_scan: SecurityScanSchema.nullable().optional(), -}); - -export const PackageClaimingSchema = z.object({ - claimable: z.boolean(), - claimed: z.boolean(), - claimed_by: z.string().nullable(), - claimed_at: z.union([z.string(), z.date()]).nullable(), - github_repo: z.string().nullable(), -}); - -export const PackageDetailSchema = PackageSchema.extend({ - homepage: z.string().nullable(), - license: z.string().nullable(), - claiming: PackageClaimingSchema, - versions: z.array(PackageVersionSchema), -}); - -export const PackageSearchResponseSchema = z.object({ - packages: z.array(PackageSchema), - total: z.number(), -}); - -// V1 Bundle API Schemas - -export const PlatformInfoSchema = z.object({ - os: z.string(), - arch: z.string(), -}); - -export const FullProvenanceSchema = z.object({ - schema_version: z.string(), - provider: z.string(), - repository: z.string(), - sha: z.string(), -}); - -export const BundleSchema = z.object({ - name: z.string(), - display_name: z.string().nullable().optional(), - description: z.string().nullable().optional(), - author: PackageAuthorSchema.nullable().optional(), - latest_version: z.string(), - icon: z.string().nullable().optional(), - server_type: z.string().nullable().optional(), - tools: z.array(PackageToolSchema).optional(), - downloads: z.number(), - published_at: z.union([z.string(), z.date()]), - verified: z.boolean(), - provenance: FullProvenanceSchema.nullable().optional(), - certification_level: z.number().nullable().optional(), -}); - -export const BundleDetailSchema = BundleSchema.extend({ - homepage: z.string().nullable().optional(), - license: z.string().nullable().optional(), - certification: CertificationSchema.nullable().optional(), - versions: z.array(z.object({ - version: z.string(), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - })), -}); - -export const BundleSearchResponseSchema = z.object({ - bundles: z.array(BundleSchema), - total: z.number(), - pagination: z.object({ - limit: z.number(), - offset: z.number(), - has_more: z.boolean(), - }), -}); - -export const VersionInfoSchema = z.object({ - version: z.string(), - artifacts_count: z.number(), - platforms: z.array(PlatformInfoSchema), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - publish_method: z.string().nullable(), - provenance: FullProvenanceSchema.nullable().optional(), -}); - -export const VersionsResponseSchema = z.object({ - name: z.string(), - latest: z.string(), - versions: z.array(VersionInfoSchema), -}); - -export const DownloadInfoSchema = z.object({ - url: z.string(), - bundle: z.object({ - name: z.string(), - version: z.string(), - platform: PlatformInfoSchema, - sha256: z.string(), - size: z.number(), - }), - expires_at: z.string().optional(), -}); - -// Internal API Schemas - -export const PaginationSchema = z.object({ - limit: z.number(), - offset: z.number(), - has_more: z.boolean(), -}); - -export const PublishResponseSchema = z.object({ - success: z.boolean(), - package: z.object({ - name: z.string(), - version: z.string(), - manifest: z.record(z.string(), z.unknown()), - }), - sha256: z.string(), - size: z.number(), - url: z.string(), - auto_claimed: z.boolean().optional(), - message: z.string().optional(), -}); - -export const InternalDownloadResponseSchema = z.object({ - url: z.string(), - package: z.object({ - name: z.string(), - version: z.string(), - sha256: z.string(), - size: z.number(), - }), - expires_at: z.string(), -}); - -export const ClaimStatusResponseSchema = z.object({ - claimable: z.boolean(), - reason: z.string().optional(), - claimed_by: z.string().nullable().optional(), - claimed_at: z.union([z.string(), z.date()]).nullable().optional(), - package_name: z.string().optional(), - github_repo: z.string().nullable().optional(), - instructions: z.object({ - steps: z.array(z.string()), - mpak_json_example: z.string(), - verification_url: z.string().nullable(), - }).optional(), -}); - -export const ClaimResponseSchema = z.object({ - success: z.boolean(), - message: z.string(), - package: z.object({ - name: z.string(), - claimed_by: z.string().nullable(), - claimed_at: z.union([z.string(), z.date()]).nullable(), - github_repo: z.string().nullable(), - }), - verification: z.object({ - mpak_json_url: z.string().nullable().optional(), - verified_at: z.string(), - }), -}); - -export const MyPackagesResponseSchema = z.object({ - packages: z.array(PackageSchema), - total: z.number(), - pagination: PaginationSchema, -}); - -export const UnclaimedPackageSchema = z.object({ - name: z.string(), - display_name: z.string().nullable(), - description: z.string().nullable(), - server_type: z.string().nullable(), - latest_version: z.string(), - downloads: z.number(), - github_repo: z.string().nullable(), - created_at: z.union([z.string(), z.date()]), -}); - -export const UnclaimedPackagesResponseSchema = z.object({ - packages: z.array(UnclaimedPackageSchema), - total: z.number(), - pagination: PaginationSchema, -}); - -// V1 API Additional Schemas - -export const VersionDetailSchema = z.object({ - name: z.string(), - version: z.string(), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - artifacts: z.array(z.object({ - platform: PlatformInfoSchema, - digest: z.string(), - size: z.number(), - download_url: z.string(), - source_url: z.string().optional(), - })), - manifest: z.record(z.string(), z.unknown()), - release: z.object({ - tag: z.string().nullable(), - url: z.string().nullable(), - }).optional(), - publish_method: z.string().nullable(), - provenance: FullProvenanceSchema.nullable(), -}); - -export const MCPBIndexSchema = z.object({ - index_version: z.string(), - mimeType: z.string(), - name: z.string(), - version: z.string(), - description: z.string().nullable(), - bundles: z.array(z.object({ - mimeType: z.string().nullable(), - digest: z.string(), - size: z.number(), - platform: PlatformInfoSchema, - urls: z.array(z.string()), - })), - annotations: z.record(z.string(), z.string()).optional(), -}); - -export const AnnounceRequestSchema = z.object({ - name: z.string(), - version: z.string(), - manifest: z.record(z.string(), z.unknown()), - release_tag: z.string(), - prerelease: z.boolean().optional().default(false), - artifact: z.object({ - filename: z.string(), - os: z.string(), - arch: z.string(), - sha256: z.string(), - size: z.number(), - }), -}); - -export const AnnounceResponseSchema = z.object({ - package: z.string(), - version: z.string(), - artifact: z.object({ - os: z.string(), - arch: z.string(), - filename: z.string(), - }), - total_artifacts: z.number(), - status: z.enum(['created', 'updated']), -}); - -// TypeScript types -export type PackageAuthor = z.infer; -export type PackageTool = z.infer; -export type PackageGitHub = z.infer; -export type Package = z.infer; -export type Artifact = z.infer; -export type Provenance = z.infer; -export type Certification = z.infer; -export type SecurityScan = z.infer; -export type PackageVersion = z.infer; -export type PackageClaiming = z.infer; -export type PackageDetail = z.infer; -export type PackageSearchResponse = z.infer; -export type PlatformInfo = z.infer; -export type FullProvenance = z.infer; -export type Bundle = z.infer; -export type BundleDetail = z.infer; -export type BundleSearchResponse = z.infer; -export type VersionInfo = z.infer; -export type VersionsResponse = z.infer; -export type DownloadInfo = z.infer; -export type VersionDetail = z.infer; -export type MCPBIndex = z.infer; -export type AnnounceRequest = z.infer; -export type AnnounceResponse = z.infer; -export type Pagination = z.infer; -export type PublishResponse = z.infer; -export type InternalDownloadResponse = z.infer; -export type ClaimStatusResponse = z.infer; -export type ClaimResponse = z.infer; -export type MyPackagesResponse = z.infer; -export type UnclaimedPackage = z.infer; -export type UnclaimedPackagesResponse = z.infer; diff --git a/apps/registry/src/schemas/generated/auth.ts b/apps/registry/src/schemas/generated/auth.ts deleted file mode 100644 index e922e3a..0000000 --- a/apps/registry/src/schemas/generated/auth.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; - -// User profile response from /app/auth/me -export const UserProfileSchema = z.object({ - id: z.string(), - email: z.string().email(), - emailVerified: z.boolean(), - username: z.string().nullable(), - name: z.string().nullable(), - avatarUrl: z.string().nullable(), - githubUsername: z.string().nullable(), - githubLinked: z.boolean(), - verified: z.boolean(), - publishedBundles: z.number(), - totalDownloads: z.number(), - role: z.string().nullable(), // admin, etc. from Clerk publicMetadata - createdAt: z.union([z.string(), z.date()]).nullable(), - lastLoginAt: z.union([z.string(), z.date()]).nullable(), -}); - -// Export TypeScript type -export type UserProfile = z.infer; diff --git a/apps/registry/src/schemas/generated/package.ts b/apps/registry/src/schemas/generated/package.ts deleted file mode 100644 index f162d13..0000000 --- a/apps/registry/src/schemas/generated/package.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from 'zod'; - -// Server type enum -export const ServerTypeSchema = z.enum(['node', 'python', 'binary']); - -// Platform enum -export const PlatformSchema = z.enum(['darwin', 'win32', 'linux']); - -// Sort options -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 -export const PackageSearchParamsSchema = z.object({ - q: z.string().optional(), - type: ServerTypeSchema.optional(), - tool: z.string().optional(), - prompt: z.string().optional(), - platform: PlatformSchema.optional(), - sort: PackageSortSchema.optional(), - limit: z.union([z.string(), z.number()]).optional(), - offset: z.union([z.string(), z.number()]).optional(), -}); - -// Export TypeScript types -export type ServerType = z.infer; -export type Platform = z.infer; -export type PackageSort = z.infer; -export type PackageSearchParams = z.infer; diff --git a/apps/registry/src/schemas/generated/skill.ts b/apps/registry/src/schemas/generated/skill.ts deleted file mode 100644 index c437718..0000000 --- a/apps/registry/src/schemas/generated/skill.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { z } from 'zod'; - -// ============================================================================= -// Agent Skills Specification - Skill Frontmatter Schema -// https://agentskills.io/specification -// ============================================================================= - -/** - * Skill name validation - * - 1-64 characters - * - Lowercase alphanumeric with single hyphens - * - Cannot start or end with hyphen - * - Must match directory name (validated separately) - */ -export const SkillNameSchema = z - .string() - .min(1) - .max(64) - .regex( - /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, - 'Lowercase alphanumeric with single hyphens, cannot start/end with hyphen' - ); - -/** - * Skill description - * - 1-1024 characters - * - Should describe what the skill does AND when to use it - */ -export const SkillDescriptionSchema = z.string().min(1).max(1024); - -// ============================================================================= -// Discovery Metadata Extension (via metadata: field) -// ============================================================================= - -/** - * Category taxonomy for skill discovery - */ -export const SkillCategorySchema = z.enum([ - 'development', // Code, debugging, architecture - 'writing', // Documentation, content, editing - 'research', // Investigation, analysis, learning - 'consulting', // Strategy, decisions, planning - 'data', // Analysis, visualization, processing - 'design', // UI/UX, visual, creative - 'operations', // DevOps, infrastructure, automation - 'security', // Auditing, compliance, protection - 'other', // Uncategorized -]); - -/** - * Author information for attribution - */ -export const SkillAuthorSchema = z.object({ - name: z.string().min(1), - url: z.string().url().optional(), - email: z.string().email().optional(), -}); - -/** - * Example usage for discovery - */ -export const SkillExampleSchema = z.object({ - prompt: z.string().min(1), - context: z.string().optional(), -}); - -/** - * Discovery metadata (via metadata: field in frontmatter) - * All fields optional - skills can start minimal and add discovery metadata later - */ -export const SkillDiscoveryMetadataSchema = z - .object({ - // Discovery - tags: z.array(z.string().max(32)).max(10).optional(), - category: SkillCategorySchema.optional(), - triggers: z.array(z.string().max(128)).max(20).optional(), - keywords: z.array(z.string().max(32)).max(30).optional(), - - // Attribution - author: SkillAuthorSchema.optional(), - - // Version (for registry tracking) - version: z.string().optional(), - - // Examples - examples: z.array(SkillExampleSchema).max(5).optional(), - }) - .passthrough(); // Allow additional custom keys - -// ============================================================================= -// Complete SKILL.md Frontmatter Schema -// ============================================================================= - -/** - * Complete SKILL.md frontmatter schema - * Combines official Agent Skills spec with discovery metadata extension - */ -export const SkillFrontmatterSchema = z.object({ - // Required (official spec) - name: SkillNameSchema, - description: SkillDescriptionSchema, - - // Optional (official spec - informational pass-through) - version: z.string().optional(), - license: z.string().optional(), - compatibility: z.string().max(500).optional(), - 'allowed-tools': z.string().optional(), // space-delimited, experimental - - // Extensible metadata (official spec) - our discovery extension - metadata: SkillDiscoveryMetadataSchema.optional(), -}); - -// ============================================================================= -// Registry API Schemas -// ============================================================================= - -/** - * Scoped skill name for registry (e.g., @nimblebraininc/strategic-thought-partner) - */ -export const ScopedSkillNameSchema = z - .string() - .regex( - /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, - 'Scoped name format: @scope/name' - ); - -/** - * Skill artifact info for announce endpoint - */ -export const SkillArtifactSchema = z.object({ - filename: z.string().regex(/\.skill$/, 'Must have .skill extension'), - sha256: z.string().length(64), - size: z.number().int().positive(), -}); - -/** - * Announce request schema for POST /v1/skills/announce - */ -export const SkillAnnounceRequestSchema = z.object({ - name: ScopedSkillNameSchema, - version: z.string(), - skill: SkillFrontmatterSchema, - release_tag: z.string(), - prerelease: z.boolean().optional().default(false), - artifact: SkillArtifactSchema, -}); - -/** - * Announce response schema - */ -export const SkillAnnounceResponseSchema = z.object({ - skill: z.string(), - version: z.string(), - status: z.enum(['created', 'exists']), -}); - -// ============================================================================= -// Search/List API Schemas -// ============================================================================= - -/** - * Skill search parameters - */ -export const SkillSearchParamsSchema = z.object({ - q: z.string().optional(), - tags: z.string().optional(), // comma-separated - category: SkillCategorySchema.optional(), - sort: z.enum(['downloads', 'recent', 'name']).optional(), - limit: z.union([z.string(), z.number()]).optional(), - offset: z.union([z.string(), z.number()]).optional(), -}); - -/** - * Skill summary for search results - */ -export const SkillSummarySchema = z.object({ - name: z.string(), // scoped name - description: z.string(), - latest_version: z.string(), - tags: z.array(z.string()).optional(), - category: SkillCategorySchema.optional(), - downloads: z.number(), - published_at: z.string(), - author: SkillAuthorSchema.optional(), -}); - -/** - * Skill search response - */ -export const SkillSearchResponseSchema = z.object({ - skills: z.array(SkillSummarySchema), - total: z.number(), - pagination: z.object({ - limit: z.number(), - offset: z.number(), - has_more: z.boolean(), - }), -}); - -/** - * Skill detail response - */ -export const SkillDetailSchema = z.object({ - name: z.string(), // scoped name - description: z.string(), - latest_version: z.string(), - license: z.string().optional(), - compatibility: z.string().optional(), - allowed_tools: z.array(z.string()).optional(), - tags: z.array(z.string()).optional(), - category: SkillCategorySchema.optional(), - triggers: z.array(z.string()).optional(), - downloads: z.number(), - published_at: z.string(), - author: SkillAuthorSchema.optional(), - examples: z.array(SkillExampleSchema).optional(), - versions: z.array( - z.object({ - version: z.string(), - published_at: z.string(), - downloads: z.number(), - }) - ), -}); - -/** - * Skill download info response - */ -export const SkillDownloadInfoSchema = z.object({ - url: z.string(), - skill: z.object({ - name: z.string(), - version: z.string(), - sha256: z.string(), - size: z.number(), - }), - expires_at: z.string(), -}); - -// ============================================================================= -// TypeScript Types -// ============================================================================= - -export type SkillName = z.infer; -export type SkillCategory = z.infer; -export type SkillAuthor = z.infer; -export type SkillExample = z.infer; -export type SkillDiscoveryMetadata = z.infer; -export type SkillFrontmatter = z.infer; -export type ScopedSkillName = z.infer; -export type SkillArtifact = z.infer; -export type SkillAnnounceRequest = z.infer; -export type SkillAnnounceResponse = z.infer; -export type SkillSearchParams = z.infer; -export type SkillSummary = z.infer; -export type SkillSearchResponse = z.infer; -export type SkillDetail = z.infer; -export type SkillDownloadInfo = z.infer; diff --git a/apps/registry/src/types.ts b/apps/registry/src/types.ts index 81fabd8..be6aaba 100644 --- a/apps/registry/src/types.ts +++ b/apps/registry/src/types.ts @@ -87,14 +87,14 @@ export interface PackageVersion { published_at: Date; } -// API response types - now imported from schemas +// API response types - imported from shared schemas package export type { Package as PackageListItem, PackageDetail as PackageInfo, -} from './schemas/generated/api-responses.js'; +} from '@nimblebrain/mpak-schemas'; -// API query params - now imported from schemas -export type { PackageSearchParams } from './schemas/generated/package.js'; +// API query params - imported from shared schemas package +export type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; // MCP Registry types export interface MCPServerDetail { diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index a2b02c9..9a834c5 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -65,12 +65,14 @@ import { createMockPackageRepo, createMockStorage, createMockPrisma, + mockArtifact, mockPackage, mockVersion, mockVersionWithArtifacts, mockVersionWithScans, } from './helpers.js'; import { verifyGitHubOIDC } from '../src/lib/oidc.js'; +import { errorHandler } from '../src/errors/middleware.js'; // --------------------------------------------------------------------------- // Test setup @@ -90,6 +92,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 +146,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 }); + }); }); // ========================================================================= @@ -264,7 +324,7 @@ describe('Bundle Routes', () => { const res = await app.inject({ method: 'GET', - url: '/@test/mcp-server/versions/1.0.0/download', + url: '/@test/mcp-server/versions/1.0.0/download?os=linux&arch=x64', headers: { accept: 'application/json' }, }); @@ -282,7 +342,7 @@ describe('Bundle Routes', () => { const res = await app.inject({ method: 'GET', - url: '/@test/mcp-server/versions/latest/download', + url: '/@test/mcp-server/versions/latest/download?os=linux&arch=x64', headers: { accept: 'application/json' }, }); @@ -317,6 +377,109 @@ describe('Bundle Routes', () => { expect(res.statusCode).toBe(404); }); + + it('returns 400 when only os is provided without arch', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?os=linux', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when only arch is provided without os', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?arch=x64', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 422 when os and arch are invalid enum values', 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.findVersionWithArtifacts).not.toHaveBeenCalled(); + }); + + it('returns 200 when both os and arch match an artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?os=linux&arch=x64', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.bundle.platform).toEqual({ os: 'linux', arch: 'x64' }); + }); + + it('returns 404 when both os and arch provided but no matching artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + 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); + }); + + it('returns 200 with any/any artifact when no platform params provided', async () => { + const anyArtifact = { + ...mockArtifact, + id: 'art-any', + os: 'any', + arch: 'any', + storagePath: '@test/mcp-server/1.0.0/any-any.mcpb', + }; + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue({ + ...mockVersion, + artifacts: [anyArtifact], + }); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.bundle.platform).toEqual({ os: 'any', arch: 'any' }); + }); + + it('returns 404 when no platform params and no any/any artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download', + 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..7ebbc03 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -28,6 +28,21 @@ 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), +}); + +/** Bundle download query parameters (os + arch). */ +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(), +}); + // ============================================================================= // 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 BundleSearchParams = z.infer; +export type BundleDownloadParams = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40d5eb3..adec441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@kubernetes/client-node': specifier: ^1.4.0 version: 1.4.0 + '@nimblebrain/mpak-schemas': + specifier: workspace:* + version: link:../../packages/schemas '@prisma/adapter-pg': specifier: ^7.2.0 version: 7.3.0