diff --git a/.changeset/grumpy-pots-watch.md b/.changeset/grumpy-pots-watch.md new file mode 100644 index 000000000..cca228d3c --- /dev/null +++ b/.changeset/grumpy-pots-watch.md @@ -0,0 +1,9 @@ +--- +'@verdaccio/types': patch +'@verdaccio/config': patch +'@verdaccio/core': patch +'@verdaccio/store': patch +'@verdaccio/api': patch +--- + +feat: add support for npm owner diff --git a/README.md b/README.md index 31abf4e82..fcdd432a0 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Verdaccio aims to support all features of a standard npm client that make sense - Registering new users (`npm adduser {newuser}`) - **supported** - Change password (`npm profile set password`) - **supported** -- Transferring ownership (`npm owner add {user} {pkg}`) - not supported, _PR-welcome_ +- Transferring ownership (`npm owner`) - **supported** - Token (`npm token`) - **supported** ### Miscellaneous diff --git a/packages/api/src/package.ts b/packages/api/src/package.ts index 62135ab77..5e6a892ed 100644 --- a/packages/api/src/package.ts +++ b/packages/api/src/package.ts @@ -28,6 +28,7 @@ export default function (route: Router, auth: Auth, storage: Storage): void { const name = req.params.package; let version = req.params.version; const write = req.query.write === 'true'; + const username = req?.remote_user?.name; const abbreviated = stringUtils.getByQualityPriorityValue(req.get('Accept')) === Storage.ABBREVIATED_HEADER; const requestOptions = { @@ -37,6 +38,7 @@ export default function (route: Router, auth: Auth, storage: Storage): void { host: req.host, remoteAddress: req.socket.remoteAddress, byPassCache: write, + username, }; try { diff --git a/packages/api/src/publish.ts b/packages/api/src/publish.ts index 62353fb90..79882f5a1 100644 --- a/packages/api/src/publish.ts +++ b/packages/api/src/publish.ts @@ -76,11 +76,11 @@ const debug = buildDebug('verdaccio:api:publish'); * * 3. Star a package * - * Permissions: start a package depends of the publish and unpublish permissions, there is no - * specific flag for star or un start. + * Permissions: staring a package depends of the publish and unpublish permissions, there is no + * specific flag for star or unstar. * The URL for star is similar to the unpublish (change package format) * - * npm has no endpoint for star a package, rather mutate the metadata and acts as, the difference + * npm has no endpoint for staring a package, rather mutate the metadata and acts as, the difference * is the users property which is part of the payload and the body only includes * * { @@ -89,7 +89,24 @@ const debug = buildDebug('verdaccio:api:publish'); "users": { [username]: boolean value (true, false) } - } + } + * + * 4. Change owners of a package + * + * Similar to staring a package, changing owners (maintainers) of a package uses the publish + * endpoint. + * + * The body includes a list of the new owners with the following format + * + * { + "_id": pkgName, + "_rev": "4-b0cdaefc9bdb77c8", + "maintainers": [ + { "name": "first owner", "email": "me@verdaccio.org" }, + { "name": "second owner", "email": "you@verdaccio.org" }, + ... + ] + } * */ export default function publish(router: Router, auth: Auth, storage: Storage): void { @@ -127,10 +144,11 @@ export default function publish(router: Router, auth: Auth, storage: Storage): v async function (req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { const packageName = req.params.package; const rev = req.params.revision; + const username = req?.remote_user?.name; logger.debug({ packageName }, `unpublishing @{packageName}`); try { - await storage.removePackage(packageName, rev); + await storage.removePackage(packageName, rev, username); debug('package %s unpublished', packageName); res.status(HTTP_STATUS.CREATED); return next({ ok: API_MESSAGE.PKG_REMOVED }); @@ -155,13 +173,14 @@ export default function publish(router: Router, auth: Auth, storage: Storage): v ): Promise { const packageName = req.params.package; const { filename, revision } = req.params; + const username = req?.remote_user?.name; logger.debug( { packageName, filename, revision }, `removing a tarball for @{packageName}-@{tarballName}-@{revision}` ); try { - await storage.removeTarball(packageName, filename, revision); + await storage.removeTarball(packageName, filename, revision, username); res.status(HTTP_STATUS.CREATED); logger.debug( @@ -188,6 +207,12 @@ export function publishPackage(storage: Storage): any { const metadata = req.body; const username = req?.remote_user?.name; + debug('publishing package %o for user %o', packageName, username); + logger.debug( + { packageName, username }, + 'publishing package @{packageName} for user @{username}' + ); + try { const message = await storage.updateManifest(metadata, { name: packageName, diff --git a/packages/api/src/user.ts b/packages/api/src/user.ts index 896694dc0..03ccb56ec 100644 --- a/packages/api/src/user.ts +++ b/packages/api/src/user.ts @@ -27,10 +27,22 @@ export default function (route: Router, auth: Auth, config: Config): void { rateLimit(config?.userRateLimit), function (req: $RequestExtend, res: Response, next: $NextFunctionVer): void { debug('verifying user'); + + if (typeof req.remote_user.name !== 'string' || req.remote_user.name === '') { + debug('user not logged in'); + res.status(HTTP_STATUS.OK); + return next({ ok: false }); + } + + const username = req.params.org_couchdb_user.split(':')[1]; const message = getAuthenticatedMessage(req.remote_user.name); debug('user authenticated message %o', message); res.status(HTTP_STATUS.OK); next({ + // 'npm owner' requires user info + // TODO: we don't have the email + name: username, + email: '', ok: message, }); } @@ -61,6 +73,10 @@ export default function (route: Router, auth: Auth, config: Config): void { debug('login or adduser'); const remoteName = req?.remote_user?.name; + if (!validatioUtils.validateUserName(req.params.org_couchdb_user, name)) { + return next(errorUtils.getBadRequest(API_ERROR.USERNAME_MISMATCH)); + } + if (typeof remoteName !== 'undefined' && typeof name === 'string' && remoteName === name) { debug('login: no remote user detected'); auth.authenticate( @@ -97,6 +113,7 @@ export default function (route: Router, auth: Auth, config: Config): void { } ); } else { + debug('adduser: %o', name); if ( validatioUtils.validatePassword( password, diff --git a/packages/api/test/integration/_helper.ts b/packages/api/test/integration/_helper.ts index fb7ea2701..41a2b379c 100644 --- a/packages/api/test/integration/_helper.ts +++ b/packages/api/test/integration/_helper.ts @@ -11,7 +11,7 @@ import { generatePackageMetadata, initializeServer as initializeServerHelper, } from '@verdaccio/test-helper'; -import { GenericBody, PackageUsers } from '@verdaccio/types'; +import { Author, GenericBody, PackageUsers } from '@verdaccio/types'; import { buildToken, generateRandomHexString } from '@verdaccio/utils'; import apiMiddleware from '../../src'; @@ -142,6 +142,37 @@ export function starPackage( return test; } +export function changeOwners( + app, + options: { + maintainers: Author[]; + name: string; + _rev: string; + _id?: string; + }, + token?: string +): supertest.Test { + const { _rev, _id, maintainers } = options; + const ownerManifest = { + _rev, + _id, + maintainers, + }; + + const test = supertest(app) + .put(`/${encodeURIComponent(options.name)}`) + .set(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON) + .send(JSON.stringify(ownerManifest)) + .set('accept', HEADERS.GZIP) + .set(HEADER_TYPE.ACCEPT_ENCODING, HEADERS.JSON); + + if (typeof token === 'string') { + test.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token)); + } + + return test; +} + export function getDisTags(app, pkgName) { return supertest(app) .get(`/-/package/${encodeURIComponent(pkgName)}/dist-tags`) diff --git a/packages/api/test/integration/config/owner.yaml b/packages/api/test/integration/config/owner.yaml new file mode 100644 index 000000000..3be6c92c1 --- /dev/null +++ b/packages/api/test/integration/config/owner.yaml @@ -0,0 +1,24 @@ +storage: ./storage + +auth: + htpasswd: + file: ./htpasswd-owner + +web: + enable: true + title: verdaccio + +log: { type: stdout, format: pretty, level: info } + +# TODO: Add test case for $owner access +packages: + '@*/*': + access: $all + publish: $authenticated + unpublish: $authenticated + '**': + access: $all + publish: $authenticated + unpublish: $authenticated + +_debug: true diff --git a/packages/api/test/integration/owner.spec.ts b/packages/api/test/integration/owner.spec.ts new file mode 100644 index 000000000..419ab0391 --- /dev/null +++ b/packages/api/test/integration/owner.spec.ts @@ -0,0 +1,118 @@ +/* eslint-disable jest/no-commented-out-tests */ +import nock from 'nock'; + +import { HTTP_STATUS } from '@verdaccio/core'; + +import { + changeOwners, + createUser, + getPackage, + initializeServer, + publishVersionWithToken, +} from './_helper'; + +describe('owner', () => { + test.each([['foo', '@scope%2Ffoo']])('should get owner of package', async (pkgName) => { + nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404); + const app = await initializeServer('owner.yaml'); + const credentials = { name: 'test', password: 'test' }; + const response = await createUser(app, credentials.name, credentials.password); + expect(response.body.ok).toMatch(`user '${credentials.name}' created`); + await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect( + HTTP_STATUS.CREATED + ); + + // expect publish to set owner to logged in user + const manifest = await getPackage(app, '', decodeURIComponent(pkgName)); + const maintainers = manifest.body.maintainers; + expect(maintainers).toHaveLength(1); + // TODO: This should eventually include the email of the user + expect(maintainers).toEqual([{ name: credentials.name, email: '' }]); + }); + + test.each([['foo', '@scope%2Ffoo']])('should add/remove owner to package', async (pkgName) => { + nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404); + const app = await initializeServer('owner.yaml'); + const credentials = { name: 'test', password: 'test' }; + const firstOwner = { name: 'test', email: '' }; + const response = await createUser(app, credentials.name, credentials.password); + expect(response.body.ok).toMatch(`user '${credentials.name}' created`); + await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect( + HTTP_STATUS.CREATED + ); + + // publish sets owner to logged in user + const manifest = await getPackage(app, '', decodeURIComponent(pkgName)); + const maintainers = manifest.body.maintainers; + expect(maintainers).toHaveLength(1); + expect(maintainers).toEqual([firstOwner]); + + // add another owner + const secondOwner = { name: 'tester', email: 'test@verdaccio.org' }; + const newOwners = [...maintainers, secondOwner]; + await changeOwners( + app, + { + _rev: manifest.body._rev, + _id: manifest.body._id, + name: pkgName, + maintainers: newOwners, + }, + response.body.token + ).expect(HTTP_STATUS.CREATED); + + const manifest2 = await getPackage(app, '', decodeURIComponent(pkgName)); + const maintainers2 = manifest2.body.maintainers; + expect(maintainers2).toHaveLength(2); + expect(maintainers2).toEqual([firstOwner, secondOwner]); + + // remove original owner + await changeOwners( + app, + { + _rev: manifest2.body._rev, + _id: manifest2.body._id, + name: pkgName, + maintainers: [secondOwner], + }, + response.body.token + ).expect(HTTP_STATUS.CREATED); + + const manifest3 = await getPackage(app, '', decodeURIComponent(pkgName)); + const maintainers3 = manifest3.body.maintainers; + expect(maintainers3).toHaveLength(1); + expect(maintainers3).toEqual([secondOwner]); + }); + + test.each([['foo', '@scope%2Ffoo']])('should fail if user is not logged in', async (pkgName) => { + nock('https://registry.npmjs.org').get(`/${pkgName}`).reply(404); + const app = await initializeServer('owner.yaml'); + const credentials = { name: 'test', password: 'test' }; + const firstOwner = { name: 'test', email: '' }; + const response = await createUser(app, credentials.name, credentials.password); + expect(response.body.ok).toMatch(`user '${credentials.name}' created`); + await publishVersionWithToken(app, pkgName, '1.0.0', response.body.token).expect( + HTTP_STATUS.CREATED + ); + + // publish sets owner to logged in user + const manifest = await getPackage(app, '', decodeURIComponent(pkgName)); + const maintainers = manifest.body.maintainers; + expect(maintainers).toHaveLength(1); + expect(maintainers).toEqual([firstOwner]); + + // try adding another owner + const secondOwner = { name: 'tester', email: 'test@verdaccio.org' }; + const newOwners = [...maintainers, secondOwner]; + await changeOwners( + app, + { + _rev: manifest.body._rev, + _id: manifest.body._id, + name: pkgName, + maintainers: newOwners, + }, + '' // no token + ).expect(HTTP_STATUS.UNAUTHORIZED); + }); +}); diff --git a/packages/api/test/integration/search.spec.ts b/packages/api/test/integration/search.spec.ts index ce0967103..41e8cef4f 100644 --- a/packages/api/test/integration/search.spec.ts +++ b/packages/api/test/integration/search.spec.ts @@ -43,6 +43,12 @@ describe('search', () => { links: { npm: '', }, + maintainers: [ + { + email: '', + name: 'test', + }, + ], name: pkg, publisher: {}, scope: '', @@ -97,6 +103,12 @@ describe('search', () => { links: { npm: '', }, + maintainers: [ + { + email: '', + name: 'test', + }, + ], name: pkg, publisher: {}, scope: '@scope', diff --git a/packages/api/test/integration/user.spec.ts b/packages/api/test/integration/user.spec.ts index 8f4746982..ae1b4ec9c 100644 --- a/packages/api/test/integration/user.spec.ts +++ b/packages/api/test/integration/user.spec.ts @@ -148,6 +148,25 @@ describe('token', () => { .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) .expect(HTTP_STATUS.OK); expect(response2.body.ok).toBe(`you are authenticated as '${credentials.name}'`); + expect(response2.body.name).toBe(credentials.name); + } + ); + + test.each([['user.yaml'], ['user.jwt.yaml']])( + 'should return name of requested user', + async (conf) => { + const app = await initializeServer(conf); + const username = 'yeti'; + const credentials = { name: 'jota', password: 'secretPass' }; + const response = await createUser(app, credentials.name, credentials.password); + expect(response.body.ok).toMatch(`user '${credentials.name}' created`); + const response3 = await supertest(app) + .get(`/-/user/org.couchdb.user:${username}`) + .set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, response.body.token)) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(HTTP_STATUS.OK); + expect(response3.body.ok).toBe(`you are authenticated as '${credentials.name}'`); + expect(response3.body.name).toBe(username); } ); @@ -165,5 +184,38 @@ describe('token', () => { .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) .expect(HTTP_STATUS.OK); }); + + test.each([['user.yaml'], ['user.jwt.yaml']])( + 'should return "false" if user is not logged in', + async (conf) => { + const app = await initializeServer(conf); + const credentials = { name: 'jota', password: '' }; + const response = await supertest(app) + .get(`/-/user/org.couchdb.user:${credentials.name}`) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(HTTP_STATUS.OK); + expect(response.body.ok).toBe(false); + } + ); + + test.each([['user.yaml'], ['user.jwt.yaml']])( + 'should fail if URL does not match user in request body', + async (conf) => { + const app = await initializeServer(conf); + const credentials = { name: 'jota', password: 'secretPass' }; + const response = await createUser(app, credentials.name, credentials.password); + expect(response.body.ok).toMatch(`user '${credentials.name}' created`); + const response2 = await supertest(app) + .put('/-/user/org.couchdb.user:yeti') // different user + .set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, response.body.token)) + .send({ + name: credentials.name, + password: credentials.password, + }) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(HTTP_STATUS.BAD_REQUEST); + expect(response2.body.error).toBe(API_ERROR.USERNAME_MISMATCH); + } + ); }); }); diff --git a/packages/config/src/conf/default.yaml b/packages/config/src/conf/default.yaml index c7348dd50..20789bfa4 100644 --- a/packages/config/src/conf/default.yaml +++ b/packages/config/src/conf/default.yaml @@ -113,6 +113,7 @@ server: # https://verdaccio.org/docs/configuration#offline-publish # publish: # allow_offline: false +# check_owner: false # https://verdaccio.org/docs/configuration#url-prefix # url_prefix: /verdaccio/ diff --git a/packages/config/src/conf/docker.yaml b/packages/config/src/conf/docker.yaml index b8d9c9ce1..dafe5b9e4 100644 --- a/packages/config/src/conf/docker.yaml +++ b/packages/config/src/conf/docker.yaml @@ -119,6 +119,7 @@ server: # https://verdaccio.org/docs/configuration#offline-publish # publish: # allow_offline: false +# check_owner: false # https://verdaccio.org/docs/configuration#url-prefix # url_prefix: /verdaccio/ diff --git a/packages/core/core/src/constants.ts b/packages/core/core/src/constants.ts index 08918f33a..c1034fb52 100644 --- a/packages/core/core/src/constants.ts +++ b/packages/core/core/src/constants.ts @@ -6,6 +6,7 @@ export const TIME_EXPIRATION_1H = '1h'; export const DIST_TAGS = 'dist-tags'; export const LATEST = 'latest'; export const USERS = 'users'; +export const MAINTAINERS = 'maintainers'; export const DEFAULT_USER = 'Anonymous'; export const HEADER_TYPE = { diff --git a/packages/core/core/src/error-utils.ts b/packages/core/core/src/error-utils.ts index fe28ea54e..168035955 100644 --- a/packages/core/core/src/error-utils.ts +++ b/packages/core/core/src/error-utils.ts @@ -39,6 +39,7 @@ export const API_ERROR = { BAD_PACKAGE_DATA: 'bad incoming package data', USERNAME_PASSWORD_REQUIRED: 'username and password is required', USERNAME_ALREADY_REGISTERED: 'username is already registered', + USERNAME_MISMATCH: 'username does not match logged in user', }; export const SUPPORT_ERRORS = { diff --git a/packages/core/core/src/index.ts b/packages/core/core/src/index.ts index 88a45630e..c992d4559 100644 --- a/packages/core/core/src/index.ts +++ b/packages/core/core/src/index.ts @@ -23,6 +23,7 @@ export { DEFAULT_PASSWORD_VALIDATION, DEFAULT_USER, USERS, + MAINTAINERS, HtpasswdHashAlgorithm, } from './constants'; const validationUtils = validatioUtils; diff --git a/packages/core/core/src/validation-utils.ts b/packages/core/core/src/validation-utils.ts index 403410d69..3a61bddd4 100644 --- a/packages/core/core/src/validation-utils.ts +++ b/packages/core/core/src/validation-utils.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import { Manifest } from '@verdaccio/types'; -import { DEFAULT_PASSWORD_VALIDATION, DIST_TAGS } from './constants'; +import { DEFAULT_PASSWORD_VALIDATION, DIST_TAGS, MAINTAINERS } from './constants'; export { validatePublishSingleVersion } from './schemes/publish-manifest'; @@ -67,7 +67,6 @@ export function validatePackage(name: string): boolean { * @param {*} manifest * @param {*} name * @return {Object} the object with additional properties as dist-tags ad versions - * FUTURE: rename to normalizeMetadata */ export function normalizeMetadata(manifest: Manifest, name: string): Manifest { assert.strictEqual(manifest.name, name); @@ -77,7 +76,11 @@ export function normalizeMetadata(manifest: Manifest, name: string): Manifest { _manifest[DIST_TAGS] = {}; } - // This may not be nee dit + if (!Array.isArray(manifest[MAINTAINERS])) { + _manifest[MAINTAINERS] = []; + } + + // This may not be needed if (!isObject(manifest['versions'])) { _manifest['versions'] = {}; } @@ -114,3 +117,11 @@ export function validatePassword( ? password.match(validation) !== null : false; } + +export function validateUserName(userName: any, expectedName: string): boolean { + return ( + typeof userName === 'string' && + userName.split(':')[0] === 'org.couchdb.user' && + userName.split(':')[1] === expectedName + ); +} diff --git a/packages/core/core/test/validation-utilts.spec.ts b/packages/core/core/test/validation-utilts.spec.ts index 382224f57..bc3a91fea 100644 --- a/packages/core/core/test/validation-utilts.spec.ts +++ b/packages/core/core/test/validation-utilts.spec.ts @@ -6,6 +6,7 @@ import { validateName, validatePackage, validatePassword, + validateUserName, } from '../src/validation-utils'; describe('validatePackage', () => { @@ -224,3 +225,17 @@ describe('validatePassword', () => { expect(validatePassword('1235678910')).toBeTruthy(); }); }); + +describe('validateUserName', () => { + test('should validate username according to expected name', () => { + expect(validateUserName('org.couchdb.user:test', 'test')).toBeTruthy(); + }); + + test('should fail to validate username if different from expected name', () => { + expect(validateUserName('org.couchdb.user:foouser', 'test')).toBeFalsy(); + }); + + test('should fail to validate username if not given', () => { + expect(validateUserName(undefined, 'test')).toBeFalsy(); + }); +}); diff --git a/packages/core/types/src/configuration.ts b/packages/core/types/src/configuration.ts index 8fdc403d8..7ee8ba8a6 100644 --- a/packages/core/types/src/configuration.ts +++ b/packages/core/types/src/configuration.ts @@ -196,6 +196,7 @@ export interface Security { export interface PublishOptions { allow_offline: boolean; + check_owners: boolean; } export interface ListenAddress { diff --git a/packages/core/types/src/manifest.ts b/packages/core/types/src/manifest.ts index 6bc887c93..fe719762b 100644 --- a/packages/core/types/src/manifest.ts +++ b/packages/core/types/src/manifest.ts @@ -178,6 +178,7 @@ export interface FullRemoteManifest { 'dist-tags': GenericBody; time: GenericBody; versions: Versions; + /** store owners of this package */ maintainers?: Author[]; /** store the latest readme **/ readme?: string; diff --git a/packages/store/src/lib/storage-utils.ts b/packages/store/src/lib/storage-utils.ts index d36387f7f..96d7b47de 100644 --- a/packages/store/src/lib/storage-utils.ts +++ b/packages/store/src/lib/storage-utils.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import semver from 'semver'; import { errorUtils, pkgUtils, searchUtils, validatioUtils } from '@verdaccio/core'; -import { API_ERROR, DIST_TAGS, HTTP_STATUS, USERS } from '@verdaccio/core'; +import { API_ERROR, DIST_TAGS, HTTP_STATUS, MAINTAINERS, USERS } from '@verdaccio/core'; import { AttachMents, Manifest, Version, Versions } from '@verdaccio/types'; import { generateRandomHexString, isNil, isObject } from '@verdaccio/utils'; @@ -28,6 +28,7 @@ export function generatePackageTemplate(name: string): Manifest { time: {}, [USERS]: {}, [DIST_TAGS]: {}, + [MAINTAINERS]: [], _uplinks: {}, _distfiles: {}, _attachments: {}, @@ -109,6 +110,7 @@ export const WHITELIST = [ 'time', '_id', 'users', + 'maintainers', ]; export function cleanUpLinksRef(result: Manifest, keepUpLinkData?: boolean): Manifest { @@ -290,6 +292,8 @@ export function mergeVersions(cacheManifest: Manifest, remoteManifest: Manifest) } } + // TODO: Should we merge owners? _cacheManifest[MAINTAINERS] + return cacheManifest; } diff --git a/packages/store/src/storage.ts b/packages/store/src/storage.ts index bf1083ced..2c06fcbdc 100644 --- a/packages/store/src/storage.ts +++ b/packages/store/src/storage.ts @@ -10,9 +10,11 @@ import { hasProxyTo } from '@verdaccio/config'; import { API_ERROR, API_MESSAGE, + DEFAULT_USER, DIST_TAGS, HEADER_TYPE, HTTP_STATUS, + MAINTAINERS, SUPPORT_ERRORS, USERS, errorUtils, @@ -83,7 +85,7 @@ import { } from './lib/storage-utils'; import { getVersion, removeLowerVersions } from './lib/versions-utils'; import { LocalStorage } from './local-storage'; -import { IGetPackageOptionsNext, StarManifestBody } from './type'; +import { IGetPackageOptionsNext, OwnerManifestBody, StarManifestBody } from './type'; const debug = buildDebug('verdaccio:storage'); @@ -119,7 +121,7 @@ class Storage { */ public async changePackage(name: string, metadata: Manifest, revision: string): Promise { debug('change existing package for package %o revision %o', name, revision); - debug(`change manifest tags for %o revision %s`, name, revision); + debug(`change manifest tags for %o revision %o`, name, revision); if ( !validatioUtils.isObject(metadata.versions) || !validatioUtils.isObject(metadata[DIST_TAGS]) @@ -128,7 +130,7 @@ class Storage { throw errorUtils.getBadData(); } - debug(`change manifest udapting manifest for %o`, name); + debug(`change manifest updating manifest for %o`, name); await this.updatePackage(name, async (localData: Manifest): Promise => { // eslint-disable-next-line guard-for-in for (const version in localData.versions) { @@ -165,13 +167,14 @@ class Storage { localData[USERS] = metadata[USERS]; localData[DIST_TAGS] = metadata[DIST_TAGS]; + localData[MAINTAINERS] = metadata[MAINTAINERS]; return localData; }); } - public async removePackage(name: string, revision): Promise { + public async removePackage(name: string, revision: string, username: string): Promise { debug('remove package %o', name); - await this.removePackageByRevision(name, revision); + await this.removePackageByRevision(name, revision, username); } /** @@ -181,8 +184,13 @@ class Storage { versions, i.e. package version should be unpublished first. Used storage: local (write) */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public async removeTarball(name: string, filename: string, _revision: string): Promise { + public async removeTarball( + name: string, + filename: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _revision: string, + username: string + ): Promise { debug('remove tarball %s for %s', filename, name); assert(validatioUtils.validateName(filename)); const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(name); @@ -197,6 +205,9 @@ class Storage { if (!cacheManifest._attachments[filename]) { throw errorUtils.getNotFound('no such file available'); } + + // check if logged in user is allowed to remove tarball + await this.checkAllowedToChangePackage(cacheManifest, username); } catch (err: any) { if (err.code === noSuchFile) { throw errorUtils.getNotFound(); @@ -484,6 +495,17 @@ class Storage { public async getPackageManifest(options: IGetPackageOptionsNext): Promise { // convert dist remotes to local bars const [manifest] = await this.getPackageNext(options); + + // If change access is requested (?write=true), then check if logged in user is allowed to change package + if (options.byPassCache === true) { + try { + await this.checkAllowedToChangePackage(manifest, options.requestOptions.username); + } catch (error: any) { + logger.error({ error: error.message }, 'getting package has failed: @{error}'); + throw errorUtils.getBadRequest(error.message); + } + } + const convertedManifest = convertDistRemoteToLocalTarballUrls( manifest, options.requestOptions, @@ -727,7 +749,11 @@ class Storage { return results; } - private async removePackageByRevision(pkgName: string, revision: string): Promise { + private async removePackageByRevision( + pkgName: string, + revision: string, + username: string + ): Promise { const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(pkgName); debug('get package metadata for %o', pkgName); if (typeof storage === 'undefined') { @@ -751,6 +777,9 @@ class Storage { // TODO: move this to another method try { + // check if logged in user is allowed to remove package + await this.checkAllowedToChangePackage(manifest, username); + await this.localStorage.getStoragePlugin().remove(pkgName); // remove each attachment const attachments = Object.keys(manifest._attachments); @@ -872,9 +901,10 @@ class Storage { } public async updateManifest( - manifest: Manifest | StarManifestBody, + manifest: Manifest | StarManifestBody | OwnerManifestBody, options: UpdateManifestOptions ): Promise { + debug('update manifest %o for user %o', manifest._id, options.requestOptions.username); if (isDeprecatedManifest(manifest as Manifest)) { // if the manifest is deprecated, we need to update the package.json await this.deprecate(manifest as Manifest, { @@ -882,13 +912,22 @@ class Storage { }); } else if ( isPublishablePackage(manifest as Manifest) === false && - validatioUtils.isObject(manifest.users) + validatioUtils.isObject((manifest as StarManifestBody).users) ) { // if user request to apply a star to the manifest await this.star(manifest as StarManifestBody, { ...options, }); return API_MESSAGE.PKG_CHANGED; + } else if ( + isPublishablePackage(manifest as Manifest) === false && + Array.isArray((manifest as OwnerManifestBody).maintainers) + ) { + // if user request to change owners of package + await this.changeOwners(manifest as OwnerManifestBody, { + ...options, + }); + return API_MESSAGE.PKG_CHANGED; } else if (validatioUtils.validatePublishSingleVersion(manifest)) { // if continue, the version to be published does not exist // we create a new package @@ -909,7 +948,7 @@ class Storage { return message; } else { debug('invalid body format'); - logger.info( + logger.warn( { packageName: options.name }, `wrong package format on publish a package @{packageName}` ); @@ -970,6 +1009,36 @@ class Storage { return API_MESSAGE.PKG_CHANGED; } + private async changeOwners( + manifest: OwnerManifestBody, + options: UpdateManifestOptions + ): Promise { + const { maintainers } = manifest; + const { requestOptions, name } = options; + debug('change owners of %o', name); + const { username } = requestOptions; + if (!username) { + throw errorUtils.getBadRequest('update owners only allowed for logged in users'); + } + if (!maintainers || maintainers.length === 0) { + throw errorUtils.getBadRequest('maintainers field is required and must not be empty'); + } + + const localPackage = await this.getPackageManifest({ + name, + requestOptions, + uplinksLook: false, + }); + + await this.changePackage( + name, + { ...localPackage, maintainers: maintainers as Author[] }, + options.revision as string + ); + + return API_MESSAGE.PKG_CHANGED; + } + /** * Get local package, on fails return null. * Errors are considered package not found. @@ -1027,7 +1096,8 @@ class Storage { options: PublishOptions ): Promise<[Manifest, string, string]> { const { name } = options; - debug('publishing a new package for %o', name); + const username = options.requestOptions.username; + debug('publishing a new package for %o as %o', name, username); let successResponseMessage; const manifest: Manifest = { ...validatioUtils.normalizeMetadata(body, name) }; const { _attachments, versions } = manifest; @@ -1065,14 +1135,15 @@ class Storage { const hasPackageInStorage = await this.hasPackage(name); if (!hasPackageInStorage) { - await this.createNewLocalCachePackage(name); + await this.createNewLocalCachePackage(name, username); successResponseMessage = API_MESSAGE.PKG_CREATED; } else { + await this.checkAllowedToChangePackage(localManifest as Manifest, username); successResponseMessage = API_MESSAGE.PKG_CHANGED; } } catch (err: any) { debug('error on change or update a package with %o', err.message); - logger.error({ err: err.message }, 'error on create package: @{err}'); + logger.error({ err: err.message }, 'error on publish new version: @{err}'); throw err; } @@ -1089,7 +1160,6 @@ class Storage { } catch (err: any) { logger.error({ err: err.message }, 'updated version has failed: @{err}'); debug('error on create a version for %o with error %o', name, err.message); - // TODO: remove tarball if add version fails throw err; } @@ -1106,8 +1176,7 @@ class Storage { logger.error({ err: err.message }, 'merge version has failed: @{err}'); debug('error on create a version for %o with error %o', name, err.message); // TODO: undo if this fails - // 1. remove tarball - // 2. remove updated version + // 1. remove updated version throw err; } @@ -1119,6 +1188,9 @@ class Storage { }); } catch (err: any) { logger.error({ err: err.message }, 'upload tarball has failed: @{err}'); + // TODO: undo if this fails + // 1. remove updated version + // 2. remove new dist tags throw err; } @@ -1293,11 +1365,14 @@ class Storage { await this.updatePackage(name, async (data: Manifest): Promise => { // keep only one readme per package data.readme = metadata.readme; - debug('%s` readme mutated', name); + debug('%s readme mutated', name); // TODO: lodash remove metadata = cleanUpReadme(metadata); metadata.contributors = normalizeContributors(metadata.contributors as Author[]); - debug('%s` contributors normalized', name); + debug('%s contributors normalized', name); + + // Copy current owners to version + metadata.maintainers = data.maintainers; // Update tarball stats if (metadata.dist) { @@ -1358,7 +1433,7 @@ class Storage { tagVersion(data, version, tag); try { - debug('%s` add on database', name); + debug('%s add on database', name); await this.localStorage.getStoragePlugin().add(name); this.logger.debug({ name, version }, 'version @{version} added to database for @{name}'); } catch (err: any) { @@ -1373,7 +1448,10 @@ class Storage { * @param name name of the package * @returns */ - private async createNewLocalCachePackage(name: string): Promise { + private async createNewLocalCachePackage( + name: string, + username: string | undefined + ): Promise { const storage: pluginUtils.StorageHandler = this.getPrivatePackageStorage(name); if (!storage) { @@ -1390,6 +1468,13 @@ class Storage { }, }; + // Set initial package owner + // TODO: Add email of user + packageData.maintainers = + username && username.length > 0 + ? [{ name: username, email: '' }] + : [{ name: DEFAULT_USER, email: '' }]; + try { await storage.createPackage(name, packageData); this.logger.info({ name }, 'created new package @{name}'); @@ -1929,6 +2014,21 @@ class Storage { return { fileCount: version.dist.fileCount, unpackedSize: version.dist.unpackedSize }; } } + + private async checkAllowedToChangePackage(manifest: Manifest, username: string | undefined) { + // Checks to perform if config "publish:check_owners" is true + debug('check if user %o is an owner and allowed to change package', username); + // if name of owner is not included in list of maintainers, then throw an error + if ( + this.config?.publish?.check_owners === true && + manifest.maintainers && + manifest.maintainers.length > 0 && + !manifest.maintainers.some((maintainer) => maintainer.name === username) + ) { + logger.error({ username }, '@{username} is not a maintainer (package owner)'); + throw Error('only owners are allowed to change package'); + } + } } export { Storage }; diff --git a/packages/store/src/type.ts b/packages/store/src/type.ts index 80a6ad0d8..25c1b870f 100644 --- a/packages/store/src/type.ts +++ b/packages/store/src/type.ts @@ -62,3 +62,9 @@ export type UpdateManifestOptions = { * values in the body. */ export type StarManifestBody = Pick; + +/** + * When the command `npm owner add/rm` is executed, the body only contains the following + * values in the body. + */ +export type OwnerManifestBody = Pick; diff --git a/packages/store/test/fixtures/config/publishWithOwnerAndCheck.yaml b/packages/store/test/fixtures/config/publishWithOwnerAndCheck.yaml new file mode 100644 index 000000000..959599129 --- /dev/null +++ b/packages/store/test/fixtures/config/publishWithOwnerAndCheck.yaml @@ -0,0 +1,18 @@ +packages: + '@scope/foo': + access: $all + publish: $authenticated + '@*/*': + access: $all + publish: $all + proxy: ver + 'foo': + access: $all + publish: $authenticated + '*': + access: $all + publish: $all + proxy: npmjs + +publish: + check_owners: true diff --git a/packages/store/test/fixtures/config/publishWithOwnerDefault.yaml b/packages/store/test/fixtures/config/publishWithOwnerDefault.yaml new file mode 100644 index 000000000..54b8abbc1 --- /dev/null +++ b/packages/store/test/fixtures/config/publishWithOwnerDefault.yaml @@ -0,0 +1,15 @@ +packages: + '@scope/foo': + access: $all + publish: $authenticated + '@*/*': + access: $all + publish: $all + proxy: ver + 'foo': + access: $all + publish: $authenticated + '*': + access: $all + publish: $all + proxy: npmjs diff --git a/packages/store/test/storage.spec.ts b/packages/store/test/storage.spec.ts index 2ee31f1f7..9292d044b 100644 --- a/packages/store/test/storage.spec.ts +++ b/packages/store/test/storage.spec.ts @@ -24,7 +24,14 @@ import { generateRemotePackageMetadata, getDeprecatedPackageMetadata, } from '@verdaccio/test-helper'; -import { AbbreviatedManifest, ConfigYaml, Manifest, PackageUsers, Version } from '@verdaccio/types'; +import { + AbbreviatedManifest, + Author, + ConfigYaml, + Manifest, + PackageUsers, + Version, +} from '@verdaccio/types'; import { Storage } from '../src'; import manifestFooRemoteNpmjs from './fixtures/manifests/foo-npmjs.json'; @@ -89,6 +96,31 @@ const executeStarPackage = async ( }); }; +const executeChangeOwners = async ( + storage, + options: { + maintainers: Author[]; + username: string; + name: string; + _rev: string; + _id?: string; + } +) => { + const { name, _rev, _id, maintainers, username } = options; + const ownerManifest = { + _rev, + _id, + maintainers, + }; + return storage.updateManifest(ownerManifest, { + signal: new AbortController().signal, + name, + uplinksLook: true, + revision: '1', + requestOptions: { ...defaultRequestOptions, username }, + }); +}; + describe('storage', () => { beforeEach(() => { nock.cleanAll(); @@ -657,6 +689,204 @@ describe('storage', () => { ).rejects.toThrow(); }); }); + describe('owner', () => { + test.each([ + ['foo', 'publishWithOwnerDefault.yaml'], + ['foo', 'publishWithOwnerAndCheck.yaml'], + ])('new package %s, %s (anonymous)', async (pkgName, configFile) => { + const config = getConfig(configFile); + const storage = new Storage(config); + await storage.init(config); + const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0'); + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: true, + requestOptions: defaultRequestOptions, + }); + const manifest = (await storage.getPackageByOptions({ + name: pkgName, + uplinksLook: true, + requestOptions: defaultRequestOptions, + })) as Manifest; + expect(manifest?.maintainers).toEqual([{ name: 'Anonymous', email: '' }]); + }); + + test.each([ + ['foo', 'publishWithOwnerDefault.yaml'], + ['foo', 'publishWithOwnerAndCheck.yaml'], + ])('new package %s, %s (logged in)', async (pkgName, configFile) => { + const config = getConfig(configFile); + const storage = new Storage(config); + await storage.init(config); + const owner = { name: 'fooUser', email: '' }; + const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0'); + const options = { ...defaultRequestOptions, username: owner.name }; + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: true, + requestOptions: options, + }); + const manifest = (await storage.getPackageByOptions({ + name: pkgName, + uplinksLook: true, + requestOptions: defaultRequestOptions, + })) as Manifest; + expect(manifest?.maintainers).toEqual([owner]); + expect(manifest?.versions['1.0.0'].maintainers).toEqual([owner]); + }); + + test.each([ + ['foo', 'publishWithOwnerDefault.yaml'], + ['foo', 'publishWithOwnerAndCheck.yaml'], + ])('add/remove owner %s, %s', async (pkgName, configFile) => { + const config = getConfig(configFile); + const storage = new Storage(config); + await storage.init(config); + const firstOwner = { name: 'fooUser', email: '' }; + const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0'); + const options = { ...defaultRequestOptions, username: firstOwner.name }; + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: false, + requestOptions: options, + }); + + // add owner + const secondOwner = { name: 'barUser', email: '' }; + const maintainers = [firstOwner, secondOwner]; + + const message = await executeChangeOwners(storage, { + _rev: bodyNewManifest._rev, + _id: bodyNewManifest._id, + name: pkgName, + username: firstOwner.name, + maintainers: maintainers, + }); + expect(message).toEqual(API_MESSAGE.PKG_CHANGED); + + const manifest = (await storage.getPackageByOptions({ + name: pkgName, + uplinksLook: false, + requestOptions: options, + })) as Manifest; + expect(manifest?.maintainers).toEqual(maintainers); + // published version should not be affected + expect(manifest?.versions['1.0.0'].maintainers).toEqual([firstOwner]); + + // remove owner + const maintainers2 = [secondOwner]; + const message2 = await executeChangeOwners(storage, { + _rev: bodyNewManifest._rev, + _id: bodyNewManifest._id, + name: pkgName, + username: firstOwner.name, + maintainers: maintainers2, + }); + expect(message2).toEqual(API_MESSAGE.PKG_CHANGED); + + const manifest2 = (await storage.getPackageByOptions({ + name: pkgName, + uplinksLook: false, + requestOptions: options, + })) as Manifest; + expect(manifest2?.maintainers).toEqual(maintainers2); + // published version should not be affected + expect(manifest2?.versions['1.0.0'].maintainers).toEqual([firstOwner]); + }); + + test.each([ + ['foo', 'publishWithOwnerDefault.yaml'], + ['foo', 'publishWithOwnerAndCheck.yaml'], + ])('should fail removing last owner %s, %s', async (pkgName, configFile) => { + const config = getConfig(configFile); + const storage = new Storage(config); + await storage.init(config); + const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0'); + const owner = 'fooUser'; + const options = { ...defaultRequestOptions, username: owner }; + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: false, + requestOptions: options, + }); + + // no owners + await expect( + executeChangeOwners(storage, { + _rev: bodyNewManifest._rev, + _id: bodyNewManifest._id, + name: pkgName, + username: owner, + maintainers: [], + }) + ).rejects.toThrow(); + }); + + test.each([['foo', 'publishWithOwnerDefault.yaml']])( + 'ok to publish as non-owner without check %s, %s', + async (pkgName, configFile) => { + const config = getConfig(configFile); + const storage = new Storage(config); + await storage.init(config); + const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0'); + const owner = 'fooUser'; + const options = { ...defaultRequestOptions, username: owner }; + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: false, + requestOptions: options, + }); + + // try to publish as user who's not an owner + const bodyNewManifest2 = generatePackageMetadata(pkgName, '1.0.1'); + const nonOwner = 'barUser'; + const options2 = { ...defaultRequestOptions, username: nonOwner }; + const message2 = await storage.updateManifest(bodyNewManifest2, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: false, + requestOptions: options2, + }); + expect(message2).toEqual(API_MESSAGE.PKG_CHANGED); + } + ); + + test.each([['foo', 'publishWithOwnerAndCheck.yaml']])( + 'should fail publishing as non-owner with check %s, %s', + async (pkgName, configFile) => { + const config = getConfig(configFile); + const storage = new Storage(config); + await storage.init(config); + const bodyNewManifest = generatePackageMetadata(pkgName, '1.0.0'); + const owner = 'fooUser'; + const options = { ...defaultRequestOptions, username: owner }; + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: false, + requestOptions: options, + }); + + // try to publish as user who's not an owner + const bodyNewManifest2 = generatePackageMetadata(pkgName, '1.0.1'); + const nonOwner = 'barUser'; + const options2 = { ...defaultRequestOptions, username: nonOwner }; + await expect( + storage.updateManifest(bodyNewManifest2, { + signal: new AbortController().signal, + name: pkgName, + uplinksLook: false, + requestOptions: options2, + }) + ).rejects.toThrow(); + } + ); + }); }); describe('getTarball', () => { @@ -1261,6 +1491,7 @@ describe('storage', () => { describe('removeTarball', () => { test('should fail on remove tarball of package does not exist', async () => { + const username = 'foouser'; const config = new Config( configExample({ ...getDefaultConfig(), @@ -1269,7 +1500,7 @@ describe('storage', () => { ); const storage = new Storage(config); await storage.init(config); - await expect(storage.removeTarball('foo', 'foo-1.0.0.tgz', 'rev')).rejects.toThrow( + await expect(storage.removeTarball('foo', 'foo-1.0.0.tgz', 'rev', username)).rejects.toThrow( API_ERROR.NO_PACKAGE ); }); @@ -1277,6 +1508,7 @@ describe('storage', () => { describe('removePackage', () => { test('should remove entirely a package', async () => { + const username = 'foouser'; const config = new Config( configExample({ ...getDefaultConfig(), @@ -1321,10 +1553,10 @@ describe('storage', () => { const _rev = manifest1._rev; // 3. remove the tarball await expect( - storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev) + storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, username) ).resolves.toBeDefined(); // 4. remove the package - await storage.removePackage(manifest1.name, _rev); + await storage.removePackage(manifest1.name, _rev, username); // 5. fails if package does not exist anymore in storage await expect( storage.getPackageByOptions({ @@ -1338,6 +1570,76 @@ describe('storage', () => { }) ).rejects.toThrow('package does not exist on uplink: foo'); }); + + test('ok to remove package as non-owner without check', async () => { + const config = getConfig('publishWithOwnerDefault.yaml'); + const storage = new Storage(config); + await storage.init(config); + const owner = 'fooUser'; + const options = { ...defaultRequestOptions, username: owner }; + + // 1. publish a package + const bodyNewManifest = generatePackageMetadata('foo', '1.0.0'); + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: 'foo', + uplinksLook: true, + requestOptions: options, + }); + // 2. request package (should be available in the local cache) + const manifest1 = (await storage.getPackageByOptions({ + name: 'foo', + uplinksLook: false, + requestOptions: options, + })) as Manifest; + const _rev = manifest1._rev; + // 3. remove the tarball as other user + const nonOwner = 'barUser'; + await expect( + storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, nonOwner) + ).resolves.toBeDefined(); + // 4. remove the package as other user + await storage.removePackage(manifest1.name, _rev, nonOwner); + // 5. fails if package does not exist anymore in storage + await expect( + storage.getPackageByOptions({ + name: 'foo', + uplinksLook: false, + requestOptions: options, + }) + ).rejects.toThrow('package does not exist on uplink: foo'); + }); + + test('should fail as non-owner with check', async () => { + const config = getConfig('publishWithOwnerAndCheck.yaml'); + const storage = new Storage(config); + await storage.init(config); + const owner = 'fooUser'; + const options = { ...defaultRequestOptions, username: owner }; + + // 1. publish a package + const bodyNewManifest = generatePackageMetadata('foo', '1.0.0'); + await storage.updateManifest(bodyNewManifest, { + signal: new AbortController().signal, + name: 'foo', + uplinksLook: true, + requestOptions: options, + }); + // 2. request package (should be available in the local cache) + const manifest1 = (await storage.getPackageByOptions({ + name: 'foo', + uplinksLook: false, + requestOptions: options, + })) as Manifest; + const _rev = manifest1._rev; + // 3. try removing the tarball + const nonOwner = 'barUser'; + await expect( + storage.removeTarball(manifest1.name, 'foo-1.0.0.tgz', _rev, nonOwner) + ).rejects.toThrow(); + // 4. try removing the package + await expect(storage.removePackage(manifest1.name, _rev, nonOwner)).rejects.toThrow(); + }); }); describe('getPackageByOptions()', () => { diff --git a/website/docs/config.md b/website/docs/config.md index 2629f8bba..3dc61f037 100644 --- a/website/docs/config.md +++ b/website/docs/config.md @@ -185,20 +185,29 @@ packages: ### Offline Publish {#offline-publish} -By default `verdaccio` does not allow you to publish packages when the client is offline. This can be can be overridden by setting this value to _true_. +By default `verdaccio` does not allow you to publish packages when the client is offline. This can be overridden by setting this value to _true_. ```yaml publish: allow_offline: false ``` +### Checking Package Ownership {#chec-owner} + +By default, [package access](packages.md) defines who is allowed to publish and unpublish packages. By setting `check_owner` to _true_, only package owners are allowed to make changes to a package. The first owner of a package is the user who published the first version. Further owners can be added or removed using [`npm owner`](https://docs.npmjs.com/cli/v10/commands/npm-owner). You can find the list of current owners in the package manifest under `maintainers`. + +```yaml +publish: + check_owner: false +``` + Since: `verdaccio@2.3.6` due [#223](https://github.com/verdaccio/verdaccio/pull/223) ### URL Prefix {#url-prefix} The prefix is intended to be used when the server runs behinds the proxy and won't work properly if is used without a reverse proxy, check the **reverse proxy setup** page for more details. -The internal logic builds correctly the public url, validates the `host` header and and bad shaped `url_prefix`. +The internal logic builds correctly the public url, validates the `host` header and bad shaped `url_prefix`. eg: `url_prefix: /verdaccio`, `url_prefix: verdaccio/`, `url_prefix: verdaccio` would be `/verdaccio/`