Files
smartregistry/ts/npm/classes.npmregistry.ts
Juergen Kunz 37e4c5be4a fix(npm): decode URL-encoded package names after regex extraction
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.
2026-03-21 11:59:52 +00:00

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,
};
}
}