1014 lines
29 KiB
TypeScript
1014 lines
29 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,
|
|
IPublishRequest,
|
|
ISearchResponse,
|
|
ISearchResult,
|
|
ITokenListResponse,
|
|
ITokenCreateRequest,
|
|
IUserAuthRequest,
|
|
INpmError,
|
|
} from './interfaces.npm.js';
|
|
import {
|
|
createNewPackument,
|
|
getAttachmentForVersion,
|
|
preparePublishedVersion,
|
|
recordPublishedVersion,
|
|
} from './helpers.npmpublish.js';
|
|
import { parseNpmRequestRoute } from './helpers.npmroutes.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;
|
|
|
|
this.logger = this.createProtocolLogger('npm-registry', 'npm');
|
|
|
|
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, '');
|
|
|
|
const tokenString = this.extractBearerToken(context);
|
|
const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null;
|
|
|
|
const actor: IRequestActor = this.buildRequestActor(context, token);
|
|
|
|
this.logger.log('debug', `handleRequest: ${context.method} ${path}`, {
|
|
method: context.method,
|
|
path,
|
|
hasAuth: !!token
|
|
});
|
|
|
|
return this.storage.withContext({ protocol: 'npm', actor }, async () => {
|
|
const route = parseNpmRequestRoute(path, context.method);
|
|
if (!route) {
|
|
return {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: this.createError('E404', 'Not found'),
|
|
};
|
|
}
|
|
|
|
switch (route.type) {
|
|
case 'root':
|
|
return this.handleRegistryInfo();
|
|
case 'search':
|
|
return this.handleSearch(context.query);
|
|
case 'userAuth':
|
|
return this.handleUserAuth(context.method, route.username, context.body, token);
|
|
case 'tokens':
|
|
return this.handleTokens(context.method, route.path, context.body, token);
|
|
case 'distTags':
|
|
return this.withPackageContext(
|
|
route.packageName,
|
|
actor,
|
|
async () => this.handleDistTags(context.method, route.packageName, route.tag, context.body, token)
|
|
);
|
|
case 'tarball':
|
|
return this.handleTarballDownload(route.packageName, route.filename, token, actor);
|
|
case 'unpublishVersion':
|
|
this.logger.log('debug', 'unpublishVersionMatch', {
|
|
packageName: route.packageName,
|
|
version: route.version,
|
|
});
|
|
return this.withPackageVersionContext(
|
|
route.packageName,
|
|
route.version,
|
|
actor,
|
|
async () => this.unpublishVersion(route.packageName, route.version, token)
|
|
);
|
|
case 'unpublishPackage':
|
|
this.logger.log('debug', 'unpublishPackageMatch', {
|
|
packageName: route.packageName,
|
|
rev: route.rev,
|
|
});
|
|
return this.withPackageContext(
|
|
route.packageName,
|
|
actor,
|
|
async () => this.unpublishPackage(route.packageName, token)
|
|
);
|
|
case 'packageVersion':
|
|
this.logger.log('debug', 'versionMatch', {
|
|
packageName: route.packageName,
|
|
version: route.version,
|
|
});
|
|
return this.withPackageVersionContext(
|
|
route.packageName,
|
|
route.version,
|
|
actor,
|
|
async () => this.handlePackageVersion(route.packageName, route.version, token, actor)
|
|
);
|
|
case 'package':
|
|
this.logger.log('debug', 'packageMatch', { packageName: route.packageName });
|
|
return this.withPackageContext(
|
|
route.packageName,
|
|
actor,
|
|
async () => this.handlePackage(context.method, route.packageName, context.body, context.query, token, actor)
|
|
);
|
|
}
|
|
|
|
});
|
|
}
|
|
|
|
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> {
|
|
const packument = await this.getLocalOrUpstreamPackument(packageName, actor, 'getPackument');
|
|
|
|
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 });
|
|
const packument = await this.getLocalOrUpstreamPackument(packageName, actor, 'handlePackageVersion');
|
|
this.logger.log('debug', 'handlePackageVersion packument', { found: !!packument });
|
|
if (packument) {
|
|
this.logger.log('debug', 'handlePackageVersion versions', { 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<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 = createNewPackument(packageName, body, new Date().toISOString());
|
|
}
|
|
|
|
// 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`),
|
|
};
|
|
}
|
|
|
|
const attachment = getAttachmentForVersion(body, version);
|
|
if (!attachment) {
|
|
return {
|
|
status: 400,
|
|
headers: {},
|
|
body: this.createError('EBADREQUEST', `No tarball for version ${version}`),
|
|
};
|
|
}
|
|
|
|
const preparedVersion = preparePublishedVersion({
|
|
packageName,
|
|
version,
|
|
versionData,
|
|
attachment,
|
|
registryUrl: this.registryUrl,
|
|
userId: token?.userId,
|
|
});
|
|
|
|
// Store tarball
|
|
await this.withPackageVersionContext(
|
|
packageName,
|
|
version,
|
|
undefined,
|
|
async () => this.storage.putNpmTarball(packageName, version, preparedVersion.tarballBuffer)
|
|
);
|
|
|
|
recordPublishedVersion(packument, version, preparedVersion.versionData, 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];
|
|
|
|
return this.withPackageVersionContext(
|
|
packageName,
|
|
version,
|
|
actor,
|
|
async (): Promise<IResponse> => {
|
|
// Try local storage first (streaming)
|
|
const streamResult = await this.storage.getNpmTarballStream(packageName, version);
|
|
if (streamResult) {
|
|
return {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Length': streamResult.size.toString(),
|
|
},
|
|
body: streamResult.stream,
|
|
};
|
|
}
|
|
|
|
// If not found locally, try upstream
|
|
let tarball: Buffer | null = null;
|
|
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 withPackageContext<T>(
|
|
packageName: string,
|
|
actor: IRequestActor | undefined,
|
|
fn: () => Promise<T>
|
|
): Promise<T> {
|
|
return this.storage.withContext(
|
|
{ protocol: 'npm', actor, metadata: { packageName } },
|
|
fn
|
|
);
|
|
}
|
|
|
|
private async getLocalOrUpstreamPackument(
|
|
packageName: string,
|
|
actor: IRequestActor | undefined,
|
|
logPrefix: string
|
|
): Promise<IPackument | null> {
|
|
const localPackument = await this.storage.getNpmPackument(packageName);
|
|
this.logger.log('debug', `${logPrefix}: ${packageName}`, {
|
|
packageName,
|
|
found: !!localPackument,
|
|
versions: localPackument ? Object.keys(localPackument.versions).length : 0,
|
|
});
|
|
|
|
if (localPackument) {
|
|
return localPackument;
|
|
}
|
|
|
|
const upstream = await this.getUpstreamForRequest(packageName, 'packument', 'GET', actor);
|
|
if (!upstream) {
|
|
return null;
|
|
}
|
|
|
|
this.logger.log('debug', `${logPrefix}: fetching from upstream`, { packageName });
|
|
const upstreamPackument = await upstream.fetchPackument(packageName);
|
|
if (upstreamPackument) {
|
|
this.logger.log('debug', `${logPrefix}: found in upstream`, {
|
|
packageName,
|
|
versions: Object.keys(upstreamPackument.versions || {}).length,
|
|
});
|
|
}
|
|
|
|
return upstreamPackument;
|
|
}
|
|
|
|
private async withPackageVersionContext<T>(
|
|
packageName: string,
|
|
version: string,
|
|
actor: IRequestActor | undefined,
|
|
fn: () => Promise<T>
|
|
): Promise<T> {
|
|
return this.storage.withContext(
|
|
{ protocol: 'npm', actor, metadata: { packageName, version } },
|
|
fn
|
|
);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// Sort results by relevance: exact match first, then prefix match, then substring match
|
|
if (text) {
|
|
const lowerText = text.toLowerCase();
|
|
results.sort((a, b) => {
|
|
const aName = a.package.name.toLowerCase();
|
|
const bName = b.package.name.toLowerCase();
|
|
const aExact = aName === lowerText ? 0 : 1;
|
|
const bExact = bName === lowerText ? 0 : 1;
|
|
if (aExact !== bExact) return aExact - bExact;
|
|
const aPrefix = aName.startsWith(lowerText) ? 0 : 1;
|
|
const bPrefix = bName.startsWith(lowerText) ? 0 : 1;
|
|
if (aPrefix !== bPrefix) return aPrefix - bPrefix;
|
|
return aName.localeCompare(bName);
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
}
|