diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf17ed..585c992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,8 @@ jobs: - name: Build run: pnpm build - - name: Lint - run: pnpm lint + - name: Lint + format check + run: pnpm lint:ci - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 1869a47..3c0e646 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -21,7 +21,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm --filter @nimblebrain/mpak... build - - run: pnpm --filter @nimblebrain/mpak lint + - run: pnpm lint - run: pnpm --filter @nimblebrain/mpak typecheck - run: pnpm --filter @nimblebrain/mpak test diff --git a/.github/workflows/schemas-publish.yml b/.github/workflows/schemas-publish.yml index ffea2bf..007065c 100644 --- a/.github/workflows/schemas-publish.yml +++ b/.github/workflows/schemas-publish.yml @@ -21,7 +21,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm --filter @nimblebrain/mpak-schemas build - - run: pnpm --filter @nimblebrain/mpak-schemas lint + - run: pnpm lint - run: pnpm --filter @nimblebrain/mpak-schemas typecheck - run: pnpm --filter @nimblebrain/mpak-schemas test diff --git a/.github/workflows/sdk-typescript-ci.yml b/.github/workflows/sdk-typescript-ci.yml index a3b1e5e..95002dc 100644 --- a/.github/workflows/sdk-typescript-ci.yml +++ b/.github/workflows/sdk-typescript-ci.yml @@ -37,10 +37,7 @@ jobs: run: pnpm --filter @nimblebrain/mpak-sdk... build - name: Lint - run: pnpm --filter @nimblebrain/mpak-sdk lint - - - name: Format check - run: pnpm --filter @nimblebrain/mpak-sdk exec prettier --check "src/**/*.ts" "tests/**/*.ts" + run: pnpm lint - name: Typecheck run: pnpm --filter @nimblebrain/mpak-sdk typecheck diff --git a/.github/workflows/sdk-typescript-publish.yml b/.github/workflows/sdk-typescript-publish.yml index 2aa2e9b..dd2f9b9 100644 --- a/.github/workflows/sdk-typescript-publish.yml +++ b/.github/workflows/sdk-typescript-publish.yml @@ -21,8 +21,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm --filter @nimblebrain/mpak-sdk... build - - run: pnpm --filter @nimblebrain/mpak-sdk lint - - run: pnpm --filter @nimblebrain/mpak-sdk exec prettier --check "src/**/*.ts" "tests/**/*.ts" + - run: pnpm lint - run: pnpm --filter @nimblebrain/mpak-sdk typecheck - run: pnpm --filter @nimblebrain/mpak-sdk test diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index a65d12f..0000000 --- a/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -dist -node_modules -.next -.astro -coverage -*.lock -pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 4cbc711..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2 -} diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 843e963..653b507 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -1,6 +1,7 @@ // @ts-check -import { defineConfig } from 'astro/config'; + import starlight from '@astrojs/starlight'; +import { defineConfig } from 'astro/config'; // https://astro.build/config export default defineConfig({ diff --git a/apps/docs/src/components/Header.astro b/apps/docs/src/components/Header.astro index 08b90d2..206e2c9 100644 --- a/apps/docs/src/components/Header.astro +++ b/apps/docs/src/components/Header.astro @@ -1,5 +1,5 @@ --- -import type { Props } from '@astrojs/starlight/props'; +// biome-ignore lint/correctness/noUnusedImports: rendered as in the template below, which biome doesn't parse import Default from '@astrojs/starlight/components/Header.astro'; --- diff --git a/apps/docs/src/content.config.ts b/apps/docs/src/content.config.ts index d9ee8c9..6a7b7a0 100644 --- a/apps/docs/src/content.config.ts +++ b/apps/docs/src/content.config.ts @@ -3,5 +3,5 @@ import { docsLoader } from '@astrojs/starlight/loaders'; import { docsSchema } from '@astrojs/starlight/schema'; export const collections = { - docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), }; diff --git a/apps/docs/src/styles/custom.css b/apps/docs/src/styles/custom.css index cd89115..d44e50e 100644 --- a/apps/docs/src/styles/custom.css +++ b/apps/docs/src/styles/custom.css @@ -12,7 +12,7 @@ --sl-color-accent-high: hsl(38, 90%, 75%); } -:root[data-theme='light'] { +:root[data-theme="light"] { /* Gold accent for light mode - darker for readability on white */ --sl-color-accent-high: hsl(38, 92%, 28%); --sl-color-accent: hsl(38, 92%, 40%); @@ -21,8 +21,8 @@ /* Fonts */ :root { - --sl-font: 'Space Grotesk', var(--sl-font-system); - --sl-font-mono: 'JetBrains Mono', var(--sl-font-system-mono); + --sl-font: "Space Grotesk", var(--sl-font-system); + --sl-font-mono: "JetBrains Mono", var(--sl-font-system-mono); } /* Browse Registry button */ @@ -44,11 +44,11 @@ background-color: var(--sl-color-accent-high); } -:root[data-theme='light'] .browse-registry-btn { +:root[data-theme="light"] .browse-registry-btn { color: white; } -:root[data-theme='light'] .browse-registry-btn:hover { +:root[data-theme="light"] .browse-registry-btn:hover { background-color: var(--sl-color-accent-high); color: white; } diff --git a/apps/registry/package.json b/apps/registry/package.json index 43b57ca..a9c05a2 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -12,7 +12,6 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "lint": "eslint src", "typecheck": "npm run db:generate && tsc --noEmit", "db:generate": "prisma generate", "db:migrate": "dotenv -e .env -- prisma migrate dev", diff --git a/apps/registry/prisma.config.ts b/apps/registry/prisma.config.ts index 688fa57..3cc85b5 100644 --- a/apps/registry/prisma.config.ts +++ b/apps/registry/prisma.config.ts @@ -10,6 +10,6 @@ export default defineConfig({ schema: path.join(__dirname, 'prisma', 'schema.prisma'), datasource: { - url: process.env['DIRECT_URL'] || process.env['DATABASE_URL']!, + url: process.env.DIRECT_URL || process.env.DATABASE_URL!, }, }); diff --git a/apps/registry/prisma/seed.ts b/apps/registry/prisma/seed.ts index cedcfac..e471079 100644 --- a/apps/registry/prisma/seed.ts +++ b/apps/registry/prisma/seed.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: intentional mpak manifest placeholders (${var} substituted at install time) */ /** * Database Seed Script * @@ -13,19 +14,19 @@ */ import 'dotenv/config'; -import { PrismaClient } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; 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) // --------------------------------------------------------------------------- const pool = new pg.Pool({ - connectionString: process.env['DATABASE_URL'], + connectionString: process.env.DATABASE_URL, }); const adapter = new PrismaPg(pool); @@ -590,7 +591,11 @@ const PACKAGES: SeedPackage[] = [ 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), + artifacts: multiPlatformArtifacts( + 'NimbleBrainInc/mcp-server-nationalparks', + '0.1.1', + 82_000, + ), }, { version: '0.1.2', @@ -603,7 +608,11 @@ const PACKAGES: SeedPackage[] = [ 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), + artifacts: multiPlatformArtifacts( + 'NimbleBrainInc/mcp-server-nationalparks', + '0.1.2', + 83_000, + ), }, { version: '0.1.3', @@ -616,7 +625,11 @@ const PACKAGES: SeedPackage[] = [ 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), + artifacts: multiPlatformArtifacts( + 'NimbleBrainInc/mcp-server-nationalparks', + '0.1.3', + 83_500, + ), }, { version: '0.1.4', @@ -629,7 +642,11 @@ const PACKAGES: SeedPackage[] = [ 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), + artifacts: multiPlatformArtifacts( + 'NimbleBrainInc/mcp-server-nationalparks', + '0.1.4', + 84_000, + ), }, { version: '0.1.5', @@ -642,7 +659,11 @@ const PACKAGES: SeedPackage[] = [ 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), + artifacts: multiPlatformArtifacts( + 'NimbleBrainInc/mcp-server-nationalparks', + '0.1.5', + 84_500, + ), }, { version: '0.2.0', @@ -655,7 +676,11 @@ const PACKAGES: SeedPackage[] = [ 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), + artifacts: multiPlatformArtifacts( + 'NimbleBrainInc/mcp-server-nationalparks', + '0.2.0', + 86_000, + ), }, ], }, @@ -667,26 +692,43 @@ const PACKAGES: SeedPackage[] = [ /** Generate a deterministic fake digest from a string */ function fakeDigest(input: string): string { - return 'sha256:' + createHash('sha256').update(input).digest('hex'); + 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`, - }]; + 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` }, + { + 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`, + }, ]; } @@ -865,7 +907,7 @@ async function seed() { }); // Create placeholder file on disk so local storage can serve it - const storagePath = process.env['STORAGE_PATH'] || './packages'; + 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}`); diff --git a/apps/registry/src/config.ts b/apps/registry/src/config.ts index 46fff9b..e536a4e 100644 --- a/apps/registry/src/config.ts +++ b/apps/registry/src/config.ts @@ -4,51 +4,56 @@ dotenvConfig({ quiet: true }); export const config = { server: { - port: parseInt(process.env['PORT'] || '3200', 10), - host: process.env['HOST'] || '0.0.0.0', - nodeEnv: process.env['NODE_ENV'] || 'development', + port: parseInt(process.env.PORT || '3200', 10), + host: process.env.HOST || '0.0.0.0', + nodeEnv: process.env.NODE_ENV || 'development', // Allowed origins for CORS (comma-separated in env) - corsOrigins: process.env['CORS_ORIGINS']?.split(',').map(s => s.trim()).filter(Boolean) || [], + corsOrigins: + process.env.CORS_ORIGINS?.split(',') + .map((s) => s.trim()) + .filter(Boolean) || [], }, clerk: { - publishableKey: process.env['CLERK_PUBLISHABLE_KEY'] || '', - secretKey: process.env['CLERK_SECRET_KEY'] || '', + publishableKey: process.env.CLERK_PUBLISHABLE_KEY || '', + secretKey: process.env.CLERK_SECRET_KEY || '', }, database: { - url: process.env['DATABASE_URL'] || 'postgresql://localhost:5432/mcpb_registry', + url: process.env.DATABASE_URL || 'postgresql://localhost:5432/mcpb_registry', }, storage: { - type: (process.env['STORAGE_TYPE'] || 'local') as 'local' | 's3', - path: process.env['STORAGE_PATH'] || './packages', + type: (process.env.STORAGE_TYPE || 'local') as 'local' | 's3', + path: process.env.STORAGE_PATH || './packages', s3: { - bucket: process.env['S3_BUCKET'] || '', - region: process.env['S3_REGION'] || 'us-east-1', - accessKeyId: process.env['S3_ACCESS_KEY_ID'] || '', - secretAccessKey: process.env['S3_SECRET_ACCESS_KEY'] || '', + bucket: process.env.S3_BUCKET || '', + region: process.env.S3_REGION || 'us-east-1', + accessKeyId: process.env.S3_ACCESS_KEY_ID || '', + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '', }, cloudfront: { - domain: process.env['CLOUDFRONT_DOMAIN'] || '', - keyPairId: process.env['CLOUDFRONT_KEY_PAIR_ID'] || '', - privateKeyPath: process.env['CLOUDFRONT_PRIVATE_KEY_PATH'] || '', - privateKey: process.env['CLOUDFRONT_PRIVATE_KEY'] || '', - privateKeyBase64: process.env['CLOUDFRONT_PRIVATE_KEY_BASE64'] || '', - urlExpirationSeconds: parseInt(process.env['CLOUDFRONT_URL_EXPIRATION'] || '900', 10), + domain: process.env.CLOUDFRONT_DOMAIN || '', + keyPairId: process.env.CLOUDFRONT_KEY_PAIR_ID || '', + privateKeyPath: process.env.CLOUDFRONT_PRIVATE_KEY_PATH || '', + privateKey: process.env.CLOUDFRONT_PRIVATE_KEY || '', + privateKeyBase64: process.env.CLOUDFRONT_PRIVATE_KEY_BASE64 || '', + urlExpirationSeconds: parseInt(process.env.CLOUDFRONT_URL_EXPIRATION || '900', 10), }, }, limits: { - maxBundleSizeMB: parseInt(process.env['MAX_BUNDLE_SIZE_MB'] || '50', 10), + maxBundleSizeMB: parseInt(process.env.MAX_BUNDLE_SIZE_MB || '50', 10), }, scanner: { - enabled: process.env['SCANNER_ENABLED'] === 'true', - image: process.env['SCANNER_IMAGE'] || '', - imageTag: process.env['SCANNER_IMAGE_TAG'] || 'latest', - namespace: process.env['SCANNER_NAMESPACE'] || 'security-scanning', - callbackSecret: process.env['SCANNER_CALLBACK_SECRET'] || '', - callbackUrl: process.env['SCANNER_CALLBACK_URL'] || `http://localhost:${process.env['PORT'] || '3200'}/app/scan-results`, - secretName: process.env['SCANNER_SECRET_NAME'] || 'scanner-secrets', - s3ResultPrefix: process.env['SCANNER_S3_RESULT_PREFIX'] || 'scan-results/', - ttlSeconds: parseInt(process.env['SCANNER_TTL_SECONDS'] || '3600', 10), - activeDeadlineSeconds: parseInt(process.env['SCANNER_ACTIVE_DEADLINE'] || '900', 10), + enabled: process.env.SCANNER_ENABLED === 'true', + image: process.env.SCANNER_IMAGE || '', + imageTag: process.env.SCANNER_IMAGE_TAG || 'latest', + namespace: process.env.SCANNER_NAMESPACE || 'security-scanning', + callbackSecret: process.env.SCANNER_CALLBACK_SECRET || '', + callbackUrl: + process.env.SCANNER_CALLBACK_URL || + `http://localhost:${process.env.PORT || '3200'}/app/scan-results`, + secretName: process.env.SCANNER_SECRET_NAME || 'scanner-secrets', + s3ResultPrefix: process.env.SCANNER_S3_RESULT_PREFIX || 'scan-results/', + ttlSeconds: parseInt(process.env.SCANNER_TTL_SECONDS || '3600', 10), + activeDeadlineSeconds: parseInt(process.env.SCANNER_ACTIVE_DEADLINE || '900', 10), }, }; diff --git a/apps/registry/src/db/client.ts b/apps/registry/src/db/client.ts index 1730af9..b2daa8e 100644 --- a/apps/registry/src/db/client.ts +++ b/apps/registry/src/db/client.ts @@ -3,10 +3,10 @@ * Singleton pattern for database connection management */ -import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; -import pg from 'pg'; import type { Prisma } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; +import pg from 'pg'; let prismaInstance: PrismaClient | null = null; let pgPool: pg.Pool | null = null; @@ -17,14 +17,14 @@ let pgPool: pg.Pool | null = null; export function getPrismaClient(): PrismaClient { if (!prismaInstance) { pgPool = new pg.Pool({ - connectionString: process.env['DATABASE_URL'], + connectionString: process.env.DATABASE_URL, }); const adapter = new PrismaPg(pgPool); prismaInstance = new PrismaClient({ adapter, - log: process.env['NODE_ENV'] === 'development' ? ['error', 'warn'] : ['error'], + log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], }); } return prismaInstance; @@ -55,7 +55,7 @@ export async function runInTransaction( options?: { maxWait?: number; timeout?: number; - } + }, ): Promise { const client = getPrismaClient(); return client.$transaction(fn, { diff --git a/apps/registry/src/db/index.ts b/apps/registry/src/db/index.ts index 28843a3..f39b488 100644 --- a/apps/registry/src/db/index.ts +++ b/apps/registry/src/db/index.ts @@ -3,33 +3,31 @@ * Main entry point for all database operations */ +export type { Prisma, TransactionClient } from './client.js'; // Client and transaction helpers -export { getPrismaClient, disconnectDatabase, runInTransaction } from './client.js'; -export type { TransactionClient, Prisma } from './client.js'; - -// Repositories -export { - PackageRepository, - UserRepository, - SkillRepository, -} from './repositories/index.js'; - +export { disconnectDatabase, getPrismaClient, runInTransaction } from './client.js'; export type { CreatePackageData, CreatePackageVersionData, - PackageSearchResult, - CreateUserData, - UpdateUserData, CreateSkillData, CreateSkillVersionData, + CreateUserData, + PackageSearchResult, SkillSearchFilters, SkillSearchResult, + UpdateUserData, +} from './repositories/index.js'; +// Repositories +export { + PackageRepository, + SkillRepository, + UserRepository, } from './repositories/index.js'; // Types export type { - IRepository, FindOptions, + IRepository, PackageSearchFilters, PackageWithRelations, } from './types.js'; diff --git a/apps/registry/src/db/repositories/index.ts b/apps/registry/src/db/repositories/index.ts index 288a2d4..03f031b 100644 --- a/apps/registry/src/db/repositories/index.ts +++ b/apps/registry/src/db/repositories/index.ts @@ -3,20 +3,19 @@ * Centralized access to all repositories */ -export { PackageRepository } from './package.repository.js'; -export { UserRepository } from './user.repository.js'; -export { SkillRepository } from './skill.repository.js'; - // Re-export types export type { CreatePackageData, CreatePackageVersionData, PackageSearchResult, } from './package.repository.js'; -export type { CreateUserData, UpdateUserData } from './user.repository.js'; +export { PackageRepository } from './package.repository.js'; export type { CreateSkillData, CreateSkillVersionData, SkillSearchFilters, SkillSearchResult, } from './skill.repository.js'; +export { SkillRepository } from './skill.repository.js'; +export type { CreateUserData, UpdateUserData } from './user.repository.js'; +export { UserRepository } from './user.repository.js'; diff --git a/apps/registry/src/db/repositories/package.repository.ts b/apps/registry/src/db/repositories/package.repository.ts index ff8e336..e7b1be4 100644 --- a/apps/registry/src/db/repositories/package.repository.ts +++ b/apps/registry/src/db/repositories/package.repository.ts @@ -5,7 +5,7 @@ import type { Artifact, Package, PackageVersion, Prisma, SecurityScan } from '@prisma/client'; import { getPrismaClient, type TransactionClient } from '../client.js'; -import type { PackageSearchFilters, FindOptions, PackageWithRelations } from '../types.js'; +import type { FindOptions, PackageSearchFilters, PackageWithRelations } from '../types.js'; // Version with artifacts included export type PackageVersionWithArtifacts = PackageVersion & { @@ -111,7 +111,7 @@ export class PackageRepository { */ async findByNameWithRelations( name: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.findUnique({ @@ -130,7 +130,7 @@ export class PackageRepository { async search( filters: PackageSearchFilters, options: FindOptions, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); @@ -165,7 +165,9 @@ export class PackageRepository { where, skip: options.skip, take: options.take, - orderBy: (options.orderBy as Prisma.PackageOrderByWithRelationInput) ?? { totalDownloads: 'desc' }, + orderBy: (options.orderBy as Prisma.PackageOrderByWithRelationInput) ?? { + totalDownloads: 'desc', + }, }), client.package.count({ where }), ]); @@ -206,7 +208,7 @@ export class PackageRepository { async update( id: string, data: Partial, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.update({ @@ -218,11 +220,7 @@ export class PackageRepository { /** * Update latest version */ - async updateLatestVersion( - id: string, - version: string, - tx?: TransactionClient - ): Promise { + async updateLatestVersion(id: string, version: string, tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); return client.package.update({ where: { id }, @@ -260,7 +258,7 @@ export class PackageRepository { */ async upsertPackage( data: CreatePackageData, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<{ package: Package; created: boolean }> { const client = tx ?? getPrismaClient(); @@ -300,7 +298,7 @@ export class PackageRepository { async findByCreator( createdBy: string, options: FindOptions, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.findMany({ @@ -319,7 +317,7 @@ export class PackageRepository { */ async findPackageForServerLookup( name: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.findUnique({ @@ -352,7 +350,7 @@ export class PackageRepository { async findPackagesForServerListing( filters: { search?: string; updatedSince?: Date }, options: { skip?: number; take?: number }, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<{ packages: PackageForServerLookup[]; total: number }> { const client = tx ?? getPrismaClient(); @@ -405,14 +403,13 @@ export class PackageRepository { // ==================== Package Version Methods ==================== - /** * Find version by package ID and version string, including latest completed security scan */ async findVersionWithLatestScan( packageId: string, version: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.packageVersion.findUnique({ @@ -439,7 +436,7 @@ export class PackageRepository { async findVersion( packageId: string, version: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.packageVersion.findUnique({ @@ -466,7 +463,10 @@ export class PackageRepository { /** * Get latest version for a package */ - async getLatestVersion(packageId: string, tx?: TransactionClient): Promise { + async getLatestVersion( + packageId: string, + tx?: TransactionClient, + ): Promise { const client = tx ?? getPrismaClient(); const versions = await client.packageVersion.findMany({ where: { packageId }, @@ -481,7 +481,7 @@ export class PackageRepository { */ async createVersion( data: CreatePackageVersionData, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.packageVersion.create({ @@ -511,7 +511,7 @@ export class PackageRepository { async upsertVersion( packageId: string, data: CreatePackageVersionData, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<{ version: PackageVersion; created: boolean }> { const client = tx ?? getPrismaClient(); @@ -555,7 +555,9 @@ export class PackageRepository { ...(data.provenanceRepository ? { provenanceRepository: data.provenanceRepository } : {}), ...(data.provenanceSha ? { provenanceSha: data.provenanceSha } : {}), ...(data.provenance ? { provenance: data.provenance as Prisma.InputJsonValue } : {}), - ...(data.serverJson !== undefined ? { serverJson: data.serverJson as Prisma.InputJsonValue } : {}), + ...(data.serverJson !== undefined + ? { serverJson: data.serverJson as Prisma.InputJsonValue } + : {}), }, }); @@ -568,7 +570,7 @@ export class PackageRepository { async findVersionWithArtifacts( packageId: string, version: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.packageVersion.findUnique({ @@ -589,7 +591,7 @@ export class PackageRepository { */ async getVersionsWithArtifacts( packageId: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.packageVersion.findMany({ @@ -606,7 +608,7 @@ export class PackageRepository { */ async getVersionsWithArtifactsAndScans( packageId: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.packageVersion.findMany({ @@ -627,10 +629,7 @@ export class PackageRepository { /** * Create an artifact for a version */ - async createArtifact( - data: CreateArtifactData, - tx?: TransactionClient - ): Promise { + async createArtifact(data: CreateArtifactData, tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); return client.artifact.create({ data: { @@ -649,10 +648,7 @@ export class PackageRepository { /** * Create multiple artifacts for a version */ - async createArtifacts( - artifacts: CreateArtifactData[], - tx?: TransactionClient - ): Promise { + async createArtifacts(artifacts: CreateArtifactData[], tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); const result = await client.artifact.createMany({ data: artifacts.map((a) => ({ @@ -686,7 +682,7 @@ export class PackageRepository { versionId: string, os: string, arch: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.artifact.findUnique({ @@ -705,7 +701,7 @@ export class PackageRepository { */ async upsertArtifact( data: CreateArtifactData, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<{ artifact: Artifact; created: boolean; oldStoragePath: string | null }> { const client = tx ?? getPrismaClient(); @@ -719,9 +715,8 @@ export class PackageRepository { }, }); - const oldStoragePath = existing && existing.storagePath !== data.storagePath - ? existing.storagePath - : null; + const oldStoragePath = + existing && existing.storagePath !== data.storagePath ? existing.storagePath : null; const artifact = await client.artifact.upsert({ where: { @@ -795,7 +790,7 @@ export class PackageRepository { async incrementVersionDownloads( packageId: string, version: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); await client.packageVersion.update({ @@ -848,7 +843,7 @@ export class PackageRepository { name: string, claimedBy: string, githubRepo: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.update({ @@ -864,10 +859,7 @@ export class PackageRepository { /** * Get all unclaimed packages */ - async findUnclaimed( - options: FindOptions, - tx?: TransactionClient - ): Promise { + async findUnclaimed(options: FindOptions, tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); const [packages, total] = await Promise.all([ @@ -895,7 +887,7 @@ export class PackageRepository { async findClaimedByUser( userId: string, options: FindOptions, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); @@ -924,7 +916,7 @@ export class PackageRepository { async updateGitHubRepo( name: string, githubRepo: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.update({ @@ -943,7 +935,7 @@ export class PackageRepository { forks: number; watchers: number; }, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.package.update({ diff --git a/apps/registry/src/db/repositories/skill.repository.ts b/apps/registry/src/db/repositories/skill.repository.ts index 135c309..b820ba4 100644 --- a/apps/registry/src/db/repositories/skill.repository.ts +++ b/apps/registry/src/db/repositories/skill.repository.ts @@ -3,7 +3,7 @@ * Handles operations for skills and skill versions */ -import type { Skill, SkillVersion, Prisma } from '@prisma/client'; +import type { Prisma, Skill, SkillVersion } from '@prisma/client'; import { getPrismaClient, type TransactionClient } from '../client.js'; import type { FindOptions } from '../types.js'; @@ -69,7 +69,7 @@ export class SkillRepository { */ async findByNameWithVersions( name: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<(Skill & { versions: SkillVersion[] }) | null> { const client = tx ?? getPrismaClient(); return client.skill.findUnique({ @@ -88,7 +88,7 @@ export class SkillRepository { async search( filters: SkillSearchFilters, options: FindOptions, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); @@ -116,7 +116,9 @@ export class SkillRepository { where, skip: options.skip, take: options.take, - orderBy: (options.orderBy as Prisma.SkillOrderByWithRelationInput) ?? { totalDownloads: 'desc' }, + orderBy: (options.orderBy as Prisma.SkillOrderByWithRelationInput) ?? { + totalDownloads: 'desc', + }, }), client.skill.count({ where }), ]); @@ -154,7 +156,7 @@ export class SkillRepository { */ async upsertSkill( data: CreateSkillData, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<{ skill: Skill; created: boolean }> { const client = tx ?? getPrismaClient(); @@ -201,11 +203,7 @@ export class SkillRepository { /** * Update latest version */ - async updateLatestVersion( - id: string, - version: string, - tx?: TransactionClient - ): Promise { + async updateLatestVersion(id: string, version: string, tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); return client.skill.update({ where: { id }, @@ -234,7 +232,7 @@ export class SkillRepository { async findVersion( skillId: string, version: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); return client.skillVersion.findUnique({ @@ -258,10 +256,7 @@ export class SkillRepository { /** * Create a skill version */ - async createVersion( - data: CreateSkillVersionData, - tx?: TransactionClient - ): Promise { + async createVersion(data: CreateSkillVersionData, tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); return client.skillVersion.create({ data: { @@ -290,7 +285,7 @@ export class SkillRepository { async upsertVersion( skillId: string, data: CreateSkillVersionData, - tx?: TransactionClient + tx?: TransactionClient, ): Promise<{ version: SkillVersion; created: boolean; oldStoragePath: string | null }> { const client = tx ?? getPrismaClient(); @@ -335,7 +330,7 @@ export class SkillRepository { async incrementVersionDownloads( skillId: string, version: string, - tx?: TransactionClient + tx?: TransactionClient, ): Promise { const client = tx ?? getPrismaClient(); await client.skillVersion.update({ diff --git a/apps/registry/src/db/repositories/user.repository.ts b/apps/registry/src/db/repositories/user.repository.ts index fb22712..7d65714 100644 --- a/apps/registry/src/db/repositories/user.repository.ts +++ b/apps/registry/src/db/repositories/user.repository.ts @@ -62,10 +62,7 @@ export class UserRepository { /** * Find user by GitHub username */ - async findByGitHubUsername( - githubUsername: string, - tx?: TransactionClient - ): Promise { + async findByGitHubUsername(githubUsername: string, tx?: TransactionClient): Promise { const client = tx ?? getPrismaClient(); return client.user.findFirst({ where: { githubUsername }, diff --git a/apps/registry/src/errors/handler.ts b/apps/registry/src/errors/handler.ts index 28b1e40..82ede61 100644 --- a/apps/registry/src/errors/handler.ts +++ b/apps/registry/src/errors/handler.ts @@ -5,8 +5,8 @@ * that can be safely exposed to the client. */ -import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; +import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; import { AppError, BadRequestError, @@ -37,8 +37,8 @@ function isPrismaError(error: unknown): error is Prisma.PrismaClientKnownRequest typeof error === 'object' && error !== null && 'code' in error && - typeof (error as Record)['code'] === 'string' && - ((error as Record)['code'] as string).startsWith('P') + typeof (error as Record).code === 'string' && + ((error as Record).code as string).startsWith('P') ); } @@ -48,7 +48,7 @@ function isPrismaError(error: unknown): error is Prisma.PrismaClientKnownRequest function handlePrismaError(error: Prisma.PrismaClientKnownRequestError): AppError { switch (error.code) { case 'P2002': { - const target = error.meta?.['target']; + const target = error.meta?.target; const fieldName = Array.isArray(target) ? target.join(', ') : target; return new ConflictError('A record with this value already exists', { field: fieldName as string, @@ -130,7 +130,7 @@ export function logError( logger: FastifyRequest['log'], error: unknown, normalizedError: AppError, - context?: Record + context?: Record, ) { const logContext = { ...context, @@ -150,7 +150,7 @@ export function logError( originalError: error.message, originalErrorName: error.name, }, - `Internal error: ${normalizedError.message}` + `Internal error: ${normalizedError.message}`, ); } else { logger.error( @@ -158,7 +158,7 @@ export function logError( ...logContext, unknownError: String(error), }, - 'Unknown error occurred' + 'Unknown error occurred', ); } } @@ -179,7 +179,7 @@ export function handleError( error: unknown, request: FastifyRequest, reply: FastifyReply, - context?: Record + context?: Record, ): void { const normalizedError = normalizeError(error); logError(request.log, error, normalizedError, context); diff --git a/apps/registry/src/errors/index.ts b/apps/registry/src/errors/index.ts index 7a57151..fc177d8 100644 --- a/apps/registry/src/errors/index.ts +++ b/apps/registry/src/errors/index.ts @@ -1,6 +1,7 @@ /** * Error handling exports */ -export * from './types.js'; + export * from './handler.js'; export * from './middleware.js'; +export * from './types.js'; diff --git a/apps/registry/src/errors/middleware.ts b/apps/registry/src/errors/middleware.ts index b01e05b..5061d2c 100644 --- a/apps/registry/src/errors/middleware.ts +++ b/apps/registry/src/errors/middleware.ts @@ -11,7 +11,7 @@ import { handleError } from './handler.js'; export function errorHandler( error: FastifyError, request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, ): void { if (reply.sent) { return; @@ -28,7 +28,7 @@ export function errorHandler( * Helper to wrap async route handlers with error handling */ export function asyncHandler( - handler: (request: FastifyRequest, reply: FastifyReply) => Promise + handler: (request: FastifyRequest, reply: FastifyReply) => Promise, ) { return async (request: FastifyRequest, reply: FastifyReply): Promise => { try { diff --git a/apps/registry/src/errors/types.ts b/apps/registry/src/errors/types.ts index 00389a4..1800500 100644 --- a/apps/registry/src/errors/types.ts +++ b/apps/registry/src/errors/types.ts @@ -20,7 +20,7 @@ export class AppError extends Error { statusCode: number = 500, code: string = 'INTERNAL_ERROR', isOperational: boolean = true, - details?: Record + details?: Record, ) { super(message); this.name = this.constructor.name; @@ -70,25 +70,37 @@ export class ValidationError extends AppError { } export class InternalServerError extends AppError { - constructor(message: string = 'An internal error occurred. Please try again later.', details?: Record) { + constructor( + message: string = 'An internal error occurred. Please try again later.', + details?: Record, + ) { super(message, 500, 'INTERNAL_ERROR', false, details); } } export class ServiceUnavailableError extends AppError { - constructor(message: string = 'Service temporarily unavailable', details?: Record) { + constructor( + message: string = 'Service temporarily unavailable', + details?: Record, + ) { super(message, 503, 'SERVICE_UNAVAILABLE', true, details); } } export class DatabaseError extends AppError { - constructor(message: string = 'A database error occurred. Please try again.', details?: Record) { + constructor( + message: string = 'A database error occurred. Please try again.', + details?: Record, + ) { super(message, 500, 'DATABASE_ERROR', false, details); } } export class TransactionTimeoutError extends AppError { - constructor(message: string = 'The operation took too long to complete. Please try again.', details?: Record) { + constructor( + message: string = 'The operation took too long to complete. Please try again.', + details?: Record, + ) { super(message, 500, 'TRANSACTION_TIMEOUT', true, details); } } diff --git a/apps/registry/src/index.ts b/apps/registry/src/index.ts index 5d5ebd1..cec3a7c 100644 --- a/apps/registry/src/index.ts +++ b/apps/registry/src/index.ts @@ -11,11 +11,11 @@ import { authPlugin } from './plugins/auth.js'; import prismaPlugin from './plugins/prisma.js'; import { storagePlugin } from './plugins/storage.js'; import { authRoutes } from './routes/auth.js'; +import { mcpRegistryRoutes } from './routes/mcp/v0.1/servers.js'; import { packageRoutes } from './routes/packages.js'; import { scannerRoutes, securityRoutes } from './routes/scanner.js'; import { bundleRoutes } from './routes/v1/bundles.js'; import { skillRoutes } from './routes/v1/skills.js'; -import { mcpRegistryRoutes } from './routes/mcp/v0.1/servers.js'; async function start() { // Validate configuration @@ -58,12 +58,13 @@ async function start() { description: 'API documentation for the mpak backend server', version: '0.1.0', }, - servers: config.server.nodeEnv === 'production' - ? [{ url: 'https://registry.mpak.dev', description: 'Production' }] - : [ - { url: `http://localhost:${config.server.port}`, description: 'Development' }, - { url: 'https://registry.mpak.dev', description: 'Production' }, - ], + servers: + config.server.nodeEnv === 'production' + ? [{ url: 'https://registry.mpak.dev', description: 'Production' }] + : [ + { url: `http://localhost:${config.server.port}`, description: 'Development' }, + { url: 'https://registry.mpak.dev', description: 'Production' }, + ], tags: [ { name: 'bundles', description: 'Bundle management API' }, { name: 'skills', description: 'Agent Skills API' }, @@ -112,100 +113,116 @@ async function start() { // Register routes // Web app API - strict CORS, frontend origins only - await fastify.register(async (instance) => { - // CORS: development allows localhost, production requires explicit CORS_ORIGINS - const appOrigin = config.server.nodeEnv === 'development' - ? [/^http:\/\/localhost(:\d+)?$/, /^http:\/\/127\.0\.0\.1(:\d+)?$/] - : config.server.corsOrigins; + await fastify.register( + async (instance) => { + // CORS: development allows localhost, production requires explicit CORS_ORIGINS + const appOrigin = + config.server.nodeEnv === 'development' + ? [/^http:\/\/localhost(:\d+)?$/, /^http:\/\/127\.0\.0\.1(:\d+)?$/] + : config.server.corsOrigins; - await instance.register(cors, { - origin: appOrigin.length > 0 ? appOrigin : false, - methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], - credentials: true, - }); + await instance.register(cors, { + origin: appOrigin.length > 0 ? appOrigin : false, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], + credentials: true, + }); - // Hide app routes from Swagger documentation - instance.addHook('onRoute', (routeOptions) => { - routeOptions.schema = routeOptions.schema ?? {}; - (routeOptions.schema as Record)['hide'] = true; - }); - await instance.register(authRoutes, { prefix: '/auth' }); - await instance.register(packageRoutes, { prefix: '/packages' }); - await instance.register(scannerRoutes); // /app/scan-results - }, { prefix: '/app' }); + // Hide app routes from Swagger documentation + instance.addHook('onRoute', (routeOptions) => { + routeOptions.schema = routeOptions.schema ?? {}; + (routeOptions.schema as Record).hide = true; + }); + await instance.register(authRoutes, { prefix: '/auth' }); + await instance.register(packageRoutes, { prefix: '/packages' }); + await instance.register(scannerRoutes); // /app/scan-results + }, + { prefix: '/app' }, + ); // Public API (CLI, OIDC) - open CORS, no cookies involved - await fastify.register(async (instance) => { - await instance.register(cors, { - origin: true, // Allow any origin - public API uses Bearer tokens, not cookies - methods: ['GET', 'HEAD', 'POST'], - credentials: false, // No cookies, just Authorization header - }); - // Stricter rate limit for bundle operations: 10 req/min per IP - await instance.register(rateLimit, { - max: 10, - timeWindow: '1 minute', - }); - // Mark every /v1/bundles response as deprecated per RFC 8594. The - // successor is the MCP-spec-aligned /v1/servers family; the legacy - // bundle-shape endpoints stay alive (announce / publish flow still - // depends on them) but consumers fetching read shapes should - // migrate. Skip POST /announce — that's a publish path, not a - // consumer-read path. - // - // RFC 8594 specifies `Deprecation = HTTP-date / "@" 1*DIGIT`. The - // boolean string "true" is a common shortcut from a superseded - // draft but isn't conformant; strict parsers ignore it. Emit an - // IMF-fixdate equal to when the deprecation took effect (the - // first commit of this PR's day) so the header round-trips - // through any conforming client. - const DEPRECATION_DATE = 'Fri, 09 May 2026 00:00:00 GMT'; - instance.addHook('onSend', async (request, reply) => { - if (request.method === 'GET' || request.method === 'HEAD') { - reply.header('Deprecation', DEPRECATION_DATE); - reply.header('Link', '; rel="successor-version"'); - } - }); - await instance.register(bundleRoutes); - await instance.register(securityRoutes); // /@:scope/:package/security routes - }, { prefix: '/v1/bundles' }); + await fastify.register( + async (instance) => { + await instance.register(cors, { + origin: true, // Allow any origin - public API uses Bearer tokens, not cookies + methods: ['GET', 'HEAD', 'POST'], + credentials: false, // No cookies, just Authorization header + }); + // Stricter rate limit for bundle operations: 10 req/min per IP + await instance.register(rateLimit, { + max: 10, + timeWindow: '1 minute', + }); + // Mark every /v1/bundles response as deprecated per RFC 8594. The + // successor is the MCP-spec-aligned /v1/servers family; the legacy + // bundle-shape endpoints stay alive (announce / publish flow still + // depends on them) but consumers fetching read shapes should + // migrate. Skip POST /announce — that's a publish path, not a + // consumer-read path. + // + // RFC 8594 specifies `Deprecation = HTTP-date / "@" 1*DIGIT`. The + // boolean string "true" is a common shortcut from a superseded + // draft but isn't conformant; strict parsers ignore it. Emit an + // IMF-fixdate equal to when the deprecation took effect (the + // first commit of this PR's day) so the header round-trips + // through any conforming client. + const DEPRECATION_DATE = 'Fri, 09 May 2026 00:00:00 GMT'; + instance.addHook('onSend', async (request, reply) => { + if (request.method === 'GET' || request.method === 'HEAD') { + reply.header('Deprecation', DEPRECATION_DATE); + reply.header('Link', '; rel="successor-version"'); + } + }); + await instance.register(bundleRoutes); + await instance.register(securityRoutes); // /@:scope/:package/security routes + }, + { prefix: '/v1/bundles' }, + ); // Skills API - await fastify.register(async (instance) => { - await instance.register(cors, { - origin: true, - methods: ['GET', 'HEAD', 'POST'], - credentials: false, - }); - // Stricter rate limit for skill operations: 10 req/min per IP - await instance.register(rateLimit, { - max: 10, - timeWindow: '1 minute', - }); - await instance.register(skillRoutes); - }, { prefix: '/v1/skills' }); + await fastify.register( + async (instance) => { + await instance.register(cors, { + origin: true, + methods: ['GET', 'HEAD', 'POST'], + credentials: false, + }); + // Stricter rate limit for skill operations: 10 req/min per IP + await instance.register(rateLimit, { + max: 10, + timeWindow: '1 minute', + }); + await instance.register(skillRoutes); + }, + { prefix: '/v1/skills' }, + ); // MCP Registry API — mounted at both /v0.1 (the upstream MCP Registry // public API prefix) and /v1 (mpak's `/v1/...` family). Same routes, // same handlers; consumers pick whichever URL space is conventional // for their stack. - await fastify.register(async (instance) => { - await instance.register(cors, { - origin: true, - methods: ['GET', 'HEAD'], - credentials: false, - }); - await instance.register(mcpRegistryRoutes); - }, { prefix: '/v0.1' }); + await fastify.register( + async (instance) => { + await instance.register(cors, { + origin: true, + methods: ['GET', 'HEAD'], + credentials: false, + }); + await instance.register(mcpRegistryRoutes); + }, + { prefix: '/v0.1' }, + ); - await fastify.register(async (instance) => { - await instance.register(cors, { - origin: true, - methods: ['GET', 'HEAD'], - credentials: false, - }); - await instance.register(mcpRegistryRoutes); - }, { prefix: '/v1' }); + await fastify.register( + async (instance) => { + await instance.register(cors, { + origin: true, + methods: ['GET', 'HEAD'], + credentials: false, + }); + await instance.register(mcpRegistryRoutes); + }, + { prefix: '/v1' }, + ); // Health check endpoint fastify.get('/health', { diff --git a/apps/registry/src/lib/oidc.ts b/apps/registry/src/lib/oidc.ts index 2bfbfa2..d28abf0 100644 --- a/apps/registry/src/lib/oidc.ts +++ b/apps/registry/src/lib/oidc.ts @@ -1,12 +1,10 @@ import * as jose from 'jose'; const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; -const MPAK_AUDIENCE = process.env['OIDC_AUDIENCE'] || 'https://mpak.dev'; +const MPAK_AUDIENCE = process.env.OIDC_AUDIENCE || 'https://mpak.dev'; // Cache JWKS at module scope so jose handles key rotation internally -const JWKS = jose.createRemoteJWKSet( - new URL(`${GITHUB_OIDC_ISSUER}/.well-known/jwks`) -); +const JWKS = jose.createRemoteJWKSet(new URL(`${GITHUB_OIDC_ISSUER}/.well-known/jwks`)); export interface GitHubOIDCClaims { repository: string; @@ -29,30 +27,28 @@ export interface GitHubOIDCClaims { /** * Verify a GitHub Actions OIDC token */ -export async function verifyGitHubOIDC( - token: string -): Promise { +export async function verifyGitHubOIDC(token: string): Promise { const { payload } = await jose.jwtVerify(token, JWKS, { issuer: GITHUB_OIDC_ISSUER, audience: MPAK_AUDIENCE, }); return { - repository: payload['repository'] as string, - repository_owner: payload['repository_owner'] as string, - repository_owner_id: payload['repository_owner_id'] as string, - workflow: payload['workflow'] as string, - workflow_ref: payload['workflow_ref'] as string, - ref: payload['ref'] as string, - ref_type: payload['ref_type'] as string, - sha: payload['sha'] as string, - actor: payload['actor'] as string, - actor_id: payload['actor_id'] as string, - run_id: payload['run_id'] as string, - run_number: payload['run_number'] as string, - run_attempt: payload['run_attempt'] as string, - event_name: payload['event_name'] as string, - job_workflow_ref: payload['job_workflow_ref'] as string, + repository: payload.repository as string, + repository_owner: payload.repository_owner as string, + repository_owner_id: payload.repository_owner_id as string, + workflow: payload.workflow as string, + workflow_ref: payload.workflow_ref as string, + ref: payload.ref as string, + ref_type: payload.ref_type as string, + sha: payload.sha as string, + actor: payload.actor as string, + actor_id: payload.actor_id as string, + run_id: payload.run_id as string, + run_number: payload.run_number as string, + run_attempt: payload.run_attempt as string, + event_name: payload.event_name as string, + job_workflow_ref: payload.job_workflow_ref as string, }; } diff --git a/apps/registry/src/plugins/auth.ts b/apps/registry/src/plugins/auth.ts index e939d42..84d90e4 100644 --- a/apps/registry/src/plugins/auth.ts +++ b/apps/registry/src/plugins/auth.ts @@ -1,9 +1,9 @@ +import { createClerkClient, verifyToken } from '@clerk/backend'; import type { FastifyPluginAsync, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; -import { createClerkClient, verifyToken } from '@clerk/backend'; import { config } from '../config.js'; -import type { AuthenticatedUser } from '../types.js'; import { UnauthorizedError } from '../errors/types.js'; +import type { AuthenticatedUser } from '../types.js'; declare module 'fastify' { interface FastifyRequest { @@ -22,7 +22,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { async function authenticate(request: FastifyRequest): Promise { const authHeader = request.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { + if (!authHeader?.startsWith('Bearer ')) { throw new UnauthorizedError('Missing or invalid authorization header'); } @@ -39,13 +39,18 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { const email = primaryEmail?.emailAddress ?? ''; const emailVerified = primaryEmail?.verification?.status === 'verified'; - const githubAccount = user.externalAccounts?.find((account) => account.provider === 'oauth_github'); + const githubAccount = user.externalAccounts?.find( + (account) => account.provider === 'oauth_github', + ); const githubUsername = githubAccount?.username ?? null; - const githubUserId = (githubAccount as unknown as Record)?.['providerUserId'] as string | null ?? null; + const githubUserId = + ((githubAccount as unknown as Record)?.providerUserId as string | null) ?? + null; - const name = user.firstName && user.lastName - ? `${user.firstName} ${user.lastName}` - : user.firstName ?? user.lastName ?? user.username ?? null; + const name = + user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : (user.firstName ?? user.lastName ?? user.username ?? null); const dbUser = await fastify.repositories.users.upsert({ clerkId: user.id, @@ -66,10 +71,10 @@ const authPlugin: FastifyPluginAsync = async (fastify) => { emailVerified, githubUsername: githubUsername ?? undefined, metadata: { - verified: (publicMetadata?.['verified'] as boolean) ?? false, - publishedBundles: (publicMetadata?.['publishedBundles'] as number) ?? 0, - totalDownloads: (publicMetadata?.['totalDownloads'] as number) ?? 0, - role: (publicMetadata?.['role'] as string) ?? undefined, + verified: (publicMetadata?.verified as boolean) ?? false, + publishedBundles: (publicMetadata?.publishedBundles as number) ?? 0, + totalDownloads: (publicMetadata?.totalDownloads as number) ?? 0, + role: (publicMetadata?.role as string) ?? undefined, }, }; diff --git a/apps/registry/src/plugins/prisma.ts b/apps/registry/src/plugins/prisma.ts index 2936f3e..1a6748d 100644 --- a/apps/registry/src/plugins/prisma.ts +++ b/apps/registry/src/plugins/prisma.ts @@ -3,16 +3,16 @@ * Provides database access via repositories throughout the application */ +import type { PrismaClient } from '@prisma/client'; import type { FastifyPluginAsync } from 'fastify'; import fp from 'fastify-plugin'; import { - getPrismaClient, disconnectDatabase, + getPrismaClient, PackageRepository, - UserRepository, SkillRepository, + UserRepository, } from '../db/index.js'; -import type { PrismaClient } from '@prisma/client'; declare module 'fastify' { interface FastifyInstance { diff --git a/apps/registry/src/plugins/storage.ts b/apps/registry/src/plugins/storage.ts index 277eb8e..d220a11 100644 --- a/apps/registry/src/plugins/storage.ts +++ b/apps/registry/src/plugins/storage.ts @@ -1,17 +1,27 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream, promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; import type { FastifyPluginAsync } from 'fastify'; import fp from 'fastify-plugin'; -import { promises as fs } from 'fs'; -import { createWriteStream } from 'fs'; -import path from 'path'; -import { createHash } from 'crypto'; -import type { Readable } from 'stream'; -import { pipeline } from 'stream/promises'; -import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; import { config } from '../config.js'; export interface StorageService { - saveBundle(scope: string, packageName: string, version: string, data: Buffer, platform?: string): Promise<{ + saveBundle( + scope: string, + packageName: string, + version: string, + data: Buffer, + platform?: string, + ): Promise<{ path: string; sha256: string; size: number; @@ -23,11 +33,16 @@ export interface StorageService { stream: Readable, sha256: string, size: number, - platform?: string + platform?: string, ): Promise<{ path: string; sha256: string; size: number }>; getBundle(storagePath: string): Promise; getBundleUrl(scope: string, packageName: string, version: string, platform?: string): string; - getSignedDownloadUrl(scope: string, packageName: string, version: string, platform?: string): Promise; + getSignedDownloadUrl( + scope: string, + packageName: string, + version: string, + platform?: string, + ): Promise; getSignedDownloadUrlFromPath(storagePath: string): Promise; deleteBundle(storagePath: string): Promise; } @@ -50,7 +65,7 @@ class LocalStorageService implements StorageService { packageName: string, version: string, data: Buffer, - platform?: string + platform?: string, ): Promise<{ path: string; sha256: string; size: number }> { const bundleDir = path.join(this.basePath, `@${scope}`, packageName, version); await fs.mkdir(bundleDir, { recursive: true }); @@ -76,7 +91,12 @@ class LocalStorageService implements StorageService { return platform ? `${base}?platform=${platform}` : base; } - async getSignedDownloadUrl(scope: string, packageName: string, version: string, platform?: string): Promise { + async getSignedDownloadUrl( + scope: string, + packageName: string, + version: string, + platform?: string, + ): Promise { return this.getBundleUrl(scope, packageName, version, platform); } @@ -96,7 +116,7 @@ class LocalStorageService implements StorageService { stream: Readable, sha256: string, size: number, - platform?: string + platform?: string, ): Promise<{ path: string; sha256: string; size: number }> { const bundleDir = path.join(this.basePath, `@${scope}`, packageName, version); await fs.mkdir(bundleDir, { recursive: true }); @@ -131,7 +151,7 @@ class S3StorageService implements StorageService { packageName: string, version: string, data: Buffer, - platform?: string + platform?: string, ): Promise<{ path: string; sha256: string; size: number }> { const filename = platform ? `${platform}.mcpb` : 'bundle.mcpb'; const key = `packages/@${scope}/${packageName}/${version}/${filename}`; @@ -195,7 +215,12 @@ class S3StorageService implements StorageService { return `https://${bucket}.s3.${region}.amazonaws.com/packages/@${scope}/${packageName}/${version}/${filename}`; } - async getSignedDownloadUrl(scope: string, packageName: string, version: string, platform?: string): Promise { + async getSignedDownloadUrl( + scope: string, + packageName: string, + version: string, + platform?: string, + ): Promise { const cloudfrontConfig = config.storage.cloudfront; if (!cloudfrontConfig.domain || !cloudfrontConfig.keyPairId) { @@ -282,7 +307,7 @@ class S3StorageService implements StorageService { stream: Readable, sha256: string, size: number, - platform?: string + platform?: string, ): Promise<{ path: string; sha256: string; size: number }> { const filename = platform ? `${platform}.mcpb` : 'bundle.mcpb'; const key = `packages/@${scope}/${packageName}/${version}/${filename}`; @@ -320,7 +345,9 @@ const storagePlugin: FastifyPluginAsync = async (fastify) => { const { bucket, region, accessKeyId, secretAccessKey } = config.storage.s3; if (!bucket || !region || !accessKeyId || !secretAccessKey) { - throw new Error('S3 storage requires bucket, region, accessKeyId, and secretAccessKey to be configured'); + throw new Error( + 'S3 storage requires bucket, region, accessKeyId, and secretAccessKey to be configured', + ); } storageService = new S3StorageService(bucket, region, accessKeyId, secretAccessKey); diff --git a/apps/registry/src/routes/auth.ts b/apps/registry/src/routes/auth.ts index 15306d9..47bf0b1 100644 --- a/apps/registry/src/routes/auth.ts +++ b/apps/registry/src/routes/auth.ts @@ -1,6 +1,6 @@ +import { type UserProfile, UserProfileSchema } from '@nimblebrain/mpak-schemas'; import type { FastifyPluginAsync } from 'fastify'; import { toJsonSchema } from '../lib/zod-schema.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/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index fc046fa..1a11e7b 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -13,8 +13,8 @@ * manifest so bundles that drop their `server.json` file keep working. */ -import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; import { resolveReverseDnsName, type ServerDetail } from '@nimblebrain/mpak-schemas'; +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; import type { PackageForServerLookup } from '../../../db/repositories/package.repository.js'; import { composeServerDetail } from '../../../services/server-detail-composer.js'; @@ -25,7 +25,12 @@ const REGISTRY_VERSION = 'v1.0.0'; * on `_meta["dev.mpak/registry"].certification`. */ function scanToCertification( - scan: { certificationLevel: number | null; controlsPassed: number | null; controlsFailed: number | null; controlsTotal: number | null } | null, + scan: { + certificationLevel: number | null; + controlsPassed: number | null; + controlsFailed: number | null; + controlsTotal: number | null; + } | null, ): | { level: number; @@ -108,7 +113,7 @@ async function resolveByName( const latest = hit.versions[0]; if (!latest) continue; const manifestMeta = - ((latest.manifest as Record | null)?.['_meta'] as + ((latest.manifest as Record | null)?._meta as | Record | undefined) ?? null; if (resolveReverseDnsName(hit.name, manifestMeta) === decodedName) { @@ -202,207 +207,232 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { search?: string; updated_since?: string; }; - }>('/servers', { - schema: { - tags: ['mcp-registry'], - description: - 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec). Each item is the latest published version of a server; per-version listings live under /servers/{name}/versions.', - querystring: { - type: 'object', - properties: { - cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, - limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, - search: { - type: 'string', - description: 'Case-insensitive substring search on name/displayName/description', - }, - updated_since: { - type: 'string', - description: - 'RFC 3339 timestamp. Returns servers with at least one version published since the given time. Filtered at the database; pagination math reflects the filter.', + }>( + '/servers', + { + schema: { + tags: ['mcp-registry'], + description: + 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec). Each item is the latest published version of a server; per-version listings live under /servers/{name}/versions.', + querystring: { + type: 'object', + properties: { + cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, + limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, + search: { + type: 'string', + description: 'Case-insensitive substring search on name/displayName/description', + }, + updated_since: { + type: 'string', + description: + 'RFC 3339 timestamp. Returns servers with at least one version published since the given time. Filtered at the database; pagination math reflects the filter.', + }, }, }, }, }, - }, async (request) => { - const limit = Math.min(parseIntParam(request.query.limit, 100), 500); - const skip = parseIntParam(request.query.cursor, 0); - const updatedSince = parseUpdatedSince(request.query.updated_since); + async (request) => { + const limit = Math.min(parseIntParam(request.query.limit, 100), 500); + const skip = parseIntParam(request.query.cursor, 0); + const updatedSince = parseUpdatedSince(request.query.updated_since); - const { packages, total } = await packageRepo.findPackagesForServerListing( - { - ...(request.query.search ? { search: request.query.search } : {}), - ...(updatedSince ? { updatedSince } : {}), - }, - { skip, take: limit }, - ); + const { packages, total } = await packageRepo.findPackagesForServerListing( + { + ...(request.query.search ? { search: request.query.search } : {}), + ...(updatedSince ? { updatedSince } : {}), + }, + { skip, take: limit }, + ); - const servers: ServerDetail[] = []; - for (const pkg of packages) { - const latest = pkg.versions[0]; - if (!latest) continue; - const detail = buildServerDetail(pkg, latest); - if (detail) servers.push(detail); - } + const servers: ServerDetail[] = []; + for (const pkg of packages) { + const latest = pkg.versions[0]; + if (!latest) continue; + const detail = buildServerDetail(pkg, latest); + if (detail) servers.push(detail); + } - const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = - { + const response: { + servers: ServerDetail[]; + metadata: { count: number; next_cursor?: string }; + } = { servers, metadata: { count: servers.length }, }; - const nextIdx = skip + limit; - if (nextIdx < total) { - response.metadata.next_cursor = String(nextIdx); - } - return response; - }); + const nextIdx = skip + limit; + if (nextIdx < total) { + response.metadata.next_cursor = String(nextIdx); + } + return response; + }, + ); // GET /servers/search - alias for /servers, exposed under the conventional name fastify.get<{ Querystring: { q?: string; limit?: string; cursor?: string }; - }>('/servers/search', { - schema: { - tags: ['mcp-registry'], - description: 'Search MCP servers by substring on name, displayName, or description', - querystring: { - type: 'object', - properties: { - q: { type: 'string', description: 'Search query' }, - limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, - cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, + }>( + '/servers/search', + { + schema: { + tags: ['mcp-registry'], + description: 'Search MCP servers by substring on name, displayName, or description', + querystring: { + type: 'object', + properties: { + q: { type: 'string', description: 'Search query' }, + limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, + cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, + }, }, }, }, - }, async (request) => { - const limit = Math.min(parseIntParam(request.query.limit, 100), 500); - const skip = parseIntParam(request.query.cursor, 0); - const { packages, total } = await packageRepo.findPackagesForServerListing( - request.query.q ? { search: request.query.q } : {}, - { skip, take: limit }, - ); - const servers: ServerDetail[] = []; - for (const pkg of packages) { - const latest = pkg.versions[0]; - if (!latest) continue; - const detail = buildServerDetail(pkg, latest); - if (detail) servers.push(detail); - } - const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = - { + async (request) => { + const limit = Math.min(parseIntParam(request.query.limit, 100), 500); + const skip = parseIntParam(request.query.cursor, 0); + const { packages, total } = await packageRepo.findPackagesForServerListing( + request.query.q ? { search: request.query.q } : {}, + { skip, take: limit }, + ); + const servers: ServerDetail[] = []; + for (const pkg of packages) { + const latest = pkg.versions[0]; + if (!latest) continue; + const detail = buildServerDetail(pkg, latest); + if (detail) servers.push(detail); + } + const response: { + servers: ServerDetail[]; + metadata: { count: number; next_cursor?: string }; + } = { servers, metadata: { count: servers.length }, }; - const nextIdx = skip + limit; - if (nextIdx < total) { - response.metadata.next_cursor = String(nextIdx); - } - return response; - }); + const nextIdx = skip + limit; + if (nextIdx < total) { + response.metadata.next_cursor = String(nextIdx); + } + return response; + }, + ); // GET /servers/{name} - Latest ServerDetail for a server fastify.get<{ Params: { name: string }; - }>('/servers/:name', { - schema: { - tags: ['mcp-registry'], - description: - 'Get the latest ServerDetail for a server. Accepts both npm-style (@scope/name) and reverse-DNS forms.', - params: { - type: 'object', - required: ['name'], - properties: { name: { type: 'string', description: 'URL-encoded server name' } }, + }>( + '/servers/:name', + { + schema: { + tags: ['mcp-registry'], + description: + 'Get the latest ServerDetail for a server. Accepts both npm-style (@scope/name) and reverse-DNS forms.', + params: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string', description: 'URL-encoded server name' } }, + }, }, }, - }, async (request, reply) => { - const pkg = await resolveByName(fastify, request.params.name); - if (!pkg) { - reply.code(404); - return { error: `Server '${decodeURIComponent(request.params.name)}' not found` }; - } - const latest = pkg.versions[0]; - if (!latest) { - reply.code(404); - return { error: `Server '${pkg.name}' has no versions` }; - } - const detail = buildServerDetail(pkg, latest); - if (!detail) { - reply.code(500); - return { error: `Server '${pkg.name}' manifest could not be projected` }; - } - return detail; - }); + async (request, reply) => { + const pkg = await resolveByName(fastify, request.params.name); + if (!pkg) { + reply.code(404); + return { error: `Server '${decodeURIComponent(request.params.name)}' not found` }; + } + const latest = pkg.versions[0]; + if (!latest) { + reply.code(404); + return { error: `Server '${pkg.name}' has no versions` }; + } + const detail = buildServerDetail(pkg, latest); + if (!detail) { + reply.code(500); + return { error: `Server '${pkg.name}' manifest could not be projected` }; + } + return detail; + }, + ); // GET /servers/{name}/versions/{version} - Version-specific ServerDetail fastify.get<{ Params: { name: string; version: string }; - }>('/servers/:name/versions/:version', { - schema: { - tags: ['mcp-registry'], - description: 'Get a version-specific ServerDetail. Use "latest" for the most recent version.', - params: { - type: 'object', - required: ['name', 'version'], - properties: { - name: { type: 'string', description: 'URL-encoded server name' }, - version: { type: 'string', description: 'Server version, or "latest"' }, + }>( + '/servers/:name/versions/:version', + { + schema: { + tags: ['mcp-registry'], + description: + 'Get a version-specific ServerDetail. Use "latest" for the most recent version.', + params: { + type: 'object', + required: ['name', 'version'], + properties: { + name: { type: 'string', description: 'URL-encoded server name' }, + version: { type: 'string', description: 'Server version, or "latest"' }, + }, }, }, }, - }, async (request, reply) => { - const pkg = await resolveByName(fastify, request.params.name); - if (!pkg) { - reply.code(404); - return { error: `Server '${decodeURIComponent(request.params.name)}' not found` }; - } - const requestedVersion = request.params.version; - const matchedVersion = - requestedVersion === 'latest' - ? pkg.versions[0] - : pkg.versions.find((v) => v.version === requestedVersion); - if (!matchedVersion) { - reply.code(404); - return { error: `Version '${requestedVersion}' not found for server '${pkg.name}'` }; - } - const detail = buildServerDetail(pkg, matchedVersion); - if (!detail) { - reply.code(500); - return { - error: `Server '${pkg.name}' version '${matchedVersion.version}' manifest could not be projected`, - }; - } - return detail; - }); + async (request, reply) => { + const pkg = await resolveByName(fastify, request.params.name); + if (!pkg) { + reply.code(404); + return { error: `Server '${decodeURIComponent(request.params.name)}' not found` }; + } + const requestedVersion = request.params.version; + const matchedVersion = + requestedVersion === 'latest' + ? pkg.versions[0] + : pkg.versions.find((v) => v.version === requestedVersion); + if (!matchedVersion) { + reply.code(404); + return { error: `Version '${requestedVersion}' not found for server '${pkg.name}'` }; + } + const detail = buildServerDetail(pkg, matchedVersion); + if (!detail) { + reply.code(500); + return { + error: `Server '${pkg.name}' version '${matchedVersion.version}' manifest could not be projected`, + }; + } + return detail; + }, + ); // GET /servers/{name}/versions - List all versions for a server fastify.get<{ Params: { name: string }; - }>('/servers/:name/versions', { - schema: { - tags: ['mcp-registry'], - description: 'List every version of a server (newest first).', - params: { - type: 'object', - required: ['name'], - properties: { name: { type: 'string', description: 'URL-encoded server name' } }, + }>( + '/servers/:name/versions', + { + schema: { + tags: ['mcp-registry'], + description: 'List every version of a server (newest first).', + params: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string', description: 'URL-encoded server name' } }, + }, }, }, - }, async (request, reply) => { - const pkg = await resolveByName(fastify, request.params.name); - if (!pkg || pkg.versions.length === 0) { - reply.code(404); + async (request, reply) => { + const pkg = await resolveByName(fastify, request.params.name); + if (!pkg || pkg.versions.length === 0) { + reply.code(404); + return { + error: `Server '${decodeURIComponent(request.params.name)}' not found`, + }; + } return { - error: `Server '${decodeURIComponent(request.params.name)}' not found`, + name: pkg.name, + versions: pkg.versions.map((v) => ({ + version: v.version, + published_at: v.publishedAt, + is_latest: v.version === pkg.latestVersion, + })), }; - } - return { - name: pkg.name, - versions: pkg.versions.map((v) => ({ - version: v.version, - published_at: v.publishedAt, - is_latest: v.version === pkg.latestVersion, - })), - }; - }); + }, + ); // GET /health - Registry-specific health probe (counts servers). // The top-level /health route is the LB liveness probe with a diff --git a/apps/registry/src/routes/packages.ts b/apps/registry/src/routes/packages.ts index 74dccea..38e11ee 100644 --- a/apps/registry/src/routes/packages.ts +++ b/apps/registry/src/routes/packages.ts @@ -1,3 +1,14 @@ +import type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; +import { + ClaimResponseSchema, + ClaimStatusResponseSchema, + InternalDownloadResponseSchema, + MyPackagesResponseSchema, + PackageDetailSchema, + PackageSearchResponseSchema, + PublishResponseSchema, + UnclaimedPackagesResponseSchema, +} from '@nimblebrain/mpak-schemas'; import AdmZip from 'adm-zip'; import type { FastifyPluginAsync } from 'fastify'; import { config } from '../config.js'; @@ -6,34 +17,31 @@ import { BadRequestError, ConflictError, ForbiddenError, + handleError, NotFoundError, ValidationError, - handleError, } from '../errors/index.js'; import { toJsonSchema } from '../lib/zod-schema.js'; -import type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; -import { - PublishResponseSchema, - PackageSearchResponseSchema, - PackageDetailSchema, - InternalDownloadResponseSchema, - ClaimStatusResponseSchema, - ClaimResponseSchema, - MyPackagesResponseSchema, - UnclaimedPackagesResponseSchema, -} 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'; +import { + fetchGitHubRepoStats, + parseGitHubRepo, + verifyPackageClaim, +} from '../services/github-verifier.js'; import { validateManifest } from '../services/manifest-validator.js'; import { triggerSecurityScan } from '../services/scanner.js'; import type { MCPBManifest } from '../types.js'; +import { extractScannerVersion } from '../utils/scanner-version.js'; // Package name validation const UNSCOPED_REGEX = /^[a-z0-9][a-z0-9-]{0,213}$/; const SCOPED_REGEX = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9-]{0,213}$/; -function parsePackageName(name: string): { scope: string | null; packageName: string; isScoped: boolean } { +function parsePackageName(name: string): { + scope: string | null; + packageName: string; + isScoped: boolean; +} { if (name.startsWith('@')) { const parts = name.split('/'); if (parts.length === 2 && parts[0] && parts[1]) { @@ -120,7 +128,7 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { // Validate package name format if (!isValidPackageName(packageName)) { throw new BadRequestError( - `Invalid package name: "${packageName}". Must match pattern for scoped (@scope/name) or unscoped (name) packages.` + `Invalid package name: "${packageName}". Must match pattern for scoped (@scope/name) or unscoped (name) packages.`, ); } @@ -129,17 +137,19 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { // SECURITY: All packages must be scoped to prevent namespace squatting if (!isScoped || !scope) { throw new BadRequestError( - 'All packages must be scoped (e.g., @username/package-name). Unscoped packages are not allowed.' + 'All packages must be scoped (e.g., @username/package-name). Unscoped packages are not allowed.', ); } // Extract server_type from manifest (supports both nested server.type and flat server_type) const manifestRecord = manifest as unknown as Record; - const serverObj = manifestRecord['server'] as Record | undefined; - const serverType = (serverObj?.['type'] as string) ?? (manifestRecord['server_type'] as string); + const serverObj = manifestRecord.server as Record | undefined; + const serverType = (serverObj?.type as string) ?? (manifestRecord.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)', + ); } // PRE-CHECK: Verify ownership and version availability BEFORE uploading @@ -148,13 +158,17 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { if (existingPackage) { // Check if package has been claimed - only the claimer can publish new versions if (existingPackage.claimedBy && existingPackage.claimedBy !== user.userId) { - throw new ForbiddenError('This package has been claimed by another user. You cannot publish to it.'); + throw new ForbiddenError( + 'This package has been claimed by another user. You cannot publish to it.', + ); } // Check if this version already exists const existingVersion = await packageRepo.findVersion(existingPackage.id, version); if (existingVersion) { - throw new ConflictError(`Version ${version} already exists. Cannot overwrite existing versions.`); + throw new ConflictError( + `Version ${version} already exists. Cannot overwrite existing versions.`, + ); } } @@ -177,7 +191,7 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { const verification = await verifyPackageClaim( packageName, githubRepo, - user.githubUsername + user.githubUsername, ); if (verification.verified) { @@ -185,7 +199,9 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { claimedBy = user.userId; claimedAt = new Date(); wasAutoClaimed = true; - fastify.log.info(`Auto-claimed package ${packageName} for user ${user.githubUsername}`); + fastify.log.info( + `Auto-claimed package ${packageName} for user ${user.githubUsername}`, + ); } else { // Log verification failure but continue with unclaimed package fastify.log.info(`Package ${packageName} not auto-claimed: ${verification.error}`); @@ -198,15 +214,21 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { } // Upload to storage ONLY AFTER verifying version doesn't exist - const { path: storagePath, sha256, size } = await fastify.storage.saveBundle( - scope, - parsedPackageName, - version, - buffer - ); + const { + path: storagePath, + sha256, + size, + } = await fastify.storage.saveBundle(scope, parsedPackageName, version, buffer); // Use transaction to ensure atomicity - let result; + let result: { + packageId: string; + versionId: string; + sha256: string; + size: number; + githubRepo: string | undefined; + wasAutoClaimed: boolean; + }; try { result = await runInTransaction(async (tx) => { let packageId: string; @@ -217,50 +239,66 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { await packageRepo.updateLatestVersion(packageId, version, tx); } else { // Create new package (unclaimed by default, even if published by a user) - const pkg = await packageRepo.create({ - name: packageName, - displayName: manifest.display_name ?? undefined, - description: manifest.description ?? undefined, - authorName: manifest.author?.name ?? undefined, - authorEmail: manifest.author?.email ?? undefined, - authorUrl: manifest.author?.url ?? undefined, - homepage: manifest.homepage ?? undefined, - license: manifest.license ?? undefined, - iconUrl: manifest.icon ?? undefined, - serverType, - verified: false, - latestVersion: version, - // Do NOT set createdBy - packages are unclaimed by default - githubRepo, - claimedBy, - claimedAt, - }, tx); + const pkg = await packageRepo.create( + { + name: packageName, + displayName: manifest.display_name ?? undefined, + description: manifest.description ?? undefined, + authorName: manifest.author?.name ?? undefined, + authorEmail: manifest.author?.email ?? undefined, + authorUrl: manifest.author?.url ?? undefined, + homepage: manifest.homepage ?? undefined, + license: manifest.license ?? undefined, + iconUrl: manifest.icon ?? undefined, + serverType, + verified: false, + latestVersion: version, + // Do NOT set createdBy - packages are unclaimed by default + githubRepo, + claimedBy, + claimedAt, + }, + tx, + ); packageId = pkg.id; } // Create package version record - const packageVersion = await packageRepo.createVersion({ - packageId, - version, - manifest, - publishedBy: user.userId, - publishedByEmail: user.email, - publishMethod: 'upload', - }, tx); + const packageVersion = await packageRepo.createVersion( + { + packageId, + version, + manifest, + publishedBy: user.userId, + publishedByEmail: user.email, + publishMethod: 'upload', + }, + tx, + ); // Create artifact for universal bundle - await packageRepo.createArtifact({ + await packageRepo.createArtifact( + { + versionId: packageVersion.id, + os: 'any', + arch: 'any', + digest: `sha256:${sha256}`, + sizeBytes: BigInt(size), + storagePath, + sourceUrl: '', // Direct upload, no source URL + }, + tx, + ); + + return { + packageId, versionId: packageVersion.id, - os: 'any', - arch: 'any', - digest: `sha256:${sha256}`, - sizeBytes: BigInt(size), - storagePath, - sourceUrl: '', // Direct upload, no source URL - }, tx); - - return { packageId, versionId: packageVersion.id, sha256, size, githubRepo, wasAutoClaimed }; + sha256, + size, + githubRepo, + wasAutoClaimed, + }; }); } catch (error) { // Transaction failed - clean up uploaded file @@ -268,7 +306,10 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { await fastify.storage.deleteBundle(storagePath); fastify.log.info(`Cleaned up uploaded file 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', + ); } // Re-throw to let global error handler sanitize and handle @@ -277,15 +318,17 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { // Fetch GitHub stats asynchronously (non-blocking) if (result.githubRepo) { - fetchGitHubRepoStats(result.githubRepo).then((stats) => { - if (stats) { - packageRepo.updateGitHubStats(result.packageId, stats).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update GitHub stats') - ); - } - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to fetch GitHub stats') - ); + fetchGitHubRepoStats(result.githubRepo) + .then((stats) => { + if (stats) { + packageRepo + .updateGitHubStats(result.packageId, stats) + .catch((err: unknown) => + fastify.log.error({ err }, 'Failed to update GitHub stats'), + ); + } + }) + .catch((err: unknown) => fastify.log.error({ err }, 'Failed to fetch GitHub stats')); } // Non-blocking security scan trigger @@ -299,11 +342,7 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { } // Return success response - const downloadUrl = fastify.storage.getBundleUrl( - scope, - parsedPackageName, - version - ); + const downloadUrl = fastify.storage.getBundleUrl(scope, parsedPackageName, version); const response: Record = { success: true, @@ -319,8 +358,9 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { // Include auto-claim info if package was claimed during publish if (result.wasAutoClaimed) { - response['auto_claimed'] = true; - response['message'] = 'Package published and automatically claimed based on mpak.json verification'; + response.auto_claimed = true; + response.message = + 'Package published and automatically claimed based on mpak.json verification'; } return response; @@ -351,87 +391,92 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { - q, - type, - sort = 'downloads', - limit = '20', - offset = '0', - } = request.query as PackageSearchParams; - - // Convert query params to numbers - const limitNum = parseInt(String(limit), 10) || 20; - const offsetNum = parseInt(String(offset), 10) || 0; - - // 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 { + q, + type, + sort = 'downloads', + limit = '20', + offset = '0', + } = request.query as PackageSearchParams; + + // Convert query params to numbers + const limitNum = parseInt(String(limit), 10) || 20; + const offsetNum = parseInt(String(offset), 10) || 0; - // Search packages - const startTime = Date.now(); - const { packages, total } = await packageRepo.search( - filters, - { + // 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' }; + } + + // Search packages + const startTime = Date.now(); + const { packages, total } = await packageRepo.search(filters, { skip: offsetNum, take: limitNum, orderBy, - } - ); - - fastify.log.info({ - op: 'search', - query: q ?? null, - type: type ?? null, - sort, - results: total, - ms: Date.now() - startTime, - }, `search: q="${q ?? '*'}" returned ${total} results`); - - // Get package versions with tools info and certification - const packagesWithDetails = await Promise.all( - packages.map(async (pkg) => { - const latestVersion = await packageRepo.findVersionWithLatestScan(pkg.id, pkg.latestVersion); - const manifest = (latestVersion?.manifest ?? {}) as Record; - const scan = latestVersion?.securityScans?.[0]; + }); - return { - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - author: pkg.authorName ? { name: pkg.authorName } : null, - latest_version: pkg.latestVersion, - icon: pkg.iconUrl, - server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], - downloads: Number(pkg.totalDownloads), - published_at: latestVersion?.publishedAt ?? pkg.createdAt, - verified: pkg.verified, - claimable: pkg.claimedBy === null, - claimed: pkg.claimedBy !== null, - github: pkg.githubRepo ? { - repo: pkg.githubRepo, - stars: pkg.githubStars, - forks: pkg.githubForks, - watchers: pkg.githubWatchers, - } : null, - certification_level: scan?.certificationLevel ?? null, - }; - }) - ); + fastify.log.info( + { + op: 'search', + query: q ?? null, + type: type ?? null, + sort, + results: total, + ms: Date.now() - startTime, + }, + `search: q="${q ?? '*'}" returned ${total} results`, + ); - return { - packages: packagesWithDetails, - total, - }; + // Get package versions with tools info and certification + const packagesWithDetails = await Promise.all( + packages.map(async (pkg) => { + const latestVersion = await packageRepo.findVersionWithLatestScan( + pkg.id, + pkg.latestVersion, + ); + const manifest = (latestVersion?.manifest ?? {}) as Record; + const scan = latestVersion?.securityScans?.[0]; + + return { + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + author: pkg.authorName ? { name: pkg.authorName } : null, + latest_version: pkg.latestVersion, + icon: pkg.iconUrl, + server_type: pkg.serverType, + tools: (manifest.tools as unknown[]) ?? [], + downloads: Number(pkg.totalDownloads), + published_at: latestVersion?.publishedAt ?? pkg.createdAt, + verified: pkg.verified, + claimable: pkg.claimedBy === null, + claimed: pkg.claimedBy !== null, + github: pkg.githubRepo + ? { + repo: pkg.githubRepo, + stars: pkg.githubStars, + forks: pkg.githubForks, + watchers: pkg.githubWatchers, + } + : null, + certification_level: scan?.certificationLevel ?? null, + }; + }), + ); + + return { + packages: packagesWithDetails, + total, + }; }, }); @@ -453,243 +498,275 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; - const name = `@${scope}/${packageName}`; - - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Package not found'); - } + const { scope, package: packageName } = request.params as { scope: string; package: string }; + const name = `@${scope}/${packageName}`; - // Refresh GitHub stats if stale (>24h) - async, non-blocking - if (pkg.githubRepo) { - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - const isStale = !pkg.githubUpdatedAt || pkg.githubUpdatedAt < oneDayAgo; + const pkg = await packageRepo.findByName(name); - if (isStale) { - fetchGitHubRepoStats(pkg.githubRepo).then((stats) => { - if (stats) { - packageRepo.updateGitHubStats(pkg.id, stats).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update GitHub stats') - ); - } - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to fetch GitHub stats') - ); + if (!pkg) { + throw new NotFoundError('Package not found'); } - } - // Get all versions with artifacts and security scans - const versionsWithArtifactsAndScans = await packageRepo.getVersionsWithArtifactsAndScans(pkg.id); - - // Get latest version manifest - const latestVersion = versionsWithArtifactsAndScans.find(v => v.version === pkg.latestVersion); - const manifest = (latestVersion?.manifest ?? {}) as Record; - - // Check claiming status - const isClaimable = pkg.claimedBy === null; - - // Helper to get certification level name - const getCertificationLevelName = (level: number | null): string | null => { - switch (level) { - case 0: return 'None'; - case 1: return 'Basic'; - case 2: return 'Standard'; - case 3: return 'Verified'; - case 4: return 'Attested'; - default: return null; - } - }; - - // Display names for security domains - const DOMAIN_DISPLAY_NAMES: Record = { - supply_chain: 'Supply Chain', - code_quality: 'Code Quality', - artifact_integrity: 'Artifact Integrity', - provenance: 'Provenance', - capability_declaration: 'Capability Declaration', - }; - - // Human-readable control names - const CONTROL_NAMES: Record = { - 'AI-01': 'Valid Manifest', - 'AI-02': 'File Hashes', - 'SC-01': 'SBOM Generation', - 'SC-02': 'Vulnerability Scan', - 'SC-03': 'Dependency Pinning', - 'CQ-01': 'Secret Detection', - 'CQ-02': 'Malicious Pattern Scan', - 'CQ-03': 'Static Analysis', - 'CQ-06': 'Slopsquat Detection', - 'PR-01': 'Repository Declaration', - 'PR-02': 'Author Verification', - 'CD-01': 'Tool Declaration', - 'CD-02': 'Permission Declaration', - 'CD-03': 'Safety Declaration', - }; - - // Severity sort order (lower = higher priority) - const SEVERITY_ORDER: Record = { - critical: 0, - high: 1, - medium: 2, - low: 3, - info: 4, - }; - - // Helper to transform security scan for API response - const transformSecurityScan = (scan: Record) => { - if (!scan) return null; - - const report = scan['report'] as Record | undefined; - const scans = (report?.['scans'] ?? {}) as Record; - - // Calculate summary from scan results - const sbomFindings = ((scans['sbom'] as Record)?.['findings'] ?? []) as unknown[]; - const vulnFindings = ((scans['vulnerability'] as Record)?.['findings'] ?? []) as Array>; - const secretFindings = ((scans['secrets'] as Record)?.['findings'] ?? []) as unknown[]; - const maliciousFindings = ((scans['malicious'] as Record)?.['findings'] ?? []) as unknown[]; - const staticFindings = ((scans['static_analysis'] as Record)?.['findings'] ?? []) as unknown[]; - - // Count vulnerabilities by severity - const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 }; - for (const f of vulnFindings) { - const sev = (f['severity'] as string | undefined)?.toLowerCase(); - if (sev === 'critical') vulnCounts.critical++; - else if (sev === 'high') vulnCounts.high++; - else if (sev === 'medium') vulnCounts.medium++; - else if (sev === 'low') vulnCounts.low++; + // Refresh GitHub stats if stale (>24h) - async, non-blocking + if (pkg.githubRepo) { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const isStale = !pkg.githubUpdatedAt || pkg.githubUpdatedAt < oneDayAgo; + + if (isStale) { + fetchGitHubRepoStats(pkg.githubRepo) + .then((stats) => { + if (stats) { + packageRepo + .updateGitHubStats(pkg.id, stats) + .catch((err: unknown) => + fastify.log.error({ err }, 'Failed to update GitHub stats'), + ); + } + }) + .catch((err: unknown) => fastify.log.error({ err }, 'Failed to fetch GitHub stats')); + } } - // Transform domains from report - const reportDomains = report?.['domains'] as Record> | undefined; - let domains: Record | undefined; - if (reportDomains) { - domains = {}; - for (const [domainKey, domain] of Object.entries(reportDomains)) { - const controls = domain['controls'] as Record> | undefined; - let controlsPassed = 0; - let controlsTotal = 0; - const transformedControls: Record = {}; - - if (controls) { - for (const [controlId, control] of Object.entries(controls)) { - const status = control['status'] as string; - controlsTotal++; - if (status === 'pass') controlsPassed++; - transformedControls[controlId] = { - status, - name: CONTROL_NAMES[controlId] || controlId, - findings_count: ((control['findings'] as unknown[]) ?? []).length, - }; + // Get all versions with artifacts and security scans + const versionsWithArtifactsAndScans = await packageRepo.getVersionsWithArtifactsAndScans( + pkg.id, + ); + + // Get latest version manifest + const latestVersion = versionsWithArtifactsAndScans.find( + (v) => v.version === pkg.latestVersion, + ); + const manifest = (latestVersion?.manifest ?? {}) as Record; + + // Check claiming status + const isClaimable = pkg.claimedBy === null; + + // Helper to get certification level name + const getCertificationLevelName = (level: number | null): string | null => { + switch (level) { + case 0: + return 'None'; + case 1: + return 'Basic'; + case 2: + return 'Standard'; + case 3: + return 'Verified'; + case 4: + return 'Attested'; + default: + return null; + } + }; + + // Display names for security domains + const DOMAIN_DISPLAY_NAMES: Record = { + supply_chain: 'Supply Chain', + code_quality: 'Code Quality', + artifact_integrity: 'Artifact Integrity', + provenance: 'Provenance', + capability_declaration: 'Capability Declaration', + }; + + // Human-readable control names + const CONTROL_NAMES: Record = { + 'AI-01': 'Valid Manifest', + 'AI-02': 'File Hashes', + 'SC-01': 'SBOM Generation', + 'SC-02': 'Vulnerability Scan', + 'SC-03': 'Dependency Pinning', + 'CQ-01': 'Secret Detection', + 'CQ-02': 'Malicious Pattern Scan', + 'CQ-03': 'Static Analysis', + 'CQ-06': 'Slopsquat Detection', + 'PR-01': 'Repository Declaration', + 'PR-02': 'Author Verification', + 'CD-01': 'Tool Declaration', + 'CD-02': 'Permission Declaration', + 'CD-03': 'Safety Declaration', + }; + + // Severity sort order (lower = higher priority) + const SEVERITY_ORDER: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, + info: 4, + }; + + // Helper to transform security scan for API response + const transformSecurityScan = (scan: Record) => { + if (!scan) return null; + + const report = scan.report as Record | undefined; + const scans = (report?.scans ?? {}) as Record; + + // Calculate summary from scan results + const sbomFindings = ((scans.sbom as Record)?.findings ?? []) as unknown[]; + const vulnFindings = ((scans.vulnerability as Record)?.findings ?? + []) as Array>; + const secretFindings = ((scans.secrets as Record)?.findings ?? + []) as unknown[]; + const maliciousFindings = ((scans.malicious as Record)?.findings ?? + []) as unknown[]; + const staticFindings = ((scans.static_analysis as Record)?.findings ?? + []) as unknown[]; + + // Count vulnerabilities by severity + const vulnCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const f of vulnFindings) { + const sev = (f.severity as string | undefined)?.toLowerCase(); + if (sev === 'critical') vulnCounts.critical++; + else if (sev === 'high') vulnCounts.high++; + else if (sev === 'medium') vulnCounts.medium++; + else if (sev === 'low') vulnCounts.low++; + } + + // Transform domains from report + const reportDomains = report?.domains as + | Record> + | undefined; + let domains: Record | undefined; + if (reportDomains) { + domains = {}; + for (const [domainKey, domain] of Object.entries(reportDomains)) { + const controls = domain.controls as Record> | undefined; + let controlsPassed = 0; + let controlsTotal = 0; + const transformedControls: Record = {}; + + if (controls) { + for (const [controlId, control] of Object.entries(controls)) { + const status = control.status as string; + controlsTotal++; + if (status === 'pass') controlsPassed++; + transformedControls[controlId] = { + status, + name: CONTROL_NAMES[controlId] || controlId, + findings_count: ((control.findings as unknown[]) ?? []).length, + }; + } } - } - domains[domainKey] = { - display_name: DOMAIN_DISPLAY_NAMES[domainKey] || domainKey, - controls_passed: controlsPassed, - controls_total: controlsTotal, - controls: transformedControls, - }; + domains[domainKey] = { + display_name: DOMAIN_DISPLAY_NAMES[domainKey] || domainKey, + controls_passed: controlsPassed, + controls_total: controlsTotal, + controls: transformedControls, + }; + } } - } - // Transform and sort findings from report (exclude info severity) - const reportFindings = (report?.['findings'] as Array>) ?? []; - const findings = reportFindings - .filter((f) => (f['severity'] as string) !== 'info') - .sort((a, b) => (SEVERITY_ORDER[a['severity'] as string] ?? 4) - (SEVERITY_ORDER[b['severity'] as string] ?? 4)) - .map((f) => ({ - id: f['id'] as string, - control: f['control'] as string, - severity: f['severity'] as string, - title: f['title'] as string, - description: f['description'] as string, - file: (f['file'] as string) ?? null, - line: (f['line'] as number) ?? null, - remediation: (f['remediation'] as string) ?? null, - })); - - // Extract scanner version from report metadata - const scannerVersion = extractScannerVersion(report); + // Transform and sort findings from report (exclude info severity) + const reportFindings = (report?.findings as Array>) ?? []; + const findings = reportFindings + .filter((f) => (f.severity as string) !== 'info') + .sort( + (a, b) => + (SEVERITY_ORDER[a.severity as string] ?? 4) - + (SEVERITY_ORDER[b.severity as string] ?? 4), + ) + .map((f) => ({ + id: f.id as string, + control: f.control as string, + severity: f.severity as string, + title: f.title as string, + description: f.description as string, + file: (f.file as string) ?? null, + line: (f.line as number) ?? null, + remediation: (f.remediation as string) ?? null, + })); + + // Extract scanner version from report metadata + const scannerVersion = extractScannerVersion(report); + + return { + status: scan.status, + risk_score: scan.riskScore, + scanned_at: scan.completedAt, + scanner_version: scannerVersion, + certification: + scan.certificationLevel !== null + ? { + level: scan.certificationLevel, + level_name: getCertificationLevelName(scan.certificationLevel as number | null), + controls_passed: scan.controlsPassed, + controls_failed: scan.controlsFailed, + controls_total: scan.controlsTotal, + } + : null, + summary: { + components: + ((report?.sbom as Record)?.component_count as number) ?? + sbomFindings.filter((f) => (f as Record).purl).length, + vulnerabilities: vulnCounts, + secrets: secretFindings.length, + malicious: maliciousFindings.length, + code_issues: staticFindings.length, + }, + domains: domains || undefined, + findings: findings.length > 0 ? findings : undefined, + }; + }; return { - status: scan['status'], - risk_score: scan['riskScore'], - scanned_at: scan['completedAt'], - scanner_version: scannerVersion, - certification: scan['certificationLevel'] !== null ? { - level: scan['certificationLevel'], - level_name: getCertificationLevelName(scan['certificationLevel'] as number | null), - controls_passed: scan['controlsPassed'], - controls_failed: scan['controlsFailed'], - controls_total: scan['controlsTotal'], - } : null, - summary: { - components: ((report?.['sbom'] as Record)?.['component_count'] as number) - ?? sbomFindings.filter((f) => (f as Record)['purl']).length, - vulnerabilities: vulnCounts, - secrets: secretFindings.length, - malicious: maliciousFindings.length, - code_issues: staticFindings.length, + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + author: pkg.authorName ? { name: pkg.authorName } : null, + latest_version: pkg.latestVersion, + icon: pkg.iconUrl, + server_type: pkg.serverType, + tools: (manifest.tools as unknown[]) ?? [], + downloads: Number(pkg.totalDownloads), + published_at: pkg.createdAt, + verified: pkg.verified, + homepage: pkg.homepage, + license: pkg.license, + claiming: { + claimable: isClaimable, + claimed: pkg.claimedBy !== null, + claimed_by: pkg.claimedBy, + claimed_at: pkg.claimedAt, + github_repo: pkg.githubRepo, }, - domains: domains || undefined, - findings: findings.length > 0 ? findings : undefined, - }; - }; - - return { - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - author: pkg.authorName ? { name: pkg.authorName } : null, - latest_version: pkg.latestVersion, - icon: pkg.iconUrl, - server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], - downloads: Number(pkg.totalDownloads), - published_at: pkg.createdAt, - verified: pkg.verified, - homepage: pkg.homepage, - license: pkg.license, - claiming: { - claimable: isClaimable, - claimed: pkg.claimedBy !== null, - claimed_by: pkg.claimedBy, - claimed_at: pkg.claimedAt, - github_repo: pkg.githubRepo, - }, - github: pkg.githubRepo ? { - repo: pkg.githubRepo, - stars: pkg.githubStars, - forks: pkg.githubForks, - watchers: pkg.githubWatchers, - updated_at: pkg.githubUpdatedAt, - } : null, - versions: versionsWithArtifactsAndScans.map((v) => ({ - version: v.version, - published_at: v.publishedAt, - downloads: Number(v.downloadCount), - readme: v.readme, - release_url: v.releaseUrl, - prerelease: v.prerelease, - manifest: v.manifest, - provenance: v.publishMethod ? { - publish_method: v.publishMethod, - repository: v.provenanceRepository, - sha: v.provenanceSha, - } : null, - artifacts: v.artifacts.map((a) => ({ - os: a.os, - arch: a.arch, - size_bytes: Number(a.sizeBytes), - digest: a.digest, - downloads: Number(a.downloadCount), + github: pkg.githubRepo + ? { + repo: pkg.githubRepo, + stars: pkg.githubStars, + forks: pkg.githubForks, + watchers: pkg.githubWatchers, + updated_at: pkg.githubUpdatedAt, + } + : null, + versions: versionsWithArtifactsAndScans.map((v) => ({ + version: v.version, + published_at: v.publishedAt, + downloads: Number(v.downloadCount), + readme: v.readme, + release_url: v.releaseUrl, + prerelease: v.prerelease, + manifest: v.manifest, + provenance: v.publishMethod + ? { + publish_method: v.publishMethod, + repository: v.provenanceRepository, + sha: v.provenanceSha, + } + : null, + artifacts: v.artifacts.map((a) => ({ + os: a.os, + arch: a.arch, + size_bytes: Number(a.sizeBytes), + digest: a.digest, + downloads: Number(a.downloadCount), + })), + security_scan: v.securityScans[0] + ? transformSecurityScan(v.securityScans[0] as unknown as Record) + : null, })), - security_scan: v.securityScans[0] ? transformSecurityScan(v.securityScans[0] as unknown as Record) : null, - })), - }; + }; }, }); @@ -708,25 +785,25 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request, reply) => { - const { scope, package: packageName } = request.params as { - scope: string; - package: string; - }; - const name = `@${scope}/${packageName}`; + const { scope, package: packageName } = request.params as { + scope: string; + package: string; + }; + const name = `@${scope}/${packageName}`; - // Get package - const pkg = await packageRepo.findByName(name); + // Get package + const pkg = await packageRepo.findByName(name); - if (!pkg) { - throw new NotFoundError('Package not found'); - } + if (!pkg) { + throw new NotFoundError('Package not found'); + } - // Redirect to the actual latest version - const latestVersionUrl = `/app/packages/@${scope}/${packageName}/versions/${pkg.latestVersion}/download`; + // Redirect to the actual latest version + const latestVersionUrl = `/app/packages/@${scope}/${packageName}/versions/${pkg.latestVersion}/download`; - // Use 302 (temporary redirect) so CDN/browsers don't cache permanently - // This ensures they always check for the latest version - return reply.code(302).redirect(latestVersionUrl); + // Use 302 (temporary redirect) so CDN/browsers don't cache permanently + // This ensures they always check for the latest version + return reply.code(302).redirect(latestVersionUrl); }, }); @@ -757,107 +834,116 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request, reply) => { - const { scope, package: packageName, version } = request.params as { - scope: string; - package: string; - version: string; - }; - const { os, arch } = request.query as { os?: string; arch?: string }; - const name = `@${scope}/${packageName}`; - - // Get package and version with artifacts - const pkg = await packageRepo.findByName(name); - - if (!pkg) { - throw new NotFoundError('Package not found'); - } + const { + scope, + package: packageName, + version, + } = request.params as { + scope: string; + package: string; + version: string; + }; + const { os, arch } = request.query as { os?: string; arch?: string }; + const name = `@${scope}/${packageName}`; - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + // Get package and version with artifacts + const pkg = await packageRepo.findByName(name); - if (!packageVersion) { - throw new NotFoundError('Version not found'); - } + if (!pkg) { + throw new NotFoundError('Package not found'); + } - // Select artifact based on platform query params, or fall back to universal/first - let artifact = packageVersion.artifacts[0]; - if (os && arch) { - // Try exact match first - const exactMatch = packageVersion.artifacts.find(a => a.os === os && a.arch === arch); - if (exactMatch) { - artifact = exactMatch; - } else { - // Fall back to universal artifact if available - const universal = packageVersion.artifacts.find(a => a.os === 'any' && a.arch === 'any'); - if (universal) { - artifact = universal; - } + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + + if (!packageVersion) { + throw new NotFoundError('Version not found'); } - } - if (!artifact) { - throw new NotFoundError('No artifacts found for this version'); - } + // Select artifact based on platform query params, or fall back to universal/first + let artifact = packageVersion.artifacts[0]; + if (os && arch) { + // Try exact match first + const exactMatch = packageVersion.artifacts.find((a) => a.os === os && a.arch === arch); + if (exactMatch) { + artifact = exactMatch; + } else { + // Fall back to universal artifact if available + const universal = packageVersion.artifacts.find( + (a) => a.os === 'any' && a.arch === 'any', + ); + if (universal) { + artifact = universal; + } + } + } - // Log download - const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; - fastify.log.info({ - op: 'download', - pkg: name, - version, - platform, - }, `download: ${name}@${version} (${platform})`); - - // Increment download counts atomically in a single transaction - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, version, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update download counts') - ); - - // Check if client wants JSON response (CLI/API) or redirect (browser) - const acceptHeader = request.headers.accept ?? ''; - const wantsJson = acceptHeader.includes('application/json'); - - // Generate signed download URL using the actual storage path - // This ensures the URL matches where the file was actually stored - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); - - if (wantsJson) { - // CLI/API mode: Return JSON with download URL and metadata - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900)); + if (!artifact) { + throw new NotFoundError('No artifacts found for this version'); + } - return { - url: downloadUrl, - package: { - name, + // Log download + const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; + fastify.log.info( + { + op: 'download', + pkg: name, version, - sha256: artifact.digest.replace('sha256:', ''), - size: Number(artifact.sizeBytes), + platform, }, - expires_at: expiresAt.toISOString(), - }; - } else { - // Browser mode: Redirect to download URL - // For local storage, this will be back to the server - // For S3/CloudFront, this will be a signed CDN URL - - // Check if this is a local storage URL (starts with /) - if (downloadUrl.startsWith('/')) { - // Local storage - serve file directly - const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); - - return reply - .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) - .send(fileBuffer); + `download: ${name}@${version} (${platform})`, + ); + + // Increment download counts atomically in a single transaction + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, version, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => fastify.log.error({ err }, 'Failed to update download counts')); + + // Check if client wants JSON response (CLI/API) or redirect (browser) + const acceptHeader = request.headers.accept ?? ''; + const wantsJson = acceptHeader.includes('application/json'); + + // Generate signed download URL using the actual storage path + // This ensures the URL matches where the file was actually stored + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); + + if (wantsJson) { + // CLI/API mode: Return JSON with download URL and metadata + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); + + return { + url: downloadUrl, + package: { + name, + version, + sha256: artifact.digest.replace('sha256:', ''), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; } else { - // S3/CloudFront - redirect to signed URL - return reply.code(302).redirect(downloadUrl); + // Browser mode: Redirect to download URL + // For local storage, this will be back to the server + // For S3/CloudFront, this will be a signed CDN URL + + // Check if this is a local storage URL (starts with /) + if (downloadUrl.startsWith('/')) { + // Local storage - serve file directly + const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); + + return reply + .header('Content-Type', 'application/octet-stream') + .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) + .send(fileBuffer); + } else { + // S3/CloudFront - redirect to signed URL + return reply.code(302).redirect(downloadUrl); + } } - } }, }); @@ -880,54 +966,54 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { scope, package: packageName } = request.params as { scope: string; package: string }; - const name = `@${scope}/${packageName}`; - - // Try to authenticate (optional - won't fail if not authenticated) - try { - await fastify.authenticate(request); - } catch { - // Not authenticated - that's okay for this endpoint - } + const { scope, package: packageName } = request.params as { scope: string; package: string }; + const name = `@${scope}/${packageName}`; - const pkg = await packageRepo.findByName(name); + // Try to authenticate (optional - won't fail if not authenticated) + try { + await fastify.authenticate(request); + } catch { + // Not authenticated - that's okay for this endpoint + } - if (!pkg) { - throw new NotFoundError('Package not found'); - } + const pkg = await packageRepo.findByName(name); + + if (!pkg) { + throw new NotFoundError('Package not found'); + } + + const isClaimable = await packageRepo.isClaimable(name); + + if (!isClaimable) { + return { + claimable: false, + reason: pkg.claimedBy ? 'Package already claimed' : 'Package cannot be claimed', + claimed_by: pkg.claimedBy, + claimed_at: pkg.claimedAt, + }; + } - const isClaimable = await packageRepo.isClaimable(name); + // Generate example mpak.json for the user (use their GitHub username if authenticated) + const githubUsername = request.user?.githubUsername ?? 'your-github-username'; + const exampleMpakJson = generateMpakJsonExample(name, githubUsername); - if (!isClaimable) { return { - claimable: false, - reason: pkg.claimedBy ? 'Package already claimed' : 'Package cannot be claimed', - claimed_by: pkg.claimedBy, - claimed_at: pkg.claimedAt, + claimable: true, + package_name: name, + github_repo: pkg.githubRepo, + instructions: { + steps: [ + `Create a file named "mpak.json" in the root of your GitHub repository${pkg.githubRepo ? ` (${pkg.githubRepo})` : ''}`, + 'Add the content shown in the "mpak_json_example" field below', + 'Commit and push the file to your main or master branch', + 'Come back here and click the "Claim Package" button', + ], + mpak_json_example: exampleMpakJson, + verification_url: pkg.githubRepo + ? `https://github.com/${pkg.githubRepo}/blob/main/mpak.json` + : null, + }, }; - } - - // Generate example mpak.json for the user (use their GitHub username if authenticated) - const githubUsername = request.user?.githubUsername ?? 'your-github-username'; - const exampleMpakJson = generateMpakJsonExample(name, githubUsername); - - return { - claimable: true, - package_name: name, - github_repo: pkg.githubRepo, - instructions: { - steps: [ - `Create a file named "mpak.json" in the root of your GitHub repository${pkg.githubRepo ? ` (${pkg.githubRepo})` : ''}`, - 'Add the content shown in the "mpak_json_example" field below', - 'Commit and push the file to your main or master branch', - 'Come back here and click the "Claim Package" button', - ], - mpak_json_example: exampleMpakJson, - verification_url: pkg.githubRepo - ? `https://github.com/${pkg.githubRepo}/blob/main/mpak.json` - : null, - }, - }; }, }); @@ -982,14 +1068,16 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { if (!repoToVerify) { throw new BadRequestError( - 'GitHub repository required. Please provide the github_repo field in your request body (e.g., "owner/repo")' + 'GitHub repository required. Please provide the github_repo field in your request body (e.g., "owner/repo")', ); } // Parse and validate GitHub repo format const parsedRepo = parseGitHubRepo(repoToVerify); if (!parsedRepo) { - throw new BadRequestError('Invalid GitHub repository format. Use format "owner/repo" or full GitHub URL'); + throw new BadRequestError( + 'Invalid GitHub repository format. Use format "owner/repo" or full GitHub URL', + ); } // Get GitHub username from user profile @@ -997,16 +1085,12 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { if (!githubUsername) { throw new BadRequestError( - 'GitHub account not linked. Please link your GitHub account to claim packages. You need to sign in with GitHub via Clerk.' + 'GitHub account not linked. Please link your GitHub account to claim packages. You need to sign in with GitHub via Clerk.', ); } // Verify package claim by checking mpak.json - const verificationResult = await verifyPackageClaim( - name, - repoToVerify, - githubUsername - ); + const verificationResult = await verifyPackageClaim(name, repoToVerify, githubUsername); if (!verificationResult.verified) { throw new ForbiddenError(verificationResult.error ?? 'Verification failed', { @@ -1023,22 +1107,18 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { } // Claim the package - const claimedPackage = await packageRepo.claimPackage( - name, - user.userId, - repoToVerify - ); + const claimedPackage = await packageRepo.claimPackage(name, user.userId, repoToVerify); // Fetch GitHub stats asynchronously (non-blocking) - fetchGitHubRepoStats(repoToVerify).then((stats) => { - if (stats) { - packageRepo.updateGitHubStats(claimedPackage.id, stats).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update GitHub stats') - ); - } - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to fetch GitHub stats') - ); + fetchGitHubRepoStats(repoToVerify) + .then((stats) => { + if (stats) { + packageRepo + .updateGitHubStats(claimedPackage.id, stats) + .catch((err: unknown) => fastify.log.error({ err }, 'Failed to update GitHub stats')); + } + }) + .catch((err: unknown) => fastify.log.error({ err }, 'Failed to fetch GitHub stats')); return { success: true, @@ -1079,7 +1159,11 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { handler: async (request) => { const user = request.user!; - const { limit = '20', offset = '0', sort = 'recent' } = request.query as { + const { + limit = '20', + offset = '0', + sort = 'recent', + } = request.query as { limit?: string; offset?: string; sort?: string; @@ -1106,7 +1190,7 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { skip: offsetNum, take: limitNum, orderBy, - } + }, ); // Get package versions with tools info @@ -1123,20 +1207,22 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { latest_version: pkg.latestVersion, icon: pkg.iconUrl, server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], + tools: (manifest.tools as unknown[]) ?? [], downloads: Number(pkg.totalDownloads), published_at: latestVersion?.publishedAt ?? pkg.createdAt, verified: pkg.verified, claimable: pkg.claimedBy === null, claimed: pkg.claimedBy !== null, - github: pkg.githubRepo ? { - repo: pkg.githubRepo, - stars: pkg.githubStars, - forks: pkg.githubForks, - watchers: pkg.githubWatchers, - } : null, + github: pkg.githubRepo + ? { + repo: pkg.githubRepo, + stars: pkg.githubStars, + forks: pkg.githubForks, + watchers: pkg.githubWatchers, + } + : null, }; - }) + }), ); return { @@ -1169,48 +1255,52 @@ export const packageRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request) => { - const { limit = '20', offset = '0', sort = 'recent' } = request.query as { - limit?: string; - offset?: string; - sort?: string; - }; - - // Convert query params to numbers - const limitNum = parseInt(String(limit), 10) || 20; - const offsetNum = parseInt(String(offset), 10) || 0; - - // Build sort options - let orderBy: Record = { createdAt: 'desc' }; - if (sort === 'name') { - orderBy = { name: 'asc' }; - } else if (sort === 'downloads') { - orderBy = { totalDownloads: 'desc' }; - } + const { + limit = '20', + offset = '0', + sort = 'recent', + } = request.query as { + limit?: string; + offset?: string; + sort?: string; + }; - const { packages, total } = await packageRepo.findUnclaimed({ - skip: offsetNum, - take: limitNum, - orderBy, - }); + // Convert query params to numbers + const limitNum = parseInt(String(limit), 10) || 20; + const offsetNum = parseInt(String(offset), 10) || 0; - return { - packages: packages.map((pkg) => ({ - name: pkg.name, - display_name: pkg.displayName, - description: pkg.description, - server_type: pkg.serverType, - latest_version: pkg.latestVersion, - downloads: Number(pkg.totalDownloads), - github_repo: pkg.githubRepo, - created_at: pkg.createdAt, - })), - total, - pagination: { - limit: limitNum, - offset: offsetNum, - has_more: offsetNum + packages.length < total, - }, - }; + // Build sort options + let orderBy: Record = { createdAt: 'desc' }; + if (sort === 'name') { + orderBy = { name: 'asc' }; + } else if (sort === 'downloads') { + orderBy = { totalDownloads: 'desc' }; + } + + const { packages, total } = await packageRepo.findUnclaimed({ + skip: offsetNum, + take: limitNum, + orderBy, + }); + + return { + packages: packages.map((pkg) => ({ + name: pkg.name, + display_name: pkg.displayName, + description: pkg.description, + server_type: pkg.serverType, + latest_version: pkg.latestVersion, + downloads: Number(pkg.totalDownloads), + github_repo: pkg.githubRepo, + created_at: pkg.createdAt, + })), + total, + pagination: { + limit: limitNum, + offset: offsetNum, + has_more: offsetNum + packages.length < total, + }, + }; }, }); }; diff --git a/apps/registry/src/routes/scanner.ts b/apps/registry/src/routes/scanner.ts index f2ff2e8..17a4566 100644 --- a/apps/registry/src/routes/scanner.ts +++ b/apps/registry/src/routes/scanner.ts @@ -5,12 +5,12 @@ * Public endpoints for viewing scan status and security badges */ +import { timingSafeEqual } from 'node:crypto'; import type { FastifyPluginAsync } from 'fastify'; -import { timingSafeEqual } from 'crypto'; import { z } from 'zod'; import { config } from '../config.js'; +import { ForbiddenError, handleError, NotFoundError, UnauthorizedError } from '../errors/index.js'; import { toJsonSchema } from '../lib/zod-schema.js'; -import { ForbiddenError, NotFoundError, UnauthorizedError, handleError } from '../errors/index.js'; import { triggerSecurityScan } from '../services/scanner.js'; // Callback request schema @@ -29,22 +29,31 @@ const SecuritySummarySchema = z.object({ risk_score: z.string().nullable(), status: z.string(), scanned_at: z.string().nullable(), - summary: z.object({ - critical_findings: z.number(), - high_findings: z.number(), - medium_findings: z.number(), - low_findings: z.number(), - total_findings: z.number(), - }).nullable(), - scans: z.record(z.string(), z.object({ - status: z.string(), - finding_count: z.number(), - })).nullable(), + summary: z + .object({ + critical_findings: z.number(), + high_findings: z.number(), + medium_findings: z.number(), + low_findings: z.number(), + total_findings: z.number(), + }) + .nullable(), + scans: z + .record( + z.string(), + z.object({ + status: z.string(), + finding_count: z.number(), + }), + ) + .nullable(), }); // Manual scan trigger request schema const ScanTriggerSchema = z.object({ - packageName: z.string().regex(/^@[a-z0-9-]+\/[a-z0-9-]+$/, 'Must be scoped package name like @scope/name'), + packageName: z + .string() + .regex(/^@[a-z0-9-]+\/[a-z0-9-]+$/, 'Must be scoped package name like @scope/name'), version: z.string().optional(), }); @@ -76,15 +85,17 @@ function extractCertificationData(report: Record | null): Certi } // Extract compliance data from mpak-scanner report format - const compliance = report['compliance'] as { - level?: number; - controls_passed?: number; - controls_failed?: number; - controls_total?: number; - } | undefined; + const compliance = report.compliance as + | { + level?: number; + controls_passed?: number; + controls_failed?: number; + controls_total?: number; + } + | undefined; // Extract findings for summary - const findings = report['findings'] as Array<{ severity?: string }> | undefined; + const findings = report.findings as Array<{ severity?: string }> | undefined; let critical = 0; let high = 0; let medium = 0; @@ -118,7 +129,6 @@ function extractCertificationData(report: Record | null): Certi }; } - /** * Extract finding counts from scan report */ @@ -131,7 +141,7 @@ function extractFindingCounts(report: Record | null): { } // First try mpak-scanner format (findings array at top level) - const findings = report['findings'] as Array<{ severity?: string }> | undefined; + const findings = report.findings as Array<{ severity?: string }> | undefined; if (Array.isArray(findings)) { let critical = 0; let high = 0; @@ -156,7 +166,9 @@ function extractFindingCounts(report: Record | null): { } // Extract domain-level scan status from mpak-scanner format - const domains = report['domains'] as Record }> | undefined; + const domains = report.domains as + | Record }> + | undefined; const scans: Record = {}; if (domains) { @@ -274,7 +286,8 @@ export const scannerRoutes: FastifyPluginAsync = async (fastify) => { throw new UnauthorizedError('Invalid callback secret'); } - const { scan_id, status, risk_score, report, report_s3_uri, pdf_s3_uri, error } = request.body; + const { scan_id, status, risk_score, report, report_s3_uri, pdf_s3_uri, error } = + request.body; // Find the scan record const scan = await fastify.prisma.securityScan.findUnique({ @@ -288,7 +301,10 @@ export const scannerRoutes: FastifyPluginAsync = async (fastify) => { // Reject callbacks for already-completed scans (idempotency guard) if (scan.status === 'completed' || scan.status === 'failed') { - fastify.log.warn({ scanId: scan_id, existingStatus: scan.status }, 'Received callback for already-finalized scan, ignoring'); + fastify.log.warn( + { scanId: scan_id, existingStatus: scan.status }, + 'Received callback for already-finalized scan, ignoring', + ); return { success: true }; } @@ -315,13 +331,16 @@ export const scannerRoutes: FastifyPluginAsync = async (fastify) => { }, }); - fastify.log.info({ - scanId: scan_id, - status, - riskScore: risk_score, - certificationLevel: certData.certificationLevel, - controlsPassed: certData.controlsPassed, - }, `Scan callback received: ${status}`); + fastify.log.info( + { + scanId: scan_id, + status, + riskScore: risk_score, + certificationLevel: certData.certificationLevel, + controlsPassed: certData.controlsPassed, + }, + `Scan callback received: ${status}`, + ); return { success: true }; } catch (err) { @@ -342,11 +361,13 @@ export const scannerRoutes: FastifyPluginAsync = async (fastify) => { description: 'Manually trigger a security scan (admin only)', body: toJsonSchema(ScanTriggerSchema), response: { - 200: toJsonSchema(z.object({ - success: z.boolean(), - scanId: z.string().optional(), - message: z.string(), - })), + 200: toJsonSchema( + z.object({ + success: z.boolean(), + scanId: z.string().optional(), + message: z.string(), + }), + ), }, }, handler: async (request, reply) => { @@ -427,12 +448,15 @@ export const scannerRoutes: FastifyPluginAsync = async (fastify) => { orderBy: { startedAt: 'desc' }, }); - fastify.log.info({ - packageName, - version: targetVersion, - scanId: newScan?.scanId, - triggeredBy: request.user?.email, - }, 'Manual scan triggered'); + fastify.log.info( + { + packageName, + version: targetVersion, + scanId: newScan?.scanId, + triggeredBy: request.user?.email, + }, + 'Manual scan triggered', + ); return { success: true, @@ -462,10 +486,12 @@ export const securityRoutes: FastifyPluginAsync = async (fastify) => { schema: { tags: ['bundles', 'security'], description: 'Get security scan status for a bundle', - params: toJsonSchema(z.object({ - scope: z.string(), - package: z.string(), - })), + params: toJsonSchema( + z.object({ + scope: z.string(), + package: z.string(), + }), + ), response: { 200: toJsonSchema(SecuritySummarySchema), }, @@ -511,7 +537,9 @@ export const securityRoutes: FastifyPluginAsync = async (fastify) => { }; } - const { summary, scans } = extractFindingCounts(scan.report as Record | null); + const { summary, scans } = extractFindingCounts( + scan.report as Record | null, + ); return { risk_score: scan.riskScore, @@ -525,5 +553,4 @@ export const securityRoutes: FastifyPluginAsync = async (fastify) => { } }, }); - }; diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 483762f..872dddc 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,43 +1,43 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, createWriteStream, promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + AnnounceRequestSchema, + AnnounceResponseSchema, + BundleDetailSchema, + type BundleDownloadParams, + BundleDownloadParamsSchema, + type BundleSearchParams, + BundleSearchParamsSchema, + type BundleSearchResponse, + BundleSearchResponseSchema, + DownloadInfoSchema, + MCPBIndexSchema, + type PackageTool, + VersionDetailSchema, + VersionsResponseSchema, +} from '@nimblebrain/mpak-schemas'; import type { Artifact } from '@prisma/client'; import type { FastifyPluginAsync } from 'fastify'; -import { createHash, randomUUID } from 'crypto'; -import { createWriteStream, createReadStream, promises as fs } from 'fs'; -import { tmpdir } from 'os'; -import path from 'path'; import { config } from '../../config.js'; import { runInTransaction } from '../../db/index.js'; +import 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, - BundleDetailSchema, - VersionsResponseSchema, - VersionDetailSchema, - DownloadInfoSchema, - MCPBIndexSchema, - AnnounceRequestSchema, - AnnounceResponseSchema, - 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, + BundleVersionPathParamsSchema, } from '../../schemas/bundles.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,11 +72,7 @@ function isValidScopedPackageName(name: string): boolean { * - 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 { +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'); } @@ -196,28 +192,31 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { // 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: offset, - take: limit, - 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]; @@ -229,14 +228,14 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { latest_version: pkg.latestVersion, icon: pkg.iconUrl, server_type: pkg.serverType, - tools: (manifest['tools'] as PackageTool[]) ?? [], + tools: (manifest.tools as PackageTool[]) ?? [], downloads: Number(pkg.totalDownloads), - published_at: latestVersion?.publishedAt ?? pkg.createdAt as Date, + published_at: latestVersion?.publishedAt ?? (pkg.createdAt as Date), verified: Boolean(pkg.verified), provenance: latestVersion ? getProvenanceSummary(latestVersion) : null, certification_level: scan?.certificationLevel ?? null, }; - }) + }), ); const response: BundleSearchResponse = { @@ -288,15 +287,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, @@ -306,7 +313,7 @@ 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 unknown[]) ?? [], downloads: Number(pkg.totalDownloads), published_at: pkg.createdAt, verified: pkg.verified, @@ -328,7 +335,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: { @@ -429,7 +437,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 @@ -442,9 +450,16 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { 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.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, + }), }, }; @@ -518,7 +533,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; @@ -549,7 +568,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { download_url: downloadUrl, source_url: a.sourceUrl || undefined, }; - }) + }), ); return { @@ -559,10 +578,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), }; @@ -613,21 +634,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 ?? ''; @@ -639,7 +661,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, @@ -674,7 +698,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), @@ -685,19 +710,24 @@ 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); const announceStart = Date.now(); // Verify the OIDC token - let claims; + let claims: Awaited>; try { claims = await verifyGitHubOIDC(token); } catch (error) { const message = error instanceof Error ? error.message : 'Token verification failed'; - fastify.log.warn({ op: 'announce', error: message }, `announce: OIDC verification failed`); + fastify.log.warn( + { op: 'announce', error: message }, + `announce: OIDC verification failed`, + ); throw new UnauthorizedError(`Invalid OIDC token: ${message}`); } @@ -732,26 +762,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: "${rawName}". Must be scoped (@scope/name) with alphanumeric characters and hyphens.` + `Invalid package name: "${rawName}". Must be scoped (@scope/name) with alphanumeric characters and hyphens.`, ); } @@ -765,35 +795,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); + 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 @@ -805,17 +843,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[]; @@ -823,27 +863,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']; + 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) @@ -856,7 +905,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 @@ -887,7 +938,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`, ); } @@ -895,7 +946,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}`, ); } @@ -908,11 +959,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(() => {}); @@ -928,21 +981,28 @@ 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 @@ -951,7 +1011,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { let readme: string | null = null; const existingVersion = await packageRepo.findVersion(packageId, version, tx); - if (!existingVersion || !existingVersion.readme) { + if (!existingVersion?.readme) { // Fetch README.md from the repository at the release tag try { const readmeUrl = `https://api.github.com/repos/${claims.repository}/contents/README.md?ref=${release_tag}`; @@ -959,41 +1019,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; @@ -1003,7 +1073,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); } @@ -1011,15 +1085,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; @@ -1037,7 +1114,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; } @@ -1048,21 +1128,27 @@ 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 }); @@ -1089,7 +1175,10 @@ 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/routes/v1/skills.ts b/apps/registry/src/routes/v1/skills.ts index 325ba57..3228707 100644 --- a/apps/registry/src/routes/v1/skills.ts +++ b/apps/registry/src/routes/v1/skills.ts @@ -1,25 +1,25 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, createWriteStream, promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + SkillAnnounceRequestSchema, + SkillAnnounceResponseSchema, + SkillDetailSchema, + SkillDownloadInfoSchema, + SkillSearchResponseSchema, +} from '@nimblebrain/mpak-schemas'; import type { FastifyPluginAsync } from 'fastify'; -import { createHash, randomUUID } from 'crypto'; -import { createWriteStream, createReadStream, promises as fs } from 'fs'; -import { tmpdir } from 'os'; -import path from 'path'; import { config } from '../../config.js'; import { runInTransaction } from '../../db/index.js'; import { BadRequestError, + handleError, NotFoundError, UnauthorizedError, - handleError, } from '../../errors/index.js'; +import { buildProvenance, verifyGitHubOIDC } from '../../lib/oidc.js'; import { toJsonSchema } from '../../lib/zod-schema.js'; -import { verifyGitHubOIDC, buildProvenance } from '../../lib/oidc.js'; -import { - SkillSearchResponseSchema, - SkillDetailSchema, - SkillDownloadInfoSchema, - SkillAnnounceRequestSchema, - SkillAnnounceResponseSchema, -} from '@nimblebrain/mpak-schemas'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { extractSkillContent } from '../../utils/skill-content.js'; @@ -106,9 +106,9 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { // Build filters const filters: Record = {}; - if (q) filters['query'] = q; - if (category) filters['category'] = category; - if (tags) filters['tags'] = tags.split(',').map((t) => t.trim()); + if (q) filters.query = q; + if (category) filters.category = category; + if (tags) filters.tags = tags.split(',').map((t) => t.trim()); // Build sort options let orderBy: Record = { totalDownloads: 'desc' }; @@ -138,7 +138,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { results: total, ms: Date.now() - startTime, }, - `skill_search: q="${q ?? '*'}" returned ${total} results` + `skill_search: q="${q ?? '*'}" returned ${total} results`, ); return { @@ -201,8 +201,10 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { // Extract examples from the latest version's frontmatter const frontmatter = (latestVersion?.frontmatter ?? {}) as Record; - const meta = (frontmatter['metadata'] ?? {}) as Record; - const examples = Array.isArray(meta['examples']) ? meta['examples'] as { prompt: string; context?: string }[] : undefined; + const meta = (frontmatter.metadata ?? {}) as Record; + const examples = Array.isArray(meta.examples) + ? (meta.examples as { prompt: string; context?: string }[]) + : undefined; // Build provenance from the latest version const provenance = latestVersion?.provenanceRepository @@ -320,7 +322,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { // Log download fastify.log.info( { op: 'skill_download', skill: name, version: version.version }, - `skill_download: ${name}@${version.version}` + `skill_download: ${name}@${version.version}`, ); // Increment download counts atomically in a single transaction @@ -328,7 +330,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { await skillRepo.incrementVersionDownloads(skill.id, version.version, tx); await skillRepo.incrementDownloads(skill.id, tx); }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update skill download counts') + fastify.log.error({ err }, 'Failed to update skill download counts'), ); // Check if client wants JSON response @@ -340,7 +342,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { if (wantsJson) { const expiresAt = new Date(); expiresAt.setSeconds( - expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900) + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), ); return { @@ -359,7 +361,10 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { return reply .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${skillName}-${version.version}.skill"`) + .header( + 'Content-Disposition', + `attachment; filename="${skillName}-${version.version}.skill"`, + ) .send(fileBuffer); } }, @@ -385,7 +390,11 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { }, }, handler: async (request, reply) => { - const { scope, name: skillName, version: versionParam } = request.params as { + const { + scope, + name: skillName, + version: versionParam, + } = request.params as { scope: string; name: string; version: string; @@ -407,7 +416,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { fastify.log.info( { op: 'skill_download', skill: name, version: version.version }, - `skill_download: ${name}@${version.version}` + `skill_download: ${name}@${version.version}`, ); // Increment download counts atomically in a single transaction @@ -415,7 +424,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { await skillRepo.incrementVersionDownloads(skill.id, version.version, tx); await skillRepo.incrementDownloads(skill.id, tx); }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update skill download counts') + fastify.log.error({ err }, 'Failed to update skill download counts'), ); const acceptHeader = request.headers.accept ?? ''; @@ -426,7 +435,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { if (wantsJson) { const expiresAt = new Date(); expiresAt.setSeconds( - expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900) + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), ); return { @@ -445,7 +454,10 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { return reply .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${skillName}-${version.version}.skill"`) + .header( + 'Content-Disposition', + `attachment; filename="${skillName}-${version.version}.skill"`, + ) .send(fileBuffer); } }, @@ -468,7 +480,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { const authHeader = request.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { throw new UnauthorizedError( - 'Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.' + 'Missing OIDC token. This endpoint requires a GitHub Actions OIDC token.', ); } @@ -476,31 +488,37 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { const announceStart = Date.now(); // Verify the OIDC token - let claims; + let claims: Awaited>; try { claims = await verifyGitHubOIDC(token); } catch (error) { const message = error instanceof Error ? error.message : 'Token verification failed'; fastify.log.warn( { op: 'skill_announce', error: message }, - `skill_announce: OIDC verification failed` + `skill_announce: OIDC verification failed`, ); throw new UnauthorizedError(`Invalid OIDC token: ${message}`); } - const { name: rawName, version, skill: frontmatter, release_tag, prerelease = false, artifact } = - request.body as { - name: string; - version: string; - skill: Record; - release_tag: string; - prerelease?: boolean; - artifact: { - filename: string; - sha256: string; - size: number; - }; + const { + name: rawName, + version, + skill: frontmatter, + release_tag, + prerelease = false, + artifact, + } = request.body as { + name: string; + version: string; + skill: Record; + release_tag: string; + prerelease?: boolean; + artifact: { + filename: string; + sha256: string; + size: number; }; + }; // Normalise to lowercase so @Foo/bar and @foo/bar are the same skill const name = rawName.toLowerCase(); @@ -508,7 +526,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { // Validate name if (!isValidScopedName(name)) { throw new BadRequestError( - `Invalid skill name: "${rawName}". Must be scoped (@scope/name) with alphanumeric characters and hyphens.` + `Invalid skill name: "${rawName}". Must be scoped (@scope/name) with alphanumeric characters and hyphens.`, ); } @@ -530,10 +548,10 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { repo: claims.repository, error: 'scope_mismatch', }, - `skill_announce: scope mismatch @${parsed.scope} != ${claims.repository_owner}` + `skill_announce: scope mismatch @${parsed.scope} != ${claims.repository_owner}`, ); throw new UnauthorizedError( - `Scope mismatch: Skill scope "@${parsed.scope}" does not match repository owner "${claims.repository_owner}".` + `Scope mismatch: Skill scope "@${parsed.scope}" does not match repository owner "${claims.repository_owner}".`, ); } @@ -546,7 +564,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { tag: release_tag, prerelease, }, - `skill_announce: starting ${name}@${version}` + `skill_announce: starting ${name}@${version}`, ); // Build provenance @@ -566,7 +584,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { if (!releaseResponse.ok) { throw new BadRequestError( - `Failed to fetch release ${release_tag}: ${releaseResponse.statusText}` + `Failed to fetch release ${release_tag}: ${releaseResponse.statusText}`, ); } @@ -580,7 +598,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { const asset = release.assets.find((a: GitHubReleaseAsset) => a.name === artifact.filename); if (!asset) { throw new BadRequestError( - `Artifact "${artifact.filename}" not found in release ${release_tag}` + `Artifact "${artifact.filename}" not found in release ${release_tag}`, ); } @@ -594,7 +612,9 @@ export const skillRoutes: 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 @@ -624,7 +644,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { // Verify size if (bytesWritten !== artifact.size) { throw new BadRequestError( - `Size mismatch for ${asset.name}: declared ${artifact.size} bytes, got ${bytesWritten} bytes` + `Size mismatch for ${asset.name}: declared ${artifact.size} bytes, got ${bytesWritten} bytes`, ); } @@ -632,7 +652,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { computedSha256 = hash.digest('hex'); if (computedSha256 !== artifact.sha256) { throw new BadRequestError( - `SHA256 mismatch for ${asset.name}: declared ${artifact.sha256}, computed ${computedSha256}` + `SHA256 mismatch for ${asset.name}: declared ${artifact.sha256}, computed ${computedSha256}`, ); } @@ -649,7 +669,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { uploadStream, computedSha256, bytesWritten, - 'skill' // Use 'skill' as the "platform" to distinguish from mcpb bundles + 'skill', // Use 'skill' as the "platform" to distinguish from mcpb bundles ); storagePath = result.path; @@ -659,7 +679,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { } // Extract metadata from frontmatter - const meta = (frontmatter['metadata'] ?? {}) as Record; + const meta = (frontmatter.metadata ?? {}) as Record; let status: 'created' | 'exists' = 'created'; let oldStoragePath: string | null = null; @@ -671,42 +691,46 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { const { skill: existingSkill } = await skillRepo.upsertSkill( { name, - description: frontmatter['description'] as string, - license: frontmatter['license'] as string | undefined, - compatibility: frontmatter['compatibility'] as string | undefined, + description: frontmatter.description as string, + license: frontmatter.license as string | undefined, + compatibility: frontmatter.compatibility as string | undefined, allowedTools: frontmatter['allowed-tools'] as string | undefined, - category: meta['category'] as string | undefined, - tags: (meta['tags'] as string[]) ?? [], - triggers: (meta['triggers'] as string[]) ?? [], - keywords: (meta['keywords'] as string[]) ?? [], - authorName: (meta['author'] as Record)?.['name'] as string | undefined, - authorEmail: (meta['author'] as Record)?.['email'] as string | undefined, - authorUrl: (meta['author'] as Record)?.['url'] as string | undefined, + category: meta.category as string | undefined, + tags: (meta.tags as string[]) ?? [], + triggers: (meta.triggers as string[]) ?? [], + keywords: (meta.keywords as string[]) ?? [], + authorName: (meta.author as Record)?.name as string | undefined, + authorEmail: (meta.author as Record)?.email as string | undefined, + authorUrl: (meta.author as Record)?.url as string | undefined, githubRepo: claims.repository, latestVersion: version, }, - tx + tx, ); // Upsert version const { created: versionCreated, oldStoragePath: oldPath } = - await skillRepo.upsertVersion(existingSkill.id, { - skillId: existingSkill.id, - version, - frontmatter, - content: skillContent, - prerelease, - releaseTag: release_tag, - releaseUrl: release.html_url, - storagePath, - sourceUrl: asset.browser_download_url, - digest: `sha256:${computedSha256}`, - sizeBytes: BigInt(artifact.size), - publishMethod: 'oidc', - provenanceRepository: provenance.repository, - provenanceSha: provenance.sha, - provenance, - }, tx); + await skillRepo.upsertVersion( + existingSkill.id, + { + skillId: existingSkill.id, + version, + frontmatter, + content: skillContent, + prerelease, + releaseTag: release_tag, + releaseUrl: release.html_url, + storagePath, + sourceUrl: asset.browser_download_url, + digest: `sha256:${computedSha256}`, + sizeBytes: BigInt(artifact.size), + publishMethod: 'oidc', + provenanceRepository: provenance.repository, + provenanceSha: provenance.sha, + provenance, + }, + tx, + ); status = versionCreated ? 'created' : 'exists'; oldStoragePath = oldPath; @@ -722,7 +746,10 @@ export const skillRoutes: 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; } @@ -733,7 +760,10 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { await fastify.storage.deleteBundle(oldStoragePath); fastify.log.info(`Cleaned up old skill: ${oldStoragePath}`); } catch (cleanupError) { - fastify.log.warn({ err: cleanupError, path: oldStoragePath }, 'Failed to cleanup old skill file'); + fastify.log.warn( + { err: cleanupError, path: oldStoragePath }, + 'Failed to cleanup old skill file', + ); } } @@ -746,7 +776,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { status, ms: Date.now() - announceStart, }, - `skill_announce: ${status} ${name}@${version} (${Date.now() - announceStart}ms)` + `skill_announce: ${status} ${name}@${version} (${Date.now() - announceStart}ms)`, ); // Non-blocking Discord notification for new or updated skills @@ -760,7 +790,7 @@ export const skillRoutes: FastifyPluginAsync = async (fastify) => { } catch (error) { fastify.log.error( { op: 'skill_announce', error: error instanceof Error ? error.message : 'unknown' }, - `skill_announce: failed` + `skill_announce: failed`, ); return handleError(error, request, reply); } diff --git a/apps/registry/src/schemas/mpak-schema.ts b/apps/registry/src/schemas/mpak-schema.ts index f7db220..a79c3b8 100644 --- a/apps/registry/src/schemas/mpak-schema.ts +++ b/apps/registry/src/schemas/mpak-schema.ts @@ -63,31 +63,31 @@ export function validateMpakJson(data: unknown): { const d = data as Record; - if (!d['name'] || typeof d['name'] !== 'string') { + if (!d.name || typeof d.name !== 'string') { errors.push('Missing or invalid "name" field'); } - if (!d['maintainers'] || !Array.isArray(d['maintainers'])) { + if (!d.maintainers || !Array.isArray(d.maintainers)) { errors.push('Missing or invalid "maintainers" field (must be an array)'); - } else if ((d['maintainers'] as unknown[]).length === 0) { + } else if ((d.maintainers as unknown[]).length === 0) { errors.push('At least one maintainer is required'); } const scopedRegex = /^@[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9][a-z0-9-]{0,213}$/; - if (d['name'] && typeof d['name'] === 'string' && !scopedRegex.test(d['name'])) { + if (d.name && typeof d.name === 'string' && !scopedRegex.test(d.name)) { errors.push( - 'Package name must be scoped (e.g., @username/package-name) and follow naming conventions' + 'Package name must be scoped (e.g., @username/package-name) and follow naming conventions', ); } - if (Array.isArray(d['maintainers'])) { + if (Array.isArray(d.maintainers)) { const usernameRegex = /^[a-z0-9][a-z0-9-]{0,38}$/i; - (d['maintainers'] as unknown[]).forEach((maintainer: unknown, index: number) => { + (d.maintainers as unknown[]).forEach((maintainer: unknown, index: number) => { if (typeof maintainer !== 'string') { errors.push(`Maintainer at index ${index} must be a string`); } else if (!usernameRegex.test(maintainer)) { errors.push( - `Invalid GitHub username at index ${index}: "${maintainer}". Must match GitHub username format.` + `Invalid GitHub username at index ${index}: "${maintainer}". Must match GitHub username format.`, ); } }); diff --git a/apps/registry/src/services/github-verifier.ts b/apps/registry/src/services/github-verifier.ts index a40dad4..dea8be5 100644 --- a/apps/registry/src/services/github-verifier.ts +++ b/apps/registry/src/services/github-verifier.ts @@ -5,7 +5,7 @@ * to verify package ownership claims. */ -import { validateMpakJson, type MpakJson } from '../schemas/mpak-schema.js'; +import { type MpakJson, validateMpakJson } from '../schemas/mpak-schema.js'; export interface GitHubRepoStats { stars: number; @@ -25,11 +25,14 @@ export interface GitHubVerificationResult { * Parse GitHub repository identifier */ export function parseGitHubRepo(input: string): { owner: string; repo: string } | null { - const cleaned = input.trim().replace(/\.git$/, '').replace(/\/$/, ''); + const cleaned = input + .trim() + .replace(/\.git$/, '') + .replace(/\/$/, ''); if (cleaned.includes('github.com')) { const match = cleaned.match(/github\.com\/([^/]+)\/([^/]+)/); - if (match && match[1] && match[2]) { + if (match?.[1] && match[2]) { return { owner: match[1], repo: match[2] }; } } @@ -46,7 +49,7 @@ export function parseGitHubRepo(input: string): { owner: string; repo: string } * Fetch mpak.json from GitHub repository */ export async function fetchMpakJsonFromGitHub( - githubRepo: string + githubRepo: string, ): Promise { const parsed = parseGitHubRepo(githubRepo); @@ -66,7 +69,7 @@ export async function fetchMpakJsonFromGitHub( try { const apiResponse = await fetch(apiUrl, { headers: { - 'Accept': 'application/vnd.github.v3.raw', + Accept: 'application/vnd.github.v3.raw', 'User-Agent': 'mpak-registry', }, }); @@ -140,14 +143,13 @@ export async function fetchMpakJsonFromGitHub( githubUrl: rawUrl, }; } - } catch (_error) { - continue; - } + } catch (_error) {} } return { success: false, - error: 'Could not find mpak.json in repository. Please add mpak.json to the root of your repository on the main or master branch.', + error: + 'Could not find mpak.json in repository. Please add mpak.json to the root of your repository on the main or master branch.', }; } @@ -156,7 +158,7 @@ export async function fetchMpakJsonFromGitHub( */ export function verifyMaintainer(mpakJson: MpakJson, githubUsername: string): boolean { return mpakJson.maintainers.some( - (maintainer) => maintainer.toLowerCase() === githubUsername.toLowerCase() + (maintainer) => maintainer.toLowerCase() === githubUsername.toLowerCase(), ); } @@ -173,7 +175,7 @@ export function verifyPackageName(mpakJson: MpakJson, expectedPackageName: strin export async function verifyPackageClaim( packageName: string, githubRepo: string, - githubUsername: string + githubUsername: string, ): Promise<{ verified: boolean; error?: string; @@ -219,9 +221,7 @@ export async function verifyPackageClaim( /** * Fetch repository stats from GitHub API */ -export async function fetchGitHubRepoStats( - githubRepo: string -): Promise { +export async function fetchGitHubRepoStats(githubRepo: string): Promise { const parsed = parseGitHubRepo(githubRepo); if (!parsed) { @@ -234,7 +234,7 @@ export async function fetchGitHubRepoStats( try { const response = await fetch(url, { headers: { - 'Accept': 'application/vnd.github.v3+json', + Accept: 'application/vnd.github.v3+json', 'User-Agent': 'mpak-registry', }, }); @@ -243,12 +243,12 @@ export async function fetchGitHubRepoStats( return null; } - const data = await response.json() as Record; + const data = (await response.json()) as Record; return { - stars: (data['stargazers_count'] as number) ?? 0, - forks: (data['forks_count'] as number) ?? 0, - watchers: (data['watchers_count'] as number) ?? 0, + stars: (data.stargazers_count as number) ?? 0, + forks: (data.forks_count as number) ?? 0, + watchers: (data.watchers_count as number) ?? 0, updatedAt: new Date(), }; } catch (_error) { diff --git a/apps/registry/src/services/manifest-validator.ts b/apps/registry/src/services/manifest-validator.ts index 9cb20b0..684d65b 100644 --- a/apps/registry/src/services/manifest-validator.ts +++ b/apps/registry/src/services/manifest-validator.ts @@ -19,56 +19,56 @@ export class ManifestValidator { const m = manifest as Record; - this.validateName(m['name']); - this.validateVersion(m['version']); + this.validateName(m.name); + this.validateVersion(m.version); let serverType: string | undefined; - if (m['server'] && typeof m['server'] === 'object') { - const server = m['server'] as Record; - serverType = server['type'] as string; - } else if (m['server_type']) { - serverType = m['server_type'] as string; + if (m.server && typeof m.server === 'object') { + const server = m.server as Record; + serverType = server.type as string; + } else if (m.server_type) { + serverType = m.server_type as string; } this.validateServerType(serverType); - if (m['display_name'] !== undefined) { - this.validateString(m['display_name'], 'display_name', 255); + if (m.display_name !== undefined) { + this.validateString(m.display_name, 'display_name', 255); } - if (m['description'] !== undefined) { - this.validateString(m['description'], 'description', 5000); + if (m.description !== undefined) { + this.validateString(m.description, 'description', 5000); } - if (m['author'] !== undefined) { - this.validateAuthor(m['author']); + if (m.author !== undefined) { + this.validateAuthor(m.author); } - if (m['homepage'] !== undefined) { - this.validateUrl(m['homepage'], 'homepage'); + if (m.homepage !== undefined) { + this.validateUrl(m.homepage, 'homepage'); } - if (m['license'] !== undefined) { - this.validateString(m['license'], 'license', 100); + if (m.license !== undefined) { + this.validateString(m.license, 'license', 100); } - if (m['icon'] !== undefined) { - this.validateString(m['icon'], 'icon', 512); + if (m.icon !== undefined) { + this.validateString(m.icon, 'icon', 512); } - if (m['platforms'] !== undefined) { - this.validatePlatforms(m['platforms']); + if (m.platforms !== undefined) { + this.validatePlatforms(m.platforms); } - if (m['tools'] !== undefined) { - this.validateArray(m['tools'], 'tools', this.validateTool.bind(this)); + if (m.tools !== undefined) { + this.validateArray(m.tools, 'tools', this.validateTool.bind(this)); } - if (m['prompts'] !== undefined) { - this.validateArray(m['prompts'], 'prompts', this.validatePrompt.bind(this)); + if (m.prompts !== undefined) { + this.validateArray(m.prompts, 'prompts', this.validatePrompt.bind(this)); } - if (m['resources'] !== undefined) { - this.validateArray(m['resources'], 'resources', this.validateResource.bind(this)); + if (m.resources !== undefined) { + this.validateArray(m.resources, 'resources', this.validateResource.bind(this)); } return this.errors.length === 0; @@ -89,7 +89,8 @@ export class ManifestValidator { if (!scopedRegex.test(value)) { this.errors.push({ field: 'name', - message: 'Package name must be scoped (e.g., @username/package-name). Unscoped packages are not allowed.', + message: + 'Package name must be scoped (e.g., @username/package-name). Unscoped packages are not allowed.', }); } @@ -111,7 +112,10 @@ export class ManifestValidator { private validateServerType(value: unknown): void { if (typeof value !== 'string') { - this.errors.push({ field: 'server_type', message: 'Server type is required and must be a string' }); + this.errors.push({ + field: 'server_type', + message: 'Server type is required and must be a string', + }); return; } @@ -156,16 +160,19 @@ export class ManifestValidator { const author = value as Record; - if (!author['name'] || typeof author['name'] !== 'string') { - this.errors.push({ field: 'author.name', message: 'Author name is required and must be a string' }); + if (!author.name || typeof author.name !== 'string') { + this.errors.push({ + field: 'author.name', + message: 'Author name is required and must be a string', + }); } - if (author['email'] !== undefined && typeof author['email'] !== 'string') { + if (author.email !== undefined && typeof author.email !== 'string') { this.errors.push({ field: 'author.email', message: 'Author email must be a string' }); } - if (author['url'] !== undefined) { - this.validateUrl(author['url'], 'author.url'); + if (author.url !== undefined) { + this.validateUrl(author.url, 'author.url'); } } @@ -188,7 +195,10 @@ export class ManifestValidator { } if (!platformConfig || typeof platformConfig !== 'object') { - this.errors.push({ field: `platforms.${platform}`, message: 'Platform config must be an object' }); + this.errors.push({ + field: `platforms.${platform}`, + message: 'Platform config must be an object', + }); } } } @@ -196,7 +206,7 @@ export class ManifestValidator { private validateArray( value: unknown, field: string, - itemValidator: (item: unknown, index: number) => void + itemValidator: (item: unknown, index: number) => void, ): void { if (!Array.isArray(value)) { this.errors.push({ field, message: `${field} must be an array` }); @@ -216,12 +226,18 @@ export class ManifestValidator { const tool = value as Record; - if (!tool['name'] || typeof tool['name'] !== 'string') { - this.errors.push({ field: `tools[${index}].name`, message: 'Tool name is required and must be a string' }); + if (!tool.name || typeof tool.name !== 'string') { + this.errors.push({ + field: `tools[${index}].name`, + message: 'Tool name is required and must be a string', + }); } - if (tool['description'] !== undefined && typeof tool['description'] !== 'string') { - this.errors.push({ field: `tools[${index}].description`, message: 'Tool description must be a string' }); + if (tool.description !== undefined && typeof tool.description !== 'string') { + this.errors.push({ + field: `tools[${index}].description`, + message: 'Tool description must be a string', + }); } } @@ -233,12 +249,18 @@ export class ManifestValidator { const prompt = value as Record; - if (!prompt['name'] || typeof prompt['name'] !== 'string') { - this.errors.push({ field: `prompts[${index}].name`, message: 'Prompt name is required and must be a string' }); + if (!prompt.name || typeof prompt.name !== 'string') { + this.errors.push({ + field: `prompts[${index}].name`, + message: 'Prompt name is required and must be a string', + }); } - if (prompt['description'] !== undefined && typeof prompt['description'] !== 'string') { - this.errors.push({ field: `prompts[${index}].description`, message: 'Prompt description must be a string' }); + if (prompt.description !== undefined && typeof prompt.description !== 'string') { + this.errors.push({ + field: `prompts[${index}].description`, + message: 'Prompt description must be a string', + }); } } @@ -250,14 +272,14 @@ export class ManifestValidator { const resource = value as Record; - if (!resource['name'] || typeof resource['name'] !== 'string') { + if (!resource.name || typeof resource.name !== 'string') { this.errors.push({ field: `resources[${index}].name`, message: 'Resource name is required and must be a string', }); } - if (resource['description'] !== undefined && typeof resource['description'] !== 'string') { + if (resource.description !== undefined && typeof resource.description !== 'string') { this.errors.push({ field: `resources[${index}].description`, message: 'Resource description must be a string', diff --git a/apps/registry/src/services/scanner.ts b/apps/registry/src/services/scanner.ts index a083756..b493526 100644 --- a/apps/registry/src/services/scanner.ts +++ b/apps/registry/src/services/scanner.ts @@ -5,10 +5,10 @@ * for vulnerabilities, malicious code, and secrets. */ +import { randomUUID } from 'node:crypto'; import * as k8s from '@kubernetes/client-node'; -import { randomUUID } from 'crypto'; -import { config } from '../config.js'; import type { PrismaClient } from '@prisma/client'; +import { config } from '../config.js'; export interface TriggerScanParams { scanId: string; @@ -163,7 +163,7 @@ export async function triggerSecurityScan( bundleStoragePath: string; packageName: string; version: string; - } + }, ): Promise { const { versionId, bundleStoragePath, packageName, version } = params; diff --git a/apps/registry/src/services/server-detail-composer.ts b/apps/registry/src/services/server-detail-composer.ts index 7512369..650f3b7 100644 --- a/apps/registry/src/services/server-detail-composer.ts +++ b/apps/registry/src/services/server-detail-composer.ts @@ -34,16 +34,16 @@ * in logs, never reaches consumers. */ -import type { Artifact, Package as DbPackage, PackageVersion } from "@prisma/client"; import { resolveReverseDnsName, type ServerDetail, ServerDetailSchema, type ServerPackage, -} from "@nimblebrain/mpak-schemas"; +} from '@nimblebrain/mpak-schemas'; +import type { Artifact, Package as DbPackage, PackageVersion } from '@prisma/client'; const UPSTREAM_SCHEMA_URL = - "https://raw.githubusercontent.com/modelcontextprotocol/registry/main/docs/reference/server-json/draft/server.schema.json"; + 'https://raw.githubusercontent.com/modelcontextprotocol/registry/main/docs/reference/server-json/draft/server.schema.json'; /** * Inputs to {@link composeServerDetail}. Carries everything mpak knows @@ -55,7 +55,7 @@ export interface ComposerInput { * projection itself; the rest of the row is here so callers can * pass the live record without additional selection. */ - pkg: Pick & { + pkg: Pick & { githubRepo?: string | null; }; /** @@ -65,10 +65,10 @@ export interface ComposerInput { */ version: Pick< PackageVersion, - "version" | "manifest" | "publishedAt" | "publishMethod" | "provenance" | "downloadCount" + 'version' | 'manifest' | 'publishedAt' | 'publishMethod' | 'provenance' | 'downloadCount' >; /** Per-platform artifacts. Empty array is fine — packages[] is omitted. */ - artifacts: Pick[]; + artifacts: Pick[]; /** Top certification record for this version, if any. */ certification?: { level: number; @@ -117,11 +117,11 @@ export function composeServerDetailOrThrow(input: ComposerInput): ServerDetail { */ function buildDetail(input: ComposerInput): Record { const manifest = (input.version.manifest ?? {}) as Record; - const manifestMeta = (manifest["_meta"] as Record | undefined) ?? null; + const manifestMeta = (manifest._meta as Record | undefined) ?? null; - const description = truncate(stringField(manifest, "description") ?? input.pkg.name, 100); + const description = truncate(stringField(manifest, 'description') ?? input.pkg.name, 100); const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); - const display = stringField(manifest, "display_name"); + const display = stringField(manifest, 'display_name'); // Upstream `Title` caps at 100 chars; truncate so a long display_name // (or a long npm scope/name when it falls back) doesn't reject the // entire record at the schema boundary and 500 the route. @@ -138,19 +138,19 @@ function buildDetail(input: ComposerInput): Record { version: input.version.version, }; - const homepage = stringField(manifest, "homepage"); - if (homepage && isHttpUrl(homepage)) detail["websiteUrl"] = homepage; + const homepage = stringField(manifest, 'homepage'); + if (homepage && isHttpUrl(homepage)) detail.websiteUrl = homepage; const repository = projectRepository(manifest, input.pkg.githubRepo); - if (repository) detail["repository"] = repository; + if (repository) detail.repository = repository; const icons = projectIcons(manifest); - if (icons.length > 0) detail["icons"] = icons; + if (icons.length > 0) detail.icons = icons; const packages = projectPackages(input, manifest); - if (packages.length > 0) detail["packages"] = packages; + if (packages.length > 0) detail.packages = packages; - detail["_meta"] = composeMeta(input, manifest, manifestMeta); + detail._meta = composeMeta(input, manifest, manifestMeta); return detail; } @@ -161,11 +161,11 @@ function projectRepository( manifest: Record, fallbackGithubRepo: string | null | undefined, ): { url: string; source: string; id?: string; subfolder?: string } | null { - const repo = manifest["repository"]; - if (repo && typeof repo === "object") { - const url = stringField(repo as Record, "url"); + const repo = manifest.repository; + if (repo && typeof repo === 'object') { + const url = stringField(repo as Record, 'url'); if (url && isHttpUrl(url)) { - return { url, source: "github" }; + return { url, source: 'github' }; } } // Fall back to the package's tracked GitHub repo when the manifest @@ -173,7 +173,7 @@ function projectRepository( // manifests pre-date the repository field. if (fallbackGithubRepo) { const url = `https://github.com/${fallbackGithubRepo}`; - return { url, source: "github" }; + return { url, source: 'github' }; } return null; } @@ -194,48 +194,51 @@ function projectRepository( function projectIcons( manifest: Record, ): { src: string; sizes?: string[]; mimeType?: string; theme?: string }[] { - const icons = manifest["icons"]; + const icons = manifest.icons; if (Array.isArray(icons)) { return icons .map(projectIcon) - .filter((i): i is { src: string; sizes?: string[]; mimeType?: string; theme?: string } => i !== null); + .filter( + (i): i is { src: string; sizes?: string[]; mimeType?: string; theme?: string } => + i !== null, + ); } // Single-icon legacy field. - const single = stringField(manifest, "icon"); + const single = stringField(manifest, 'icon'); if (single && isHttpUrl(single) && single.length <= 255) { - return [{ src: single, sizes: ["any"] }]; + return [{ src: single, sizes: ['any'] }]; } return []; } const ICON_SIZE_PATTERN = /^(\d+x\d+|any)$/; const ICON_MIME_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/svg+xml", - "image/webp", + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/svg+xml', + 'image/webp', ]); function projectIcon( raw: unknown, ): { src: string; sizes?: string[]; mimeType?: string; theme?: string } | null { - if (!raw || typeof raw !== "object") return null; + if (!raw || typeof raw !== 'object') return null; const obj = raw as Record; - const src = stringField(obj, "src"); + const src = stringField(obj, 'src'); if (!src || !isHttpUrl(src) || src.length > 255) return null; const out: { src: string; sizes?: string[]; mimeType?: string; theme?: string } = { src }; - const sizes = obj["sizes"]; + const sizes = obj.sizes; if (Array.isArray(sizes)) { const valid = sizes.filter( - (s): s is string => typeof s === "string" && ICON_SIZE_PATTERN.test(s), + (s): s is string => typeof s === 'string' && ICON_SIZE_PATTERN.test(s), ); if (valid.length > 0) out.sizes = valid; } - const mimeType = stringField(obj, "mimeType"); + const mimeType = stringField(obj, 'mimeType'); if (mimeType && ICON_MIME_TYPES.has(mimeType)) out.mimeType = mimeType; - const theme = stringField(obj, "theme"); - if (theme === "light" || theme === "dark") out.theme = theme; + const theme = stringField(obj, 'theme'); + if (theme === 'light' || theme === 'dark') out.theme = theme; return out; } @@ -250,21 +253,21 @@ function projectPackages(input: ComposerInput, manifest: Record if (input.artifacts.length === 0) { return [ { - registryType: "mpak", + registryType: 'mpak', identifier: input.pkg.name, version: input.version.version, - transport: { type: "stdio" }, + transport: { type: 'stdio' }, ...(envVars.length > 0 ? { environmentVariables: envVars } : {}), }, ]; } return input.artifacts.map((art) => { - const sha = art.digest.replace(/^sha256:/, ""); + const sha = art.digest.replace(/^sha256:/, ''); const pkg: ServerPackage = { - registryType: "mpak", + registryType: 'mpak', identifier: input.pkg.name, version: input.version.version, - transport: { type: "stdio" }, + transport: { type: 'stdio' }, ...(envVars.length > 0 ? { environmentVariables: envVars } : {}), }; // Upstream `fileSha256` requires 64 hex chars. A malformed digest @@ -283,25 +286,29 @@ function projectPackages(input: ComposerInput, manifest: Record * manifest declares which user_config field maps to which env var); * falls back to the field's upper-snake-cased key. */ -function projectEnvironmentVariables( - manifest: Record, -): { name: string; description?: string; isSecret?: boolean; isRequired?: boolean; default?: string }[] { - const userConfig = manifest["user_config"]; - if (!userConfig || typeof userConfig !== "object") return []; +function projectEnvironmentVariables(manifest: Record): { + name: string; + description?: string; + isSecret?: boolean; + isRequired?: boolean; + default?: string; +}[] { + const userConfig = manifest.user_config; + if (!userConfig || typeof userConfig !== 'object') return []; const envMap = readEnvMap(manifest); const out: ReturnType = []; for (const [field, raw] of Object.entries(userConfig)) { - if (!raw || typeof raw !== "object") continue; + if (!raw || typeof raw !== 'object') continue; const f = raw as Record; const envName = envMap[field] ?? field.toUpperCase(); const entry: ReturnType[number] = { name: envName }; - const description = stringField(f, "description"); + const description = stringField(f, 'description'); if (description) entry.description = description; - if (typeof f["sensitive"] === "boolean") entry.isSecret = f["sensitive"] as boolean; - if (typeof f["required"] === "boolean") entry.isRequired = f["required"] as boolean; - const def = f["default"]; - if (typeof def === "string") entry.default = def; - else if (typeof def === "number" || typeof def === "boolean") entry.default = String(def); + if (typeof f.sensitive === 'boolean') entry.isSecret = f.sensitive as boolean; + if (typeof f.required === 'boolean') entry.isRequired = f.required as boolean; + const def = f.default; + if (typeof def === 'string') entry.default = def; + else if (typeof def === 'number' || typeof def === 'boolean') entry.default = String(def); out.push(entry); } return out; @@ -313,15 +320,15 @@ function projectEnvironmentVariables( * field name on the right side so we can map field → env var name. */ function readEnvMap(manifest: Record): Record { - const server = manifest["server"]; - if (!server || typeof server !== "object") return {}; - const mcpConfig = (server as Record)["mcp_config"]; - if (!mcpConfig || typeof mcpConfig !== "object") return {}; - const env = (mcpConfig as Record)["env"]; - if (!env || typeof env !== "object") return {}; + const server = manifest.server; + if (!server || typeof server !== 'object') return {}; + const mcpConfig = (server as Record).mcp_config; + if (!mcpConfig || typeof mcpConfig !== 'object') return {}; + const env = (mcpConfig as Record).env; + if (!env || typeof env !== 'object') return {}; const out: Record = {}; for (const [envName, value] of Object.entries(env)) { - if (typeof value !== "string") continue; + if (typeof value !== 'string') continue; const m = /\$\{?user_config\.([a-zA-Z0-9_]+)\}?/.exec(value); if (m?.[1]) { out[m[1]] = envName; @@ -347,34 +354,34 @@ function composeMeta( }; // Carry author overrides under `dev.mpak/registry` (e.g. their reverse-DNS // `name`) verbatim alongside our enrichment. - const authorMpak = manifestMeta?.["dev.mpak/registry"]; - if (authorMpak && typeof authorMpak === "object") { + const authorMpak = manifestMeta?.['dev.mpak/registry']; + if (authorMpak && typeof authorMpak === 'object') { Object.assign(mpakBlock, authorMpak); - mpakBlock["npmName"] = input.pkg.name; // mpak source-of-truth wins + mpakBlock.npmName = input.pkg.name; // mpak source-of-truth wins } const downloads = Number(input.pkg.totalDownloads ?? input.version.downloadCount ?? 0); - if (Number.isFinite(downloads)) mpakBlock["downloads"] = downloads; + if (Number.isFinite(downloads)) mpakBlock.downloads = downloads; if (input.version.publishedAt) { - mpakBlock["published_at"] = input.version.publishedAt.toISOString(); + mpakBlock.published_at = input.version.publishedAt.toISOString(); } if (input.version.publishMethod) { - mpakBlock["publishMethod"] = input.version.publishMethod; + mpakBlock.publishMethod = input.version.publishMethod; } if (input.version.provenance) { - mpakBlock["provenance"] = input.version.provenance; + mpakBlock.provenance = input.version.provenance; } if (input.certification) { - mpakBlock["certification"] = input.certification; + mpakBlock.certification = input.certification; } if (input.artifacts.length > 0) { - mpakBlock["artifacts"] = input.artifacts.map((a) => ({ + mpakBlock.artifacts = input.artifacts.map((a) => ({ platform: { os: a.os, arch: a.arch }, url: a.sourceUrl, - sha256: a.digest.replace(/^sha256:/, ""), + sha256: a.digest.replace(/^sha256:/, ''), size: Number(a.sizeBytes), })); } - meta["dev.mpak/registry"] = mpakBlock; + meta['dev.mpak/registry'] = mpakBlock; return meta; } @@ -382,7 +389,7 @@ function composeMeta( function stringField(obj: Record, key: string): string | undefined { const v = obj[key]; - return typeof v === "string" ? v : undefined; + return typeof v === 'string' ? v : undefined; } function truncate(s: string, max: number): string { @@ -393,7 +400,7 @@ function truncate(s: string, max: number): string { function isHttpUrl(url: string): boolean { try { const parsed = new URL(url); - return parsed.protocol === "http:" || parsed.protocol === "https:"; + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch { return false; } diff --git a/apps/registry/src/types.ts b/apps/registry/src/types.ts index be6aaba..12fda93 100644 --- a/apps/registry/src/types.ts +++ b/apps/registry/src/types.ts @@ -88,14 +88,13 @@ export interface PackageVersion { } // API response types - imported from shared schemas package +// API query params - imported from shared schemas package export type { Package as PackageListItem, PackageDetail as PackageInfo, + PackageSearchParams, } from '@nimblebrain/mpak-schemas'; -// API query params - imported from shared schemas package -export type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; - // MCP Registry types export interface MCPServerDetail { name: string; diff --git a/apps/registry/src/utils/discord.ts b/apps/registry/src/utils/discord.ts index 1cbf219..82535a5 100644 --- a/apps/registry/src/utils/discord.ts +++ b/apps/registry/src/utils/discord.ts @@ -3,7 +3,7 @@ * Non-blocking notifications for package announcements */ -const DISCORD_WEBHOOK_URL = process.env['DISCORD_WEBHOOK_URL'] || ''; +const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || ''; export type PackageType = 'bundle' | 'skill'; @@ -27,7 +27,9 @@ export function notifyDiscordAnnounce(data: AnnounceNotification): void { `**${data.name}** v${data.version}`, data.repo ? `[GitHub](https://github.com/${data.repo})` : null, `[View on mpak.dev](${registryUrl})`, - ].filter(Boolean).join('\n'); + ] + .filter(Boolean) + .join('\n'); if (!DISCORD_WEBHOOK_URL) return; diff --git a/apps/registry/src/utils/scanner-version.ts b/apps/registry/src/utils/scanner-version.ts index 7cf82c8..b3a9cc5 100644 --- a/apps/registry/src/utils/scanner-version.ts +++ b/apps/registry/src/utils/scanner-version.ts @@ -9,6 +9,6 @@ export function extractScannerVersion( report: Record | undefined | null, ): string | null { - const scanMeta = report?.['scan'] as Record | undefined; - return (scanMeta?.['scanner_version'] as string) ?? null; + const scanMeta = report?.scan as Record | undefined; + return (scanMeta?.scanner_version as string) ?? null; } diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index 93134c1..956c666 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -6,10 +6,10 @@ * so tests run without a database or network access. */ -import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; -import type { Mock } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; import sensible from '@fastify/sensible'; +import Fastify, { type FastifyInstance } from 'fastify'; +import type { Mock } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Module mocks (hoisted before all imports) @@ -61,18 +61,18 @@ vi.mock('../src/utils/badge.js', () => ({ // Imports (after mocks) // --------------------------------------------------------------------------- +import { errorHandler } from '../src/errors/middleware.js'; +import { verifyGitHubOIDC } from '../src/lib/oidc.js'; import { createMockPackageRepo, - createMockStorage, createMockPrisma, + createMockStorage, mockArtifact, mockPackage, mockVersion, mockVersionWithArtifacts, mockVersionWithScans, } from './helpers.js'; -import { verifyGitHubOIDC } from '../src/lib/oidc.js'; -import { errorHandler } from '../src/errors/middleware.js'; // --------------------------------------------------------------------------- // Test setup diff --git a/apps/registry/tests/errors.test.ts b/apps/registry/tests/errors.test.ts index c6e52f2..41dca41 100644 --- a/apps/registry/tests/errors.test.ts +++ b/apps/registry/tests/errors.test.ts @@ -4,21 +4,21 @@ * Tests error types, normalization, and response formatting. */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { formatErrorResponse, normalizeError } from '../src/errors/handler.js'; import { AppError, BadRequestError, - UnauthorizedError, - ForbiddenError, - NotFoundError, ConflictError, - ValidationError, - InternalServerError, DatabaseError, - TransactionTimeoutError, + ForbiddenError, + InternalServerError, + NotFoundError, ServiceUnavailableError, + TransactionTimeoutError, + UnauthorizedError, + ValidationError, } from '../src/errors/types.js'; -import { normalizeError, formatErrorResponse } from '../src/errors/handler.js'; // --------------------------------------------------------------------------- // Error types diff --git a/apps/registry/tests/health.test.ts b/apps/registry/tests/health.test.ts index 92ac7bf..00eddd1 100644 --- a/apps/registry/tests/health.test.ts +++ b/apps/registry/tests/health.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('validateConfig', () => { const originalEnv = { ...process.env }; @@ -19,22 +19,24 @@ describe('validateConfig', () => { }); it('throws when CLERK_SECRET_KEY is missing in production', async () => { - process.env['NODE_ENV'] = 'production'; - process.env['CLERK_SECRET_KEY'] = ''; + process.env.NODE_ENV = 'production'; + process.env.CLERK_SECRET_KEY = ''; const { validateConfig } = await import('../src/config.js'); expect(() => validateConfig()).toThrow('CLERK_SECRET_KEY is required in production'); }); it('throws when scanner enabled without callback secret', async () => { - process.env['SCANNER_ENABLED'] = 'true'; - process.env['SCANNER_CALLBACK_SECRET'] = ''; + process.env.SCANNER_ENABLED = 'true'; + process.env.SCANNER_CALLBACK_SECRET = ''; const { validateConfig } = await import('../src/config.js'); - expect(() => validateConfig()).toThrow('SCANNER_CALLBACK_SECRET is required when SCANNER_ENABLED=true'); + expect(() => validateConfig()).toThrow( + 'SCANNER_CALLBACK_SECRET is required when SCANNER_ENABLED=true', + ); }); it('passes with valid development config', async () => { - process.env['DATABASE_URL'] = 'postgresql://localhost:5432/test'; - process.env['SCANNER_ENABLED'] = 'false'; + process.env.DATABASE_URL = 'postgresql://localhost:5432/test'; + process.env.SCANNER_ENABLED = 'false'; const { validateConfig } = await import('../src/config.js'); expect(() => validateConfig()).not.toThrow(); }); diff --git a/apps/registry/tests/helpers.ts b/apps/registry/tests/helpers.ts index 065327a..4afee48 100644 --- a/apps/registry/tests/helpers.ts +++ b/apps/registry/tests/helpers.ts @@ -2,8 +2,8 @@ * Test helpers: mock factories for Fastify decorators and repository data. */ -import Fastify, { type FastifyInstance } from 'fastify'; import sensible from '@fastify/sensible'; +import Fastify, { type FastifyInstance } from 'fastify'; import { vi } from 'vitest'; /** @@ -183,7 +183,8 @@ export const mockArtifact = { mimeType: 'application/octet-stream', sizeBytes: BigInt(1024), storagePath: '@test/mcp-server/1.0.0/linux-x64.mcpb', - sourceUrl: 'https://github.com/test-org/mcp-server/releases/download/v1.0.0/server-linux-x64.mcpb', + sourceUrl: + 'https://github.com/test-org/mcp-server/releases/download/v1.0.0/server-linux-x64.mcpb', downloadCount: BigInt(50), createdAt: new Date('2024-01-01'), }; diff --git a/apps/registry/tests/oidc.test.ts b/apps/registry/tests/oidc.test.ts index 2342238..7d10452 100644 --- a/apps/registry/tests/oidc.test.ts +++ b/apps/registry/tests/oidc.test.ts @@ -6,7 +6,7 @@ * in the route tests. */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { buildProvenance, type GitHubOIDCClaims } from '../src/lib/oidc.js'; const mockClaims: GitHubOIDCClaims = { diff --git a/apps/registry/tests/scanner.test.ts b/apps/registry/tests/scanner.test.ts index bd99db7..34f7e38 100644 --- a/apps/registry/tests/scanner.test.ts +++ b/apps/registry/tests/scanner.test.ts @@ -5,9 +5,9 @@ * summary endpoint (GET). */ -import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; import sensible from '@fastify/sensible'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Module mocks @@ -103,11 +103,7 @@ describe('Scanner Routes', () => { controls_failed: 5, controls_total: 25, }, - findings: [ - { severity: 'high' }, - { severity: 'medium' }, - { severity: 'low' }, - ], + findings: [{ severity: 'high' }, { severity: 'medium' }, { severity: 'low' }], }, }; @@ -275,11 +271,7 @@ describe('Scanner Routes', () => { status: 'completed', completedAt: new Date('2024-06-01'), report: { - findings: [ - { severity: 'high' }, - { severity: 'medium' }, - { severity: 'medium' }, - ], + findings: [{ severity: 'high' }, { severity: 'medium' }, { severity: 'medium' }], }, }, ], diff --git a/apps/registry/tests/server-detail-composer.test.ts b/apps/registry/tests/server-detail-composer.test.ts index d08da4c..c3d1965 100644 --- a/apps/registry/tests/server-detail-composer.test.ts +++ b/apps/registry/tests/server-detail-composer.test.ts @@ -1,8 +1,9 @@ +/** biome-ignore-all lint/suspicious/noTemplateCurlyInString: intentional mpak manifest placeholders (${var} substituted at install time) */ import { describe, expect, it } from 'vitest'; import { + type ComposerInput, composeServerDetail, composeServerDetailOrThrow, - type ComposerInput, } from '../src/services/server-detail-composer.js'; const FULL_MANIFEST = { @@ -67,7 +68,12 @@ function input(over: Partial = {}): ComposerInput { storagePath: '@nimblebraininc/echo/0.1.6/linux-x64.mcpb', }, ], - certification: over.certification ?? { level: 1, controlsPassed: 15, controlsFailed: 1, controlsTotal: 16 }, + certification: over.certification ?? { + level: 1, + controlsPassed: 15, + controlsFailed: 1, + controlsTotal: 16, + }, }; } @@ -97,18 +103,18 @@ describe('composeServerDetail', () => { permissions: { native: 'none' }, }); const mpakMeta = detail?._meta?.['dev.mpak/registry'] as Record; - expect(mpakMeta['npmName']).toBe('@nimblebraininc/echo'); - expect(mpakMeta['downloads']).toBe(412); - expect(mpakMeta['published_at']).toBe('2026-04-09T12:00:00.000Z'); - expect(mpakMeta['publishMethod']).toBe('oidc'); - expect(mpakMeta['certification']).toEqual({ + expect(mpakMeta.npmName).toBe('@nimblebraininc/echo'); + expect(mpakMeta.downloads).toBe(412); + expect(mpakMeta.published_at).toBe('2026-04-09T12:00:00.000Z'); + expect(mpakMeta.publishMethod).toBe('oidc'); + expect(mpakMeta.certification).toEqual({ level: 1, controlsPassed: 15, controlsFailed: 1, controlsTotal: 16, }); - expect(Array.isArray(mpakMeta['artifacts'])).toBe(true); - expect((mpakMeta['artifacts'] as unknown[])[0]).toMatchObject({ + expect(Array.isArray(mpakMeta.artifacts)).toBe(true); + expect((mpakMeta.artifacts as unknown[])[0]).toMatchObject({ platform: { os: 'linux', arch: 'x64' }, url: 'https://github.com/NimbleBrainInc/mcp-echo/releases/download/v0.1.6/x.mcpb', sha256: '7352521191f69533f3e05fd905dea30ed43c329c930ee9840ccf9796a531f41b', @@ -116,7 +122,7 @@ describe('composeServerDetail', () => { }); }); - it('honors author reverse-DNS override under the publisher\'s curated org-mapped namespace', () => { + it("honors author reverse-DNS override under the publisher's curated org-mapped namespace", () => { // @nimblebraininc → ai.nimblebrain (per ORG_REVERSE_DNS_MAP), // so this publisher may claim any ai.nimblebrain/* name. const m = { @@ -127,7 +133,7 @@ describe('composeServerDetail', () => { expect(detail?.name).toBe('ai.nimblebrain/custom-name'); }); - it('honors author override under the publisher\'s mechanical-default namespace', () => { + it("honors author override under the publisher's mechanical-default namespace", () => { // Any publisher implicitly owns `dev.mpak./*`. const m = { ...FULL_MANIFEST, @@ -137,7 +143,7 @@ describe('composeServerDetail', () => { expect(detail?.name).toBe('dev.mpak.nimblebraininc/relabeled'); }); - it('silently ignores a squatted override (publisher claiming a namespace they don\'t own)', () => { + it("silently ignores a squatted override (publisher claiming a namespace they don't own)", () => { // @nimblebraininc trying to label themselves under com.acme — not // their org, not their mechanical default. Override drops; record // falls back to the curated/mechanical default. Prevents diff --git a/apps/registry/tests/servers.test.ts b/apps/registry/tests/servers.test.ts index 7acf882..af53a89 100644 --- a/apps/registry/tests/servers.test.ts +++ b/apps/registry/tests/servers.test.ts @@ -7,9 +7,9 @@ * use of the pre-joined security-scan column. */ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; import sensible from '@fastify/sensible'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../src/config.js', () => ({ config: { @@ -33,13 +33,8 @@ vi.mock('../src/db/index.js', () => ({ disconnectDatabase: vi.fn(), })); -import { - createMockPackageRepo, - mockArtifact, - mockPackage, - mockVersion, -} from './helpers.js'; import { errorHandler } from '../src/errors/middleware.js'; +import { createMockPackageRepo, mockArtifact, mockPackage, mockVersion } from './helpers.js'; /** * Build a "lookup row" — Package + versions[] each carrying artifacts @@ -214,7 +209,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@test/mcp-server'), + url: `/servers/${encodeURIComponent('@test/mcp-server')}`, }); expect(res.statusCode).toBe(200); @@ -226,7 +221,7 @@ describe('MCP Registry routes', () => { await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@Test/MCP-Server'), + url: `/servers/${encodeURIComponent('@Test/MCP-Server')}`, }); expect(packageRepo.findPackageForServerLookup).toHaveBeenCalledWith('@test/mcp-server'); @@ -247,7 +242,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('ai.nimblebrain/echo'), + url: `/servers/${encodeURIComponent('ai.nimblebrain/echo')}`, }); expect(res.statusCode).toBe(200); @@ -261,7 +256,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@missing/server'), + url: `/servers/${encodeURIComponent('@missing/server')}`, }); expect(res.statusCode).toBe(404); @@ -285,7 +280,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@test/mcp-server'), + url: `/servers/${encodeURIComponent('@test/mcp-server')}`, }); expect(res.statusCode).toBe(200); @@ -314,7 +309,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions/1.0.0', + url: `/servers/${encodeURIComponent('@test/mcp-server')}/versions/1.0.0`, }); expect(res.statusCode).toBe(200); @@ -327,7 +322,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions/latest', + url: `/servers/${encodeURIComponent('@test/mcp-server')}/versions/latest`, }); expect(res.statusCode).toBe(200); @@ -340,7 +335,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions/9.9.9', + url: `/servers/${encodeURIComponent('@test/mcp-server')}/versions/9.9.9`, }); expect(res.statusCode).toBe(404); @@ -357,7 +352,7 @@ describe('MCP Registry routes', () => { const res = await app.inject({ method: 'GET', - url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions', + url: `/servers/${encodeURIComponent('@test/mcp-server')}/versions`, }); expect(res.statusCode).toBe(200); diff --git a/apps/registry/tests/setup.ts b/apps/registry/tests/setup.ts index a3557ec..5dbbcef 100644 --- a/apps/registry/tests/setup.ts +++ b/apps/registry/tests/setup.ts @@ -1,5 +1,5 @@ -import Fastify, { FastifyInstance } from 'fastify'; import sensible from '@fastify/sensible'; +import Fastify, { type FastifyInstance } from 'fastify'; /** * Create a test Fastify instance with common configuration. diff --git a/apps/registry/tests/skill-content.test.ts b/apps/registry/tests/skill-content.test.ts index ea3ac1e..7107662 100644 --- a/apps/registry/tests/skill-content.test.ts +++ b/apps/registry/tests/skill-content.test.ts @@ -76,14 +76,8 @@ name: my-skill it('picks first match when multiple SKILL.md files exist', () => { const zip = new AdmZip(); - zip.addFile( - 'a-skill/SKILL.md', - Buffer.from('---\nname: first\n---\nFirst body.') - ); - zip.addFile( - 'b-skill/SKILL.md', - Buffer.from('---\nname: second\n---\nSecond body.') - ); + zip.addFile('a-skill/SKILL.md', Buffer.from('---\nname: first\n---\nFirst body.')); + zip.addFile('b-skill/SKILL.md', Buffer.from('---\nname: second\n---\nSecond body.')); const result = extractSkillContent(zip.toBuffer()); expect(result).toBe('First body.'); }); diff --git a/apps/registry/tests/skills.test.ts b/apps/registry/tests/skills.test.ts index 76bc5eb..ad959d8 100644 --- a/apps/registry/tests/skills.test.ts +++ b/apps/registry/tests/skills.test.ts @@ -6,10 +6,10 @@ * without a database or network access. */ -import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; -import type { Mock } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; import sensible from '@fastify/sensible'; +import Fastify, { type FastifyInstance } from 'fastify'; +import type { Mock } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Module mocks (hoisted before all imports) @@ -61,8 +61,8 @@ vi.mock('../src/utils/skill-content.js', () => ({ // Imports (after mocks) // --------------------------------------------------------------------------- -import { createMockSkillRepo, createMockStorage, createMockPrisma } from './helpers.js'; import { verifyGitHubOIDC } from '../src/lib/oidc.js'; +import { createMockPrisma, createMockSkillRepo, createMockStorage } from './helpers.js'; // --------------------------------------------------------------------------- // Test setup diff --git a/apps/registry/tsconfig.json b/apps/registry/tsconfig.json index 6198a54..feee810 100644 --- a/apps/registry/tsconfig.json +++ b/apps/registry/tsconfig.json @@ -5,10 +5,6 @@ "rootDir": "./src", "types": ["node"], "exactOptionalPropertyTypes": false, - "noPropertyAccessFromIndexSignature": false, - "noUncheckedIndexedAccess": false, - "exactOptionalPropertyTypes": false, - "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": false }, "include": ["src/**/*"], diff --git a/apps/web/package.json b/apps/web/package.json index cb60923..29aac5d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,8 +18,6 @@ "postbuild": "tsx scripts/prerender.ts", "preview": "vite preview", "typecheck": "tsc --noEmit", - "lint": "eslint src/**/*.ts", - "lint:fix": "eslint src/**/*.ts --fix", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/apps/web/scripts/generate-feed.ts b/apps/web/scripts/generate-feed.ts index 763a1a0..87782f6 100644 --- a/apps/web/scripts/generate-feed.ts +++ b/apps/web/scripts/generate-feed.ts @@ -120,7 +120,7 @@ function generateFeed(packages: Package[], skills: Skill[]): string { ${item.pubDate} ${item.category} ${item.link} - ` + `, ) .join('\n'); @@ -152,8 +152,8 @@ async function main() { const feed = generateFeed(packages, skills); - const fs = await import('fs'); - const path = await import('path'); + const fs = await import('node:fs'); + const path = await import('node:path'); const outputPath = path.join(process.cwd(), 'public', 'feed.xml'); fs.writeFileSync(outputPath, feed, { encoding: 'utf-8', mode: 0o644 }); diff --git a/apps/web/scripts/generate-og-image.ts b/apps/web/scripts/generate-og-image.ts index 45af7ab..f84c982 100644 --- a/apps/web/scripts/generate-og-image.ts +++ b/apps/web/scripts/generate-og-image.ts @@ -11,8 +11,8 @@ * public/og-image.png */ +import { join } from 'node:path'; import { chromium } from '@playwright/test'; -import { join } from 'path'; const WIDTH = 1200; const HEIGHT = 630; diff --git a/apps/web/scripts/generate-sitemap.ts b/apps/web/scripts/generate-sitemap.ts index ba3f741..cddabc3 100644 --- a/apps/web/scripts/generate-sitemap.ts +++ b/apps/web/scripts/generate-sitemap.ts @@ -65,7 +65,7 @@ async function fetchWithTimeout(url: string, label: string): Promise { const data = await fetchWithTimeout( `${API_URL}/app/packages?limit=1000`, - 'packages' + 'packages', ); return data?.packages ?? []; } @@ -73,7 +73,7 @@ async function fetchAllPackages(): Promise { async function fetchAllSkills(): Promise { const data = await fetchWithTimeout( `${API_URL}/v1/skills/search?limit=1000`, - 'skills' + 'skills', ); return data?.skills ?? []; } @@ -119,9 +119,9 @@ function generateSitemap(packages: Package[], skills: Skill[]): string { ${BASE_URL}${url.loc} ${url.changefreq} ${url.priority}${ - 'lastmod' in url ? `\n ${url.lastmod}` : '' - } - ` + 'lastmod' in url ? `\n ${url.lastmod}` : '' + } + `, ) .join('\n'); @@ -133,18 +133,15 @@ ${urlEntries} async function main() { console.log('Fetching data from API...'); - const [packages, skills] = await Promise.all([ - fetchAllPackages(), - fetchAllSkills(), - ]); + const [packages, skills] = await Promise.all([fetchAllPackages(), fetchAllSkills()]); console.log(`Found ${packages.length} packages, ${skills.length} skills`); console.log('Generating sitemap...'); const sitemap = generateSitemap(packages, skills); // Write to file - const fs = await import('fs'); - const path = await import('path'); + const fs = await import('node:fs'); + const path = await import('node:path'); const outputPath = path.join(process.cwd(), 'public', 'sitemap.xml'); const staticPageCount = 10; diff --git a/apps/web/scripts/prerender.ts b/apps/web/scripts/prerender.ts index 41b46ac..81b57b4 100644 --- a/apps/web/scripts/prerender.ts +++ b/apps/web/scripts/prerender.ts @@ -7,10 +7,11 @@ * tsx scripts/prerender.ts * SKIP_PRERENDER=true tsx scripts/prerender.ts # skip in CI */ + +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; import { chromium } from '@playwright/test'; -import http from 'http'; -import fs from 'fs'; -import path from 'path'; const DIST = path.resolve(import.meta.dirname, '../dist'); const PORT = 4173; @@ -29,10 +30,7 @@ const STATIC_ROUTES = [ ]; // Browse pages - will wait longer for API data -const DATA_ROUTES = [ - '/bundles', - '/skills', -]; +const DATA_ROUTES = ['/bundles', '/skills']; const ALL_ROUTES = [...STATIC_ROUTES, ...DATA_ROUTES]; @@ -122,8 +120,14 @@ async function prerender() { let html = await page.content(); // Strip Vite HMR scripts if any leaked through - html = html.replace(/]*type="module"[^>]*src="\/@vite\/client"[^>]*><\/script>/g, ''); - html = html.replace(/]*type="module"[^>]*src="\/@react-refresh"[^>]*><\/script>/g, ''); + html = html.replace( + /]*type="module"[^>]*src="\/@vite\/client"[^>]*><\/script>/g, + '', + ); + html = html.replace( + /]*type="module"[^>]*src="\/@react-refresh"[^>]*><\/script>/g, + '', + ); // Determine output path let outputPath: string; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 56f84f5..06a857d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,24 +1,24 @@ -import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'; import { AuthProvider } from './auth/AuthProvider'; import RootLayout from './layouts/RootLayout'; +import AboutPage from './pages/AboutPage'; +import BrowsePackagesPage from './pages/BrowsePackagesPage'; +import ContactPage from './pages/ContactPage'; import ErrorPage from './pages/ErrorPage'; import HomePage from './pages/HomePage'; -import BrowsePackagesPage from './pages/BrowsePackagesPage'; -import PackageDetailPage from './pages/PackageDetailPage'; import LoginPage from './pages/LoginPage'; -import UserPackagesPage from './pages/UserPackagesPage'; -import AboutPage from './pages/AboutPage'; -import ContactPage from './pages/ContactPage'; -import SkillsPage from './pages/SkillsPage'; -import SkillDetailPage from './pages/SkillDetailPage'; -import SecurityPage from './pages/SecurityPage'; -import SecurityControlsPage from './pages/SecurityControlsPage'; -import PublishGatewayPage from './pages/PublishGatewayPage'; +import PackageDetailPage from './pages/PackageDetailPage'; +import PrivacyPage from './pages/PrivacyPage'; import PublishBundlesPage from './pages/PublishBundlesPage'; +import PublishGatewayPage from './pages/PublishGatewayPage'; import PublishSkillsPage from './pages/PublishSkillsPage'; -import PrivacyPage from './pages/PrivacyPage'; +import SecurityControlsPage from './pages/SecurityControlsPage'; +import SecurityPage from './pages/SecurityPage'; +import SkillDetailPage from './pages/SkillDetailPage'; +import SkillsPage from './pages/SkillsPage'; import TermsPage from './pages/TermsPage'; +import UserPackagesPage from './pages/UserPackagesPage'; // Create QueryClient instance const queryClient = new QueryClient({ @@ -35,7 +35,11 @@ const router = createBrowserRouter([ { path: '/', element: , - errorElement: , + errorElement: ( + + + + ), children: [ { index: true, element: }, { path: 'bundles', element: }, diff --git a/apps/web/src/auth/AuthProvider.tsx b/apps/web/src/auth/AuthProvider.tsx index 0d94074..0c48d32 100644 --- a/apps/web/src/auth/AuthProvider.tsx +++ b/apps/web/src/auth/AuthProvider.tsx @@ -1,12 +1,12 @@ -import { createContext, useContext, useEffect, useRef, useState } from 'react'; -import type { ReactNode } from 'react'; import { ClerkProvider, useAuth as useClerkAuth, useUser as useClerkUser, } from '@clerk/clerk-react'; +import type { ReactNode } from 'react'; +import { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { type User, useMe } from '../hooks/useAuthQueries'; import { addAccessTokenInterceptor } from '../lib/httpClient'; -import { useMe, type User } from '../hooks/useAuthQueries'; // Whether Clerk auth is configured (build-time constant) export const authEnabled = !!import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; @@ -35,12 +35,12 @@ export function useAuth(): AuthState { // Conditional rendering helpers (replace Clerk's /) export function AuthGuard({ children }: { children: ReactNode }) { const { isAuthenticated } = useAuth(); - return isAuthenticated ? <>{children} : null; + return isAuthenticated ? children : null; } export function GuestGuard({ children }: { children: ReactNode }) { const { isAuthenticated, isLoaded } = useAuth(); - return isLoaded && !isAuthenticated ? <>{children} : null; + return isLoaded && !isAuthenticated ? children : null; } // --- Clerk implementation (only rendered when VITE_CLERK_PUBLISHABLE_KEY is set) --- @@ -62,7 +62,7 @@ function ClerkAuthInner({ children }: { children: ReactNode }) { // Fetch backend user when signed in and interceptor is ready const { data: user, error: meError } = useMe( - interceptorReady && isLoaded && !!isSignedIn && !!clerkUser + interceptorReady && isLoaded && !!isSignedIn && !!clerkUser, ); const value: AuthState = { diff --git a/apps/web/src/components/BadgeSection.test.tsx b/apps/web/src/components/BadgeSection.test.tsx index 1b99e26..5ffca5e 100644 --- a/apps/web/src/components/BadgeSection.test.tsx +++ b/apps/web/src/components/BadgeSection.test.tsx @@ -1,18 +1,24 @@ -import { render, screen, waitFor } from '../test/test-utils'; import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../test/test-utils'; import BadgeSection from './BadgeSection'; describe('BadgeSection', () => { it('renders correct badge URL for bundle type', () => { render(); const img = screen.getByAltText('mpak badge'); - expect(img).toHaveAttribute('src', expect.stringContaining('/v1/bundles/@scope/test-pkg/badge.svg')); + expect(img).toHaveAttribute( + 'src', + expect.stringContaining('/v1/bundles/@scope/test-pkg/badge.svg'), + ); }); it('renders correct badge URL for skill type', () => { render(); const img = screen.getByAltText('mpak badge'); - expect(img).toHaveAttribute('src', expect.stringContaining('/v1/skills/@scope/test-skill/badge.svg')); + expect(img).toHaveAttribute( + 'src', + expect.stringContaining('/v1/skills/@scope/test-skill/badge.svg'), + ); }); it('copies markdown to clipboard on click', async () => { diff --git a/apps/web/src/components/BadgeSection.tsx b/apps/web/src/components/BadgeSection.tsx index d90194b..5a845ae 100644 --- a/apps/web/src/components/BadgeSection.tsx +++ b/apps/web/src/components/BadgeSection.tsx @@ -41,11 +41,7 @@ export default function BadgeSection({ packageName, packageType = 'bundle' }: Ba {/* Preview */}
Preview - mpak badge + mpak badge
{/* Markdown code block */} @@ -53,20 +49,43 @@ export default function BadgeSection({ packageName, packageType = 'bundle' }: Ba
Markdown
- - {markdownCode} - + {markdownCode}
diff --git a/apps/web/src/components/Breadcrumbs.tsx b/apps/web/src/components/Breadcrumbs.tsx index dfb7657..7b6c68e 100644 --- a/apps/web/src/components/Breadcrumbs.tsx +++ b/apps/web/src/components/Breadcrumbs.tsx @@ -1,5 +1,5 @@ -import { Link } from 'react-router-dom'; import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; import { generateBreadcrumbSchema } from '../lib/schema'; import { SITE_URL } from '../lib/siteConfig'; @@ -50,9 +50,10 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {