@fias/plugin-dev-harness 1.1.0 → 1.1.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.
@@ -49,27 +49,71 @@ const fs = __importStar(require("fs"));
49
49
  const chalk_1 = __importDefault(require("chalk"));
50
50
  const mock_handler_1 = require("../bridge/mock-handler");
51
51
  const live_handler_1 = require("../bridge/live-handler");
52
+ const credentials_1 = require("../config/credentials");
52
53
  async function startHarnessServer(options) {
53
54
  const app = (0, express_1.default)();
54
55
  app.use(express_1.default.json());
55
- // Create appropriate bridge handler
56
- const handler = options.isLive
56
+ // Mutable mode can be toggled at runtime via UI
57
+ let currentMode = options.isLive ? 'live' : 'mock';
58
+ let currentApiKey = options.apiKey;
59
+ // Always create mock handler
60
+ const mockHandler = new mock_handler_1.MockBridgeHandler({
61
+ mockUser: options.mockUser,
62
+ mockTheme: options.mockTheme,
63
+ mockEntities: options.mockEntities,
64
+ });
65
+ // Create live handler (recreated when credentials change)
66
+ let liveHandler = currentApiKey
57
67
  ? new live_handler_1.LiveBridgeHandler({
58
- apiKey: options.apiKey,
68
+ apiKey: currentApiKey,
59
69
  apiUrl: options.apiUrl,
60
70
  permissions: options.permissions,
61
71
  mockUser: options.mockUser,
62
72
  mockTheme: options.mockTheme,
63
73
  })
64
- : new mock_handler_1.MockBridgeHandler({
74
+ : null;
75
+ function getActiveHandler() {
76
+ return currentMode === 'live' && liveHandler ? liveHandler : mockHandler;
77
+ }
78
+ // Mode toggle endpoint — called by harness.js
79
+ app.post('/api/mode', (req, res) => {
80
+ const { mode } = req.body;
81
+ if (mode === 'live') {
82
+ if (!liveHandler) {
83
+ res.status(400).json({ error: 'No API key configured. Use the login button first.' });
84
+ return;
85
+ }
86
+ currentMode = 'live';
87
+ }
88
+ else {
89
+ currentMode = 'mock';
90
+ }
91
+ console.log(chalk_1.default.dim(` Mode switched to ${currentMode.toUpperCase()}`));
92
+ res.json({ mode: currentMode });
93
+ });
94
+ // Login endpoint — saves API key and creates live handler
95
+ app.post('/api/login', (req, res) => {
96
+ const { apiKey } = req.body;
97
+ if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
98
+ res.status(400).json({ error: 'API key is required' });
99
+ return;
100
+ }
101
+ currentApiKey = apiKey.trim();
102
+ (0, credentials_1.saveCredentials)({ apiKey: currentApiKey });
103
+ liveHandler = new live_handler_1.LiveBridgeHandler({
104
+ apiKey: currentApiKey,
105
+ apiUrl: options.apiUrl,
106
+ permissions: options.permissions,
65
107
  mockUser: options.mockUser,
66
108
  mockTheme: options.mockTheme,
67
- mockEntities: options.mockEntities,
68
109
  });
110
+ console.log(chalk_1.default.green(' API key saved to ~/.fias/credentials'));
111
+ res.json({ success: true });
112
+ });
69
113
  // Bridge API endpoint — called by harness.js
70
114
  app.post('/api/bridge', async (req, res) => {
71
115
  try {
72
- const result = await handler.handle(req.body);
116
+ const result = await getActiveHandler().handle(req.body);
73
117
  res.json(result);
74
118
  }
75
119
  catch (err) {
@@ -78,15 +122,15 @@ async function startHarnessServer(options) {
78
122
  });
79
123
  }
80
124
  });
81
- // Credit balance endpoint (live mode only)
125
+ // Credit balance endpoint
82
126
  app.get('/api/credits', async (_req, res) => {
83
- if (!options.isLive) {
84
- res.json({ balance: Infinity, lifetimeUsed: 0, lifetimeGranted: 0 });
127
+ if (currentMode !== 'live' || !currentApiKey) {
128
+ res.json({ balance: null });
85
129
  return;
86
130
  }
87
131
  try {
88
132
  const response = await fetch(`${options.apiUrl}/developer/credits`, {
89
- headers: { Authorization: `Bearer ${options.apiKey}` },
133
+ headers: { Authorization: `Bearer ${currentApiKey}` },
90
134
  });
91
135
  if (!response.ok) {
92
136
  res.status(response.status).json({ error: 'Failed to fetch credits' });
@@ -100,11 +144,25 @@ async function startHarnessServer(options) {
100
144
  .json({ error: err instanceof Error ? err.message : 'Failed to fetch credits' });
101
145
  }
102
146
  });
147
+ // Plugin reachability check — called by harness.js before loading iframe
148
+ app.get('/api/check-plugin', async (_req, res) => {
149
+ try {
150
+ const controller = new AbortController();
151
+ const timeout = setTimeout(() => controller.abort(), 3000);
152
+ const response = await fetch(options.pluginUrl, { signal: controller.signal });
153
+ clearTimeout(timeout);
154
+ res.json({ reachable: response.ok });
155
+ }
156
+ catch {
157
+ res.json({ reachable: false });
158
+ }
159
+ });
103
160
  // Harness config endpoint — provides config to the frontend
104
161
  app.get('/api/config', (_req, res) => {
105
162
  res.json({
106
163
  pluginUrl: options.pluginUrl,
107
- isLive: options.isLive,
164
+ mode: currentMode,
165
+ hasCredentials: Boolean(currentApiKey),
108
166
  permissions: options.permissions,
109
167
  mockTheme: options.mockTheme,
110
168
  });
@@ -137,8 +195,20 @@ async function startHarnessServer(options) {
137
195
  });
138
196
  return new Promise((resolve) => {
139
197
  app.listen(options.port, () => {
140
- console.log(chalk_1.default.green(` Harness server running at http://localhost:${options.port}\n`));
141
- console.log(chalk_1.default.dim(' Press Ctrl+C to stop\n'));
198
+ console.log(chalk_1.default.green(`\n Harness server running at http://localhost:${options.port}`));
199
+ console.log(chalk_1.default.dim(` Plugin URL: ${options.pluginUrl}`));
200
+ console.log(chalk_1.default.dim(` Mode: ${currentMode.toUpperCase()}${currentApiKey ? '' : ' (no API key — click MOCK badge in toolbar to login)'}\n`));
201
+ // Check if plugin dev server is reachable
202
+ checkPluginReachable(options.pluginUrl).then((reachable) => {
203
+ if (reachable) {
204
+ console.log(chalk_1.default.green(` ✓ Plugin server is reachable at ${options.pluginUrl}\n`));
205
+ }
206
+ else {
207
+ console.log(chalk_1.default.yellow(` ⚠ Plugin server not reachable at ${options.pluginUrl}\n` +
208
+ ` Make sure your plugin dev server is running (npm run dev)\n`));
209
+ }
210
+ console.log(chalk_1.default.dim(' Press Ctrl+C to stop\n'));
211
+ });
142
212
  resolve();
143
213
  });
144
214
  });
@@ -146,37 +216,55 @@ async function startHarnessServer(options) {
146
216
  /**
147
217
  * Fallback HTML when static files haven't been copied to dist.
148
218
  * This enables the harness to work during development.
219
+ * Mirrors the structure of harness.html but with inline styles/scripts.
149
220
  */
150
221
  function generateFallbackHtml(options) {
222
+ const initialMode = options.isLive ? 'live' : 'mock';
151
223
  return `<!DOCTYPE html>
152
224
  <html lang="en">
153
225
  <head>
154
226
  <meta charset="UTF-8" />
155
227
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
156
- <title>FIAS Dev Harness</title>
228
+ <title>Fias Arche Dev</title>
157
229
  <style>${EMBEDDED_CSS}</style>
158
230
  </head>
159
231
  <body>
160
232
  <div class="toolbar">
161
233
  <div class="toolbar-left">
162
- <span class="logo">FIAS Dev Harness</span>
163
- <span class="mode-badge ${options.isLive ? 'mode-live' : 'mode-mock'}">
164
- ${options.isLive ? 'LIVE' : 'MOCK'}
165
- </span>
166
- <div class="permissions">
167
- ${options.permissions.map((p) => `<span class="perm-badge">${p}</span>`).join('')}
168
- </div>
234
+ <span class="logo">Fias Arche Dev</span>
235
+ <button id="mode-badge" class="mode-badge mode-${initialMode === 'live' ? 'live' : 'mock'}" title="Click to toggle mode">
236
+ ${initialMode.toUpperCase()}
237
+ </button>
238
+ <span id="plugin-url" class="plugin-url">${options.pluginUrl}</span>
169
239
  </div>
170
240
  <div class="toolbar-right">
171
- <span id="credit-balance" class="credit-balance" style="display: ${options.isLive ? 'inline' : 'none'}"></span>
241
+ <span id="credit-balance" class="credit-balance" style="display: none"></span>
242
+ <span id="theme-badge" class="theme-badge theme-${options.mockTheme || 'dark'}">${(options.mockTheme || 'dark').toUpperCase()}</span>
172
243
  <button id="theme-toggle" class="btn-icon" title="Toggle theme">☀/☾</button>
173
244
  <button id="reload-btn" class="btn-icon" title="Reload plugin">↻</button>
174
245
  </div>
175
246
  </div>
247
+ <div id="login-modal" class="modal-overlay" style="display: none">
248
+ <div class="modal">
249
+ <h3>Connect to FIAS</h3>
250
+ <p>Enter your API key to enable live mode with real AI responses.</p>
251
+ <p class="modal-hint">Get an API key from your FIAS platform account settings.</p>
252
+ <input id="login-input" type="password" class="modal-input" placeholder="fias_sk_..." autocomplete="off" />
253
+ <div id="login-error" class="modal-error" style="display: none"></div>
254
+ <div class="modal-actions">
255
+ <button id="login-cancel" class="btn-secondary">Cancel</button>
256
+ <button id="login-submit" class="btn-primary">Save &amp; Connect</button>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ <div id="plugin-status" class="plugin-status">
261
+ <div class="status-spinner"></div>
262
+ <p>Connecting to plugin server...</p>
263
+ </div>
176
264
  <iframe
177
265
  id="plugin-iframe"
178
- src="${options.pluginUrl}"
179
- sandbox="allow-scripts allow-forms"
266
+ class="hidden"
267
+ sandbox="allow-scripts allow-forms allow-same-origin"
180
268
  ></iframe>
181
269
  <div id="console-panel" class="console-panel">
182
270
  <div class="console-header" id="console-toggle">
@@ -189,21 +277,58 @@ function generateFallbackHtml(options) {
189
277
  </body>
190
278
  </html>`;
191
279
  }
280
+ async function checkPluginReachable(url) {
281
+ try {
282
+ const controller = new AbortController();
283
+ const timeout = setTimeout(() => controller.abort(), 3000);
284
+ const response = await fetch(url, { signal: controller.signal });
285
+ clearTimeout(timeout);
286
+ return response.ok;
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
192
292
  const EMBEDDED_CSS = `
193
293
  * { margin: 0; padding: 0; box-sizing: border-box; }
194
294
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0f; color: #e4e4e7; display: flex; flex-direction: column; height: 100vh; }
195
295
  .toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: #18181b; border-bottom: 1px solid #3f3f46; flex-shrink: 0; }
196
296
  .toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 12px; }
197
297
  .logo { font-weight: 600; font-size: 14px; color: #a78bfa; }
198
- .mode-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
298
+ .mode-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; border: 1px solid transparent; cursor: pointer; transition: opacity 0.15s; }
299
+ .mode-badge:hover { opacity: 0.8; }
199
300
  .mode-mock { background: #166534; color: #86efac; }
200
301
  .mode-live { background: #854d0e; color: #fde047; }
201
- .permissions { display: flex; gap: 4px; }
202
- .perm-badge { padding: 2px 6px; border-radius: 3px; font-size: 10px; background: #27272a; color: #a1a1aa; }
302
+ .theme-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
303
+ .theme-dark { background: #1e1b4b; color: #a78bfa; }
304
+ .theme-light { background: #ede9fe; color: #6d28d9; }
203
305
  .credit-balance { font-size: 12px; color: #fde047; }
204
306
  .btn-icon { background: #27272a; border: 1px solid #3f3f46; color: #e4e4e7; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 14px; }
205
307
  .btn-icon:hover { background: #3f3f46; }
308
+ .plugin-url { font-size: 11px; color: #6b7280; font-family: monospace; }
309
+ .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; }
310
+ .modal { background: #27272a; border: 1px solid #3f3f46; border-radius: 8px; padding: 24px; width: 420px; max-width: 90vw; }
311
+ .modal h3 { font-size: 16px; color: #e4e4e7; margin-bottom: 8px; }
312
+ .modal p { font-size: 13px; color: #a1a1aa; line-height: 1.5; margin-bottom: 4px; }
313
+ .modal-hint { font-size: 12px !important; color: #6b7280 !important; margin-bottom: 16px !important; }
314
+ .modal-input { width: 100%; padding: 8px 12px; background: #18181b; border: 1px solid #3f3f46; border-radius: 4px; color: #e4e4e7; font-family: monospace; font-size: 13px; margin-bottom: 12px; outline: none; }
315
+ .modal-input:focus { border-color: #a78bfa; }
316
+ .modal-error { font-size: 12px; color: #fca5a5; margin-bottom: 12px; }
317
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
318
+ .btn-primary { background: #6d28d9; color: #e4e4e7; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; }
319
+ .btn-primary:hover { background: #7c3aed; }
320
+ .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
321
+ .btn-secondary { background: transparent; color: #a1a1aa; border: 1px solid #3f3f46; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
322
+ .btn-secondary:hover { background: #3f3f46; }
323
+ .plugin-status { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; color: #a1a1aa; font-size: 14px; }
324
+ .plugin-status.hidden { display: none; }
325
+ .plugin-status.error { color: #fca5a5; }
326
+ .plugin-status p { max-width: 500px; text-align: center; line-height: 1.6; }
327
+ .plugin-status code { background: #27272a; padding: 2px 8px; border-radius: 4px; font-family: monospace; font-size: 13px; color: #e4e4e7; }
328
+ .status-spinner { width: 24px; height: 24px; border: 3px solid #3f3f46; border-top-color: #a78bfa; border-radius: 50%; animation: spin 0.8s linear infinite; }
329
+ @keyframes spin { to { transform: rotate(360deg); } }
206
330
  #plugin-iframe { flex: 1; border: none; width: 100%; }
331
+ #plugin-iframe.hidden { display: none; }
207
332
  .console-panel { flex-shrink: 0; border-top: 1px solid #3f3f46; background: #18181b; max-height: 250px; display: flex; flex-direction: column; }
208
333
  .console-header { display: flex; justify-content: space-between; padding: 6px 16px; cursor: pointer; font-size: 12px; color: #a1a1aa; border-bottom: 1px solid #27272a; }
209
334
  .console-body { overflow-y: auto; padding: 8px 16px; font-family: monospace; font-size: 11px; flex: 1; display: none; }
@@ -215,218 +340,161 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
215
340
  .log-time { color: #6b7280; margin-right: 8px; }
216
341
  .log-cost { color: #fbbf24; margin-left: 8px; }
217
342
  `;
343
+ // The embedded JS mirrors harness.js but is served inline for the fallback page.
344
+ // It uses the same API endpoints as the static version.
218
345
  const EMBEDDED_JS = `
219
346
  (function() {
220
- const iframe = document.getElementById('plugin-iframe');
221
- const consoleBody = document.getElementById('console-body');
222
- const consoleCount = document.getElementById('console-count');
223
- const consoleToggle = document.getElementById('console-toggle');
224
- const creditBalance = document.getElementById('credit-balance');
225
- const themeToggle = document.getElementById('theme-toggle');
226
- const reloadBtn = document.getElementById('reload-btn');
347
+ var iframe = document.getElementById('plugin-iframe');
348
+ var consoleBody = document.getElementById('console-body');
349
+ var consoleCount = document.getElementById('console-count');
350
+ var consoleToggle = document.getElementById('console-toggle');
351
+ var creditBalance = document.getElementById('credit-balance');
352
+ var themeToggle = document.getElementById('theme-toggle');
353
+ var reloadBtn = document.getElementById('reload-btn');
354
+ var modeBadge = document.getElementById('mode-badge');
355
+ var themeBadge = document.getElementById('theme-badge');
356
+ var pluginStatus = document.getElementById('plugin-status');
357
+ var loginModal = document.getElementById('login-modal');
358
+ var loginInput = document.getElementById('login-input');
359
+ var loginError = document.getElementById('login-error');
360
+ var loginSubmit = document.getElementById('login-submit');
361
+ var loginCancel = document.getElementById('login-cancel');
227
362
 
228
- let messageCount = 0;
229
- let currentTheme = 'dark';
363
+ var messageCount = 0;
364
+ var currentTheme = 'dark';
365
+ var currentMode = 'mock';
366
+ var hasCredentials = false;
367
+ var cachedConfig = null;
230
368
 
231
- // Fetch config
232
- fetch('/api/config')
233
- .then(r => r.json())
234
- .then(config => {
235
- currentTheme = config.mockTheme || 'dark';
236
- if (config.isLive) fetchCredits();
237
- });
369
+ var PERMISSION_MAP = {
370
+ get_user: 'user:profile:read', get_theme: 'theme:read', entity_invoke: 'entities:invoke',
371
+ storage_read: 'storage:sandbox', storage_write: 'storage:sandbox',
372
+ storage_list: 'storage:sandbox', storage_delete: 'storage:sandbox',
373
+ };
374
+ var RATE_LIMITS = {
375
+ entity_invoke: { maxPerMinute: 60 }, storage_write: { maxPerMinute: 120 },
376
+ storage_read: { maxPerMinute: 300 }, storage_list: { maxPerMinute: 60 },
377
+ storage_delete: { maxPerMinute: 60 },
378
+ };
379
+ var rateBuckets = {};
238
380
 
239
- // Console toggle
240
- consoleToggle.addEventListener('click', () => {
241
- consoleBody.classList.toggle('open');
242
- });
381
+ var SYSTEM_FONTS = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
382
+ var MONO_FONTS = '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace';
383
+ var SPACING = { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '32px' };
384
+ var FONTS = { body: SYSTEM_FONTS, heading: SYSTEM_FONTS, mono: MONO_FONTS };
243
385
 
244
- // Reload button
245
- reloadBtn.addEventListener('click', () => {
246
- iframe.src = iframe.src;
386
+ fetch('/api/config').then(function(r) { return r.json(); }).then(function(config) {
387
+ cachedConfig = config;
388
+ currentTheme = config.mockTheme || 'dark';
389
+ currentMode = config.mode || 'mock';
390
+ hasCredentials = config.hasCredentials || false;
391
+ updateThemeBadge();
392
+ updateModeBadge();
393
+ if (currentMode === 'live') { creditBalance.style.display = 'inline'; fetchCredits(); }
394
+ checkPluginReachable(function(reachable) {
395
+ if (reachable) { pluginStatus.classList.add('hidden'); iframe.classList.remove('hidden'); iframe.src = config.pluginUrl; }
396
+ else { pluginStatus.classList.add('error'); pluginStatus.innerHTML = '<p><strong>Plugin server not reachable</strong></p><p>Make sure your plugin dev server is running.</p>'; iframe.classList.add('hidden'); }
397
+ });
247
398
  });
248
399
 
249
- // Theme toggle
250
- themeToggle.addEventListener('click', () => {
400
+ consoleToggle.addEventListener('click', function() { consoleBody.classList.toggle('open'); });
401
+ reloadBtn.addEventListener('click', function() { if (cachedConfig) { iframe.src = cachedConfig.pluginUrl; } });
402
+
403
+ themeToggle.addEventListener('click', function() {
251
404
  currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
405
+ updateThemeBadge();
252
406
  sendToPlugin({ type: 'theme_update', messageId: 'theme_' + Date.now(), payload: getTheme() });
253
407
  });
254
408
 
255
- function getTheme() {
256
- if (currentTheme === 'light') {
257
- return { mode: 'light', colors: { background: '#ffffff', foreground: '#18181b', primary: '#7c3aed', secondary: '#ede9fe', accent: '#8b5cf6', muted: '#f4f4f5', border: '#e4e4e7' }};
258
- }
259
- return { mode: 'dark', colors: { background: '#0a0a0f', foreground: '#e4e4e7', primary: '#6d28d9', secondary: '#1e1b4b', accent: '#8b5cf6', muted: '#27272a', border: '#3f3f46' }};
260
- }
409
+ modeBadge.addEventListener('click', function() {
410
+ if (currentMode === 'mock') {
411
+ if (!hasCredentials) { showLoginModal(); return; }
412
+ switchMode('live');
413
+ } else { switchMode('mock'); }
414
+ });
261
415
 
262
- // Permission map (matches production PluginBridgeHost)
263
- const PERMISSION_MAP = {
264
- get_user: 'user:profile:read',
265
- get_theme: 'theme:read',
266
- entity_invoke: 'entities:invoke',
267
- storage_read: 'storage:sandbox',
268
- storage_write: 'storage:sandbox',
269
- storage_list: 'storage:sandbox',
270
- storage_delete: 'storage:sandbox',
271
- };
416
+ function switchMode(newMode) {
417
+ fetch('/api/mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: newMode }) })
418
+ .then(function(r) { if (!r.ok) return r.json().then(function(e) { throw new Error(e.error); }); return r.json(); })
419
+ .then(function(data) {
420
+ currentMode = data.mode; updateModeBadge();
421
+ if (currentMode === 'live') { creditBalance.style.display = 'inline'; fetchCredits(); }
422
+ else { creditBalance.style.display = 'none'; creditBalance.textContent = ''; }
423
+ }).catch(function(err) { logMessage('error', err.message); });
424
+ }
272
425
 
273
- // Rate limits (matches production PluginBridgeHost)
274
- const RATE_LIMITS = {
275
- entity_invoke: { maxPerMinute: 60 },
276
- storage_write: { maxPerMinute: 120 },
277
- storage_read: { maxPerMinute: 300 },
278
- storage_list: { maxPerMinute: 60 },
279
- storage_delete: { maxPerMinute: 60 },
280
- };
281
- const rateBuckets = {};
426
+ function showLoginModal() { loginModal.style.display = 'flex'; loginInput.value = ''; loginError.style.display = 'none'; loginInput.focus(); }
427
+ function hideLoginModal() { loginModal.style.display = 'none'; }
428
+ loginCancel.addEventListener('click', hideLoginModal);
429
+ loginModal.addEventListener('click', function(e) { if (e.target === loginModal) hideLoginModal(); });
430
+ loginInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') submitLogin(); if (e.key === 'Escape') hideLoginModal(); });
431
+ loginSubmit.addEventListener('click', submitLogin);
282
432
 
283
- function checkRateLimit(type) {
284
- const limit = RATE_LIMITS[type];
285
- if (!limit) return;
286
- const now = Date.now();
287
- const bucket = rateBuckets[type];
288
- if (!bucket || now - bucket.windowStart > 60000) {
289
- rateBuckets[type] = { count: 1, windowStart: now };
290
- return;
291
- }
292
- if (bucket.count >= limit.maxPerMinute) {
293
- throw new Error('Rate limit exceeded for ' + type + ': max ' + limit.maxPerMinute + '/minute');
294
- }
295
- bucket.count++;
433
+ function submitLogin() {
434
+ var apiKey = loginInput.value.trim();
435
+ if (!apiKey) { loginError.textContent = 'Please enter an API key.'; loginError.style.display = 'block'; return; }
436
+ loginSubmit.disabled = true; loginSubmit.textContent = 'Connecting...';
437
+ fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: apiKey }) })
438
+ .then(function(r) { if (!r.ok) return r.json().then(function(e) { throw new Error(e.error); }); return r.json(); })
439
+ .then(function() { hasCredentials = true; hideLoginModal(); switchMode('live'); })
440
+ .catch(function(err) { loginError.textContent = err.message; loginError.style.display = 'block'; })
441
+ .finally(function() { loginSubmit.disabled = false; loginSubmit.textContent = 'Save & Connect'; });
296
442
  }
297
443
 
298
- // Listen for plugin messages
299
- window.addEventListener('message', async (event) => {
444
+ window.addEventListener('message', function(event) {
300
445
  if (event.source !== iframe.contentWindow) return;
301
-
302
- const data = event.data;
446
+ var data = event.data;
303
447
  if (!data || typeof data !== 'object' || !data.type || !data.messageId) return;
304
-
305
448
  logMessage('recv', data.type, data.payload);
306
-
307
- // Fire-and-forget messages
308
449
  if (data.type === 'ready') return;
450
+ if (data.type === 'resize') { var h = data.payload && data.payload.height; if (typeof h === 'number' && h > 0) { iframe.style.height = h + 'px'; iframe.style.flex = 'none'; } return; }
451
+ if (data.type === 'toast') { var msg = data.payload && data.payload.message; if (typeof msg === 'string') logMessage('toast', msg); return; }
452
+ if (data.type === 'navigate') { var p = data.payload && data.payload.path; if (typeof p === 'string') logMessage('nav', p); return; }
453
+ handleRequest(data);
454
+ });
309
455
 
310
- if (data.type === 'resize') {
311
- const height = data.payload && data.payload.height;
312
- if (typeof height === 'number' && height > 0) {
313
- iframe.style.height = height + 'px';
314
- iframe.style.flex = 'none';
315
- }
316
- return;
317
- }
318
-
319
- if (data.type === 'toast') {
320
- const msg = data.payload && data.payload.message;
321
- if (typeof msg === 'string') {
322
- logMessage('toast', msg, data.payload);
323
- }
324
- return;
325
- }
326
-
327
- if (data.type === 'navigate') {
328
- const navPath = data.payload && data.payload.path;
329
- if (typeof navPath === 'string') {
330
- logMessage('nav', navPath);
331
- }
332
- return;
333
- }
334
-
335
- // Request/response messages
456
+ function handleRequest(data) {
336
457
  try {
337
- // Check permissions
338
- const requiredPerm = PERMISSION_MAP[data.type];
339
- if (requiredPerm) {
340
- const config = await fetch('/api/config').then(r => r.json());
341
- if (!config.permissions.includes(requiredPerm)) {
342
- throw new Error('Permission denied: ' + requiredPerm + ' not granted');
343
- }
344
- }
345
-
458
+ var requiredPerm = PERMISSION_MAP[data.type];
459
+ if (requiredPerm && cachedConfig && cachedConfig.permissions.indexOf(requiredPerm) === -1) throw new Error('Permission denied: ' + requiredPerm);
346
460
  checkRateLimit(data.type);
461
+ } catch (err) { logMessage('error', err.message); sendToPlugin({ type: 'response', messageId: data.messageId, payload: null, error: err.message }); return; }
462
+ fetch('/api/bridge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: data.type, payload: data.payload }) })
463
+ .then(function(r) { if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || 'Bridge call failed'); }); return r.json(); })
464
+ .then(function(result) {
465
+ logMessage('send', 'response', result);
466
+ if (data.type === 'entity_invoke' && result.metadata && result.metadata.cost > 0) { logMessage('cost', 'Credits: ' + result.metadata.cost.toFixed(4)); fetchCredits(); }
467
+ sendToPlugin({ type: 'response', messageId: data.messageId, payload: result });
468
+ })
469
+ .catch(function(err) { logMessage('error', err.message); sendToPlugin({ type: 'response', messageId: data.messageId, payload: null, error: err.message }); });
470
+ }
347
471
 
348
- const response = await fetch('/api/bridge', {
349
- method: 'POST',
350
- headers: { 'Content-Type': 'application/json' },
351
- body: JSON.stringify({ type: data.type, payload: data.payload }),
352
- });
353
-
354
- if (!response.ok) {
355
- const err = await response.json().catch(() => ({}));
356
- throw new Error(err.error || 'Bridge call failed');
357
- }
358
-
359
- const result = await response.json();
360
- logMessage('send', 'response', result);
361
-
362
- // Show cost for entity invocations
363
- if (data.type === 'entity_invoke' && result.metadata && result.metadata.cost > 0) {
364
- logMessage('cost', 'Credits used: ' + result.metadata.cost.toFixed(4));
365
- fetchCredits();
366
- }
367
-
368
- sendToPlugin({ type: 'response', messageId: data.messageId, payload: result });
369
- } catch (err) {
370
- logMessage('error', err.message);
371
- sendToPlugin({ type: 'response', messageId: data.messageId, payload: null, error: err.message });
372
- }
373
- });
374
-
375
- // Send init message when iframe loads
376
- iframe.addEventListener('load', () => {
377
- fetch('/api/config')
378
- .then(r => r.json())
379
- .then(config => {
380
- sendToPlugin({
381
- type: 'init',
382
- messageId: 'init_0',
383
- payload: {
384
- archId: 'dev_harness',
385
- permissions: config.permissions,
386
- theme: getTheme(),
387
- currentPath: '/',
388
- },
389
- });
390
- logMessage('send', 'init');
391
- });
472
+ iframe.addEventListener('load', function() {
473
+ if (!cachedConfig) return;
474
+ sendToPlugin({ type: 'init', messageId: 'init_0', payload: { archId: 'dev_harness', permissions: cachedConfig.permissions, theme: getTheme(), currentPath: '/' } });
475
+ logMessage('send', 'init');
392
476
  });
393
477
 
394
- function sendToPlugin(message) {
395
- iframe.contentWindow && iframe.contentWindow.postMessage(message, '*');
478
+ function updateModeBadge() {
479
+ modeBadge.textContent = currentMode.toUpperCase();
480
+ modeBadge.className = 'mode-badge ' + (currentMode === 'live' ? 'mode-live' : 'mode-mock');
396
481
  }
397
-
398
- function fetchCredits() {
399
- fetch('/api/credits')
400
- .then(r => r.json())
401
- .then(data => {
402
- if (data.balance !== undefined && data.balance !== Infinity) {
403
- creditBalance.textContent = 'Credits: ' + data.balance.toFixed(2);
404
- }
405
- })
406
- .catch(() => {});
482
+ function updateThemeBadge() { themeBadge.textContent = currentTheme.toUpperCase(); themeBadge.className = 'theme-badge theme-' + currentTheme; }
483
+ function sendToPlugin(message) { iframe.contentWindow && iframe.contentWindow.postMessage(message, '*'); }
484
+ function checkRateLimit(type) { var limit = RATE_LIMITS[type]; if (!limit) return; var now = Date.now(); var bucket = rateBuckets[type]; if (!bucket || now - bucket.windowStart > 60000) { rateBuckets[type] = { count: 1, windowStart: now }; return; } if (bucket.count >= limit.maxPerMinute) throw new Error('Rate limit exceeded for ' + type); bucket.count++; }
485
+ function getTheme() {
486
+ if (currentTheme === 'light') return { mode: 'light', colors: { primary: '#7c3aed', secondary: '#ede9fe', background: '#ffffff', surface: '#f4f4f5', text: '#18181b', textSecondary: '#71717a', border: '#e4e4e7', error: '#ef4444', warning: '#f59e0b', success: '#22c55e' }, spacing: SPACING, fonts: FONTS };
487
+ return { mode: 'dark', colors: { primary: '#6d28d9', secondary: '#1e1b4b', background: '#0a0a0f', surface: '#27272a', text: '#e4e4e7', textSecondary: '#a1a1aa', border: '#3f3f46', error: '#ef4444', warning: '#f59e0b', success: '#22c55e' }, spacing: SPACING, fonts: FONTS };
407
488
  }
408
-
409
- function logMessage(direction, type, payload) {
410
- messageCount++;
411
- consoleCount.textContent = messageCount + ' messages';
412
-
413
- const entry = document.createElement('div');
414
- entry.className = 'log-entry';
415
-
416
- const time = new Date().toLocaleTimeString();
417
- let cls = 'log-info';
418
- if (direction === 'error') cls = 'log-error';
419
- if (direction === 'warn' || direction === 'cost') cls = 'log-warn';
420
-
421
- let text = '<span class="log-time">' + time + '</span>';
422
- text += '<span class="' + cls + '">[' + direction.toUpperCase() + '] ' + type + '</span>';
423
- if (payload && typeof payload === 'object') {
424
- text += ' <span style="color:#6b7280">' + JSON.stringify(payload).substring(0, 120) + '</span>';
425
- }
426
-
427
- entry.innerHTML = text;
428
- consoleBody.appendChild(entry);
429
- consoleBody.scrollTop = consoleBody.scrollHeight;
489
+ function checkPluginReachable(cb) { fetch('/api/check-plugin').then(function(r) { return r.json(); }).then(function(d) { cb(d.reachable); }).catch(function() { cb(true); }); }
490
+ function fetchCredits() { fetch('/api/credits').then(function(r) { return r.json(); }).then(function(d) { if (d.balance != null && isFinite(d.balance)) creditBalance.textContent = 'Credits: ' + d.balance.toFixed(2); }).catch(function() {}); }
491
+ function logMessage(dir, type, payload) {
492
+ messageCount++; consoleCount.textContent = messageCount + ' messages';
493
+ var entry = document.createElement('div'); entry.className = 'log-entry';
494
+ var cls = 'log-info'; if (dir === 'error') cls = 'log-error'; if (dir === 'warn' || dir === 'cost' || dir === 'toast') cls = 'log-warn';
495
+ var text = '<span class="log-time">' + new Date().toLocaleTimeString() + '</span><span class="' + cls + '">[' + dir.toUpperCase() + '] ' + type + '</span>';
496
+ if (payload && typeof payload === 'object') text += ' <span style="color:#6b7280">' + JSON.stringify(payload).substring(0, 120) + '</span>';
497
+ entry.innerHTML = text; consoleBody.appendChild(entry); consoleBody.scrollTop = consoleBody.scrollHeight;
430
498
  }
431
499
  })();
432
500
  `;