[READ-ONLY] a fast, modern browser for the npm registry
at main 472 lines 14 kB view raw
1/** 2 * Mock connector H3 application. Same API as the real server (server.ts) 3 * but backed by in-memory state. Used by the mock CLI and E2E tests. 4 */ 5 6import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' 7import type { CorsOptions } from 'h3-next' 8import { serve, type Server } from 'srvx' 9import type { 10 OperationType, 11 ApiResponse, 12 ConnectorEndpoints, 13 AssertEndpointsImplemented, 14} from './types.ts' 15import type { MockConnectorStateManager } from './mock-state.ts' 16 17// Endpoint completeness check — errors if this list diverges from ConnectorEndpoints. 18// oxlint-disable-next-line no-unused-vars 19const _endpointCheck: AssertEndpointsImplemented< 20 | 'POST /connect' 21 | 'GET /state' 22 | 'POST /operations' 23 | 'POST /operations/batch' 24 | 'DELETE /operations' 25 | 'DELETE /operations/all' 26 | 'POST /approve' 27 | 'POST /approve-all' 28 | 'POST /retry' 29 | 'POST /execute' 30 | 'GET /org/:org/users' 31 | 'GET /org/:org/teams' 32 | 'GET /team/:scopeTeam/users' 33 | 'GET /package/:pkg/collaborators' 34 | 'GET /user/packages' 35 | 'GET /user/orgs' 36> = true 37void _endpointCheck 38 39const corsOptions: CorsOptions = { 40 origin: '*', 41 methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], 42 allowHeaders: ['Content-Type', 'Authorization'], 43} 44 45function createMockConnectorApp(stateManager: MockConnectorStateManager) { 46 const app = new H3() 47 48 app.use((event: H3Event) => { 49 const corsResult = handleCors(event, corsOptions) 50 if (corsResult !== false) { 51 return corsResult 52 } 53 }) 54 55 function requireAuth(event: H3Event): void { 56 const authHeader = event.req.headers.get('authorization') 57 if (!authHeader || !authHeader.startsWith('Bearer ')) { 58 throw new HTTPError({ statusCode: 401, message: 'Authorization required' }) 59 } 60 const token = authHeader.slice(7) 61 if (token !== stateManager.token) { 62 throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) 63 } 64 if (!stateManager.isConnected()) { 65 throw new HTTPError({ statusCode: 401, message: 'Not connected' }) 66 } 67 } 68 69 // POST /connect 70 app.post('/connect', async (event: H3Event) => { 71 const body = (await event.req.json()) as { token?: string } 72 const token = body?.token 73 74 if (!token || token !== stateManager.token) { 75 throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) 76 } 77 78 stateManager.connect(token) 79 80 return { 81 success: true, 82 data: { 83 npmUser: stateManager.config.npmUser, 84 avatar: stateManager.config.avatar ?? null, 85 connectedAt: stateManager.state.connectedAt ?? Date.now(), 86 }, 87 } satisfies ApiResponse<ConnectorEndpoints['POST /connect']['data']> 88 }) 89 90 // GET /state 91 app.get('/state', (event: H3Event) => { 92 requireAuth(event) 93 94 return { 95 success: true, 96 data: { 97 npmUser: stateManager.config.npmUser, 98 avatar: stateManager.config.avatar ?? null, 99 operations: stateManager.getOperations(), 100 }, 101 } satisfies ApiResponse<ConnectorEndpoints['GET /state']['data']> 102 }) 103 104 // POST /operations 105 app.post('/operations', async (event: H3Event) => { 106 requireAuth(event) 107 108 const body = (await event.req.json()) as { 109 type?: string 110 params?: Record<string, string> 111 description?: string 112 command?: string 113 dependsOn?: string 114 } 115 if (!body?.type || !body.description || !body.command) { 116 throw new HTTPError({ statusCode: 400, message: 'Missing required fields' }) 117 } 118 119 const operation = stateManager.addOperation({ 120 type: body.type as OperationType, 121 params: body.params ?? {}, 122 description: body.description, 123 command: body.command, 124 dependsOn: body.dependsOn, 125 }) 126 127 return { 128 success: true, 129 data: operation, 130 } satisfies ApiResponse<ConnectorEndpoints['POST /operations']['data']> 131 }) 132 133 // POST /operations/batch 134 app.post('/operations/batch', async (event: H3Event) => { 135 requireAuth(event) 136 137 const body = await event.req.json() 138 if (!Array.isArray(body)) { 139 throw new HTTPError({ statusCode: 400, message: 'Expected array of operations' }) 140 } 141 142 const operations = stateManager.addOperations(body) 143 return { 144 success: true, 145 data: operations, 146 } satisfies ApiResponse<ConnectorEndpoints['POST /operations/batch']['data']> 147 }) 148 149 // DELETE /operations?id=<id> 150 app.delete('/operations', (event: H3Event) => { 151 requireAuth(event) 152 153 const id = new URL(event.req.url).searchParams.get('id') 154 if (!id) { 155 throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) 156 } 157 158 const removed = stateManager.removeOperation(id) 159 if (!removed) { 160 throw new HTTPError({ statusCode: 404, message: 'Operation not found or cannot be removed' }) 161 } 162 163 return { success: true } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations']['data']> 164 }) 165 166 // DELETE /operations/all 167 app.delete('/operations/all', (event: H3Event) => { 168 requireAuth(event) 169 170 const removed = stateManager.clearOperations() 171 return { 172 success: true, 173 data: { removed }, 174 } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations/all']['data']> 175 }) 176 177 // POST /approve?id=<id> 178 app.post('/approve', (event: H3Event) => { 179 requireAuth(event) 180 181 const id = new URL(event.req.url).searchParams.get('id') 182 if (!id) { 183 throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) 184 } 185 186 const operation = stateManager.approveOperation(id) 187 if (!operation) { 188 throw new HTTPError({ statusCode: 404, message: 'Operation not found or not pending' }) 189 } 190 191 return { 192 success: true, 193 data: operation, 194 } satisfies ApiResponse<ConnectorEndpoints['POST /approve']['data']> 195 }) 196 197 // POST /approve-all 198 app.post('/approve-all', (event: H3Event) => { 199 requireAuth(event) 200 201 const approved = stateManager.approveAll() 202 return { 203 success: true, 204 data: { approved }, 205 } satisfies ApiResponse<ConnectorEndpoints['POST /approve-all']['data']> 206 }) 207 208 // POST /retry?id=<id> 209 app.post('/retry', (event: H3Event) => { 210 requireAuth(event) 211 212 const id = new URL(event.req.url).searchParams.get('id') 213 if (!id) { 214 throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) 215 } 216 217 const operation = stateManager.retryOperation(id) 218 if (!operation) { 219 throw new HTTPError({ statusCode: 404, message: 'Operation not found or not failed' }) 220 } 221 222 return { 223 success: true, 224 data: operation, 225 } satisfies ApiResponse<ConnectorEndpoints['POST /retry']['data']> 226 }) 227 228 // POST /execute 229 app.post('/execute', async (event: H3Event) => { 230 requireAuth(event) 231 232 const body = await event.req.json().catch(() => ({})) 233 const { otp } = body as { otp?: string; interactive?: boolean; openUrls?: boolean } 234 235 const { results, otpRequired, authFailure, urls } = stateManager.executeOperations({ otp }) 236 237 return { 238 success: true, 239 data: { 240 results, 241 otpRequired, 242 authFailure, 243 urls, 244 }, 245 } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']> 246 }) 247 248 // GET /org/:org/users 249 app.get('/org/:org/users', (event: H3Event) => { 250 requireAuth(event) 251 252 const org = event.context.params?.org 253 if (!org) { 254 throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) 255 } 256 257 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 258 const users = stateManager.getOrgUsers(normalizedOrg) 259 if (users === null) { 260 return { success: true, data: {} } satisfies ApiResponse< 261 ConnectorEndpoints['GET /org/:org/users']['data'] 262 > 263 } 264 265 return { success: true, data: users } satisfies ApiResponse< 266 ConnectorEndpoints['GET /org/:org/users']['data'] 267 > 268 }) 269 270 // GET /org/:org/teams 271 app.get('/org/:org/teams', (event: H3Event) => { 272 requireAuth(event) 273 274 const org = event.context.params?.org 275 if (!org) { 276 throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) 277 } 278 279 const normalizedOrg = org.startsWith('@') ? org : `@${org}` 280 const orgName = normalizedOrg.slice(1) 281 282 const teams = stateManager.getOrgTeams(normalizedOrg) 283 const formattedTeams = teams ? teams.map(t => `${orgName}:${t}`) : [] 284 return { success: true, data: formattedTeams } satisfies ApiResponse< 285 ConnectorEndpoints['GET /org/:org/teams']['data'] 286 > 287 }) 288 289 // GET /team/:scopeTeam/users 290 app.get('/team/:scopeTeam/users', (event: H3Event) => { 291 requireAuth(event) 292 293 const scopeTeam = event.context.params?.scopeTeam 294 if (!scopeTeam) { 295 throw new HTTPError({ statusCode: 400, message: 'Missing scopeTeam parameter' }) 296 } 297 298 if (!scopeTeam.startsWith('@') || !scopeTeam.includes(':')) { 299 throw new HTTPError({ 300 statusCode: 400, 301 message: 'Invalid scope:team format (expected @scope:team)', 302 }) 303 } 304 305 const [scope, team] = scopeTeam.split(':') 306 if (!scope || !team) { 307 throw new HTTPError({ statusCode: 400, message: 'Invalid scope:team format' }) 308 } 309 310 const users = stateManager.getTeamUsers(scope, team) 311 return { success: true, data: users ?? [] } satisfies ApiResponse< 312 ConnectorEndpoints['GET /team/:scopeTeam/users']['data'] 313 > 314 }) 315 316 // GET /package/:pkg/collaborators 317 app.get('/package/:pkg/collaborators', (event: H3Event) => { 318 requireAuth(event) 319 320 const pkg = event.context.params?.pkg 321 if (!pkg) { 322 throw new HTTPError({ statusCode: 400, message: 'Missing package parameter' }) 323 } 324 325 const collaborators = stateManager.getPackageCollaborators(decodeURIComponent(pkg)) 326 return { success: true, data: collaborators ?? {} } satisfies ApiResponse< 327 ConnectorEndpoints['GET /package/:pkg/collaborators']['data'] 328 > 329 }) 330 331 // GET /user/packages 332 app.get('/user/packages', (event: H3Event) => { 333 requireAuth(event) 334 335 const packages = stateManager.getUserPackages() 336 return { success: true, data: packages } satisfies ApiResponse< 337 ConnectorEndpoints['GET /user/packages']['data'] 338 > 339 }) 340 341 // GET /user/orgs 342 app.get('/user/orgs', (event: H3Event) => { 343 requireAuth(event) 344 345 const orgs = stateManager.getUserOrgs() 346 return { success: true, data: orgs } satisfies ApiResponse< 347 ConnectorEndpoints['GET /user/orgs']['data'] 348 > 349 }) 350 351 // -- Test-only endpoints -- 352 353 // POST /__test__/reset 354 app.post('/__test__/reset', () => { 355 stateManager.reset() 356 return { success: true } 357 }) 358 359 // POST /__test__/org 360 app.post('/__test__/org', async (event: H3Event) => { 361 const body = (await event.req.json()) as { 362 org?: string 363 users?: Record<string, 'developer' | 'admin' | 'owner'> 364 teams?: string[] 365 teamMembers?: Record<string, string[]> 366 } 367 if (!body?.org) { 368 throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) 369 } 370 371 stateManager.setOrgData(body.org, { 372 users: body.users, 373 teams: body.teams, 374 teamMembers: body.teamMembers, 375 }) 376 377 return { success: true } 378 }) 379 380 // POST /__test__/user-orgs 381 app.post('/__test__/user-orgs', async (event: H3Event) => { 382 const body = (await event.req.json()) as { orgs?: string[] } 383 if (!body?.orgs) { 384 throw new HTTPError({ statusCode: 400, message: 'Missing orgs parameter' }) 385 } 386 387 stateManager.setUserOrgs(body.orgs) 388 return { success: true } 389 }) 390 391 // POST /__test__/user-packages 392 app.post('/__test__/user-packages', async (event: H3Event) => { 393 const body = (await event.req.json()) as { 394 packages?: Record<string, 'read-only' | 'read-write'> 395 } 396 if (!body?.packages) { 397 throw new HTTPError({ statusCode: 400, message: 'Missing packages parameter' }) 398 } 399 400 stateManager.setUserPackages(body.packages) 401 return { success: true } 402 }) 403 404 // POST /__test__/package 405 app.post('/__test__/package', async (event: H3Event) => { 406 const body = (await event.req.json()) as { 407 package?: string 408 collaborators?: Record<string, 'read-only' | 'read-write'> 409 } 410 if (!body?.package) { 411 throw new HTTPError({ statusCode: 400, message: 'Missing package parameter' }) 412 } 413 414 stateManager.setPackageData(body.package, { 415 collaborators: body.collaborators ?? {}, 416 }) 417 418 return { success: true } 419 }) 420 421 return app 422} 423 424/** Wraps the mock H3 app in an HTTP server via srvx. */ 425export class MockConnectorServer { 426 private server: Server | null = null 427 private stateManager: MockConnectorStateManager 428 429 constructor(stateManager: MockConnectorStateManager) { 430 this.stateManager = stateManager 431 } 432 433 async start(): Promise<void> { 434 if (this.server) { 435 throw new Error('Mock connector server is already running') 436 } 437 438 const app = createMockConnectorApp(this.stateManager) 439 440 this.server = serve({ 441 port: this.stateManager.port, 442 hostname: '127.0.0.1', 443 fetch: app.fetch, 444 }) 445 446 await this.server.ready() 447 console.log(`[Mock Connector] Started on http://127.0.0.1:${this.stateManager.port}`) 448 } 449 450 async stop(): Promise<void> { 451 if (!this.server) return 452 await this.server.close() 453 console.log('[Mock Connector] Stopped') 454 this.server = null 455 } 456 457 get state(): MockConnectorStateManager { 458 return this.stateManager 459 } 460 461 get port(): number { 462 return this.stateManager.port 463 } 464 465 get token(): string { 466 return this.stateManager.token 467 } 468 469 reset(): void { 470 this.stateManager.reset() 471 } 472}