[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 3ff5d584d87769f4ed0bc54981e54dfa5ff39005 746 lines 21 kB view raw
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 }