@fias/plugin-dev-harness 1.1.5 → 1.1.6

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.
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Fias Arche Dev — Client-side Bridge Host
3
+ *
4
+ * Ported from the production PluginBridgeHost
5
+ * (client/src/arche/components/PluginBridge.ts).
6
+ *
7
+ * Handles postMessage communication with the plugin iframe,
8
+ * enforcing permissions and rate limits, and proxying server-side
9
+ * operations through the harness Express server.
10
+ */
11
+ (function () {
12
+ 'use strict';
13
+
14
+ var iframe = document.getElementById('plugin-iframe');
15
+ var consoleBody = document.getElementById('console-body');
16
+ var consoleCount = document.getElementById('console-count');
17
+ var consoleToggle = document.getElementById('console-toggle');
18
+ var creditBalance = document.getElementById('credit-balance');
19
+ var themeToggle = document.getElementById('theme-toggle');
20
+ var reloadBtn = document.getElementById('reload-btn');
21
+ var modeBadge = document.getElementById('mode-badge');
22
+ var pluginStatus = document.getElementById('plugin-status');
23
+ var themeBadge = document.getElementById('theme-badge');
24
+ var loginModal = document.getElementById('login-modal');
25
+ var loginInput = document.getElementById('login-input');
26
+ var loginError = document.getElementById('login-error');
27
+ var loginSubmit = document.getElementById('login-submit');
28
+ var loginCancel = document.getElementById('login-cancel');
29
+
30
+ var messageCount = 0;
31
+ var currentTheme = 'dark';
32
+ var currentMode = 'mock';
33
+ var hasCredentials = false;
34
+ var cachedConfig = null;
35
+
36
+ /** Permission requirements per bridge call type (matches production) */
37
+ var PERMISSION_MAP = {
38
+ get_user: 'user:profile:read',
39
+ get_theme: 'theme:read',
40
+ entity_invoke: 'entities:invoke',
41
+ storage_read: 'storage:sandbox',
42
+ storage_write: 'storage:sandbox',
43
+ storage_list: 'storage:sandbox',
44
+ storage_delete: 'storage:sandbox',
45
+ };
46
+
47
+ /** Rate limits per message type (matches production) */
48
+ var RATE_LIMITS = {
49
+ entity_invoke: { maxPerMinute: 60 },
50
+ storage_write: { maxPerMinute: 120 },
51
+ storage_read: { maxPerMinute: 300 },
52
+ storage_list: { maxPerMinute: 60 },
53
+ storage_delete: { maxPerMinute: 60 },
54
+ };
55
+ var rateBuckets = {};
56
+
57
+ // ────────────────────────────────────────────────────────────────
58
+ // Initialization
59
+ // ────────────────────────────────────────────────────────────────
60
+
61
+ fetchConfig().then(function (config) {
62
+ cachedConfig = config;
63
+ currentTheme = config.mockTheme || 'dark';
64
+ currentMode = config.mode || 'mock';
65
+ hasCredentials = config.hasCredentials || false;
66
+
67
+ updateThemeBadge();
68
+ updateModeBadge();
69
+
70
+ if (currentMode === 'live') {
71
+ creditBalance.style.display = 'inline';
72
+ fetchCredits();
73
+ }
74
+
75
+ // Check if plugin server is reachable, then load
76
+ checkPluginReachable(config.pluginUrl, function (reachable) {
77
+ if (reachable) {
78
+ pluginStatus.classList.add('hidden');
79
+ iframe.classList.remove('hidden');
80
+ iframe.src = config.pluginUrl;
81
+ } else {
82
+ showPluginError(config.pluginUrl);
83
+ }
84
+ });
85
+ });
86
+
87
+ // ────────────────────────────────────────────────────────────────
88
+ // UI Controls
89
+ // ────────────────────────────────────────────────────────────────
90
+
91
+ consoleToggle.addEventListener('click', function () {
92
+ consoleBody.classList.toggle('open');
93
+ });
94
+
95
+ reloadBtn.addEventListener('click', function () {
96
+ if (cachedConfig) {
97
+ // Re-check reachability on reload
98
+ pluginStatus.classList.remove('hidden', 'error');
99
+ pluginStatus.innerHTML =
100
+ '<div class="status-spinner"></div><p>Connecting to plugin server...</p>';
101
+ iframe.classList.add('hidden');
102
+ checkPluginReachable(cachedConfig.pluginUrl, function (reachable) {
103
+ if (reachable) {
104
+ pluginStatus.classList.add('hidden');
105
+ iframe.classList.remove('hidden');
106
+ iframe.src = cachedConfig.pluginUrl;
107
+ } else {
108
+ showPluginError(cachedConfig.pluginUrl);
109
+ }
110
+ });
111
+ } else {
112
+ iframe.src = iframe.src;
113
+ }
114
+ });
115
+
116
+ themeToggle.addEventListener('click', function () {
117
+ currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
118
+ updateThemeBadge();
119
+ sendToPlugin({
120
+ type: 'theme_update',
121
+ messageId: 'theme_' + Date.now(),
122
+ payload: getTheme(),
123
+ });
124
+ logMessage('send', 'theme_update', { mode: currentTheme });
125
+ });
126
+
127
+ // Mode toggle — click the badge to switch between mock/live
128
+ modeBadge.addEventListener('click', function () {
129
+ if (currentMode === 'mock') {
130
+ // Switching to live — need credentials
131
+ if (!hasCredentials) {
132
+ showLoginModal();
133
+ return;
134
+ }
135
+ switchMode('live');
136
+ } else {
137
+ switchMode('mock');
138
+ }
139
+ });
140
+
141
+ function switchMode(newMode) {
142
+ fetch('/api/mode', {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ mode: newMode }),
146
+ })
147
+ .then(function (r) {
148
+ if (!r.ok) {
149
+ return r.json().then(function (err) {
150
+ throw new Error(err.error || 'Failed to switch mode');
151
+ });
152
+ }
153
+ return r.json();
154
+ })
155
+ .then(function (data) {
156
+ currentMode = data.mode;
157
+ updateModeBadge();
158
+ logMessage('info', 'Mode switched to ' + currentMode.toUpperCase());
159
+
160
+ if (currentMode === 'live') {
161
+ creditBalance.style.display = 'inline';
162
+ fetchCredits();
163
+ } else {
164
+ creditBalance.style.display = 'none';
165
+ creditBalance.textContent = '';
166
+ }
167
+ })
168
+ .catch(function (err) {
169
+ logMessage('error', err.message);
170
+ });
171
+ }
172
+
173
+ // ────────────────────────────────────────────────────────────────
174
+ // Login Modal
175
+ // ────────────────────────────────────────────────────────────────
176
+
177
+ function showLoginModal() {
178
+ loginModal.style.display = 'flex';
179
+ loginInput.value = '';
180
+ loginError.style.display = 'none';
181
+ loginInput.focus();
182
+ }
183
+
184
+ function hideLoginModal() {
185
+ loginModal.style.display = 'none';
186
+ loginInput.value = '';
187
+ loginError.style.display = 'none';
188
+ }
189
+
190
+ loginCancel.addEventListener('click', hideLoginModal);
191
+
192
+ loginModal.addEventListener('click', function (e) {
193
+ if (e.target === loginModal) hideLoginModal();
194
+ });
195
+
196
+ loginInput.addEventListener('keydown', function (e) {
197
+ if (e.key === 'Enter') submitLogin();
198
+ if (e.key === 'Escape') hideLoginModal();
199
+ });
200
+
201
+ loginSubmit.addEventListener('click', submitLogin);
202
+
203
+ function submitLogin() {
204
+ var apiKey = loginInput.value.trim();
205
+ if (!apiKey) {
206
+ loginError.textContent = 'Please enter an API key.';
207
+ loginError.style.display = 'block';
208
+ return;
209
+ }
210
+
211
+ loginSubmit.disabled = true;
212
+ loginSubmit.textContent = 'Connecting...';
213
+
214
+ fetch('/api/login', {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ apiKey: apiKey }),
218
+ })
219
+ .then(function (r) {
220
+ if (!r.ok) {
221
+ return r.json().then(function (err) {
222
+ throw new Error(err.error || 'Login failed');
223
+ });
224
+ }
225
+ return r.json();
226
+ })
227
+ .then(function () {
228
+ hasCredentials = true;
229
+ hideLoginModal();
230
+ logMessage('info', 'API key saved successfully');
231
+ // Automatically switch to live mode after login
232
+ switchMode('live');
233
+ })
234
+ .catch(function (err) {
235
+ loginError.textContent = err.message;
236
+ loginError.style.display = 'block';
237
+ })
238
+ .finally(function () {
239
+ loginSubmit.disabled = false;
240
+ loginSubmit.textContent = 'Save & Connect';
241
+ });
242
+ }
243
+
244
+ // ────────────────────────────────────────────────────────────────
245
+ // Message Handling
246
+ // ────────────────────────────────────────────────────────────────
247
+
248
+ window.addEventListener('message', function (event) {
249
+ if (event.source !== iframe.contentWindow) return;
250
+
251
+ var data = event.data;
252
+ if (!data || typeof data !== 'object' || !data.type || !data.messageId) return;
253
+
254
+ logMessage('recv', data.type, data.payload);
255
+
256
+ // Fire-and-forget messages
257
+ if (data.type === 'ready') return;
258
+
259
+ if (data.type === 'resize') {
260
+ var height = data.payload && data.payload.height;
261
+ if (typeof height === 'number' && height > 0) {
262
+ iframe.style.height = height + 'px';
263
+ iframe.style.flex = 'none';
264
+ }
265
+ return;
266
+ }
267
+
268
+ if (data.type === 'toast') {
269
+ var msg = data.payload && data.payload.message;
270
+ if (typeof msg === 'string') {
271
+ logMessage('toast', msg, data.payload);
272
+ }
273
+ return;
274
+ }
275
+
276
+ if (data.type === 'navigate') {
277
+ var navPath = data.payload && data.payload.path;
278
+ if (typeof navPath === 'string') {
279
+ logMessage('nav', navPath);
280
+ }
281
+ return;
282
+ }
283
+
284
+ // Request/response messages — enforce permissions + rate limits, then proxy
285
+ handleRequest(data);
286
+ });
287
+
288
+ function handleRequest(data) {
289
+ try {
290
+ // Check permissions
291
+ var requiredPerm = PERMISSION_MAP[data.type];
292
+ if (requiredPerm && cachedConfig && cachedConfig.permissions.indexOf(requiredPerm) === -1) {
293
+ throw new Error('Permission denied: ' + requiredPerm + ' not granted');
294
+ }
295
+
296
+ // Check rate limit
297
+ checkRateLimit(data.type);
298
+ } catch (err) {
299
+ logMessage('error', err.message);
300
+ sendToPlugin({
301
+ type: 'response',
302
+ messageId: data.messageId,
303
+ payload: null,
304
+ error: err.message,
305
+ });
306
+ return;
307
+ }
308
+
309
+ // Proxy to harness server
310
+ fetch('/api/bridge', {
311
+ method: 'POST',
312
+ headers: { 'Content-Type': 'application/json' },
313
+ body: JSON.stringify({ type: data.type, payload: data.payload }),
314
+ })
315
+ .then(function (response) {
316
+ if (!response.ok) {
317
+ return response.json().then(function (err) {
318
+ throw new Error(err.error || 'Bridge call failed');
319
+ });
320
+ }
321
+ return response.json();
322
+ })
323
+ .then(function (result) {
324
+ logMessage('send', 'response', result);
325
+
326
+ // Show cost for entity invocations in live mode
327
+ if (
328
+ data.type === 'entity_invoke' &&
329
+ result.metadata &&
330
+ result.metadata.cost > 0
331
+ ) {
332
+ logMessage('cost', 'Credits used: ' + result.metadata.cost.toFixed(4));
333
+ fetchCredits();
334
+ }
335
+
336
+ sendToPlugin({
337
+ type: 'response',
338
+ messageId: data.messageId,
339
+ payload: result,
340
+ });
341
+ })
342
+ .catch(function (err) {
343
+ logMessage('error', err.message);
344
+ sendToPlugin({
345
+ type: 'response',
346
+ messageId: data.messageId,
347
+ payload: null,
348
+ error: err.message,
349
+ });
350
+ });
351
+ }
352
+
353
+ // ────────────────────────────────────────────────────────────────
354
+ // Iframe Init
355
+ // ────────────────────────────────────────────────────────────────
356
+
357
+ iframe.addEventListener('load', function () {
358
+ if (!cachedConfig) return;
359
+
360
+ sendToPlugin({
361
+ type: 'init',
362
+ messageId: 'init_0',
363
+ payload: {
364
+ archId: 'dev_harness',
365
+ permissions: cachedConfig.permissions,
366
+ theme: getTheme(),
367
+ currentPath: '/',
368
+ },
369
+ });
370
+ logMessage('send', 'init');
371
+ });
372
+
373
+ // ────────────────────────────────────────────────────────────────
374
+ // Helpers
375
+ // ────────────────────────────────────────────────────────────────
376
+
377
+ function updateModeBadge() {
378
+ var opposite = currentMode === 'live' ? 'Mock' : 'Live';
379
+ modeBadge.textContent = currentMode.toUpperCase() + ' \u21C6';
380
+ modeBadge.className = 'mode-badge ' + (currentMode === 'live' ? 'mode-live' : 'mode-mock');
381
+ modeBadge.title = 'Click to switch to ' + opposite + ' mode';
382
+ }
383
+
384
+ function updateThemeBadge() {
385
+ themeBadge.textContent = currentTheme.toUpperCase();
386
+ themeBadge.className = 'theme-badge theme-' + currentTheme;
387
+ document.body.style.background = currentTheme === 'light' ? '#ffffff' : '#0a0a0a';
388
+ }
389
+
390
+ function sendToPlugin(message) {
391
+ // targetOrigin '*' because the plugin may be on a different port.
392
+ // Security is enforced by checking event.source on incoming messages.
393
+ iframe.contentWindow && iframe.contentWindow.postMessage(message, '*');
394
+ }
395
+
396
+ function checkRateLimit(type) {
397
+ var limit = RATE_LIMITS[type];
398
+ if (!limit) return;
399
+
400
+ var now = Date.now();
401
+ var bucket = rateBuckets[type];
402
+
403
+ if (!bucket || now - bucket.windowStart > 60000) {
404
+ rateBuckets[type] = { count: 1, windowStart: now };
405
+ return;
406
+ }
407
+
408
+ if (bucket.count >= limit.maxPerMinute) {
409
+ throw new Error(
410
+ 'Rate limit exceeded for ' + type + ': max ' + limit.maxPerMinute + '/minute',
411
+ );
412
+ }
413
+
414
+ bucket.count++;
415
+ }
416
+
417
+ var SYSTEM_FONTS = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
418
+ var MONO_FONTS = '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace';
419
+ var SPACING = { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '32px' };
420
+ var FONTS = { body: SYSTEM_FONTS, heading: SYSTEM_FONTS, mono: MONO_FONTS };
421
+
422
+ function getTheme() {
423
+ if (currentTheme === 'light') {
424
+ return {
425
+ mode: 'light',
426
+ colors: {
427
+ primary: '#171717', secondary: '#e5e5e5',
428
+ background: '#ffffff', surface: '#fafafa',
429
+ text: '#0a0a0a', textSecondary: '#737373',
430
+ border: '#e5e5e5',
431
+ error: '#dc2626', warning: '#d97706', success: '#16a34a',
432
+ },
433
+ spacing: SPACING,
434
+ fonts: FONTS,
435
+ };
436
+ }
437
+ return {
438
+ mode: 'dark',
439
+ colors: {
440
+ primary: '#ffffff', secondary: '#1f1f1f',
441
+ background: '#0a0a0a', surface: '#171717',
442
+ text: '#ffffff', textSecondary: '#a6a6a6',
443
+ border: '#2e2e2e',
444
+ error: '#ef4444', warning: '#f59e0b', success: '#22c55e',
445
+ },
446
+ spacing: SPACING,
447
+ fonts: FONTS,
448
+ };
449
+ }
450
+
451
+ function fetchConfig() {
452
+ return fetch('/api/config').then(function (r) {
453
+ return r.json();
454
+ });
455
+ }
456
+
457
+ function fetchCredits() {
458
+ fetch('/api/credits')
459
+ .then(function (r) {
460
+ return r.json();
461
+ })
462
+ .then(function (data) {
463
+ if (data.balance !== undefined && data.balance !== null && isFinite(data.balance)) {
464
+ creditBalance.textContent = 'Credits: ' + data.balance.toFixed(2);
465
+ }
466
+ })
467
+ .catch(function () {});
468
+ }
469
+
470
+ function logMessage(direction, type, payload) {
471
+ messageCount++;
472
+ consoleCount.textContent = messageCount + ' messages';
473
+
474
+ var entry = document.createElement('div');
475
+ entry.className = 'log-entry';
476
+
477
+ var time = new Date().toLocaleTimeString();
478
+ var cls = 'log-info';
479
+ if (direction === 'error') cls = 'log-error';
480
+ if (direction === 'warn' || direction === 'cost') cls = 'log-warn';
481
+ if (direction === 'toast') cls = 'log-warn';
482
+
483
+ var text = '<span class="log-time">' + escapeHtml(time) + '</span>';
484
+ text +=
485
+ '<span class="' +
486
+ cls +
487
+ '">[' +
488
+ escapeHtml(direction.toUpperCase()) +
489
+ '] ' +
490
+ escapeHtml(String(type)) +
491
+ '</span>';
492
+ if (payload && typeof payload === 'object') {
493
+ text +=
494
+ ' <span style="color:#6b7280">' +
495
+ escapeHtml(JSON.stringify(payload).substring(0, 120)) +
496
+ '</span>';
497
+ }
498
+
499
+ entry.innerHTML = text;
500
+ consoleBody.appendChild(entry);
501
+ consoleBody.scrollTop = consoleBody.scrollHeight;
502
+ }
503
+
504
+ function escapeHtml(str) {
505
+ var div = document.createElement('div');
506
+ div.appendChild(document.createTextNode(str));
507
+ return div.innerHTML;
508
+ }
509
+
510
+ function checkPluginReachable(url, callback) {
511
+ var done = false;
512
+
513
+ // Try fetching through the harness server to avoid CORS issues
514
+ fetch('/api/check-plugin')
515
+ .then(function (r) { return r.json(); })
516
+ .then(function (data) {
517
+ if (!done) {
518
+ done = true;
519
+ callback(data.reachable);
520
+ }
521
+ })
522
+ .catch(function () {
523
+ if (!done) {
524
+ done = true;
525
+ // If the check endpoint doesn't exist, assume reachable and let iframe handle it
526
+ callback(true);
527
+ }
528
+ });
529
+
530
+ // Timeout after 5 seconds
531
+ setTimeout(function () {
532
+ if (!done) {
533
+ done = true;
534
+ callback(false);
535
+ }
536
+ }, 5000);
537
+ }
538
+
539
+ function showPluginError(url) {
540
+ pluginStatus.classList.add('error');
541
+ pluginStatus.innerHTML =
542
+ '<p><strong>Plugin server not reachable</strong></p>' +
543
+ '<p>The harness cannot connect to your plugin at<br/><code>' + escapeHtml(url) + '</code></p>' +
544
+ '<p>Make sure your plugin dev server is running:</p>' +
545
+ '<p><code>npm run dev</code></p>' +
546
+ '<p style="color:#6b7280;font-size:12px;margin-top:8px;">' +
547
+ 'The harness will automatically retry when you click the reload button (&#8635;).</p>';
548
+ iframe.classList.add('hidden');
549
+ }
550
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fias/plugin-dev-harness",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Development harness for building and testing FIAS plugin arches locally",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -30,14 +30,14 @@
30
30
  ],
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
+ "@fias/arche-sdk": "workspace:*",
34
+ "chalk": "^4.1.2",
33
35
  "commander": "^12.0.0",
34
- "express": "^4.21.0",
35
- "open": "^10.0.0",
36
- "chalk": "^4.1.2"
36
+ "express": "^4.21.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/express": "^5.0.0",
40
- "@types/node": "^25.0.0",
40
+ "@types/node": "^25.5.0",
41
41
  "typescript": "^5.3.3"
42
42
  }
43
43
  }