forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}