Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/components/OrgMembersPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { NewOperation } from '~/composables/useConnector'
import { buildScopeTeam } from '~/utils/npm'

const props = defineProps<{
orgName: string
Expand Down Expand Up @@ -144,10 +145,10 @@ async function loadTeamMemberships() {
try {
const teamsResult = await listOrgTeams(props.orgName)
if (teamsResult) {
// Teams come as "org:team" format
// Teams come as "org:team" format from npm, need @scope:team for API calls
const teamPromises = teamsResult.map(async (fullTeamName: string) => {
const teamName = fullTeamName.replace(`${props.orgName}:`, '')
const membersResult = await listTeamUsers(fullTeamName)
const membersResult = await listTeamUsers(buildScopeTeam(props.orgName, teamName))
if (membersResult) {
teamMembers.value[teamName] = membersResult
}
Expand Down Expand Up @@ -183,7 +184,7 @@ async function handleAddMember() {
// Second operation: add user to team (if a team is selected)
// This depends on the org operation completing first
if (newTeam.value && addedOrgOp) {
const scopeTeam = `${props.orgName}:${newTeam.value}`
const scopeTeam = buildScopeTeam(props.orgName, newTeam.value)
const teamOperation: NewOperation = {
type: 'team:add-user',
params: {
Expand Down
11 changes: 6 additions & 5 deletions app/components/OrgTeamsPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { NewOperation } from '~/composables/useConnector'
import { buildScopeTeam } from '~/utils/npm'

const props = defineProps<{
orgName: string
Expand Down Expand Up @@ -103,7 +104,7 @@ async function loadTeamUsers(teamName: string) {
isLoadingUsers.value[teamName] = true

try {
const scopeTeam = `${props.orgName}:${teamName}`
const scopeTeam = buildScopeTeam(props.orgName, teamName)
const result = await listTeamUsers(scopeTeam)
if (result) {
teamUsers.value[teamName] = result
Expand Down Expand Up @@ -135,7 +136,7 @@ async function handleCreateTeam() {
isCreatingTeam.value = true
try {
const teamName = newTeamName.value.trim()
const scopeTeam = `${props.orgName}:${teamName}`
const scopeTeam = buildScopeTeam(props.orgName, teamName)
const operation: NewOperation = {
type: 'team:create',
params: { scopeTeam },
Expand All @@ -153,7 +154,7 @@ async function handleCreateTeam() {

// Destroy team
async function handleDestroyTeam(teamName: string) {
const scopeTeam = `${props.orgName}:${teamName}`
const scopeTeam = buildScopeTeam(props.orgName, teamName)
const operation: NewOperation = {
type: 'team:destroy',
params: { scopeTeam },
Expand All @@ -171,7 +172,7 @@ async function handleAddUser(teamName: string) {
isAddingUser.value = true
try {
const username = newUserUsername.value.trim().replace(/^@/, '')
const scopeTeam = `${props.orgName}:${teamName}`
const scopeTeam = buildScopeTeam(props.orgName, teamName)

let dependsOnId: string | undefined

Expand Down Expand Up @@ -213,7 +214,7 @@ async function handleAddUser(teamName: string) {

// Remove user from team
async function handleRemoveUser(teamName: string, username: string) {
const scopeTeam = `${props.orgName}:${teamName}`
const scopeTeam = buildScopeTeam(props.orgName, teamName)
const operation: NewOperation = {
type: 'team:rm-user',
params: { scopeTeam, user: username },
Expand Down
3 changes: 2 additions & 1 deletion app/components/PackageAccessControls.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { NewOperation } from '~/composables/useConnector'
import { buildScopeTeam } from '~/utils/npm'

const props = defineProps<{
packageName: string
Expand Down Expand Up @@ -96,7 +97,7 @@ async function handleGrantAccess() {

isGranting.value = true
try {
const scopeTeam = `${orgName.value}:${selectedTeam.value}`
const scopeTeam = buildScopeTeam(orgName.value, selectedTeam.value)
const operation: NewOperation = {
type: 'access:grant',
params: {
Expand Down
11 changes: 11 additions & 0 deletions app/utils/npm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Constructs a scope:team string in the format expected by npm.
* npm operations require the format @scope:team (with @ prefix).
*
* @param orgName - The organization name (with or without @)
* @param teamName - The team name
* @returns The scope:team string in @scope:team format
*/
export function buildScopeTeam(orgName: string, teamName: string): `@${string}:${string}` {
return `@${orgName.replace(/^@/, '')}:${teamName}`
}
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@clack/prompts": "1.0.0-alpha.9",
"citty": "^0.2.0",
"h3-next": "npm:h3@^2.0.1-rc.11",
"obug": "^2.1.1",
"picocolors": "^1.1.1",
"srvx": "^0.10.1",
"validate-npm-package-name": "^7.0.2"
Expand Down
6 changes: 6 additions & 0 deletions cli/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as p from '@clack/prompts'
import pc from 'picocolors'
import { createDebug } from 'obug'

let isInitialized = false

Expand Down Expand Up @@ -47,6 +48,11 @@ export function logInfo(message: string): void {
p.log.info(message)
}

/**
* Log a debug message with `obug` (minimal fork of `debug`)
*/
export const logDebug = createDebug('npmx-connector')
Copy link
Copy Markdown
Member Author

@serhalp serhalp Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but debugging was difficult without stack traces and support for non-string objects in general. Given that clack doesn't have affordances for this, debug/obug was the least intrusive approach I could think of.


/**
* Log a message (generic)
*/
Expand Down
13 changes: 13 additions & 0 deletions cli/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { H3, HTTPError, handleCors, type H3Event } from 'h3-next'
import type { CorsOptions } from 'h3-next'

import type { ConnectorState, PendingOperation, OperationType, ApiResponse } from './types.ts'
import { logDebug, logError } from './logger.ts'
import {
getNpmUser,
orgAddUser,
Expand All @@ -20,6 +21,7 @@ import {
ownerAdd,
ownerRemove,
packageInit,
validateScopeTeam,
type NpmExecResult,
} from './npm-client.ts'

Expand Down Expand Up @@ -447,6 +449,17 @@ export function createConnectorApp(expectedToken: string) {
// Decode the team name (handles encoded colons like nuxt%3Adevelopers)
const scopeTeam = decodeURIComponent(scopeTeamRaw)

try {
validateScopeTeam(scopeTeam)
} catch (err) {
logError('scope:team validation failed')
logDebug(err, { scopeTeamRaw, scopeTeam })
throw new HTTPError({
statusCode: 400,
message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`,
})
Comment on lines +455 to +460
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may look a little funny at a glance, but the logError is the backend-user-facing pretty clack error output, the logDebug is for a developer troubleshooting, and the throw is for the response back to the browser for this invalid request.

Copy link
Copy Markdown

@mxdvl mxdvl Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it help putting quotes around the invalid input for clarity?

}

const result = await teamListUsers(scopeTeam)
if (result.exitCode !== 0) {
return {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions test/unit/cli-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { createConnectorApp } from '../../cli/src/server.ts'

const TEST_TOKEN = 'test-token-123'

describe('connector server', () => {
describe('GET /team/:scopeTeam/users', () => {
it('returns 400 for invalid scope:team format (missing @ prefix)', async () => {
const app = createConnectorApp(TEST_TOKEN)

const response = await app.fetch(
new Request('http://localhost/team/netlify%3Adevelopers/users', {
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
}),
)

expect(response.status).toBe(400)
const body = await response.json()
expect(body.message).toContain('Invalid scope:team format')
})

it('returns 401 without auth token', async () => {
const app = createConnectorApp(TEST_TOKEN)

const response = await app.fetch(
new Request('http://localhost/team/@netlify%3Adevelopers/users'),
)

expect(response.status).toBe(401)
})
})
})
22 changes: 22 additions & 0 deletions test/unit/npm-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'

import { buildScopeTeam } from '../../app/utils/npm'
import { validateScopeTeam } from '../../cli/src/npm-client'

describe('buildScopeTeam', () => {
it('constructs scope:team with @ prefix', () => {
expect(buildScopeTeam('netlify', 'developers')).toBe('@netlify:developers')
expect(buildScopeTeam('nuxt', 'core')).toBe('@nuxt:core')
})

it('strips existing @ prefix from orgName', () => {
expect(buildScopeTeam('@netlify', 'developers')).toBe('@netlify:developers')
expect(buildScopeTeam('@nuxt', 'core')).toBe('@nuxt:core')
})

it('produces format accepted by validateScopeTeam', () => {
expect(() => validateScopeTeam(buildScopeTeam('netlify', 'developers'))).not.toThrow()
expect(() => validateScopeTeam(buildScopeTeam('nuxt', 'core'))).not.toThrow()
expect(() => validateScopeTeam(buildScopeTeam('my-org', 'my-team'))).not.toThrow()
})
})