forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import crypto from 'node:crypto'
2import { H3, HTTPError, handleCors, type H3Event } from 'h3-next'
3import type { CorsOptions } from 'h3-next'
4import * as v from 'valibot'
5
6import type { ConnectorState, PendingOperation, ApiResponse } from './types.ts'
7import { logDebug, logError } from './logger.ts'
8import {
9 getNpmUser,
10 getNpmAvatar,
11 orgAddUser,
12 orgRemoveUser,
13 orgListUsers,
14 teamCreate,
15 teamDestroy,
16 teamAddUser,
17 teamRemoveUser,
18 teamListTeams,
19 teamListUsers,
20 accessGrant,
21 accessRevoke,
22 accessListCollaborators,
23 ownerAdd,
24 ownerRemove,
25 packageInit,
26 listUserPackages,
27 type NpmExecResult,
28} from './npm-client.ts'
29import {
30 ConnectBodySchema,
31 ExecuteBodySchema,
32 CreateOperationBodySchema,
33 BatchOperationsBodySchema,
34 OrgNameSchema,
35 ScopeTeamSchema,
36 PackageNameSchema,
37 OperationIdSchema,
38 safeParse,
39 validateOperationParams,
40} from './schemas.ts'
41
42// Read version from package.json
43import pkg from '../package.json' with { type: 'json' }
44
45export const CONNECTOR_VERSION = pkg.version
46
47function generateToken(): string {
48 return crypto.randomBytes(16).toString('hex')
49}
50
51function generateOperationId(): string {
52 return crypto.randomBytes(8).toString('hex')
53}
54
55const corsOptions: CorsOptions = {
56 origin: ['https://npmx.dev', /^http:\/\/localhost:\d+$/, /^http:\/\/127.0.0.1:\d+$/],
57 methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
58 allowHeaders: ['Content-Type', 'Authorization'],
59}
60
61export function createConnectorApp(expectedToken: string) {
62 const state: ConnectorState = {
63 session: {
64 token: expectedToken,
65 connectedAt: 0,
66 npmUser: null,
67 avatar: null,
68 },
69 operations: [],
70 }
71
72 const app = new H3()
73
74 // Handle CORS for all requests (including preflight)
75 app.use((event: H3Event) => {
76 const corsResult = handleCors(event, corsOptions)
77 if (corsResult !== false) {
78 return corsResult
79 }
80 })
81
82 function validateToken(authHeader: string | null): boolean {
83 if (!authHeader) return false
84 const token = authHeader.replace('Bearer ', '')
85 return token === expectedToken
86 }
87
88 app.post('/connect', async (event: H3Event) => {
89 const rawBody = await event.req.json()
90 const parsed = safeParse(ConnectBodySchema, rawBody)
91 if (!parsed.success) {
92 throw new HTTPError({ statusCode: 400, message: parsed.error })
93 }
94
95 if (parsed.data.token !== expectedToken) {
96 throw new HTTPError({ statusCode: 401, message: 'Invalid token' })
97 }
98
99 const [npmUser, avatar] = await Promise.all([getNpmUser(), getNpmAvatar()])
100 state.session.connectedAt = Date.now()
101 state.session.npmUser = npmUser
102 state.session.avatar = avatar
103
104 return {
105 success: true,
106 data: {
107 npmUser,
108 avatar,
109 connectedAt: state.session.connectedAt,
110 },
111 } as ApiResponse
112 })
113
114 app.get('/state', event => {
115 const auth = event.req.headers.get('authorization')
116 if (!validateToken(auth)) {
117 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
118 }
119
120 return {
121 success: true,
122 data: {
123 npmUser: state.session.npmUser,
124 avatar: state.session.avatar,
125 operations: state.operations,
126 },
127 } as ApiResponse
128 })
129
130 app.post('/operations', async event => {
131 const auth = event.req.headers.get('authorization')
132 if (!validateToken(auth)) {
133 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
134 }
135
136 const rawBody = await event.req.json()
137 const parsed = safeParse(CreateOperationBodySchema, rawBody)
138 if (!parsed.success) {
139 throw new HTTPError({ statusCode: 400, message: parsed.error })
140 }
141
142 const { type, params, description, command } = parsed.data
143
144 // Validate params based on operation type
145 try {
146 validateOperationParams(type, params)
147 } catch (err) {
148 const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err)
149 throw new HTTPError({ statusCode: 400, message: `Invalid params: ${message}` })
150 }
151
152 const operation: PendingOperation = {
153 id: generateOperationId(),
154 type,
155 params,
156 description,
157 command,
158 status: 'pending',
159 createdAt: Date.now(),
160 }
161
162 state.operations.push(operation)
163
164 return {
165 success: true,
166 data: operation,
167 } as ApiResponse
168 })
169
170 app.post('/operations/batch', async event => {
171 const auth = event.req.headers.get('authorization')
172 if (!validateToken(auth)) {
173 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
174 }
175
176 const rawBody = await event.req.json()
177 const parsed = safeParse(BatchOperationsBodySchema, rawBody)
178 if (!parsed.success) {
179 throw new HTTPError({ statusCode: 400, message: parsed.error })
180 }
181
182 // Validate each operation's params
183 for (let i = 0; i < parsed.data.length; i++) {
184 const op = parsed.data[i]
185 if (!op) continue
186 try {
187 validateOperationParams(op.type, op.params)
188 } catch (err) {
189 const message = err instanceof v.ValiError ? err.issues[0]?.message : String(err)
190 throw new HTTPError({
191 statusCode: 400,
192 message: `Operation ${i}: Invalid params: ${message}`,
193 })
194 }
195 }
196
197 const created: PendingOperation[] = []
198 for (const op of parsed.data) {
199 const operation: PendingOperation = {
200 id: generateOperationId(),
201 type: op.type,
202 params: op.params,
203 description: op.description,
204 command: op.command,
205 status: 'pending',
206 createdAt: Date.now(),
207 }
208 state.operations.push(operation)
209 created.push(operation)
210 }
211
212 return {
213 success: true,
214 data: created,
215 } as ApiResponse
216 })
217
218 app.post('/approve', event => {
219 const auth = event.req.headers.get('authorization')
220 if (!validateToken(auth)) {
221 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
222 }
223
224 const url = new URL(event.req.url)
225 const id = url.searchParams.get('id')
226
227 const idValidation = safeParse(OperationIdSchema, id)
228 if (!idValidation.success) {
229 throw new HTTPError({ statusCode: 400, message: idValidation.error })
230 }
231
232 const operation = state.operations.find(op => op.id === idValidation.data)
233 if (!operation) {
234 throw new HTTPError({ statusCode: 404, message: 'Operation not found' })
235 }
236
237 if (operation.status !== 'pending') {
238 throw new HTTPError({
239 statusCode: 400,
240 message: 'Operation is not pending',
241 })
242 }
243
244 operation.status = 'approved'
245
246 return {
247 success: true,
248 data: operation,
249 } as ApiResponse
250 })
251
252 app.post('/approve-all', event => {
253 const auth = event.req.headers.get('authorization')
254 if (!validateToken(auth)) {
255 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
256 }
257
258 const pendingOps = state.operations.filter(op => op.status === 'pending')
259 for (const op of pendingOps) {
260 op.status = 'approved'
261 }
262
263 return {
264 success: true,
265 data: { approved: pendingOps.length },
266 } as ApiResponse
267 })
268
269 app.post('/retry', event => {
270 const auth = event.req.headers.get('authorization')
271 if (!validateToken(auth)) {
272 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
273 }
274
275 const url = new URL(event.req.url)
276 const id = url.searchParams.get('id')
277
278 const idValidation = safeParse(OperationIdSchema, id)
279 if (!idValidation.success) {
280 throw new HTTPError({ statusCode: 400, message: idValidation.error })
281 }
282
283 const operation = state.operations.find(op => op.id === idValidation.data)
284 if (!operation) {
285 throw new HTTPError({ statusCode: 404, message: 'Operation not found' })
286 }
287
288 if (operation.status !== 'failed') {
289 throw new HTTPError({
290 statusCode: 400,
291 message: 'Only failed operations can be retried',
292 })
293 }
294
295 // Reset the operation for retry
296 operation.status = 'approved'
297 operation.result = undefined
298
299 return {
300 success: true,
301 data: operation,
302 } as ApiResponse
303 })
304
305 app.post('/execute', async event => {
306 const auth = event.req.headers.get('authorization')
307 if (!validateToken(auth)) {
308 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
309 }
310
311 // OTP can be passed directly in the request body for this execution
312 let otp: string | undefined
313 try {
314 const rawBody = await event.req.json()
315 if (rawBody) {
316 const parsed = safeParse(ExecuteBodySchema, rawBody)
317 if (!parsed.success) {
318 throw new HTTPError({ statusCode: 400, message: parsed.error })
319 }
320 otp = parsed.data.otp
321 }
322 } catch (err) {
323 // Re-throw HTTPError, ignore JSON parse errors (empty body is fine)
324 if (err instanceof HTTPError) throw err
325 }
326
327 const approvedOps = state.operations.filter(op => op.status === 'approved')
328 const results: Array<{ id: string; result: NpmExecResult }> = []
329 let otpRequired = false
330 const completedIds = new Set<string>()
331 const failedIds = new Set<string>()
332
333 // Execute operations in waves, respecting dependencies
334 // Each wave contains operations whose dependencies are satisfied
335 while (true) {
336 // Find operations ready to run (no pending dependencies)
337 const readyOps = approvedOps.filter(op => {
338 // Already processed
339 if (completedIds.has(op.id) || failedIds.has(op.id)) return false
340 // No dependency - ready
341 if (!op.dependsOn) return true
342 // Dependency completed successfully - ready
343 if (completedIds.has(op.dependsOn)) return true
344 // Dependency failed - skip this one too
345 if (failedIds.has(op.dependsOn)) {
346 op.status = 'failed'
347 op.result = {
348 stdout: '',
349 stderr: 'Skipped: dependency failed',
350 exitCode: 1,
351 }
352 failedIds.add(op.id)
353 results.push({ id: op.id, result: op.result })
354 return false
355 }
356 // Dependency still pending - not ready
357 return false
358 })
359
360 // No more operations to run
361 if (readyOps.length === 0) break
362
363 // If we've hit an OTP error and no OTP was provided, stop
364 if (otpRequired && !otp) break
365
366 // Execute ready operations in parallel
367 const runningOps = readyOps.map(async op => {
368 op.status = 'running'
369 const result = await executeOperation(op, otp)
370 op.result = result
371 op.status = result.exitCode === 0 ? 'completed' : 'failed'
372
373 if (result.exitCode === 0) {
374 completedIds.add(op.id)
375 } else {
376 failedIds.add(op.id)
377 }
378
379 // Track if OTP is needed
380 if (result.requiresOtp) {
381 otpRequired = true
382 }
383
384 results.push({ id: op.id, result })
385 })
386
387 await Promise.all(runningOps)
388 }
389
390 // Check if any operation had an auth failure
391 const authFailure = results.some(r => r.result.authFailure)
392
393 return {
394 success: true,
395 data: {
396 results,
397 otpRequired,
398 authFailure,
399 },
400 } as ApiResponse
401 })
402
403 app.delete('/operations', event => {
404 const auth = event.req.headers.get('authorization')
405 if (!validateToken(auth)) {
406 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
407 }
408
409 const url = new URL(event.req.url)
410 const id = url.searchParams.get('id')
411
412 const idValidation = safeParse(OperationIdSchema, id)
413 if (!idValidation.success) {
414 throw new HTTPError({ statusCode: 400, message: idValidation.error })
415 }
416
417 const index = state.operations.findIndex(op => op.id === idValidation.data)
418 if (index === -1) {
419 throw new HTTPError({ statusCode: 404, message: 'Operation not found' })
420 }
421
422 const operation = state.operations[index]
423 if (!operation || operation.status === 'running') {
424 throw new HTTPError({
425 statusCode: 400,
426 message: 'Cannot cancel running operation',
427 })
428 }
429
430 state.operations.splice(index, 1)
431
432 return { success: true } as ApiResponse
433 })
434
435 app.delete('/operations/all', event => {
436 const auth = event.req.headers.get('authorization')
437 if (!validateToken(auth)) {
438 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
439 }
440
441 const removed = state.operations.filter(op => op.status !== 'running').length
442 state.operations = state.operations.filter(op => op.status === 'running')
443
444 return {
445 success: true,
446 data: { removed },
447 } as ApiResponse
448 })
449
450 // List endpoints (read-only data fetching)
451
452 app.get('/org/:org/users', async event => {
453 const auth = event.req.headers.get('authorization')
454 if (!validateToken(auth)) {
455 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
456 }
457
458 const orgRaw = event.context.params?.org
459 const orgValidation = safeParse(OrgNameSchema, orgRaw)
460 if (!orgValidation.success) {
461 throw new HTTPError({ statusCode: 400, message: orgValidation.error })
462 }
463
464 const result = await orgListUsers(orgValidation.data)
465 if (result.exitCode !== 0) {
466 return {
467 success: false,
468 error: result.stderr || 'Failed to list org users',
469 } as ApiResponse
470 }
471
472 try {
473 const users = JSON.parse(result.stdout) as Record<string, 'developer' | 'admin' | 'owner'>
474 return {
475 success: true,
476 data: users,
477 } as ApiResponse
478 } catch {
479 return {
480 success: false,
481 error: 'Failed to parse org users',
482 } as ApiResponse
483 }
484 })
485
486 app.get('/org/:org/teams', async event => {
487 const auth = event.req.headers.get('authorization')
488 if (!validateToken(auth)) {
489 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
490 }
491
492 const orgRaw = event.context.params?.org
493 const orgValidation = safeParse(OrgNameSchema, orgRaw)
494 if (!orgValidation.success) {
495 throw new HTTPError({ statusCode: 400, message: orgValidation.error })
496 }
497
498 const result = await teamListTeams(orgValidation.data)
499 if (result.exitCode !== 0) {
500 return {
501 success: false,
502 error: result.stderr || 'Failed to list teams',
503 } as ApiResponse
504 }
505
506 try {
507 const teams = JSON.parse(result.stdout) as string[]
508 return {
509 success: true,
510 data: teams,
511 } as ApiResponse
512 } catch {
513 return {
514 success: false,
515 error: 'Failed to parse teams',
516 } as ApiResponse
517 }
518 })
519
520 app.get('/team/:scopeTeam/users', async event => {
521 const auth = event.req.headers.get('authorization')
522 if (!validateToken(auth)) {
523 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
524 }
525
526 const scopeTeamRaw = event.context.params?.scopeTeam
527 if (!scopeTeamRaw) {
528 throw new HTTPError({ statusCode: 400, message: 'Team name required' })
529 }
530
531 // Decode the team name (handles encoded colons like nuxt%3Adevelopers)
532 const scopeTeam = decodeURIComponent(scopeTeamRaw)
533
534 const validationResult = safeParse(ScopeTeamSchema, scopeTeam)
535 if (!validationResult.success) {
536 logError('scope:team validation failed')
537 logDebug(validationResult.error, { scopeTeamRaw, scopeTeam })
538 throw new HTTPError({
539 statusCode: 400,
540 message: `Invalid scope:team format: ${scopeTeam}. Expected @scope:team`,
541 })
542 }
543
544 const result = await teamListUsers(scopeTeam)
545 if (result.exitCode !== 0) {
546 return {
547 success: false,
548 error: result.stderr || 'Failed to list team users',
549 } as ApiResponse
550 }
551
552 try {
553 const users = JSON.parse(result.stdout) as string[]
554 return {
555 success: true,
556 data: users,
557 } as ApiResponse
558 } catch {
559 return {
560 success: false,
561 error: 'Failed to parse team users',
562 } as ApiResponse
563 }
564 })
565
566 app.get('/package/:pkg/collaborators', async event => {
567 const auth = event.req.headers.get('authorization')
568 if (!validateToken(auth)) {
569 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
570 }
571
572 const pkgRaw = event.context.params?.pkg
573 if (!pkgRaw) {
574 throw new HTTPError({ statusCode: 400, message: 'Package name required' })
575 }
576
577 // Decode the package name (handles scoped packages like @nuxt%2Fkit)
578 const decodedPkg = decodeURIComponent(pkgRaw)
579
580 const pkgValidation = safeParse(PackageNameSchema, decodedPkg)
581 if (!pkgValidation.success) {
582 throw new HTTPError({ statusCode: 400, message: pkgValidation.error })
583 }
584
585 const result = await accessListCollaborators(pkgValidation.data)
586 if (result.exitCode !== 0) {
587 return {
588 success: false,
589 error: result.stderr || 'Failed to list collaborators',
590 } as ApiResponse
591 }
592
593 try {
594 const collaborators = JSON.parse(result.stdout) as Record<string, 'read-only' | 'read-write'>
595 return {
596 success: true,
597 data: collaborators,
598 } as ApiResponse
599 } catch {
600 return {
601 success: false,
602 error: 'Failed to parse collaborators',
603 } as ApiResponse
604 }
605 })
606
607 // User-specific endpoints
608
609 app.get('/user/packages', async event => {
610 const auth = event.req.headers.get('authorization')
611 if (!validateToken(auth)) {
612 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
613 }
614
615 const npmUser = state.session.npmUser
616 if (!npmUser) {
617 return {
618 success: false,
619 error: 'Not logged in to npm',
620 } as ApiResponse
621 }
622
623 const result = await listUserPackages(npmUser)
624 if (result.exitCode !== 0) {
625 return {
626 success: false,
627 error: result.stderr || 'Failed to list user packages',
628 } as ApiResponse
629 }
630
631 try {
632 // npm access list packages returns { "packageName": "read-write" | "read-only" }
633 const packages = JSON.parse(result.stdout) as Record<string, 'read-write' | 'read-only'>
634 return {
635 success: true,
636 data: packages,
637 } as ApiResponse
638 } catch {
639 return {
640 success: false,
641 error: 'Failed to parse user packages',
642 } as ApiResponse
643 }
644 })
645
646 app.get('/user/orgs', async event => {
647 const auth = event.req.headers.get('authorization')
648 if (!validateToken(auth)) {
649 throw new HTTPError({ statusCode: 401, message: 'Unauthorized' })
650 }
651
652 const npmUser = state.session.npmUser
653 if (!npmUser) {
654 return {
655 success: false,
656 error: 'Not logged in to npm',
657 } as ApiResponse
658 }
659
660 // Get user's packages and extract org names from scoped packages
661 const result = await listUserPackages(npmUser)
662 if (result.exitCode !== 0) {
663 return {
664 success: false,
665 error: result.stderr || 'Failed to list user packages',
666 } as ApiResponse
667 }
668
669 try {
670 const packages = JSON.parse(result.stdout) as Record<string, string>
671 const orgs = new Set<string>()
672
673 // Extract org names from scoped packages (e.g., @myorg/mypackage -> myorg)
674 for (const pkgName of Object.keys(packages)) {
675 if (pkgName.startsWith('@')) {
676 const match = pkgName.match(/^@([^/]+)\//)
677 if (match && match[1]) {
678 // Exclude the user's own scope (personal packages)
679 if (match[1].toLowerCase() !== npmUser.toLowerCase()) {
680 orgs.add(match[1])
681 }
682 }
683 }
684 }
685
686 return {
687 success: true,
688 data: Array.from(orgs).sort(),
689 } as ApiResponse
690 } catch {
691 return {
692 success: false,
693 error: 'Failed to parse user orgs',
694 } as ApiResponse
695 }
696 })
697
698 return app
699}
700
701async function executeOperation(op: PendingOperation, otp?: string): Promise<NpmExecResult> {
702 const { type, params } = op
703
704 switch (type) {
705 case 'org:add-user':
706 return orgAddUser(
707 params.org,
708 params.user,
709 params.role as 'developer' | 'admin' | 'owner',
710 otp,
711 )
712 case 'org:rm-user':
713 return orgRemoveUser(params.org, params.user, otp)
714 case 'team:create':
715 return teamCreate(params.scopeTeam, otp)
716 case 'team:destroy':
717 return teamDestroy(params.scopeTeam, otp)
718 case 'team:add-user':
719 return teamAddUser(params.scopeTeam, params.user, otp)
720 case 'team:rm-user':
721 return teamRemoveUser(params.scopeTeam, params.user, otp)
722 case 'access:grant':
723 return accessGrant(
724 params.permission as 'read-only' | 'read-write',
725 params.scopeTeam,
726 params.pkg,
727 otp,
728 )
729 case 'access:revoke':
730 return accessRevoke(params.scopeTeam, params.pkg, otp)
731 case 'owner:add':
732 return ownerAdd(params.user, params.pkg, otp)
733 case 'owner:rm':
734 return ownerRemove(params.user, params.pkg, otp)
735 case 'package:init':
736 return packageInit(params.name, params.author, otp)
737 default:
738 return {
739 stdout: '',
740 stderr: `Unknown operation type: ${type}`,
741 exitCode: 1,
742 }
743 }
744}
745
746export { generateToken }