import crypto from 'node:crypto' import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' import type { CorsOptions } from 'h3-next' import * as v from 'valibot' import type { ConnectorState, PendingOperation, ApiResponse, ConnectorEndpoints, AssertEndpointsImplemented, } from './types.ts' // Endpoint completeness check — errors if this list diverges from ConnectorEndpoints. const _endpointCheck: AssertEndpointsImplemented< | 'POST /connect' | 'GET /state' | 'POST /operations' | 'POST /operations/batch' | 'DELETE /operations' | 'DELETE /operations/all' | 'POST /approve' | 'POST /approve-all' | 'POST /retry' | 'POST /execute' | 'GET /org/:org/users' | 'GET /org/:org/teams' | 'GET /team/:scopeTeam/users' | 'GET /package/:pkg/collaborators' | 'GET /user/packages' | 'GET /user/orgs' > = true void _endpointCheck import { logDebug, logError } from './logger.ts' import { getNpmUser, getNpmAvatar, orgAddUser, orgRemoveUser, orgListUsers, teamCreate, teamDestroy, teamAddUser, teamRemoveUser, teamListTeams, teamListUsers, accessGrant, accessRevoke, accessListCollaborators, ownerAdd, ownerRemove, packageInit, listUserPackages, extractUrls, type ExecNpmOptions, type NpmExecResult, } from './npm-client.ts' import { ConnectBodySchema, ExecuteBodySchema, CreateOperationBodySchema, BatchOperationsBodySchema, OrgNameSchema, ScopeTeamSchema, PackageNameSchema, OperationIdSchema, safeParse, validateOperationParams, } from './schemas.ts' // Read version from package.json import pkg from '../package.json' with { type: 'json' } export const CONNECTOR_VERSION = pkg.version function generateToken(): string { return crypto.randomBytes(16).toString('hex') } function generateOperationId(): string { return crypto.randomBytes(8).toString('hex') } const corsOptions: CorsOptions = { origin: ['https://npmx.dev', /^http:\/\/localhost:\d+$/, /^http:\/\/127.0.0.1:\d+$/], methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], } export function createConnectorApp(expectedToken: string) { const state: ConnectorState = { session: { token: expectedToken, connectedAt: 0, npmUser: null, avatar: null, }, operations: [], } const app = new H3() // Handle CORS for all requests (including preflight) app.use((event: H3Event) => { const corsResult = handleCors(event, corsOptions) if (corsResult !== false) { return corsResult } }) function validateToken(authHeader: string | null): boolean { if (!authHeader) return false const token = authHeader.replace('Bearer ', '') return token === expectedToken } app.post('/connect', async (event: H3Event) => { const rawBody = await event.req.json() const parsed = safeParse(ConnectBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) } if (parsed.data.token !== expectedToken) { throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) } const [npmUser, avatar] = await Promise.all([getNpmUser(), getNpmAvatar()]) state.session.connectedAt = Date.now() state.session.npmUser = npmUser state.session.avatar = avatar return { success: true, data: { npmUser, avatar, connectedAt: state.session.connectedAt, }, } satisfies ApiResponse }) app.get('/state', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } return { success: true, data: { npmUser: state.session.npmUser, avatar: state.session.avatar, operations: state.operations, }, } satisfies ApiResponse }) app.post('/operations', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const rawBody = await event.req.json() const parsed = safeParse(CreateOperationBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) } const { type, params, description, command } = parsed.data // Validate params based on operation type try { validateOperationParams(type, params) } catch (err) { const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err) throw new HTTPError({ statusCode: 400, message: `Invalid params: ${message}` }) } const operation: PendingOperation = { id: generateOperationId(), type, params, description, command, status: 'pending', createdAt: Date.now(), } state.operations.push(operation) return { success: true, data: operation, } satisfies ApiResponse }) app.post('/operations/batch', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const rawBody = await event.req.json() const parsed = safeParse(BatchOperationsBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) } // Validate each operation's params for (let i = 0; i < parsed.data.length; i++) { const op = parsed.data[i] if (!op) continue try { validateOperationParams(op.type, op.params) } catch (err) { const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err) throw new HTTPError({ statusCode: 400, message: `Operation ${i}: Invalid params: ${message}`, }) } } const created: PendingOperation[] = [] for (const op of parsed.data) { const operation: PendingOperation = { id: generateOperationId(), type: op.type, params: op.params, description: op.description, command: op.command, status: 'pending', createdAt: Date.now(), } state.operations.push(operation) created.push(operation) } return { success: true, data: created, } satisfies ApiResponse }) app.post('/approve', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const url = new URL(event.req.url) const id = url.searchParams.get('id') const idValidation = safeParse(OperationIdSchema, id) if (!idValidation.success) { throw new HTTPError({ statusCode: 400, message: idValidation.error }) } const operation = state.operations.find(op => op.id === idValidation.data) if (!operation) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) } if (operation.status !== 'pending') { throw new HTTPError({ statusCode: 400, message: 'Operation is not pending', }) } operation.status = 'approved' return { success: true, data: operation, } satisfies ApiResponse }) app.post('/approve-all', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const pendingOps = state.operations.filter(op => op.status === 'pending') for (const op of pendingOps) { op.status = 'approved' } return { success: true, data: { approved: pendingOps.length }, } satisfies ApiResponse }) app.post('/retry', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const url = new URL(event.req.url) const id = url.searchParams.get('id') const idValidation = safeParse(OperationIdSchema, id) if (!idValidation.success) { throw new HTTPError({ statusCode: 400, message: idValidation.error }) } const operation = state.operations.find(op => op.id === idValidation.data) if (!operation) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) } if (operation.status !== 'failed') { throw new HTTPError({ statusCode: 400, message: 'Only failed operations can be retried', }) } // Reset the operation for retry operation.status = 'approved' operation.result = undefined return { success: true, data: operation, } satisfies ApiResponse }) app.post('/execute', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } // OTP, interactive flag, and openUrls can be passed in the request body let otp: string | undefined let interactive = false let openUrls = false try { const rawBody = await event.req.json() if (rawBody) { const parsed = safeParse(ExecuteBodySchema, rawBody) if (!parsed.success) { throw new HTTPError({ statusCode: 400, message: parsed.error }) } otp = parsed.data.otp interactive = parsed.data.interactive ?? false openUrls = parsed.data.openUrls ?? false } } catch (err) { // Re-throw HTTPError, ignore JSON parse errors (empty body is fine) if (err instanceof HTTPError) throw err } const approvedOps = state.operations.filter(op => op.status === 'approved') const results: Array<{ id: string; result: NpmExecResult }> = [] let otpRequired = false const completedIds = new Set() const failedIds = new Set() // Collect all URLs across all operations in this execution batch const allUrls: string[] = [] // Execute operations in waves, respecting dependencies // Each wave contains operations whose dependencies are satisfied while (true) { // Find operations ready to run (no pending dependencies) const readyOps = approvedOps.filter(op => { // Already processed if (completedIds.has(op.id) || failedIds.has(op.id)) return false // No dependency - ready if (!op.dependsOn) return true // Dependency completed successfully - ready if (completedIds.has(op.dependsOn)) return true // Dependency failed - skip this one too if (failedIds.has(op.dependsOn)) { op.status = 'failed' op.result = { stdout: '', stderr: 'Skipped: dependency failed', exitCode: 1, } failedIds.add(op.id) results.push({ id: op.id, result: op.result }) return false } // Dependency still pending - not ready return false }) // No more operations to run if (readyOps.length === 0) break // If we've hit an OTP error and no OTP was provided, stop if (otpRequired && !otp) break // Execute ready operations in parallel const runningOps = readyOps.map(async op => { op.status = 'running' const result = await executeOperation(op, { otp, interactive, openUrls }) op.result = result op.authUrl = undefined op.status = result.exitCode === 0 ? 'completed' : 'failed' if (result.exitCode === 0) { completedIds.add(op.id) } else { failedIds.add(op.id) } // Track if OTP is needed if (result.requiresOtp) { otpRequired = true } // Collect URLs from this operation's output if (result.urls && result.urls.length > 0) { allUrls.push(...result.urls) } results.push({ id: op.id, result }) }) await Promise.all(runningOps) } // Check if any operation had an auth failure const authFailure = results.some(r => r.result.authFailure) const urls = [...new Set(allUrls)] return { success: true, data: { results, otpRequired, authFailure, urls: urls.length > 0 ? urls : undefined, }, } satisfies ApiResponse }) app.delete('/operations', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const url = new URL(event.req.url) const id = url.searchParams.get('id') const idValidation = safeParse(OperationIdSchema, id) if (!idValidation.success) { throw new HTTPError({ statusCode: 400, message: idValidation.error }) } const index = state.operations.findIndex(op => op.id === idValidation.data) if (index === -1) { throw new HTTPError({ statusCode: 404, message: 'Operation not found' }) } const operation = state.operations[index] if (!operation || operation.status === 'running') { throw new HTTPError({ statusCode: 400, message: 'Cannot cancel running operation', }) } state.operations.splice(index, 1) return { success: true } satisfies ApiResponse }) app.delete('/operations/all', event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const removed = state.operations.filter(op => op.status !== 'running').length state.operations = state.operations.filter(op => op.status === 'running') return { success: true, data: { removed }, } satisfies ApiResponse }) // List endpoints (read-only data fetching) app.get('/org/:org/users', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const orgRaw = event.context.params?.org const orgValidation = safeParse(OrgNameSchema, orgRaw) if (!orgValidation.success) { throw new HTTPError({ statusCode: 400, message: orgValidation.error }) } const result = await orgListUsers(orgValidation.data) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list org users', } as ApiResponse } try { const users = JSON.parse(result.stdout) as Record return { success: true, data: users, } satisfies ApiResponse } catch { return { success: false, error: 'Failed to parse org users', } as ApiResponse } }) app.get('/org/:org/teams', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const orgRaw = event.context.params?.org const orgValidation = safeParse(OrgNameSchema, orgRaw) if (!orgValidation.success) { throw new HTTPError({ statusCode: 400, message: orgValidation.error }) } const result = await teamListTeams(orgValidation.data) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list teams', } as ApiResponse } try { const teams = JSON.parse(result.stdout) as string[] return { success: true, data: teams, } satisfies ApiResponse } catch { return { success: false, error: 'Failed to parse teams', } as ApiResponse } }) app.get('/team/:scopeTeam/users', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const scopeTeamRaw = event.context.params?.scopeTeam if (!scopeTeamRaw) { throw new HTTPError({ statusCode: 400, message: 'Team name required' }) } // Decode the team name (handles encoded colons like nuxt%3Adevelopers) const scopeTeam = decodeURIComponent(scopeTeamRaw) const validationResult = safeParse(ScopeTeamSchema, scopeTeam) if (!validationResult.success) { logError('scope:team validation failed') logDebug(validationResult.error, { scopeTeamRaw, scopeTeam }) throw new HTTPError({ statusCode: 400, message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`, }) } const result = await teamListUsers(scopeTeam) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list team users', } as ApiResponse } try { const users = JSON.parse(result.stdout) as string[] return { success: true, data: users, } satisfies ApiResponse } catch { return { success: false, error: 'Failed to parse team users', } as ApiResponse } }) app.get('/package/:pkg/collaborators', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const pkgRaw = event.context.params?.pkg if (!pkgRaw) { throw new HTTPError({ statusCode: 400, message: 'Package name required' }) } // Decode the package name (handles scoped packages like @nuxt%2Fkit) const decodedPkg = decodeURIComponent(pkgRaw) const pkgValidation = safeParse(PackageNameSchema, decodedPkg) if (!pkgValidation.success) { throw new HTTPError({ statusCode: 400, message: pkgValidation.error }) } const result = await accessListCollaborators(pkgValidation.data) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list collaborators', } as ApiResponse } try { const collaborators = JSON.parse(result.stdout) as Record return { success: true, data: collaborators, } satisfies ApiResponse } catch { return { success: false, error: 'Failed to parse collaborators', } as ApiResponse } }) // User-specific endpoints app.get('/user/packages', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const npmUser = state.session.npmUser if (!npmUser) { return { success: false, error: 'Not logged in to npm', } as ApiResponse } const result = await listUserPackages(npmUser) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list user packages', } as ApiResponse } try { // npm access list packages returns { "packageName": "read-write" | "read-only" } const packages = JSON.parse(result.stdout) as Record return { success: true, data: packages, } satisfies ApiResponse } catch { return { success: false, error: 'Failed to parse user packages', } as ApiResponse } }) app.get('/user/orgs', async event => { const auth = event.req.headers.get('authorization') if (!validateToken(auth)) { throw new HTTPError({ statusCode: 401, message: 'Unauthorized' }) } const npmUser = state.session.npmUser if (!npmUser) { return { success: false, error: 'Not logged in to npm', } as ApiResponse } // Get user's packages and extract org names from scoped packages const result = await listUserPackages(npmUser) if (result.exitCode !== 0) { return { success: false, error: result.stderr || 'Failed to list user packages', } as ApiResponse } try { const packages = JSON.parse(result.stdout) as Record const orgs = new Set() // Extract org names from scoped packages (e.g., @myorg/mypackage -> myorg) for (const pkgName of Object.keys(packages)) { if (pkgName.startsWith('@')) { const match = pkgName.match(/^@([^/]+)\//) if (match && match[1]) { // Exclude the user's own scope (personal packages) if (match[1].toLowerCase() !== npmUser.toLowerCase()) { orgs.add(match[1]) } } } } return { success: true, data: Array.from(orgs).sort(), } satisfies ApiResponse } catch { return { success: false, error: 'Failed to parse user orgs', } as ApiResponse } }) return app } async function executeOperation( op: PendingOperation, options: { otp?: string; interactive?: boolean; openUrls?: boolean } = {}, ): Promise { const { type, params } = op // Build exec options that get passed through to execNpm, which // internally routes to either execFile or PTY-based execution. const execOptions: ExecNpmOptions = { otp: options.otp, interactive: options.interactive, openUrls: options.openUrls, onAuthUrl: options.interactive ? url => { // Set authUrl on the operation so /state exposes it to the // frontend while npm is still polling for authentication. op.authUrl = url } : undefined, } let result: NpmExecResult switch (type) { case 'org:add-user': case 'org:set-role': result = await orgAddUser( params.org, params.user, params.role as 'developer' | 'admin' | 'owner', execOptions, ) break case 'org:rm-user': result = await orgRemoveUser(params.org, params.user, execOptions) break case 'team:create': result = await teamCreate(params.scopeTeam, execOptions) break case 'team:destroy': result = await teamDestroy(params.scopeTeam, execOptions) break case 'team:add-user': result = await teamAddUser(params.scopeTeam, params.user, execOptions) break case 'team:rm-user': result = await teamRemoveUser(params.scopeTeam, params.user, execOptions) break case 'access:grant': result = await accessGrant( params.permission as 'read-only' | 'read-write', params.scopeTeam, params.pkg, execOptions, ) break case 'access:revoke': result = await accessRevoke(params.scopeTeam, params.pkg, execOptions) break case 'owner:add': result = await ownerAdd(params.user, params.pkg, execOptions) break case 'owner:rm': result = await ownerRemove(params.user, params.pkg, execOptions) break case 'package:init': // package:init has its own special execution path (temp dir + publish) // and does not support interactive mode result = await packageInit(params.name, params.author, options.otp) break default: return { stdout: '', stderr: `Unknown operation type: ${type}`, exitCode: 1, } } // Extract URLs from output if not already populated if (!result.urls) { const urls = extractUrls((result.stdout || '') + '\n' + (result.stderr || '')) if (urls.length > 0) result.urls = urls } return result } export { generateToken }