multi registry support v3

This commit is contained in:
2025-11-19 15:32:00 +00:00
parent e4480bff5d
commit 754ec7b7db
19 changed files with 1661 additions and 1740 deletions

View File

@@ -0,0 +1,707 @@
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<void> {
// NPM registry initialization
}
public getBasePath(): string {
return this.basePath;
}
public async handleRequest(context: IRequestContext): Promise<IResponse> {
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<boolean> {
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<string, string>,
token: IAuthToken | null
): Promise<IResponse> {
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<string, string>
): Promise<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
// 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<string, string>): Promise<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
// 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<IResponse> {
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,
};
}
}

6
ts/npm/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* NPM Registry module exports
*/
export { NpmRegistry } from './classes.npmregistry.js';
export * from './interfaces.npm.js';