Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 (without @)
* @param teamName - The team name
* @returns The scope:team string in @scope:team format
*/
export function buildScopeTeam(orgName: string, teamName: string): string {
Comment thread
danielroe marked this conversation as resolved.
Outdated
return `@${orgName}:${teamName}`
}
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"consola": "^3.4.2",
"defu": "^6.1.4",
"h3-next": "npm:h3@^2.0.1-rc.11",
"obug": "^2.1.1",
"ofetch": "^1.5.1",
"picocolors": "^1.1.1",
"srvx": "^0.10.1",
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)
})
})
})
17 changes: 17 additions & 0 deletions test/unit/npm-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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('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()
})
})