851 lines
25 KiB
TypeScript
851 lines
25 KiB
TypeScript
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, '');
|
|
console.log(`[NPM handleRequest] method=${context.method}, path=${path}`);
|
|
|
|
// Extract token from Authorization header
|
|
const authHeader = context.headers['authorization'] || context.headers['Authorization'];
|
|
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
|
|
console.log(`[NPM handleRequest] authHeader=${authHeader}, tokenString=${tokenString}`);
|
|
const token = tokenString ? await this.authManager.validateToken(tokenString, 'npm') : null;
|
|
console.log(`[NPM handleRequest] token validated:`, 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 [, 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);
|
|
}
|
|
|
|
// Unpublish specific version: DELETE /{package}/-/{version}
|
|
const unpublishVersionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-\/([^\/]+)$/);
|
|
if (unpublishVersionMatch && context.method === 'DELETE') {
|
|
const [, packageName, version] = unpublishVersionMatch;
|
|
console.log(`[unpublishVersionMatch] packageName=${packageName}, version=${version}`);
|
|
return this.unpublishVersion(packageName, version, token);
|
|
}
|
|
|
|
// Unpublish entire package: DELETE /{package}/-rev/{rev}
|
|
const unpublishPackageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/-rev\/([^\/]+)$/);
|
|
if (unpublishPackageMatch && context.method === 'DELETE') {
|
|
const [, packageName, rev] = unpublishPackageMatch;
|
|
console.log(`[unpublishPackageMatch] packageName=${packageName}, rev=${rev}`);
|
|
return this.unpublishPackage(packageName, token);
|
|
}
|
|
|
|
// Package version: /{package}/{version}
|
|
const versionMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)\/([^\/]+)$/);
|
|
if (versionMatch) {
|
|
const [, packageName, version] = versionMatch;
|
|
console.log(`[versionMatch] matched! packageName=${packageName}, version=${version}`);
|
|
return this.handlePackageVersion(packageName, version, token);
|
|
}
|
|
|
|
// Package operations: /{package}
|
|
const packageMatch = path.match(/^\/(@?[^\/]+(?:\/[^\/]+)?)$/);
|
|
if (packageMatch) {
|
|
const packageName = packageMatch[1];
|
|
console.log(`[packageMatch] matched! packageName=${packageName}`);
|
|
return this.handlePackage(context.method, packageName, context.body, context.query, 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);
|
|
console.log(`[getPackument] packageName=${packageName}, versions=`, packument ? Object.keys(packument.versions) : 'null');
|
|
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> {
|
|
console.log(`[handlePackageVersion] packageName=${packageName}, version=${version}`);
|
|
const packument = await this.storage.getNpmPackument(packageName);
|
|
console.log(`[handlePackageVersion] packument found:`, !!packument);
|
|
if (packument) {
|
|
console.log(`[handlePackageVersion] 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> {
|
|
console.log(`[publishPackage] packageName=${packageName}, token=`, token);
|
|
const hasPermission = await this.checkPermission(token, packageName, 'write');
|
|
console.log(`[publishPackage] hasPermission=${hasPermission}`);
|
|
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
|
|
console.log(`[publishPackage] Saving packument with versions:`, Object.keys(packument.versions));
|
|
await this.storage.putNpmPackument(packageName, packument);
|
|
console.log(`[publishPackage] Packument saved successfully`);
|
|
|
|
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
|
|
): 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
|
|
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]);
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
console.error('[handleSearch] Error:', error);
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
}
|