Scoped npm packages use %2f encoding for the slash in URLs (e.g. @scope%2fpackage). Previously, the encoded name was used as-is for storage and packument metadata, causing npm install to fail with EINVALIDPACKAGENAME. Now each regex extraction point decodes the package name via decodeURIComponent while keeping the path encoded for correct regex matching.
1005 lines
30 KiB
TypeScript
1005 lines
30 KiB
TypeScript
import { Smartlog } from '@push.rocks/smartlog';
|
|
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, IRequestActor } from '../core/interfaces.core.js';
|
|
import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
|
|
import { NpmUpstream } from './classes.npmupstream.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;
|
|
private logger: Smartlog;
|
|
private upstreamProvider: IUpstreamProvider | null = null;
|
|
|
|
constructor(
|
|
storage: RegistryStorage,
|
|
authManager: AuthManager,
|
|
basePath: string = '/npm',
|
|
registryUrl: string = 'http://localhost:5000/npm',
|
|
upstreamProvider?: IUpstreamProvider
|
|
) {
|
|
super();
|
|
this.storage = storage;
|
|
this.authManager = authManager;
|
|
this.basePath = basePath;
|
|
this.registryUrl = registryUrl;
|
|
this.upstreamProvider = upstreamProvider || null;
|
|
|
|
// Initialize logger
|
|
this.logger = new Smartlog({
|
|
logContext: {
|
|
company: 'push.rocks',
|
|
companyunit: 'smartregistry',
|
|
containerName: 'npm-registry',
|
|
environment: (process.env.NODE_ENV as any) || 'development',
|
|
runtime: 'node',
|
|
zone: 'npm'
|
|
}
|
|
});
|
|
this.logger.enableConsole();
|
|
|
|
if (upstreamProvider) {
|
|
this.logger.log('info', 'NPM upstream provider configured');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract scope from npm package name.
|
|
* @example "@company/utils" -> "company"
|
|
* @example "lodash" -> null
|
|
*/
|
|
private extractScope(packageName: string): string | null {
|
|
if (packageName.startsWith('@')) {
|
|
const slashIndex = packageName.indexOf('/');
|
|
if (slashIndex > 1) {
|
|
return packageName.substring(1, slashIndex);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get upstream for a specific request.
|
|
* Calls the provider to resolve upstream config dynamically.
|
|
*/
|
|
private async getUpstreamForRequest(
|
|
resource: string,
|
|
resourceType: string,
|
|
method: string,
|
|
actor?: IRequestActor
|
|
): Promise<NpmUpstream | null> {
|
|
if (!this.upstreamProvider) return null;
|
|
|
|
const config = await this.upstreamProvider.resolveUpstreamConfig({
|
|
protocol: 'npm',
|
|
resource,
|
|
scope: this.extractScope(resource),
|
|
actor,
|
|
method,
|
|
resourceType,
|
|
});
|
|
|
|
if (!config?.enabled) return null;
|
|
return new NpmUpstream(config, this.registryUrl, this.logger);
|
|
}
|
|
|
|
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;
|
|
|
|
// Build actor context for upstream resolution
|
|
const actor: IRequestActor = {
|
|
userId: token?.userId,
|
|
ip: context.headers['x-forwarded-for'] || context.headers['x-real-ip'],
|
|
userAgent: context.headers['user-agent'],
|
|
...context.actor, // Include any pre-populated actor info
|
|
};
|
|
|
|
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
|
method: context.method,
|
|
path,
|
|
hasAuth: !!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 [, rawPkgName, tag] = distTagsMatch;
|
|
return this.handleDistTags(context.method, decodeURIComponent(rawPkgName), tag, context.body, token);
|
|
}
|
|
|
|
// Tarball download: /{package}/-/{filename}.tgz
|
|
const tarballMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/(.+\.tgz)$/);
|
|
if (tarballMatch) {
|
|
const [, rawPkgName, filename] = tarballMatch;
|
|
return this.handleTarballDownload(decodeURIComponent(rawPkgName), filename, token, actor);
|
|
}
|
|
|
|
// Unpublish specific version: DELETE /{package}/-/{version}
|
|
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
|
|
if (unpublishVersionMatch && context.method === 'DELETE') {
|
|
const [, rawPkgName, version] = unpublishVersionMatch;
|
|
this.logger.log('debug', 'unpublishVersionMatch', { packageName: decodeURIComponent(rawPkgName), version });
|
|
return this.unpublishVersion(decodeURIComponent(rawPkgName), version, token);
|
|
}
|
|
|
|
// Unpublish entire package: DELETE /{package}/-rev/{rev}
|
|
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
|
|
if (unpublishPackageMatch && context.method === 'DELETE') {
|
|
const [, rawPkgName, rev] = unpublishPackageMatch;
|
|
this.logger.log('debug', 'unpublishPackageMatch', { packageName: decodeURIComponent(rawPkgName), rev });
|
|
return this.unpublishPackage(decodeURIComponent(rawPkgName), token);
|
|
}
|
|
|
|
// Package version: /{package}/{version}
|
|
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
|
|
if (versionMatch) {
|
|
const [, rawPkgName, version] = versionMatch;
|
|
this.logger.log('debug', 'versionMatch', { packageName: decodeURIComponent(rawPkgName), version });
|
|
return this.handlePackageVersion(decodeURIComponent(rawPkgName), version, token, actor);
|
|
}
|
|
|
|
// Package operations: /{package}
|
|
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
|
|
if (packageMatch) {
|
|
const packageName = decodeURIComponent(packageMatch[1]);
|
|
this.logger.log('debug', 'packageMatch', { packageName });
|
|
return this.handlePackage(context.method, packageName, context.body, context.query, token, actor);
|
|
}
|
|
|
|
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,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
switch (method) {
|
|
case 'GET':
|
|
return this.getPackument(packageName, token, query, actor);
|
|
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>,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
let packument = await this.storage.getNpmPackument(packageName);
|
|
this.logger.log('debug', `getPackument: ${packageName}`, {
|
|
packageName,
|
|
found: !!packument,
|
|
versions: packument ? Object.keys(packument.versions).length : 0
|
|
});
|
|
|
|
// If not found locally, try upstream
|
|
if (!packument) {
|
|
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
|
|
if (upstream) {
|
|
this.logger.log('debug', `getPackument: fetching from upstream`, { packageName });
|
|
const upstreamPackument = await upstream.fetchPackument(packageName);
|
|
if (upstreamPackument) {
|
|
this.logger.log('debug', `getPackument: found in upstream`, {
|
|
packageName,
|
|
versions: Object.keys(upstreamPackument.versions || {}).length
|
|
});
|
|
packument = upstreamPackument;
|
|
// Optionally cache the packument locally (without tarballs)
|
|
// We don't store tarballs here - they'll be fetched on demand
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
this.logger.log('debug', 'handlePackageVersion', { packageName, version });
|
|
let packument = await this.storage.getNpmPackument(packageName);
|
|
this.logger.log('debug', 'handlePackageVersion packument', { found: !!packument });
|
|
if (packument) {
|
|
this.logger.log('debug', 'handlePackageVersion versions', { versions: Object.keys(packument.versions || {}) });
|
|
}
|
|
|
|
// If not found locally, try upstream
|
|
if (!packument) {
|
|
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
|
|
if (upstream) {
|
|
this.logger.log('debug', 'handlePackageVersion: fetching from upstream', { packageName });
|
|
const upstreamPackument = await upstream.fetchPackument(packageName);
|
|
if (upstreamPackument) {
|
|
packument = upstreamPackument;
|
|
}
|
|
}
|
|
}
|
|
|
|
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> {
|
|
this.logger.log('info', `publishPackage: ${packageName}`, {
|
|
packageName,
|
|
versions: Object.keys(body.versions || {}),
|
|
hasAuth: !!token
|
|
});
|
|
|
|
const hasPermission = await this.checkPermission(token, packageName, 'write');
|
|
if (!hasPermission) {
|
|
this.logger.log('warn', `publishPackage: unauthorized`, { packageName, userId: token?.userId });
|
|
}
|
|
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);
|
|
this.logger.log('success', `publishPackage: saved ${packageName}`, {
|
|
packageName,
|
|
versions: Object.keys(packument.versions),
|
|
distTags: packument['dist-tags']
|
|
});
|
|
|
|
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<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'),
|
|
};
|
|
}
|
|
|
|
// 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<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,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
// Extract version from filename: package-name-1.0.0.tgz
|
|
const versionMatch = filename.match(/-([\d.]+(?:-[a-z0-9.]+)?)\.tgz$/i);
|
|
if (!versionMatch) {
|
|
return {
|
|
status: 400,
|
|
headers: {},
|
|
body: this.createError('EBADREQUEST', 'Invalid tarball filename'),
|
|
};
|
|
}
|
|
|
|
const version = versionMatch[1];
|
|
let tarball = await this.storage.getNpmTarball(packageName, version);
|
|
|
|
// If not found locally, try upstream
|
|
if (!tarball) {
|
|
const upstream = await this.getUpstreamForRequest(packageName, 'tarball', 'GET', actor);
|
|
if (upstream) {
|
|
this.logger.log('debug', 'handleTarballDownload: fetching from upstream', {
|
|
packageName,
|
|
version,
|
|
});
|
|
const upstreamTarball = await upstream.fetchTarball(packageName, version);
|
|
if (upstreamTarball) {
|
|
tarball = upstreamTarball;
|
|
// Cache the tarball locally for future requests
|
|
await this.storage.putNpmTarball(packageName, version, tarball);
|
|
this.logger.log('debug', 'handleTarballDownload: cached tarball locally', {
|
|
packageName,
|
|
version,
|
|
size: tarball.length,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
this.logger.log('debug', `handleSearch: query="${text}"`, { text, size, from });
|
|
|
|
// 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<string>();
|
|
for (const path of packagePaths) {
|
|
const match = path.match(/^npm\/packages\/([^\/]+)\/index\.json$/);
|
|
if (match) {
|
|
packageNames.add(match[1]);
|
|
}
|
|
}
|
|
|
|
this.logger.log('debug', `handleSearch: found ${packageNames.size} packages`, {
|
|
totalPackages: packageNames.size,
|
|
pathsScanned: packagePaths.length
|
|
});
|
|
|
|
// 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) {
|
|
this.logger.log('error', 'handleSearch failed', { error: (error as Error).message });
|
|
}
|
|
|
|
// 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<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: 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<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,
|
|
};
|
|
}
|
|
}
|