@matware/e2e-runner 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
@@ -0,0 +1,816 @@
1
+ /**
2
+ * Hub Routes - Sync API Endpoints
3
+ *
4
+ * Provides REST endpoints for multi-instance sync when running in hub mode.
5
+ *
6
+ * Endpoints:
7
+ * - POST /api/sync/register - Register new agent
8
+ * - POST /api/sync/auth - Authenticate and get JWT
9
+ * - GET /api/sync/status - Get sync status
10
+ * - POST /api/sync/push - Push runs from agent
11
+ * - GET /api/sync/pull - Pull runs from other instances
12
+ * - GET /api/sync/instances - List instances (admin)
13
+ * - PATCH /api/sync/instances/:id - Update instance (admin)
14
+ * - GET /api/sync/screenshots/:hash - Get screenshot
15
+ * - POST /api/sync/screenshots - Upload screenshot
16
+ */
17
+
18
+ import {
19
+ generateApiKey,
20
+ generateTotpSecret,
21
+ generateTotpUri,
22
+ hashApiKey,
23
+ signJwt,
24
+ encrypt,
25
+ } from './auth.js';
26
+
27
+ import {
28
+ migrateSyncSchema,
29
+ createInstance,
30
+ getInstance,
31
+ getInstanceById,
32
+ listInstances,
33
+ updateInstanceStatus,
34
+ updateInstanceLastSeen,
35
+ deleteInstance,
36
+ logAudit,
37
+ queryAuditLog,
38
+ runExists,
39
+ getRemoteRuns,
40
+ cleanupNonces,
41
+ } from './schema.js';
42
+
43
+ import {
44
+ createAuthMiddleware,
45
+ createRateLimitMiddleware,
46
+ requirePermission,
47
+ authenticateWithCredentials,
48
+ getJwtSecret,
49
+ getMasterKey,
50
+ getClientIp,
51
+ generateRequestId,
52
+ } from './middleware.js';
53
+
54
+ import { getDb, ensureProject, persistRunFromSync } from '../db.js';
55
+ import fs from 'fs';
56
+ import path from 'path';
57
+ import crypto from 'crypto';
58
+
59
+ // ═══════════════════════════════════════════════════════════════════════════
60
+ // ROUTE HANDLER
61
+ // ═══════════════════════════════════════════════════════════════════════════
62
+
63
+ /**
64
+ * Handle sync API requests.
65
+ * @param {object} req - HTTP request
66
+ * @param {object} res - HTTP response
67
+ * @param {object} config - App config
68
+ * @param {string} pathname - URL pathname
69
+ * @returns {boolean} - true if handled, false if not a sync route
70
+ */
71
+ export async function handleSyncRoutes(req, res, config, pathname) {
72
+ // Only handle /api/sync/* routes
73
+ if (!pathname.startsWith('/api/sync')) {
74
+ return false;
75
+ }
76
+
77
+ const method = req.method;
78
+ const requestId = generateRequestId();
79
+ res.setHeader('X-Request-Id', requestId);
80
+
81
+ // Ensure schema is migrated
82
+ migrateSyncSchema();
83
+
84
+ // Apply rate limiting
85
+ const rateLimitMiddleware = createRateLimitMiddleware();
86
+ const rateLimitResult = await new Promise(resolve => {
87
+ rateLimitMiddleware(req, res, () => resolve(true));
88
+ // If middleware sent response, resolve will never be called
89
+ setTimeout(() => resolve(false), 0);
90
+ });
91
+ if (!rateLimitResult && res.writableEnded) return true;
92
+
93
+ try {
94
+ // ─── Public endpoints (no auth required) ───────────────────────────────
95
+
96
+ if (pathname === '/api/sync/register' && method === 'POST') {
97
+ return await handleRegister(req, res, config, requestId);
98
+ }
99
+
100
+ if (pathname === '/api/sync/auth' && method === 'POST') {
101
+ return await handleAuth(req, res, config, requestId);
102
+ }
103
+
104
+ // ─── Protected endpoints (auth required) ───────────────────────────────
105
+
106
+ // Apply auth middleware
107
+ const authMiddleware = createAuthMiddleware(config);
108
+ const authResult = await new Promise(resolve => {
109
+ authMiddleware(req, res, () => resolve(true));
110
+ setTimeout(() => resolve(false), 0);
111
+ });
112
+ if (!authResult && res.writableEnded) return true;
113
+
114
+ // Route to handlers
115
+ if (pathname === '/api/sync/status' && method === 'GET') {
116
+ return await handleStatus(req, res, config);
117
+ }
118
+
119
+ if (pathname === '/api/sync/push' && method === 'POST') {
120
+ return await handlePush(req, res, config, requestId);
121
+ }
122
+
123
+ if (pathname === '/api/sync/pull' && method === 'GET') {
124
+ return await handlePull(req, res, config);
125
+ }
126
+
127
+ if (pathname === '/api/sync/instances' && method === 'GET') {
128
+ // Require admin permission
129
+ if (!requirePermissionSync(req, res, 'instance:read')) return true;
130
+ return await handleListInstances(req, res, config);
131
+ }
132
+
133
+ const instanceMatch = pathname.match(/^\/api\/sync\/instances\/([^/]+)$/);
134
+ if (instanceMatch && method === 'PATCH') {
135
+ if (!requirePermissionSync(req, res, 'instance:write')) return true;
136
+ return await handleUpdateInstance(req, res, config, instanceMatch[1]);
137
+ }
138
+
139
+ if (instanceMatch && method === 'DELETE') {
140
+ if (!requirePermissionSync(req, res, 'instance:write')) return true;
141
+ return await handleDeleteInstance(req, res, config, instanceMatch[1]);
142
+ }
143
+
144
+ const screenshotMatch = pathname.match(/^\/api\/sync\/screenshots\/([a-f0-9]+)$/);
145
+ if (screenshotMatch && method === 'GET') {
146
+ return await handleGetScreenshot(req, res, config, screenshotMatch[1]);
147
+ }
148
+
149
+ if (pathname === '/api/sync/screenshots' && method === 'POST') {
150
+ return await handleUploadScreenshot(req, res, config);
151
+ }
152
+
153
+ if (pathname === '/api/sync/audit' && method === 'GET') {
154
+ if (!requirePermissionSync(req, res, 'audit:read')) return true;
155
+ return await handleAuditLog(req, res, config);
156
+ }
157
+
158
+ // Not found
159
+ jsonResponse(res, { error: 'Not found' }, 404);
160
+ return true;
161
+
162
+ } catch (err) {
163
+ console.error('[sync] Route error:', err);
164
+ logAudit({
165
+ instanceId: req.auth?.instanceId || 'unknown',
166
+ action: pathname,
167
+ status: 'error',
168
+ ipAddress: getClientIp(req),
169
+ requestId,
170
+ details: { error: err.message },
171
+ });
172
+ jsonResponse(res, { error: 'Internal server error' }, 500);
173
+ return true;
174
+ }
175
+ }
176
+
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+ // ENDPOINT HANDLERS
179
+ // ═══════════════════════════════════════════════════════════════════════════
180
+
181
+ /**
182
+ * POST /api/sync/register
183
+ * Register a new agent instance.
184
+ */
185
+ async function handleRegister(req, res, config, requestId) {
186
+ const body = await parseJsonBody(req);
187
+
188
+ if (!body.instanceId || !body.displayName) {
189
+ return jsonResponse(res, { error: 'Missing instanceId or displayName' }, 400);
190
+ }
191
+
192
+ // Check if registration is allowed
193
+ if (!config.sync?.hub?.allowRegistration) {
194
+ return jsonResponse(res, { error: 'Registration is disabled' }, 403);
195
+ }
196
+
197
+ // Check if instance already exists
198
+ if (getInstance(body.instanceId)) {
199
+ return jsonResponse(res, { error: 'Instance ID already registered' }, 409);
200
+ }
201
+
202
+ // Generate credentials
203
+ const apiKey = generateApiKey();
204
+ const totpSecret = generateTotpSecret();
205
+
206
+ // Encrypt TOTP secret if master key is available
207
+ const masterKey = getMasterKey(config);
208
+ const storedTotpSecret = masterKey ? encrypt(totpSecret, masterKey) : totpSecret;
209
+
210
+ // Determine initial status
211
+ const status = config.sync?.hub?.requireApproval ? 'pending' : 'active';
212
+
213
+ try {
214
+ const id = createInstance({
215
+ instanceId: body.instanceId,
216
+ displayName: body.displayName,
217
+ hostname: body.hostname || null,
218
+ environment: body.environment || 'development',
219
+ apiKeyHash: hashApiKey(apiKey),
220
+ totpSecret: storedTotpSecret,
221
+ role: body.role || 'member',
222
+ status,
223
+ });
224
+
225
+ logAudit({
226
+ instanceId: body.instanceId,
227
+ action: 'instance.register',
228
+ status: 'success',
229
+ ipAddress: getClientIp(req),
230
+ requestId,
231
+ details: { displayName: body.displayName, initialStatus: status },
232
+ });
233
+
234
+ // Return credentials (only shown once!)
235
+ jsonResponse(res, {
236
+ success: true,
237
+ instance: {
238
+ id,
239
+ instanceId: body.instanceId,
240
+ displayName: body.displayName,
241
+ status,
242
+ },
243
+ credentials: {
244
+ apiKey,
245
+ totpSecret,
246
+ totpUri: generateTotpUri(totpSecret, body.instanceId),
247
+ },
248
+ message: status === 'pending'
249
+ ? 'Instance registered. Waiting for admin approval.'
250
+ : 'Instance registered and active.',
251
+ });
252
+
253
+ } catch (err) {
254
+ console.error('[sync] Registration error:', err);
255
+ return jsonResponse(res, { error: 'Failed to register instance' }, 500);
256
+ }
257
+
258
+ return true;
259
+ }
260
+
261
+ /**
262
+ * POST /api/sync/auth
263
+ * Authenticate with API key + TOTP, receive JWT.
264
+ */
265
+ async function handleAuth(req, res, config, requestId) {
266
+ const body = await parseJsonBody(req);
267
+
268
+ const { instanceId, apiKey, totpCode, timestamp, nonce } = body;
269
+
270
+ if (!instanceId || !apiKey || !totpCode) {
271
+ return jsonResponse(res, { error: 'Missing instanceId, apiKey, or totpCode' }, 400);
272
+ }
273
+
274
+ const result = authenticateWithCredentials({
275
+ instanceId,
276
+ apiKey,
277
+ totpCode,
278
+ timestamp: timestamp || Date.now(),
279
+ nonce,
280
+ }, config);
281
+
282
+ if (!result.success) {
283
+ logAudit({
284
+ instanceId,
285
+ action: 'auth.login',
286
+ status: 'denied',
287
+ ipAddress: getClientIp(req),
288
+ requestId,
289
+ details: { error: result.error },
290
+ });
291
+ return jsonResponse(res, { error: result.error }, 401);
292
+ }
293
+
294
+ const instance = result.instance;
295
+ const jwtSecret = getJwtSecret(config);
296
+
297
+ // Generate tokens
298
+ const accessToken = signJwt({
299
+ sub: instance.instance_id,
300
+ role: instance.role,
301
+ dbId: instance.id,
302
+ }, jwtSecret, 3600); // 1 hour
303
+
304
+ const refreshToken = signJwt({
305
+ sub: instance.instance_id,
306
+ type: 'refresh',
307
+ dbId: instance.id,
308
+ }, jwtSecret, 86400 * 7); // 7 days
309
+
310
+ logAudit({
311
+ instanceId,
312
+ action: 'auth.login',
313
+ status: 'success',
314
+ ipAddress: getClientIp(req),
315
+ requestId,
316
+ });
317
+
318
+ // Update last seen
319
+ updateInstanceLastSeen(instanceId, getClientIp(req));
320
+
321
+ jsonResponse(res, {
322
+ accessToken,
323
+ refreshToken,
324
+ expiresIn: 3600,
325
+ tokenType: 'Bearer',
326
+ instance: {
327
+ instanceId: instance.instance_id,
328
+ displayName: instance.display_name,
329
+ role: instance.role,
330
+ },
331
+ });
332
+
333
+ return true;
334
+ }
335
+
336
+ /**
337
+ * GET /api/sync/status
338
+ * Get sync hub status.
339
+ */
340
+ async function handleStatus(req, res, config) {
341
+ const instances = listInstances();
342
+ const activeCount = instances.filter(i => i.status === 'active').length;
343
+ const onlineCount = instances.filter(i => {
344
+ if (!i.last_seen) return false;
345
+ const lastSeen = new Date(i.last_seen + 'Z').getTime();
346
+ return Date.now() - lastSeen < 5 * 60 * 1000; // 5 minutes
347
+ }).length;
348
+
349
+ jsonResponse(res, {
350
+ mode: 'hub',
351
+ instances: {
352
+ total: instances.length,
353
+ active: activeCount,
354
+ online: onlineCount,
355
+ },
356
+ caller: {
357
+ instanceId: req.auth.instanceId,
358
+ role: req.auth.role,
359
+ },
360
+ });
361
+
362
+ return true;
363
+ }
364
+
365
+ /**
366
+ * POST /api/sync/push
367
+ * Push runs from an agent.
368
+ */
369
+ async function handlePush(req, res, config, requestId) {
370
+ const body = await parseJsonBody(req);
371
+
372
+ const { project, runs, testResults, screenshots } = body;
373
+
374
+ if (!project || !runs || !Array.isArray(runs)) {
375
+ return jsonResponse(res, { error: 'Missing project or runs' }, 400);
376
+ }
377
+
378
+ const instanceDbId = req.auth.instanceDbId;
379
+ const db = getDb();
380
+ const syncedRuns = [];
381
+
382
+ try {
383
+ // Ensure project exists
384
+ const projectId = ensureProject(
385
+ `sync:${req.auth.instanceId}:${project.slug || project.name}`,
386
+ project.name,
387
+ null, // screenshotsDir
388
+ null // testsDir
389
+ );
390
+
391
+ // Process runs
392
+ for (const run of runs) {
393
+ // Check for duplicates
394
+ if (runExists(instanceDbId, run.runId)) {
395
+ continue; // Skip duplicate
396
+ }
397
+
398
+ // Insert run
399
+ const runDbId = persistRunFromSync({
400
+ projectId,
401
+ runId: run.runId,
402
+ total: run.total,
403
+ passed: run.passed,
404
+ failed: run.failed,
405
+ passRate: run.passRate,
406
+ duration: run.duration,
407
+ generatedAt: run.generatedAt,
408
+ suiteName: run.suiteName,
409
+ triggeredBy: run.triggeredBy,
410
+ syncInstanceId: instanceDbId,
411
+ syncOrigin: 'remote',
412
+ });
413
+
414
+ // Insert test results
415
+ if (testResults && Array.isArray(testResults)) {
416
+ const runResults = testResults.filter(tr => tr.runId === run.runId);
417
+ for (const result of runResults) {
418
+ db.prepare(`
419
+ INSERT INTO test_results (run_id, name, success, error, duration_ms, attempt, max_attempts, error_screenshot, console_logs, network_errors)
420
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
421
+ `).run(
422
+ runDbId,
423
+ result.name,
424
+ result.success ? 1 : 0,
425
+ result.error,
426
+ result.durationMs,
427
+ result.attempt || 1,
428
+ result.maxAttempts || 1,
429
+ result.errorScreenshot,
430
+ result.consoleLogs ? JSON.stringify(result.consoleLogs) : null,
431
+ result.networkErrors ? JSON.stringify(result.networkErrors) : null
432
+ );
433
+ }
434
+ }
435
+
436
+ syncedRuns.push({ runId: run.runId, dbId: runDbId });
437
+ }
438
+
439
+ // Handle screenshots
440
+ if (screenshots && Array.isArray(screenshots)) {
441
+ for (const ss of screenshots) {
442
+ if (ss.hash && ss.data) {
443
+ // Store screenshot
444
+ const screenshotsDir = config.screenshotsDir || path.join(process.env.HOME, '.e2e-runner', 'screenshots');
445
+ if (!fs.existsSync(screenshotsDir)) {
446
+ fs.mkdirSync(screenshotsDir, { recursive: true });
447
+ }
448
+
449
+ const ssPath = path.join(screenshotsDir, `${ss.hash}.png`);
450
+ if (!fs.existsSync(ssPath)) {
451
+ const buffer = Buffer.from(ss.data, 'base64');
452
+ fs.writeFileSync(ssPath, buffer);
453
+ }
454
+
455
+ // Record in sync_screenshots
456
+ db.prepare(`
457
+ INSERT OR IGNORE INTO sync_screenshots (hash, instance_id, storage_type, cached_path, size_bytes)
458
+ VALUES (?, ?, 'cached', ?, ?)
459
+ `).run(ss.hash, instanceDbId, ssPath, ss.data.length);
460
+ }
461
+ }
462
+ }
463
+
464
+ logAudit({
465
+ instanceId: req.auth.instanceId,
466
+ action: 'sync.push',
467
+ status: 'success',
468
+ ipAddress: getClientIp(req),
469
+ requestId,
470
+ details: { runsCount: syncedRuns.length, project: project.name },
471
+ });
472
+
473
+ jsonResponse(res, {
474
+ success: true,
475
+ synced: syncedRuns.length,
476
+ runs: syncedRuns,
477
+ });
478
+
479
+ } catch (err) {
480
+ console.error('[sync] Push error:', err);
481
+ logAudit({
482
+ instanceId: req.auth.instanceId,
483
+ action: 'sync.push',
484
+ status: 'error',
485
+ ipAddress: getClientIp(req),
486
+ requestId,
487
+ details: { error: err.message },
488
+ });
489
+ return jsonResponse(res, { error: 'Push failed: ' + err.message }, 500);
490
+ }
491
+
492
+ return true;
493
+ }
494
+
495
+ /**
496
+ * GET /api/sync/pull
497
+ * Pull runs from other instances.
498
+ */
499
+ async function handlePull(req, res, config) {
500
+ const url = new URL(req.url, 'http://localhost');
501
+ const since = url.searchParams.get('since');
502
+ const projectSlug = url.searchParams.get('project');
503
+ const limit = parseInt(url.searchParams.get('limit') || '50');
504
+
505
+ const db = getDb();
506
+ let query = `
507
+ SELECT r.*, p.name as project_name, si.instance_id as source_instance, si.display_name as source_display_name
508
+ FROM runs r
509
+ JOIN projects p ON r.project_id = p.id
510
+ LEFT JOIN sync_instances si ON r.sync_instance_id = si.id
511
+ WHERE r.sync_instance_id != ? OR r.sync_instance_id IS NULL
512
+ `;
513
+ const params = [req.auth.instanceDbId];
514
+
515
+ if (since) {
516
+ query += ` AND r.generated_at > ?`;
517
+ params.push(since);
518
+ }
519
+
520
+ if (projectSlug) {
521
+ query += ` AND p.name LIKE ?`;
522
+ params.push(`%${projectSlug}%`);
523
+ }
524
+
525
+ query += ` ORDER BY r.generated_at DESC LIMIT ?`;
526
+ params.push(limit);
527
+
528
+ const runs = db.prepare(query).all(...params);
529
+
530
+ // Get test results for each run
531
+ const runsWithResults = runs.map(run => {
532
+ const testResults = db.prepare(`
533
+ SELECT * FROM test_results WHERE run_id = ?
534
+ `).all(run.id);
535
+
536
+ return {
537
+ ...run,
538
+ testResults,
539
+ };
540
+ });
541
+
542
+ jsonResponse(res, {
543
+ runs: runsWithResults,
544
+ count: runsWithResults.length,
545
+ since,
546
+ });
547
+
548
+ return true;
549
+ }
550
+
551
+ /**
552
+ * GET /api/sync/instances
553
+ * List all registered instances.
554
+ */
555
+ async function handleListInstances(req, res, config) {
556
+ const url = new URL(req.url, 'http://localhost');
557
+ const status = url.searchParams.get('status');
558
+
559
+ const instances = listInstances(status).map(i => ({
560
+ id: i.id,
561
+ instanceId: i.instance_id,
562
+ displayName: i.display_name,
563
+ hostname: i.hostname,
564
+ environment: i.environment,
565
+ role: i.role,
566
+ status: i.status,
567
+ lastSeen: i.last_seen,
568
+ lastIp: i.last_ip,
569
+ createdAt: i.created_at,
570
+ approvedAt: i.approved_at,
571
+ }));
572
+
573
+ jsonResponse(res, { instances });
574
+ return true;
575
+ }
576
+
577
+ /**
578
+ * PATCH /api/sync/instances/:id
579
+ * Update instance status/role.
580
+ */
581
+ async function handleUpdateInstance(req, res, config, instanceId) {
582
+ const body = await parseJsonBody(req);
583
+ const instance = getInstance(instanceId);
584
+
585
+ if (!instance) {
586
+ return jsonResponse(res, { error: 'Instance not found' }, 404);
587
+ }
588
+
589
+ const db = getDb();
590
+ const updates = [];
591
+ const params = [];
592
+
593
+ if (body.status && ['pending', 'active', 'suspended'].includes(body.status)) {
594
+ updates.push('status = ?');
595
+ params.push(body.status);
596
+
597
+ if (body.status === 'active' && instance.status === 'pending') {
598
+ updates.push('approved_at = datetime("now")');
599
+ updates.push('approved_by = ?');
600
+ params.push(req.auth.instanceDbId);
601
+ }
602
+ }
603
+
604
+ if (body.role && ['admin', 'member', 'readonly'].includes(body.role)) {
605
+ updates.push('role = ?');
606
+ params.push(body.role);
607
+ }
608
+
609
+ if (body.displayName) {
610
+ updates.push('display_name = ?');
611
+ params.push(body.displayName);
612
+ }
613
+
614
+ if (updates.length === 0) {
615
+ return jsonResponse(res, { error: 'No valid updates provided' }, 400);
616
+ }
617
+
618
+ params.push(instanceId);
619
+ db.prepare(`UPDATE sync_instances SET ${updates.join(', ')} WHERE instance_id = ?`).run(...params);
620
+
621
+ logAudit({
622
+ instanceId: req.auth.instanceId,
623
+ action: 'instance.update',
624
+ resourceType: 'instance',
625
+ resourceId: instanceId,
626
+ status: 'success',
627
+ ipAddress: getClientIp(req),
628
+ details: body,
629
+ });
630
+
631
+ jsonResponse(res, { success: true, updated: instanceId });
632
+ return true;
633
+ }
634
+
635
+ /**
636
+ * DELETE /api/sync/instances/:id
637
+ * Delete an instance.
638
+ */
639
+ async function handleDeleteInstance(req, res, config, instanceId) {
640
+ if (instanceId === req.auth.instanceId) {
641
+ return jsonResponse(res, { error: 'Cannot delete yourself' }, 400);
642
+ }
643
+
644
+ const deleted = deleteInstance(instanceId);
645
+
646
+ if (!deleted) {
647
+ return jsonResponse(res, { error: 'Instance not found' }, 404);
648
+ }
649
+
650
+ logAudit({
651
+ instanceId: req.auth.instanceId,
652
+ action: 'instance.delete',
653
+ resourceType: 'instance',
654
+ resourceId: instanceId,
655
+ status: 'success',
656
+ ipAddress: getClientIp(req),
657
+ });
658
+
659
+ jsonResponse(res, { success: true, deleted: instanceId });
660
+ return true;
661
+ }
662
+
663
+ /**
664
+ * GET /api/sync/screenshots/:hash
665
+ * Get a screenshot by hash.
666
+ */
667
+ async function handleGetScreenshot(req, res, config, hash) {
668
+ const db = getDb();
669
+
670
+ // Check sync_screenshots first
671
+ const syncSs = db.prepare('SELECT * FROM sync_screenshots WHERE hash = ?').get(hash);
672
+ if (syncSs && syncSs.cached_path && fs.existsSync(syncSs.cached_path)) {
673
+ res.writeHead(200, { 'Content-Type': 'image/png' });
674
+ fs.createReadStream(syncSs.cached_path).pipe(res);
675
+ return true;
676
+ }
677
+
678
+ // Check local screenshot_hashes
679
+ const localSs = db.prepare('SELECT * FROM screenshot_hashes WHERE hash = ?').get(hash);
680
+ if (localSs && fs.existsSync(localSs.file_path)) {
681
+ res.writeHead(200, { 'Content-Type': 'image/png' });
682
+ fs.createReadStream(localSs.file_path).pipe(res);
683
+ return true;
684
+ }
685
+
686
+ jsonResponse(res, { error: 'Screenshot not found' }, 404);
687
+ return true;
688
+ }
689
+
690
+ /**
691
+ * POST /api/sync/screenshots
692
+ * Upload a screenshot.
693
+ */
694
+ async function handleUploadScreenshot(req, res, config) {
695
+ const body = await parseJsonBody(req);
696
+
697
+ if (!body.hash || !body.data) {
698
+ return jsonResponse(res, { error: 'Missing hash or data' }, 400);
699
+ }
700
+
701
+ const screenshotsDir = config.screenshotsDir || path.join(process.env.HOME, '.e2e-runner', 'screenshots');
702
+ if (!fs.existsSync(screenshotsDir)) {
703
+ fs.mkdirSync(screenshotsDir, { recursive: true });
704
+ }
705
+
706
+ const ssPath = path.join(screenshotsDir, `${body.hash}.png`);
707
+ const buffer = Buffer.from(body.data, 'base64');
708
+
709
+ // Verify hash
710
+ const actualHash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
711
+ if (actualHash !== body.hash) {
712
+ return jsonResponse(res, { error: 'Hash mismatch' }, 400);
713
+ }
714
+
715
+ fs.writeFileSync(ssPath, buffer);
716
+
717
+ const db = getDb();
718
+ db.prepare(`
719
+ INSERT OR IGNORE INTO sync_screenshots (hash, instance_id, storage_type, cached_path, size_bytes)
720
+ VALUES (?, ?, 'cached', ?, ?)
721
+ `).run(body.hash, req.auth.instanceDbId, ssPath, buffer.length);
722
+
723
+ jsonResponse(res, { success: true, hash: body.hash });
724
+ return true;
725
+ }
726
+
727
+ /**
728
+ * GET /api/sync/audit
729
+ * Query audit log.
730
+ */
731
+ async function handleAuditLog(req, res, config) {
732
+ const url = new URL(req.url, 'http://localhost');
733
+
734
+ const logs = queryAuditLog({
735
+ instanceId: url.searchParams.get('instance'),
736
+ action: url.searchParams.get('action'),
737
+ status: url.searchParams.get('status'),
738
+ since: url.searchParams.get('since'),
739
+ until: url.searchParams.get('until'),
740
+ limit: parseInt(url.searchParams.get('limit') || '100'),
741
+ });
742
+
743
+ jsonResponse(res, { logs });
744
+ return true;
745
+ }
746
+
747
+ // ═══════════════════════════════════════════════════════════════════════════
748
+ // HELPERS
749
+ // ═══════════════════════════════════════════════════════════════════════════
750
+
751
+ /**
752
+ * Parse JSON body from request.
753
+ */
754
+ function parseJsonBody(req) {
755
+ return new Promise((resolve, reject) => {
756
+ let body = '';
757
+ req.on('data', chunk => {
758
+ body += chunk;
759
+ if (body.length > 10 * 1024 * 1024) { // 10MB limit
760
+ reject(new Error('Body too large'));
761
+ }
762
+ });
763
+ req.on('end', () => {
764
+ try {
765
+ resolve(body ? JSON.parse(body) : {});
766
+ } catch (err) {
767
+ reject(new Error('Invalid JSON'));
768
+ }
769
+ });
770
+ req.on('error', reject);
771
+ });
772
+ }
773
+
774
+ /**
775
+ * Send JSON response.
776
+ */
777
+ function jsonResponse(res, data, status = 200) {
778
+ res.writeHead(status, { 'Content-Type': 'application/json' });
779
+ res.end(JSON.stringify(data));
780
+ }
781
+
782
+ /**
783
+ * Synchronous permission check.
784
+ */
785
+ function requirePermissionSync(req, res, permission) {
786
+ const { hasPermission } = require('./middleware.js');
787
+
788
+ if (!req.auth) {
789
+ jsonResponse(res, { error: 'Not authenticated' }, 401);
790
+ return false;
791
+ }
792
+
793
+ if (!hasPermission(req.auth.role, permission)) {
794
+ logAudit({
795
+ instanceId: req.auth.instanceId,
796
+ action: 'auth.authorize',
797
+ status: 'denied',
798
+ ipAddress: getClientIp(req),
799
+ details: { required: permission, role: req.auth.role },
800
+ });
801
+ jsonResponse(res, { error: `Permission denied: requires ${permission}` }, 403);
802
+ return false;
803
+ }
804
+
805
+ return true;
806
+ }
807
+
808
+ // Periodically clean up nonces (unref to not prevent process exit)
809
+ const nonceCleanupInterval = setInterval(() => {
810
+ try {
811
+ cleanupNonces();
812
+ } catch {
813
+ // Ignore errors during cleanup
814
+ }
815
+ }, 60000);
816
+ nonceCleanupInterval.unref();