@fias/plugin-dev-harness 1.4.0 → 1.4.2

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.
@@ -1,603 +0,0 @@
1
- /**
2
- * Fias Arche Dev — Client-side Bridge Host
3
- *
4
- * Handles postMessage communication with the plugin iframe,
5
- * enforcing permissions and rate limits, and proxying server-side
6
- * operations through the harness Express server.
7
- */
8
- (function () {
9
- 'use strict';
10
-
11
- var iframe = document.getElementById('plugin-iframe');
12
- var consoleBody = document.getElementById('console-body');
13
- var consoleCount = document.getElementById('console-count');
14
- var consoleToggle = document.getElementById('console-toggle');
15
- var creditBalance = document.getElementById('credit-balance');
16
- var themeToggle = document.getElementById('theme-toggle');
17
- var reloadBtn = document.getElementById('reload-btn');
18
- var modeBadge = document.getElementById('mode-badge');
19
- var pluginStatus = document.getElementById('plugin-status');
20
- var themeBadge = document.getElementById('theme-badge');
21
- var loginModal = document.getElementById('login-modal');
22
- var loginEmail = document.getElementById('login-email');
23
- var loginPassword = document.getElementById('login-password');
24
- var loginError = document.getElementById('login-error');
25
- var loginSubmit = document.getElementById('login-submit');
26
- var loginCancel = document.getElementById('login-cancel');
27
- var envSelector = document.getElementById('env-selector');
28
-
29
- var messageCount = 0;
30
- var currentTheme = 'dark';
31
- var currentMode = 'mock';
32
- var currentEnvironment = 'staging';
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
- currentEnvironment = config.environment || 'staging';
66
- hasCredentials = config.hasCredentials || false;
67
-
68
- envSelector.value = currentEnvironment;
69
- updateThemeBadge();
70
- updateModeBadge();
71
-
72
- if (currentMode === 'live') {
73
- creditBalance.style.display = 'inline';
74
- fetchCredits();
75
- }
76
-
77
- checkPluginReachable(config.pluginUrl, function (reachable) {
78
- if (reachable) {
79
- pluginStatus.classList.add('hidden');
80
- iframe.classList.remove('hidden');
81
- iframe.src = config.pluginUrl;
82
- } else {
83
- showPluginError(config.pluginUrl);
84
- }
85
- });
86
- });
87
-
88
- // ────────────────────────────────────────────────────────────────
89
- // UI Controls
90
- // ────────────────────────────────────────────────────────────────
91
-
92
- consoleToggle.addEventListener('click', function () {
93
- consoleBody.classList.toggle('open');
94
- });
95
-
96
- reloadBtn.addEventListener('click', function () {
97
- if (cachedConfig) {
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
128
- modeBadge.addEventListener('click', function () {
129
- if (currentMode === 'mock') {
130
- if (!hasCredentials) {
131
- showLoginModal();
132
- return;
133
- }
134
- switchMode('live');
135
- } else {
136
- switchMode('mock');
137
- }
138
- });
139
-
140
- // Environment selector
141
- envSelector.addEventListener('change', function () {
142
- fetch('/api/environment', {
143
- method: 'POST',
144
- headers: { 'Content-Type': 'application/json' },
145
- body: JSON.stringify({ environment: envSelector.value }),
146
- })
147
- .then(function (r) {
148
- return r.json();
149
- })
150
- .then(function (data) {
151
- currentEnvironment = data.environment;
152
- currentMode = data.mode;
153
- hasCredentials = data.hasCredentials;
154
- updateModeBadge();
155
-
156
- if (currentMode === 'live') {
157
- creditBalance.style.display = 'inline';
158
- fetchCredits();
159
- } else {
160
- creditBalance.style.display = 'none';
161
- creditBalance.textContent = '';
162
- }
163
-
164
- logMessage(
165
- 'info',
166
- 'Environment: ' +
167
- currentEnvironment.toUpperCase() +
168
- (hasCredentials ? '' : ' (not authenticated)'),
169
- );
170
- })
171
- .catch(function (err) {
172
- logMessage('error', err.message);
173
- });
174
- });
175
-
176
- function switchMode(newMode) {
177
- fetch('/api/mode', {
178
- method: 'POST',
179
- headers: { 'Content-Type': 'application/json' },
180
- body: JSON.stringify({ mode: newMode }),
181
- })
182
- .then(function (r) {
183
- if (!r.ok) {
184
- return r.json().then(function (err) {
185
- throw new Error(err.error || 'Failed to switch mode');
186
- });
187
- }
188
- return r.json();
189
- })
190
- .then(function (data) {
191
- currentMode = data.mode;
192
- updateModeBadge();
193
- logMessage('info', 'Mode switched to ' + currentMode.toUpperCase());
194
-
195
- if (currentMode === 'live') {
196
- creditBalance.style.display = 'inline';
197
- fetchCredits();
198
- } else {
199
- creditBalance.style.display = 'none';
200
- creditBalance.textContent = '';
201
- }
202
- })
203
- .catch(function (err) {
204
- logMessage('error', err.message);
205
- });
206
- }
207
-
208
- // ────────────────────────────────────────────────────────────────
209
- // Login Modal
210
- // ────────────────────────────────────────────────────────────────
211
-
212
- var loginTarget = document.getElementById('login-target');
213
-
214
- function showLoginModal() {
215
- loginModal.style.display = 'flex';
216
- loginEmail.value = '';
217
- loginPassword.value = '';
218
- loginError.style.display = 'none';
219
- loginSubmit.disabled = false;
220
- loginSubmit.textContent = 'Sign in';
221
- loginTarget.textContent = currentEnvironment === 'production' ? 'fias.io' : 'staging.fias.io';
222
- loginEmail.focus();
223
- }
224
-
225
- function hideLoginModal() {
226
- loginModal.style.display = 'none';
227
- }
228
-
229
- loginCancel.addEventListener('click', hideLoginModal);
230
-
231
- loginModal.addEventListener('click', function (e) {
232
- if (e.target === loginModal) hideLoginModal();
233
- });
234
-
235
- loginEmail.addEventListener('keydown', function (e) {
236
- if (e.key === 'Enter') loginPassword.focus();
237
- if (e.key === 'Escape') hideLoginModal();
238
- });
239
-
240
- loginPassword.addEventListener('keydown', function (e) {
241
- if (e.key === 'Enter') submitLogin();
242
- if (e.key === 'Escape') hideLoginModal();
243
- });
244
-
245
- loginSubmit.addEventListener('click', submitLogin);
246
-
247
- function submitLogin() {
248
- var email = loginEmail.value.trim();
249
- var password = loginPassword.value;
250
-
251
- if (!email || !password) {
252
- loginError.textContent = 'Email and password are required.';
253
- loginError.style.display = 'block';
254
- return;
255
- }
256
-
257
- loginSubmit.disabled = true;
258
- loginSubmit.textContent = 'Signing in...';
259
- loginError.style.display = 'none';
260
-
261
- fetch('/api/auth/login', {
262
- method: 'POST',
263
- headers: { 'Content-Type': 'application/json' },
264
- body: JSON.stringify({ email: email, password: password }),
265
- })
266
- .then(function (r) {
267
- if (!r.ok) {
268
- return r.json().then(function (err) {
269
- throw new Error(err.error || 'Sign in failed');
270
- });
271
- }
272
- return r.json();
273
- })
274
- .then(function () {
275
- hasCredentials = true;
276
- hideLoginModal();
277
- logMessage('info', 'Signed in for ' + currentEnvironment.toUpperCase());
278
- switchMode('live');
279
- })
280
- .catch(function (err) {
281
- loginError.textContent = err.message;
282
- loginError.style.display = 'block';
283
- })
284
- .finally(function () {
285
- loginSubmit.disabled = false;
286
- loginSubmit.textContent = 'Sign in';
287
- });
288
- }
289
-
290
- // ────────────────────────────────────────────────────────────────
291
- // Message Handling
292
- // ────────────────────────────────────────────────────────────────
293
-
294
- window.addEventListener('message', function (event) {
295
- if (event.source !== iframe.contentWindow) return;
296
-
297
- var data = event.data;
298
- if (!data || typeof data !== 'object' || !data.type || !data.messageId) return;
299
-
300
- logMessage('recv', data.type, data.payload);
301
-
302
- if (data.type === 'ready') return;
303
-
304
- if (data.type === 'resize') {
305
- var height = data.payload && data.payload.height;
306
- if (typeof height === 'number' && height > 0) {
307
- iframe.style.height = height + 'px';
308
- iframe.style.flex = 'none';
309
- }
310
- return;
311
- }
312
-
313
- if (data.type === 'toast') {
314
- var msg = data.payload && data.payload.message;
315
- if (typeof msg === 'string') {
316
- logMessage('toast', msg, data.payload);
317
- }
318
- return;
319
- }
320
-
321
- if (data.type === 'navigate') {
322
- var navPath = data.payload && data.payload.path;
323
- if (typeof navPath === 'string') {
324
- logMessage('nav', navPath);
325
- }
326
- return;
327
- }
328
-
329
- handleRequest(data);
330
- });
331
-
332
- function handleRequest(data) {
333
- try {
334
- var requiredPerm = PERMISSION_MAP[data.type];
335
- if (requiredPerm && cachedConfig && cachedConfig.permissions.indexOf(requiredPerm) === -1) {
336
- throw new Error('Permission denied: ' + requiredPerm + ' not granted');
337
- }
338
- checkRateLimit(data.type);
339
- } catch (err) {
340
- logMessage('error', err.message);
341
- sendToPlugin({
342
- type: 'response',
343
- messageId: data.messageId,
344
- payload: null,
345
- error: err.message,
346
- });
347
- return;
348
- }
349
-
350
- fetch('/api/bridge', {
351
- method: 'POST',
352
- headers: { 'Content-Type': 'application/json' },
353
- body: JSON.stringify({ type: data.type, payload: data.payload }),
354
- })
355
- .then(function (response) {
356
- if (!response.ok) {
357
- return response.json().then(function (err) {
358
- throw new Error(err.error || 'Bridge call failed');
359
- });
360
- }
361
- return response.json();
362
- })
363
- .then(function (result) {
364
- logMessage('send', 'response', result);
365
-
366
- if (
367
- data.type === 'entity_invoke' &&
368
- result.metadata &&
369
- result.metadata.cost > 0
370
- ) {
371
- logMessage('cost', 'Credits used: ' + result.metadata.cost.toFixed(4));
372
- fetchCredits();
373
- }
374
-
375
- sendToPlugin({
376
- type: 'response',
377
- messageId: data.messageId,
378
- payload: result,
379
- });
380
- })
381
- .catch(function (err) {
382
- logMessage('error', err.message);
383
- sendToPlugin({
384
- type: 'response',
385
- messageId: data.messageId,
386
- payload: null,
387
- error: err.message,
388
- });
389
- });
390
- }
391
-
392
- // ────────────────────────────────────────────────────────────────
393
- // Iframe Init
394
- // ────────────────────────────────────────────────────────────────
395
-
396
- iframe.addEventListener('load', function () {
397
- if (!cachedConfig) return;
398
-
399
- sendToPlugin({
400
- type: 'init',
401
- messageId: 'init_0',
402
- payload: {
403
- archId: 'dev_harness',
404
- permissions: cachedConfig.permissions,
405
- theme: getTheme(),
406
- currentPath: '/',
407
- },
408
- });
409
- logMessage('send', 'init');
410
- });
411
-
412
- // ────────────────────────────────────────────────────────────────
413
- // Helpers
414
- // ────────────────────────────────────────────────────────────────
415
-
416
- function updateModeBadge() {
417
- var opposite = currentMode === 'live' ? 'Mock' : 'Live';
418
- modeBadge.textContent = currentMode.toUpperCase() + ' \u21C6';
419
- modeBadge.className = 'mode-badge ' + (currentMode === 'live' ? 'mode-live' : 'mode-mock');
420
- modeBadge.title = 'Click to switch to ' + opposite + ' mode';
421
- }
422
-
423
- function updateThemeBadge() {
424
- themeBadge.textContent = currentTheme.toUpperCase();
425
- themeBadge.className = 'theme-badge theme-' + currentTheme;
426
- document.body.style.background = currentTheme === 'light' ? '#ffffff' : '#0a0a0a';
427
- }
428
-
429
- function sendToPlugin(message) {
430
- iframe.contentWindow && iframe.contentWindow.postMessage(message, '*');
431
- }
432
-
433
- function checkRateLimit(type) {
434
- var limit = RATE_LIMITS[type];
435
- if (!limit) return;
436
-
437
- var now = Date.now();
438
- var bucket = rateBuckets[type];
439
-
440
- if (!bucket || now - bucket.windowStart > 60000) {
441
- rateBuckets[type] = { count: 1, windowStart: now };
442
- return;
443
- }
444
-
445
- if (bucket.count >= limit.maxPerMinute) {
446
- throw new Error(
447
- 'Rate limit exceeded for ' + type + ': max ' + limit.maxPerMinute + '/minute',
448
- );
449
- }
450
-
451
- bucket.count++;
452
- }
453
-
454
- var SYSTEM_FONTS = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
455
- var MONO_FONTS = '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace';
456
- var SPACING = { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '32px' };
457
- var FONTS = { body: SYSTEM_FONTS, heading: SYSTEM_FONTS, mono: MONO_FONTS };
458
-
459
- var COMPONENTS = {
460
- borderRadius: '0.5rem',
461
- buttonRadius: '0.375rem',
462
- cardRadius: '0.5rem',
463
- inputRadius: '0.375rem',
464
- shadowSm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
465
- shadowMd: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
466
- shadowLg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
467
- borderWidth: '1px',
468
- };
469
-
470
- function getTheme() {
471
- if (currentTheme === 'light') {
472
- return {
473
- mode: 'light',
474
- colors: {
475
- primary: '#171717', primaryText: '#ffffff',
476
- secondary: '#e5e5e5', accent: '#2563eb',
477
- background: '#ffffff', surface: '#fafafa',
478
- card: '#ffffff', cardText: '#0a0a0a',
479
- text: '#0a0a0a', textSecondary: '#737373',
480
- muted: '#f5f5f5', mutedText: '#a3a3a3',
481
- border: '#e5e5e5',
482
- error: '#dc2626', warning: '#d97706', success: '#16a34a', info: '#2563eb',
483
- },
484
- spacing: SPACING,
485
- fonts: FONTS,
486
- components: COMPONENTS,
487
- };
488
- }
489
- return {
490
- mode: 'dark',
491
- colors: {
492
- primary: '#ffffff', primaryText: '#0a0a0a',
493
- secondary: '#1f1f1f', accent: '#3b82f6',
494
- background: '#0a0a0a', surface: '#171717',
495
- card: '#141414', cardText: '#ffffff',
496
- text: '#ffffff', textSecondary: '#a6a6a6',
497
- muted: '#1e1e1e', mutedText: '#737373',
498
- border: '#2e2e2e',
499
- error: '#ef4444', warning: '#f59e0b', success: '#22c55e', info: '#3b82f6',
500
- },
501
- spacing: SPACING,
502
- fonts: FONTS,
503
- components: COMPONENTS,
504
- };
505
- }
506
-
507
- function fetchConfig() {
508
- return fetch('/api/config').then(function (r) {
509
- return r.json();
510
- });
511
- }
512
-
513
- function fetchCredits() {
514
- fetch('/api/credits')
515
- .then(function (r) {
516
- return r.json();
517
- })
518
- .then(function (data) {
519
- if (data.balance !== undefined && data.balance !== null && isFinite(data.balance)) {
520
- creditBalance.textContent = 'Credits: ' + data.balance.toFixed(2);
521
- }
522
- })
523
- .catch(function () {});
524
- }
525
-
526
- function logMessage(direction, type, payload) {
527
- messageCount++;
528
- consoleCount.textContent = messageCount + ' messages';
529
-
530
- var entry = document.createElement('div');
531
- entry.className = 'log-entry';
532
-
533
- var time = new Date().toLocaleTimeString();
534
- var cls = 'log-info';
535
- if (direction === 'error') cls = 'log-error';
536
- if (direction === 'warn' || direction === 'cost') cls = 'log-warn';
537
- if (direction === 'toast') cls = 'log-warn';
538
-
539
- var text = '<span class="log-time">' + escapeHtml(time) + '</span>';
540
- text +=
541
- '<span class="' +
542
- cls +
543
- '">[' +
544
- escapeHtml(direction.toUpperCase()) +
545
- '] ' +
546
- escapeHtml(String(type)) +
547
- '</span>';
548
- if (payload && typeof payload === 'object') {
549
- text +=
550
- ' <span style="color:#6b7280">' +
551
- escapeHtml(JSON.stringify(payload).substring(0, 120)) +
552
- '</span>';
553
- }
554
-
555
- entry.innerHTML = text;
556
- consoleBody.appendChild(entry);
557
- consoleBody.scrollTop = consoleBody.scrollHeight;
558
- }
559
-
560
- function escapeHtml(str) {
561
- var div = document.createElement('div');
562
- div.appendChild(document.createTextNode(str));
563
- return div.innerHTML;
564
- }
565
-
566
- function checkPluginReachable(url, callback) {
567
- var done = false;
568
-
569
- fetch('/api/check-plugin')
570
- .then(function (r) { return r.json(); })
571
- .then(function (data) {
572
- if (!done) {
573
- done = true;
574
- callback(data.reachable);
575
- }
576
- })
577
- .catch(function () {
578
- if (!done) {
579
- done = true;
580
- callback(true);
581
- }
582
- });
583
-
584
- setTimeout(function () {
585
- if (!done) {
586
- done = true;
587
- callback(false);
588
- }
589
- }, 5000);
590
- }
591
-
592
- function showPluginError(url) {
593
- pluginStatus.classList.add('error');
594
- pluginStatus.innerHTML =
595
- '<p><strong>Plugin server not reachable</strong></p>' +
596
- '<p>The harness cannot connect to your plugin at<br/><code>' + escapeHtml(url) + '</code></p>' +
597
- '<p>Make sure your plugin dev server is running:</p>' +
598
- '<p><code>npm run dev</code></p>' +
599
- '<p style="color:#6b7280;font-size:12px;margin-top:8px;">' +
600
- 'The harness will automatically retry when you click the reload button (&#8635;).</p>';
601
- iframe.classList.add('hidden');
602
- }
603
- })();