import { BaseRegistry } from '../core/classes.baseregistry.js'; import { RegistryStorage } from '../core/classes.registrystorage.js'; import { AuthManager } from '../core/classes.authmanager.js'; import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js'; import type { IPackument, INpmVersion, IPublishRequest, ISearchResponse, ISearchResult, ITokenListResponse, ITokenCreateRequest, IUserAuthRequest, INpmError, } from './interfaces.npm.js'; /** * NPM Registry implementation * Compliant with npm registry API */ export class NpmRegistry extends BaseRegistry { private storage: RegistryStorage; private authManager: AuthManager; private basePath: string = '/npm'; private registryUrl: string; constructor( storage: RegistryStorage, authManager: AuthManager, basePath: string = '/npm', registryUrl: string = 'http://localhost:5000/npm' ) { super(); this.storage = storage; this.authManager = authManager; this.basePath = basePath; this.registryUrl = registryUrl; } public async init(): Promise { // NPM registry initialization } public getBasePath(): string { return this.basePath; } public async handleRequest(context: IRequestContext): Promise { const path = context.path.replace(this.basePath, ''); console.log(`[NPM handleRequest] method=${context.method}, path=${path}`); // Extract token from Authorization header const authHeader = context.headers['authorization'] || context.headers['Authorization']; const tokenString = authHeader?.replace(/^Bearer\s+/i, ''); console.log(`[NPM handleRequest] authHeader=${authHeader}, tokenString=${tokenString}`); const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null; console.log(`[NPM handleRequest] token validated:`, token); // Registry root if (path === '/' || path === '') { return this.handleRegistryInfo(); } // Search: /-/v1/search if (path.startsWith('/-/v1/search')) { return this.handleSearch(context.query); } // User authentication: /-/user/org.couchdb.user:{username} const userMatch = path.match(/^\/-\/user\/org\.couchdb\.user:(.+)$/); if (userMatch) { return this.handleUserAuth(context.method, userMatch[1], context.body, token); } // Token operations: /-/npm/v1/tokens if (path.startsWith('/-/npm/v1/tokens')) { return this.handleTokens(context.method, path, context.body, token); } // Dist-tags: /-/package/{package}/dist-tags const distTagsMatch = path.match(/^\/-\/package\/(@?[^\/]+(?:\/[^\/]+)?)\/dist-tags(?:\/(.+))?$/); if (distTagsMatch) { const [, packageName, tag] = distTagsMatch; return this.handleDistTags(context.method, packageName, tag, context.body, token); } // Tarball download: /{package}/-/{filename}.tgz const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/); if (tarballMatch) { const [, packageName, filename] = tarballMatch; return this.handleTarballDownload(packageName, filename, token); } // Unpublish specific version: DELETE /{package}/-/{version} const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/); if (unpublishVersionMatch && context.method === 'DELETE') { const [, packageName, version] = unpublishVersionMatch; console.log(`[unpublishVersionMatch] packageName=${packageName}, version=${version}`); return this.unpublishVersion(packageName, version, token); } // Unpublish entire package: DELETE /{package}/-rev/{rev} const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/); if (unpublishPackageMatch && context.method === 'DELETE') { const [, packageName, rev] = unpublishPackageMatch; console.log(`[unpublishPackageMatch] packageName=${packageName}, rev=${rev}`); return this.unpublishPackage(packageName, token); } // Package version: /{package}/{version} const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/); if (versionMatch) { const [, packageName, version] = versionMatch; console.log(`[versionMatch] matched! packageName=${packageName}, version=${version}`); return this.handlePackageVersion(packageName, version, token); } // Package operations: /{package} const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/); if (packageMatch) { const packageName = packageMatch[1]; console.log(`[packageMatch] matched! packageName=${packageName}`); return this.handlePackage(context.method, packageName, context.body, context.query, token); } return { status: 404, headers: { 'Content-Type': 'application/json' }, body: this.createError('E404', 'Not found'), }; } protected async checkPermission( token: IAuthToken | null, resource: string, action: string ): Promise { if (!token) return false; return this.authManager.authorize(token, `npm:package:${resource}`, action); } // ======================================================================== // REQUEST HANDLERS // ======================================================================== private handleRegistryInfo(): IResponse { return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { db_name: 'registry', doc_count: 0, doc_del_count: 0, update_seq: 0, purge_seq: 0, compact_running: false, disk_size: 0, data_size: 0, instance_start_time: Date.now().toString(), disk_format_version: 0, committed_update_seq: 0, }, }; } private async handlePackage( method: string, packageName: string, body: any, query: Record, token: IAuthToken | null ): Promise { switch (method) { case 'GET': return this.getPackument(packageName, token, query); case 'PUT': return this.publishPackage(packageName, body, token); case 'DELETE': return this.unpublishPackage(packageName, token); default: return { status: 405, headers: {}, body: this.createError('EBADREQUEST', 'Method not allowed'), }; } } private async getPackument( packageName: string, token: IAuthToken | null, query: Record ): Promise { const packument = await this.storage.getNpmPackument(packageName); if (!packument) { return { status: 404, headers: { 'Content-Type': 'application/json' }, body: this.createError('E404', `Package '${packageName}' not found`), }; } // Check if abbreviated version requested const accept = query['accept'] || ''; if (accept.includes('application/vnd.npm.install-v1+json')) { // Return abbreviated packument const abbreviated = { name: packument.name, modified: packument.time?.modified || new Date().toISOString(), 'dist-tags': packument['dist-tags'], versions: packument.versions, }; return { status: 200, headers: { 'Content-Type': 'application/vnd.npm.install-v1+json' }, body: abbreviated, }; } return { status: 200, headers: { 'Content-Type': 'application/json' }, body: packument, }; } private async handlePackageVersion( packageName: string, version: string, token: IAuthToken | null ): Promise { console.log(`[handlePackageVersion] packageName=${packageName}, version=${version}`); const packument = await this.storage.getNpmPackument(packageName); console.log(`[handlePackageVersion] packument found:`, !!packument); if (packument) { console.log(`[handlePackageVersion] versions:`, Object.keys(packument.versions || {})); } if (!packument) { return { status: 404, headers: { 'Content-Type': 'application/json' }, body: this.createError('E404', 'Package not found'), }; } // Resolve version (could be "latest" or actual version) let actualVersion = version; if (version === 'latest') { actualVersion = packument['dist-tags']?.latest; if (!actualVersion) { return { status: 404, headers: {}, body: this.createError('E404', 'No latest version'), }; } } const versionData = packument.versions[actualVersion]; if (!versionData) { return { status: 404, headers: {}, body: this.createError('E404', 'Version not found'), }; } return { status: 200, headers: { 'Content-Type': 'application/json' }, body: versionData, }; } private async publishPackage( packageName: string, body: IPublishRequest, token: IAuthToken | null ): Promise { console.log(`[publishPackage] packageName=${packageName}, token=`, token); const hasPermission = await this.checkPermission(token, packageName, 'write'); console.log(`[publishPackage] hasPermission=${hasPermission}`); if (!hasPermission) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Unauthorized'), }; } if (!body || !body.versions || !body._attachments) { return { status: 400, headers: {}, body: this.createError('EBADREQUEST', 'Invalid publish request'), }; } // Get existing packument or create new one let packument = await this.storage.getNpmPackument(packageName); const isNew = !packument; if (isNew) { packument = { _id: packageName, name: packageName, description: body.description, 'dist-tags': body['dist-tags'] || { latest: Object.keys(body.versions)[0] }, versions: {}, time: { created: new Date().toISOString(), modified: new Date().toISOString(), }, maintainers: body.maintainers || [], readme: body.readme, }; } // Process each new version for (const [version, versionData] of Object.entries(body.versions)) { // Check if version already exists if (packument.versions[version]) { return { status: 403, headers: {}, body: this.createError('EPUBLISHCONFLICT', `Version ${version} already exists`), }; } // Find attachment for this version const attachmentKey = Object.keys(body._attachments).find(key => key.includes(version) ); if (!attachmentKey) { return { status: 400, headers: {}, body: this.createError('EBADREQUEST', `No tarball for version ${version}`), }; } const attachment = body._attachments[attachmentKey]; // Decode base64 tarball const tarballBuffer = Buffer.from(attachment.data, 'base64'); // Calculate shasum const crypto = await import('crypto'); const shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex'); const integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`; // Store tarball await this.storage.putNpmTarball(packageName, version, tarballBuffer); // Update version data with dist info const safeName = packageName.replace('@', '').replace('/', '-'); versionData.dist = { tarball: `${this.registryUrl}/${packageName}/-/${safeName}-${version}.tgz`, shasum, integrity, fileCount: 0, unpackedSize: tarballBuffer.length, }; versionData._id = `${packageName}@${version}`; versionData._npmUser = token ? { name: token.userId, email: '' } : undefined; // Add version to packument packument.versions[version] = versionData; if (packument.time) { packument.time[version] = new Date().toISOString(); packument.time.modified = new Date().toISOString(); } } // Update dist-tags if (body['dist-tags']) { packument['dist-tags'] = { ...packument['dist-tags'], ...body['dist-tags'] }; } // Save packument await this.storage.putNpmPackument(packageName, packument); return { status: 201, headers: { 'Content-Type': 'application/json' }, body: { ok: true, id: packageName, rev: packument._rev || '1-' + Date.now() }, }; } private async unpublishVersion( packageName: string, version: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, packageName, 'delete')) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Unauthorized'), }; } const packument = await this.storage.getNpmPackument(packageName); if (!packument) { return { status: 404, headers: {}, body: this.createError('E404', 'Package not found'), }; } // Check if version exists if (!packument.versions[version]) { return { status: 404, headers: {}, body: this.createError('E404', 'Version not found'), }; } // Delete tarball await this.storage.deleteNpmTarball(packageName, version); // Remove version from packument delete packument.versions[version]; if (packument.time) { delete packument.time[version]; packument.time.modified = new Date().toISOString(); } // Update latest tag if this was the latest version if (packument['dist-tags']?.latest === version) { const remainingVersions = Object.keys(packument.versions); if (remainingVersions.length > 0) { packument['dist-tags'].latest = remainingVersions[remainingVersions.length - 1]; } else { delete packument['dist-tags'].latest; } } // Save updated packument await this.storage.putNpmPackument(packageName, packument); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { ok: true }, }; } private async unpublishPackage( packageName: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, packageName, 'delete')) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Unauthorized'), }; } const packument = await this.storage.getNpmPackument(packageName); if (!packument) { return { status: 404, headers: {}, body: this.createError('E404', 'Package not found'), }; } // Delete all tarballs for (const version of Object.keys(packument.versions)) { await this.storage.deleteNpmTarball(packageName, version); } // Delete packument await this.storage.deleteNpmPackument(packageName); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { ok: true }, }; } private async handleTarballDownload( packageName: string, filename: string, token: IAuthToken | null ): Promise { // Extract version from filename: package-name-1.0.0.tgz const versionMatch = filename.match(/-([\d.]+(?:-[a-z0-9.]+)?)\.tgz$/); if (!versionMatch) { return { status: 400, headers: {}, body: this.createError('EBADREQUEST', 'Invalid tarball filename'), }; } const version = versionMatch[1]; const tarball = await this.storage.getNpmTarball(packageName, version); if (!tarball) { return { status: 404, headers: {}, body: this.createError('E404', 'Tarball not found'), }; } return { status: 200, headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': tarball.length.toString(), }, body: tarball, }; } private async handleSearch(query: Record): Promise { const text = query.text || ''; const size = parseInt(query.size || '20', 10); const from = parseInt(query.from || '0', 10); // Simple search implementation const results: ISearchResult[] = []; try { // List all package paths const packagePaths = await this.storage.listObjects('npm/packages/'); // Extract unique package names from paths (format: npm/packages/{packageName}/...) const packageNames = new Set(); for (const path of packagePaths) { const match = path.match(/^npm\/packages\/([^\/]+)\/index\.json$/); if (match) { packageNames.add(match[1]); } } // Load packuments and filter by search text for (const packageName of packageNames) { if (!text || packageName.toLowerCase().includes(text.toLowerCase())) { const packument = await this.storage.getNpmPackument(packageName); if (packument) { const latestVersion = packument['dist-tags']?.latest; const versionData = latestVersion ? packument.versions[latestVersion] : null; results.push({ package: { name: packument.name, version: latestVersion || '0.0.0', description: packument.description || versionData?.description || '', keywords: versionData?.keywords || [], date: packument.time?.modified || new Date().toISOString(), links: {}, author: versionData?.author || {}, publisher: versionData?._npmUser || {}, maintainers: packument.maintainers || [], }, score: { final: 1.0, detail: { quality: 1.0, popularity: 1.0, maintenance: 1.0, }, }, searchScore: 1.0, }); } } } } catch (error) { console.error('[handleSearch] Error:', error); } // Apply pagination const paginatedResults = results.slice(from, from + size); const response: ISearchResponse = { objects: paginatedResults, total: results.length, time: new Date().toISOString(), }; return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } private async handleUserAuth( method: string, username: string, body: IUserAuthRequest, token: IAuthToken | null ): Promise { if (method !== 'PUT') { return { status: 405, headers: {}, body: this.createError('EBADREQUEST', 'Method not allowed'), }; } if (!body || !body.name || !body.password) { return { status: 400, headers: {}, body: this.createError('EBADREQUEST', 'Invalid request'), }; } // Authenticate user const userId = await this.authManager.authenticate({ username: body.name, password: body.password, }); if (!userId) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Invalid credentials'), }; } // Create NPM token const npmToken = await this.authManager.createNpmToken(userId, false); return { status: 201, headers: { 'Content-Type': 'application/json' }, body: { ok: true, id: `org.couchdb.user:${username}`, rev: '1-' + Date.now(), token: npmToken, }, }; } private async handleTokens( method: string, path: string, body: any, token: IAuthToken | null ): Promise { if (!token) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Unauthorized'), }; } // List tokens: GET /-/npm/v1/tokens if (path === '/-/npm/v1/tokens' && method === 'GET') { return this.listTokens(token); } // Create token: POST /-/npm/v1/tokens if (path === '/-/npm/v1/tokens' && method === 'POST') { return this.createToken(body, token); } // Delete token: DELETE /-/npm/v1/tokens/token/{key} const deleteMatch = path.match(/^\/-\/npm\/v1\/tokens\/token\/(.+)$/); if (deleteMatch && method === 'DELETE') { return this.deleteToken(deleteMatch[1], token); } return { status: 404, headers: {}, body: this.createError('E404', 'Not found'), }; } private async listTokens(token: IAuthToken): Promise { const tokens = await this.authManager.listUserTokens(token.userId); const response: ITokenListResponse = { objects: tokens.map(t => ({ token: '********', key: t.key, readonly: t.readonly, created: t.created, updated: t.created, })), total: tokens.length, urls: {}, }; return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } private async createToken(body: ITokenCreateRequest, token: IAuthToken): Promise { if (!body || !body.password) { return { status: 400, headers: {}, body: this.createError('EBADREQUEST', 'Password required'), }; } // Verify password (simplified - in production, verify against stored password) const readonly = body.readonly || false; const newToken = await this.authManager.createNpmToken(token.userId, readonly); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { token: newToken, key: 'sha512-' + newToken.substring(0, 16) + '...', cidr_whitelist: body.cidr_whitelist || [], readonly, created: new Date().toISOString(), updated: new Date().toISOString(), }, }; } private async deleteToken(key: string, token: IAuthToken): Promise { // In production, lookup token by key hash and delete return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { ok: true }, }; } private async handleDistTags( method: string, packageName: string, tag: string | undefined, body: any, token: IAuthToken | null ): Promise { const packument = await this.storage.getNpmPackument(packageName); if (!packument) { return { status: 404, headers: {}, body: this.createError('E404', 'Package not found'), }; } // GET /-/package/{package}/dist-tags if (method === 'GET' && !tag) { return { status: 200, headers: { 'Content-Type': 'application/json' }, body: packument['dist-tags'] || {}, }; } // PUT /-/package/{package}/dist-tags/{tag} if (method === 'PUT' && tag) { if (!await this.checkPermission(token, packageName, 'write')) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Unauthorized'), }; } if (typeof body !== 'string') { return { status: 400, headers: {}, body: this.createError('EBADREQUEST', 'Version string required'), }; } packument['dist-tags'] = packument['dist-tags'] || {}; packument['dist-tags'][tag] = body; await this.storage.putNpmPackument(packageName, packument); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { ok: true }, }; } // DELETE /-/package/{package}/dist-tags/{tag} if (method === 'DELETE' && tag) { if (!await this.checkPermission(token, packageName, 'write')) { return { status: 401, headers: {}, body: this.createError('EUNAUTHORIZED', 'Unauthorized'), }; } if (tag === 'latest') { return { status: 403, headers: {}, body: this.createError('EFORBIDDEN', 'Cannot delete latest tag'), }; } if (packument['dist-tags'] && packument['dist-tags'][tag]) { delete packument['dist-tags'][tag]; await this.storage.putNpmPackument(packageName, packument); } return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { ok: true }, }; } return { status: 405, headers: {}, body: this.createError('EBADREQUEST', 'Method not allowed'), }; } // ======================================================================== // HELPER METHODS // ======================================================================== private createError(code: string, message: string): INpmError { return { error: code, reason: message, }; } }