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, ''); // Extract token from Authorization header const authHeader = context.headers['authorization'] || context.headers['Authorization']; const tokenString = authHeader?.replace(/^Bearer\s+/i, ''); const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null; // 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); } // Package operations: /{package} const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/); if (packageMatch) { const packageName = packageMatch[1]; return this.handlePackage(context.method, packageName, context.body, context.query, token); } // Package version: /{package}/{version} const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/); if (versionMatch) { const [, packageName, version] = versionMatch; return this.handlePackageVersion(packageName, version, 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 { const packument = await this.storage.getNpmPackument(packageName); 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 { if (!await this.checkPermission(token, packageName, 'write')) { 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 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 (in production, use proper search index) const results: ISearchResult[] = []; // For now, return empty results // In production, implement full-text search across packuments const response: ISearchResponse = { objects: results, 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: 201, 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, }; } }