somratpro Claude Sonnet 4.6 commited on
Commit
aa128fa
·
1 Parent(s): 8e5610b

fix(health-server): resolve iframe redirect causing 'refused to connect' (#11)

Browse files

- health-server.js: guard private-space redirect behind iframe detection
(window.top !== window.self) — fixes X-Frame-Options DENY on huggingface.co
blocking iframe navigation
- health-server.js: add privacyDetectionReady Promise to eliminate race
condition where fail-secure default triggered false redirects on public spaces
- health-server.js: add /api/is-private endpoint + client-side syncPrivacy()
- health-server.js: add SPACE_PRIVACY env var override to skip HF API detection
- health-server.js: add isFromHFApp referer guard to skip redirect inside HF UI
- cloudflare-proxy-setup.py: remove AI provider domains from DEFAULT_ALLOWED
to prevent routing API keys through worker without explicit opt-in
- env-builder.js: add tag badge system (critical/credential/feature/optional/
advanced/build) with per-field tags; add SPACE_PRIVACY field
- env-builder.js: split bundle generation — explicit generateBundle() button
vs auto refresh() for summary/counts only
- env-builder.html: add tag legend, ⚡ Required button, # Generate Bundle
button, toolbar hint; fix body overflow for proper scroll
- Dockerfile: add dbus/dbus-x11 for Chromium stability; fix libasound2
fallback for Debian bookworm rename

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. Dockerfile +2 -0
  2. cloudflare-proxy-setup.py +13 -1
  3. env-builder.html +88 -12
  4. env-builder.js +65 -43
  5. health-server.js +163 -47
Dockerfile CHANGED
@@ -14,6 +14,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
14
  python3-venv \
15
  python3-pip \
16
  chromium \
 
 
17
  libnss3 \
18
  libatk1.0-0 \
19
  libatk-bridge2.0-0 \
 
14
  python3-venv \
15
  python3-pip \
16
  chromium \
17
+ dbus \
18
+ dbus-x11 \
19
  libnss3 \
20
  libatk1.0-0 \
21
  libatk-bridge2.0-0 \
cloudflare-proxy-setup.py CHANGED
@@ -16,6 +16,8 @@ API_BASE = "https://api.cloudflare.com/client/v4"
16
  ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
17
  ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
18
  DEFAULT_ALLOWED = [
 
 
19
  "api.telegram.org",
20
  "discord.com",
21
  "discordapp.com",
@@ -24,13 +26,23 @@ DEFAULT_ALLOWED = [
24
  "slack.com",
25
  "api.slack.com",
26
  "web.whatsapp.com",
 
27
  "graph.facebook.com",
28
  "graph.instagram.com",
29
- "api.openai.com",
 
 
30
  "googleapis.com",
31
  "google.com",
32
  "googleusercontent.com",
33
  "gstatic.com",
 
 
 
 
 
 
 
34
  ]
35
 
36
 
 
16
  ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
17
  ENV_FILE = Path("/tmp/huggingmes-cloudflare-proxy.env")
18
  DEFAULT_ALLOWED = [
19
+ # Messaging & social — primary use-case for Cloudflare proxy on HF Spaces
20
+ # (geo-restrictions on Telegram, Discord, WhatsApp, etc.)
21
  "api.telegram.org",
22
  "discord.com",
23
  "discordapp.com",
 
26
  "slack.com",
27
  "api.slack.com",
28
  "web.whatsapp.com",
29
+ # Social — confirmed/likely blocked by HF firewall
30
  "graph.facebook.com",
31
  "graph.instagram.com",
32
+ "api.twitter.com",
33
+ "api.x.com",
34
+ # Google
35
  "googleapis.com",
36
  "google.com",
37
  "googleusercontent.com",
38
  "gstatic.com",
39
+ # Email HTTP APIs (SMTP ports are blocked)
40
+ "api.resend.com",
41
+ "api.sendgrid.com",
42
+ # NOTE: AI-provider domains (api.openai.com, api.anthropic.com, etc.) are
43
+ # intentionally NOT included here. Proxying AI calls routes API keys through
44
+ # the Cloudflare Worker without explicit opt-in. Users who need AI API calls
45
+ # proxied can add specific domains via CLOUDFLARE_PROXY_DOMAINS env var.
46
  ]
47
 
48
 
env-builder.html CHANGED
@@ -36,16 +36,16 @@
36
  --panel-w: 340px;
37
  }
38
 
39
- html { scroll-behavior: smooth; }
40
 
41
  body {
42
  font-family: var(--sans);
43
  background: var(--bg);
44
  color: var(--text);
45
- min-height: 100vh;
 
46
  display: flex;
47
  flex-direction: column;
48
- overflow-x: hidden;
49
  }
50
 
51
  .topbar {
@@ -396,17 +396,64 @@ body {
396
  border-radius: 20px;
397
  }
398
 
399
- .badge-s {
400
- background: rgba(240,95,95,.12);
401
- color: var(--red);
402
- border: 1px solid rgba(240,95,95,.25);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  }
404
 
405
- .badge-f {
406
- background: rgba(61,214,140,.1);
407
- color: var(--green);
408
- border: 1px solid rgba(61,214,140,.2);
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
 
411
  .card-input { position: relative; }
412
 
@@ -696,6 +743,7 @@ body {
696
  <main class="main">
697
 
698
  <div class="toolbar">
 
699
  <div class="search-wrap">
700
  <span class="search-icon">⌕</span>
701
  <input id="search" type="text" placeholder="Search variables…" autocomplete="off" spellcheck="false">
@@ -703,6 +751,7 @@ body {
703
 
704
  <div class="tb-sep"></div>
705
 
 
706
  <button id="selectCommon" class="btn">★ Common</button>
707
  <button id="selectVisible" class="btn">☑ Visible</button>
708
  <button id="clearAll" class="btn btn-ghost">✕ Clear</button>
@@ -711,6 +760,30 @@ body {
711
  <div class="content-wrap">
712
 
713
  <div class="sections-scroll">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  <div id="sections"></div>
715
 
716
  <div id="customSec" class="sec" data-section="Custom Env">
@@ -741,8 +814,11 @@ body {
741
  <span class="pblock-title">📦 Bundle Output</span>
742
  </div>
743
  <div class="pblock-body">
744
- <textarea id="bundleOut" placeholder="Your encoded bundle will appear here…" readonly spellcheck="false"></textarea>
745
  <input type="text" id="envLineOut" placeholder="HUGGINGMES_ENV_BUNDLE=…" readonly spellcheck="false">
 
 
 
746
  <div class="row-btns">
747
  <button id="copyBundle" class="btn btn-amber">⎘ Bundle</button>
748
  <button id="copyEnvLine" class="btn">⎘ Env Line</button>
 
36
  --panel-w: 340px;
37
  }
38
 
39
+ html { scroll-behavior: smooth; height: 100%; }
40
 
41
  body {
42
  font-family: var(--sans);
43
  background: var(--bg);
44
  color: var(--text);
45
+ height: 100vh;
46
+ overflow: hidden;
47
  display: flex;
48
  flex-direction: column;
 
49
  }
50
 
51
  .topbar {
 
396
  border-radius: 20px;
397
  }
398
 
399
+ /* Tag badge styles */
400
+ .badge-critical { background: rgba(240,80,80,.14); color: #f05f5f; border: 1px solid rgba(240,80,80,.3); }
401
+ .badge-credential{ background: rgba(220,140,60,.13); color: #e09040; border: 1px solid rgba(220,140,60,.28); }
402
+ .badge-feature { background: rgba(70,140,250,.12); color: #5a9eff; border: 1px solid rgba(70,140,250,.25); }
403
+ .badge-optional { background: rgba(61,214,140,.10); color: #3dd68c; border: 1px solid rgba(61,214,140,.22); }
404
+ .badge-advanced { background: rgba(160,100,230,.12);color: #b07ae0; border: 1px solid rgba(160,100,230,.25); }
405
+ .badge-build { background: rgba(240,185,60,.12); color: #e0b030; border: 1px solid rgba(240,185,60,.28); }
406
+
407
+ /* Card left-border accents */
408
+ .env-card:has(.badge-critical) { border-left: 3px solid rgba(240,80,80,.4); }
409
+ .env-card:has(.badge-critical):hover { border-left-color: rgba(240,80,80,.7); }
410
+ .env-card:has(.badge-critical).selected { border-left-color: #f05f5f; }
411
+ .env-card:has(.badge-credential){ border-left: 3px solid rgba(220,140,60,.3); }
412
+
413
+ /* Section count badge */
414
+ .sec-count {
415
+ font-family: var(--mono);
416
+ font-size: 10px;
417
+ color: var(--text3);
418
+ background: var(--bg3);
419
+ border: 1px solid var(--border);
420
+ border-radius: 10px;
421
+ padding: 1px 7px;
422
  }
423
 
424
+ /* Tag legend */
425
+ .tag-legend {
426
+ margin-bottom: 14px;
427
+ background: var(--bg2);
428
+ border: 1px solid var(--border);
429
+ border-radius: var(--r);
430
+ overflow: hidden;
431
+ }
432
+ .legend-summary {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 10px;
436
+ padding: 7px 12px;
437
+ cursor: pointer;
438
+ list-style: none;
439
+ user-select: none;
440
+ outline: none;
441
  }
442
+ .legend-summary::-webkit-details-marker { display: none; }
443
+ .legend-chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
444
+ .legend-hint { font-size: 10px; color: var(--text3); white-space: nowrap; flex-shrink: 0; }
445
+ .tag-legend[open] .legend-hint { opacity: 0; }
446
+ .legend-body {
447
+ padding: 8px 12px 10px;
448
+ border-top: 1px solid var(--border);
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 6px;
452
+ }
453
+ .legend-row { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text2); }
454
+ .legend-row .badge { flex-shrink: 0; width: 74px; text-align: center; }
455
+ .legend-tip { font-size: 9.5px; color: var(--text3); margin-top: 4px; padding-top: 6px; border-top: 1px solid var(--border); }
456
+ .toolbar-hint { color: var(--text3); font-size: 12px; margin-right: 6px; white-space: nowrap; }
457
 
458
  .card-input { position: relative; }
459
 
 
743
  <main class="main">
744
 
745
  <div class="toolbar">
746
+ <span class="toolbar-hint">Tip: Start with <strong>⚡ Required</strong>, then fill keys and click <strong># Generate Bundle</strong>.</span>
747
  <div class="search-wrap">
748
  <span class="search-icon">⌕</span>
749
  <input id="search" type="text" placeholder="Search variables…" autocomplete="off" spellcheck="false">
 
751
 
752
  <div class="tb-sep"></div>
753
 
754
+ <button id="selectRequired" class="btn">⚡ Required</button>
755
  <button id="selectCommon" class="btn">★ Common</button>
756
  <button id="selectVisible" class="btn">☑ Visible</button>
757
  <button id="clearAll" class="btn btn-ghost">✕ Clear</button>
 
760
  <div class="content-wrap">
761
 
762
  <div class="sections-scroll">
763
+
764
+ <details class="tag-legend">
765
+ <summary class="legend-summary">
766
+ <div class="legend-chips">
767
+ <span class="badge badge-critical">critical</span>
768
+ <span class="badge badge-credential">credential</span>
769
+ <span class="badge badge-feature">feature</span>
770
+ <span class="badge badge-optional">optional</span>
771
+ <span class="badge badge-advanced">advanced</span>
772
+ <span class="badge badge-build">build</span>
773
+ </div>
774
+ <span class="legend-hint">▸ Tag legend</span>
775
+ </summary>
776
+ <div class="legend-body">
777
+ <div class="legend-row"><span class="badge badge-critical">critical</span> Required for the space to function at all</div>
778
+ <div class="legend-row"><span class="badge badge-credential">credential</span> API keys, tokens, secrets — keep private</div>
779
+ <div class="legend-row"><span class="badge badge-feature">feature</span> Unlocks an optional feature or integration</div>
780
+ <div class="legend-row"><span class="badge badge-optional">optional</span> Useful but not required; has a default</div>
781
+ <div class="legend-row"><span class="badge badge-advanced">advanced</span> Fine-tuning for specific deployments</div>
782
+ <div class="legend-row"><span class="badge badge-build">build</span> Affects Docker build, not runtime</div>
783
+ <div class="legend-tip">Use <strong>⚡ Required</strong> to auto-select all critical fields, then fill in credential keys.</div>
784
+ </div>
785
+ </details>
786
+
787
  <div id="sections"></div>
788
 
789
  <div id="customSec" class="sec" data-section="Custom Env">
 
814
  <span class="pblock-title">📦 Bundle Output</span>
815
  </div>
816
  <div class="pblock-body">
817
+ <textarea id="bundleOut" placeholder="Select variables and click # Generate Bundle…" readonly spellcheck="false"></textarea>
818
  <input type="text" id="envLineOut" placeholder="HUGGINGMES_ENV_BUNDLE=…" readonly spellcheck="false">
819
+ <div class="row-btns">
820
+ <button id="generateBundle" class="btn btn-amber" style="width:100%;"># Generate Bundle</button>
821
+ </div>
822
  <div class="row-btns">
823
  <button id="copyBundle" class="btn btn-amber">⎘ Bundle</button>
824
  <button id="copyEnvLine" class="btn">⎘ Env Line</button>
env-builder.js CHANGED
@@ -103,26 +103,27 @@ const ICONS = {
103
  };
104
 
105
  // ── Field definitions ──
 
106
  const FIELDS = [
107
  // ── Core ──
108
  {
109
  "g": "Core", "icon": "⚡",
110
  "k": "GATEWAY_TOKEN",
111
  "lbl": "Gateway token — protects the Hermes web UI",
112
- "type": "password", "secret": 1, "common": 1
113
  },
114
  {
115
  "g": "Core", "icon": "⚡",
116
  "k": "LLM_MODEL",
117
  "lbl": "Default model (provider/model-name format)",
118
  "type": "model", "options_key": "LLM_MODEL",
119
- "ph": "gemini/gemini-2.5-flash", "common": 1
120
  },
121
  {
122
  "g": "Core", "icon": "⚡",
123
  "k": "LLM_API_KEY",
124
  "lbl": "API key for the chosen provider",
125
- "type": "password", "secret": 1, "common": 1
126
  },
127
 
128
  // ── Backup ──
@@ -130,19 +131,19 @@ const FIELDS = [
130
  "g": "Backup", "icon": "💾",
131
  "k": "HF_TOKEN",
132
  "lbl": "HuggingFace token — enables state backup to a private dataset",
133
- "type": "password", "secret": 1, "common": 1
134
  },
135
  {
136
  "g": "Backup", "icon": "💾",
137
  "k": "BACKUP_DATASET_NAME",
138
  "lbl": "Name of the HF dataset used for backups",
139
- "type": "text", "ph": "huggingmes-backup", "common": 1
140
  },
141
  {
142
  "g": "Backup", "icon": "💾",
143
  "k": "SYNC_INTERVAL",
144
  "lbl": "Backup sync interval (seconds)",
145
- "type": "number", "ph": "600"
146
  },
147
 
148
  // ── Telegram ──
@@ -150,13 +151,13 @@ const FIELDS = [
150
  "g": "Telegram", "icon": "📱",
151
  "k": "TELEGRAM_BOT_TOKEN",
152
  "lbl": "Telegram bot token from @BotFather",
153
- "type": "password", "secret": 1, "common": 1
154
  },
155
  {
156
  "g": "Telegram", "icon": "📱",
157
  "k": "TELEGRAM_ALLOWED_USERS",
158
  "lbl": "Allowed Telegram user IDs (comma-separated)",
159
- "type": "text", "ph": "123456789,987654321", "common": 1
160
  },
161
  {
162
  "g": "Telegram", "icon": "📱",
@@ -164,19 +165,19 @@ const FIELDS = [
164
  "lbl": "Telegram update mode",
165
  "type": "select",
166
  "options": ["webhook", "polling"],
167
- "ph": "webhook"
168
  },
169
  {
170
  "g": "Telegram", "icon": "📱",
171
  "k": "TELEGRAM_WEBHOOK_URL",
172
  "lbl": "Override webhook URL (auto-detected from SPACE_HOST if blank)",
173
- "type": "text", "ph": "https://your-space.hf.space/telegram"
174
  },
175
  {
176
  "g": "Telegram", "icon": "📱",
177
  "k": "TELEGRAM_BASE_URL",
178
  "lbl": "Custom Telegram API base URL (for proxies)",
179
- "type": "text", "ph": "https://proxy.example.com/bot"
180
  },
181
 
182
  // ── Terminal ──
@@ -184,19 +185,19 @@ const FIELDS = [
184
  "g": "Terminal", "icon": "💻",
185
  "k": "DEV_MODE",
186
  "lbl": "Enable JupyterLab terminal (on by default)",
187
- "type": "toggle", "ph": "true", "common": 1
188
  },
189
  {
190
  "g": "Terminal", "icon": "💻",
191
  "k": "JUPYTER_TOKEN",
192
  "lbl": "Override terminal password (defaults to GATEWAY_TOKEN)",
193
- "type": "password", "secret": 1
194
  },
195
  {
196
  "g": "Terminal", "icon": "💻",
197
  "k": "JUPYTER_ROOT_DIR",
198
  "lbl": "JupyterLab root directory",
199
- "type": "text", "ph": "/opt/data/workspace"
200
  },
201
 
202
  // ── Providers ──
@@ -204,43 +205,43 @@ const FIELDS = [
204
  "g": "Providers", "icon": "🔑",
205
  "k": "ANTHROPIC_API_KEY",
206
  "lbl": "Anthropic API key",
207
- "type": "password", "secret": 1
208
  },
209
  {
210
  "g": "Providers", "icon": "🔑",
211
  "k": "OPENAI_API_KEY",
212
  "lbl": "OpenAI API key",
213
- "type": "password", "secret": 1
214
  },
215
  {
216
  "g": "Providers", "icon": "🔑",
217
  "k": "GOOGLE_API_KEY",
218
  "lbl": "Google / Gemini API key",
219
- "type": "password", "secret": 1
220
  },
221
  {
222
  "g": "Providers", "icon": "🔑",
223
  "k": "GEMINI_API_KEY",
224
  "lbl": "Gemini API key (alias for GOOGLE_API_KEY)",
225
- "type": "password", "secret": 1
226
  },
227
  {
228
  "g": "Providers", "icon": "🔑",
229
  "k": "OPENROUTER_API_KEY",
230
  "lbl": "OpenRouter API key",
231
- "type": "password", "secret": 1
232
  },
233
  {
234
  "g": "Providers", "icon": "🔑",
235
  "k": "DEEPSEEK_API_KEY",
236
  "lbl": "DeepSeek API key",
237
- "type": "password", "secret": 1
238
  },
239
  {
240
  "g": "Providers", "icon": "🔑",
241
  "k": "XAI_API_KEY",
242
  "lbl": "xAI (Grok) API key",
243
- "type": "password", "secret": 1
244
  },
245
  {
246
  "g": "Providers", "icon": "🔑",
@@ -248,37 +249,37 @@ const FIELDS = [
248
  "lbl": "Force Hermes inference provider (overrides auto-detect)",
249
  "type": "select",
250
  "options": ["auto", "anthropic", "openai", "gemini", "openrouter", "huggingface", "custom", "deepseek", "xai"],
251
- "ph": "auto"
252
  },
253
  {
254
  "g": "Providers", "icon": "🔑",
255
  "k": "CUSTOM_BASE_URL",
256
  "lbl": "Custom OpenAI-compatible base URL",
257
- "type": "text", "ph": "https://your-api.example.com/v1"
258
  },
259
  {
260
  "g": "Providers", "icon": "🔑",
261
  "k": "CUSTOM_API_KEY",
262
  "lbl": "API key for the custom provider",
263
- "type": "password", "secret": 1
264
  },
265
  {
266
  "g": "Providers", "icon": "🔑",
267
  "k": "CUSTOM_PROVIDER",
268
  "lbl": "Provider name for custom endpoints",
269
- "type": "text", "ph": "custom"
270
  },
271
  {
272
  "g": "Providers", "icon": "🔑",
273
  "k": "CUSTOM_MODEL_CONTEXT_LENGTH",
274
  "lbl": "Context length for custom model",
275
- "type": "number", "ph": "131072"
276
  },
277
  {
278
  "g": "Providers", "icon": "🔑",
279
  "k": "CUSTOM_MODEL_MAX_TOKENS",
280
  "lbl": "Max output tokens for custom model",
281
- "type": "number", "ph": "8192"
282
  },
283
 
284
  // ── Cloudflare ──
@@ -286,19 +287,19 @@ const FIELDS = [
286
  "g": "Cloudflare", "icon": "☁️",
287
  "k": "CLOUDFLARE_WORKERS_TOKEN",
288
  "lbl": "Cloudflare Workers API token (for Telegram proxy setup)",
289
- "type": "password", "secret": 1
290
  },
291
  {
292
  "g": "Cloudflare", "icon": "☁️",
293
  "k": "CLOUDFLARE_PROXY_URL",
294
  "lbl": "Cloudflare proxy URL for Telegram (if already deployed)",
295
- "type": "text", "ph": "https://your-worker.your-subdomain.workers.dev"
296
  },
297
  {
298
  "g": "Cloudflare", "icon": "☁️",
299
  "k": "CLOUDFLARE_PROXY_DEBUG",
300
  "lbl": "Enable Cloudflare proxy debug logging",
301
- "type": "toggle", "ph": "false"
302
  },
303
 
304
  // ── Advanced ──
@@ -306,25 +307,31 @@ const FIELDS = [
306
  "g": "Advanced", "icon": "⚙️",
307
  "k": "WEBHOOK_URL",
308
  "lbl": "URL to POST a JSON notification on gateway (re)start",
309
- "type": "text", "ph": "https://..."
 
 
 
 
 
 
310
  },
311
  {
312
  "g": "Advanced", "icon": "⚙️",
313
  "k": "GATEWAY_READY_TIMEOUT",
314
  "lbl": "Seconds to wait for gateway API port before failing",
315
- "type": "number", "ph": "120"
316
  },
317
  {
318
  "g": "Advanced", "icon": "⚙️",
319
  "k": "API_SERVER_PORT",
320
  "lbl": "Hermes gateway internal API port",
321
- "type": "number", "ph": "8642"
322
  },
323
  {
324
  "g": "Advanced", "icon": "⚙️",
325
  "k": "DASHBOARD_PORT",
326
  "lbl": "Hermes dashboard internal port",
327
- "type": "number", "ph": "9119"
328
  },
329
  {
330
  "g": "Advanced", "icon": "⚙️",
@@ -332,13 +339,13 @@ const FIELDS = [
332
  "lbl": "Background process notification level",
333
  "type": "select",
334
  "options": ["result", "progress", "none"],
335
- "ph": "result"
336
  },
337
  {
338
  "g": "Advanced", "icon": "⚙️",
339
  "k": "TELEGRAM_WEBHOOK_SECRET",
340
  "lbl": "Secret token for Telegram webhook validation (auto-generated if blank)",
341
- "type": "password", "secret": 1
342
  }
343
  ];
344
 
@@ -478,18 +485,21 @@ function valueControlHTML(field) {
478
  </div>`;
479
  }
480
 
 
 
 
 
 
481
  function cardHTML(f) {
482
- const badge = f.secret
483
- ? '<span class="badge badge-s">secret</span>'
484
- : '<span class="badge badge-f">safe</span>';
485
- return `<div class="env-card" data-row data-group="${esc(f.g)}" data-search="${esc((f.g + ' ' + f.k + ' ' + (f.lbl || '')).toLowerCase())}">
486
  <div class="card-top">
487
- <input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''}>
488
  <div class="card-info">
489
  <div class="card-key">${esc(f.k)}</div>
490
  <div class="card-lbl">${esc(f.lbl || '')}</div>
491
  </div>
492
- ${badge}
493
  </div>
494
  <div class="card-input">${valueControlHTML(f)}</div>
495
  </div>`;
@@ -550,12 +560,18 @@ function collect() {
550
  return obj;
551
  }
552
 
553
- function refresh() {
554
  const obj = collect();
555
  const keys = Object.keys(obj).sort();
556
  const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
557
  $('bundleOut').value = bundle;
558
  $('envLineOut').value = bundle ? `${BUNDLE_KEY}=${bundle}` : '';
 
 
 
 
 
 
559
  const s = $('summary');
560
  if (keys.length) {
561
  s.innerHTML = `<strong>${keys.length}</strong> variable${keys.length > 1 ? 's' : ''} selected<div class="sum-keys">${keys.map(k => `<span class="sum-key">${esc(k)}</span>`).join('')}</div>`;
@@ -755,6 +771,11 @@ refresh();
755
 
756
  // ── Events ──
757
  $('search').oninput = filter;
 
 
 
 
 
758
  $('selectCommon').onclick = () => {
759
  document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
760
  markSelected(); refresh();
@@ -764,6 +785,7 @@ $('selectVisible').onclick = () => {
764
  markSelected(); refresh();
765
  };
766
  $('clearAll').onclick = () => { clearForm(); markSelected(); filter(); refresh(); };
 
767
  $('applyImport').onclick = () => {
768
  try { applyObj(parseEnv($('importText').value), true); showToast('Imported ✓'); }
769
  catch (e) { showToast('Import failed'); alert(e.message); }
 
103
  };
104
 
105
  // ── Field definitions ──
106
+ // tag: "critical" | "credential" | "feature" | "optional" | "advanced" | "build"
107
  const FIELDS = [
108
  // ── Core ──
109
  {
110
  "g": "Core", "icon": "⚡",
111
  "k": "GATEWAY_TOKEN",
112
  "lbl": "Gateway token — protects the Hermes web UI",
113
+ "type": "password", "secret": 1, "common": 1, "tag": "critical"
114
  },
115
  {
116
  "g": "Core", "icon": "⚡",
117
  "k": "LLM_MODEL",
118
  "lbl": "Default model (provider/model-name format)",
119
  "type": "model", "options_key": "LLM_MODEL",
120
+ "ph": "gemini/gemini-2.5-flash", "common": 1, "tag": "critical"
121
  },
122
  {
123
  "g": "Core", "icon": "⚡",
124
  "k": "LLM_API_KEY",
125
  "lbl": "API key for the chosen provider",
126
+ "type": "password", "secret": 1, "common": 1, "tag": "credential"
127
  },
128
 
129
  // ── Backup ──
 
131
  "g": "Backup", "icon": "💾",
132
  "k": "HF_TOKEN",
133
  "lbl": "HuggingFace token — enables state backup to a private dataset",
134
+ "type": "password", "secret": 1, "common": 1, "tag": "credential"
135
  },
136
  {
137
  "g": "Backup", "icon": "💾",
138
  "k": "BACKUP_DATASET_NAME",
139
  "lbl": "Name of the HF dataset used for backups",
140
+ "type": "text", "ph": "huggingmes-backup", "common": 1, "tag": "optional"
141
  },
142
  {
143
  "g": "Backup", "icon": "💾",
144
  "k": "SYNC_INTERVAL",
145
  "lbl": "Backup sync interval (seconds)",
146
+ "type": "number", "ph": "600", "tag": "optional"
147
  },
148
 
149
  // ── Telegram ──
 
151
  "g": "Telegram", "icon": "📱",
152
  "k": "TELEGRAM_BOT_TOKEN",
153
  "lbl": "Telegram bot token from @BotFather",
154
+ "type": "password", "secret": 1, "common": 1, "tag": "credential"
155
  },
156
  {
157
  "g": "Telegram", "icon": "📱",
158
  "k": "TELEGRAM_ALLOWED_USERS",
159
  "lbl": "Allowed Telegram user IDs (comma-separated)",
160
+ "type": "text", "ph": "123456789,987654321", "common": 1, "tag": "feature"
161
  },
162
  {
163
  "g": "Telegram", "icon": "📱",
 
165
  "lbl": "Telegram update mode",
166
  "type": "select",
167
  "options": ["webhook", "polling"],
168
+ "ph": "webhook", "tag": "optional"
169
  },
170
  {
171
  "g": "Telegram", "icon": "📱",
172
  "k": "TELEGRAM_WEBHOOK_URL",
173
  "lbl": "Override webhook URL (auto-detected from SPACE_HOST if blank)",
174
+ "type": "text", "ph": "https://your-space.hf.space/telegram", "tag": "optional"
175
  },
176
  {
177
  "g": "Telegram", "icon": "📱",
178
  "k": "TELEGRAM_BASE_URL",
179
  "lbl": "Custom Telegram API base URL (for proxies)",
180
+ "type": "text", "ph": "https://proxy.example.com/bot", "tag": "optional"
181
  },
182
 
183
  // ── Terminal ──
 
185
  "g": "Terminal", "icon": "💻",
186
  "k": "DEV_MODE",
187
  "lbl": "Enable JupyterLab terminal (on by default)",
188
+ "type": "toggle", "ph": "true", "common": 1, "tag": "feature"
189
  },
190
  {
191
  "g": "Terminal", "icon": "💻",
192
  "k": "JUPYTER_TOKEN",
193
  "lbl": "Override terminal password (defaults to GATEWAY_TOKEN)",
194
+ "type": "password", "secret": 1, "tag": "credential"
195
  },
196
  {
197
  "g": "Terminal", "icon": "💻",
198
  "k": "JUPYTER_ROOT_DIR",
199
  "lbl": "JupyterLab root directory",
200
+ "type": "text", "ph": "/opt/data/workspace", "tag": "optional"
201
  },
202
 
203
  // ── Providers ──
 
205
  "g": "Providers", "icon": "🔑",
206
  "k": "ANTHROPIC_API_KEY",
207
  "lbl": "Anthropic API key",
208
+ "type": "password", "secret": 1, "tag": "credential"
209
  },
210
  {
211
  "g": "Providers", "icon": "🔑",
212
  "k": "OPENAI_API_KEY",
213
  "lbl": "OpenAI API key",
214
+ "type": "password", "secret": 1, "tag": "credential"
215
  },
216
  {
217
  "g": "Providers", "icon": "🔑",
218
  "k": "GOOGLE_API_KEY",
219
  "lbl": "Google / Gemini API key",
220
+ "type": "password", "secret": 1, "tag": "credential"
221
  },
222
  {
223
  "g": "Providers", "icon": "🔑",
224
  "k": "GEMINI_API_KEY",
225
  "lbl": "Gemini API key (alias for GOOGLE_API_KEY)",
226
+ "type": "password", "secret": 1, "tag": "credential"
227
  },
228
  {
229
  "g": "Providers", "icon": "🔑",
230
  "k": "OPENROUTER_API_KEY",
231
  "lbl": "OpenRouter API key",
232
+ "type": "password", "secret": 1, "tag": "credential"
233
  },
234
  {
235
  "g": "Providers", "icon": "🔑",
236
  "k": "DEEPSEEK_API_KEY",
237
  "lbl": "DeepSeek API key",
238
+ "type": "password", "secret": 1, "tag": "credential"
239
  },
240
  {
241
  "g": "Providers", "icon": "🔑",
242
  "k": "XAI_API_KEY",
243
  "lbl": "xAI (Grok) API key",
244
+ "type": "password", "secret": 1, "tag": "credential"
245
  },
246
  {
247
  "g": "Providers", "icon": "🔑",
 
249
  "lbl": "Force Hermes inference provider (overrides auto-detect)",
250
  "type": "select",
251
  "options": ["auto", "anthropic", "openai", "gemini", "openrouter", "huggingface", "custom", "deepseek", "xai"],
252
+ "ph": "auto", "tag": "advanced"
253
  },
254
  {
255
  "g": "Providers", "icon": "🔑",
256
  "k": "CUSTOM_BASE_URL",
257
  "lbl": "Custom OpenAI-compatible base URL",
258
+ "type": "text", "ph": "https://your-api.example.com/v1", "tag": "feature"
259
  },
260
  {
261
  "g": "Providers", "icon": "🔑",
262
  "k": "CUSTOM_API_KEY",
263
  "lbl": "API key for the custom provider",
264
+ "type": "password", "secret": 1, "tag": "credential"
265
  },
266
  {
267
  "g": "Providers", "icon": "🔑",
268
  "k": "CUSTOM_PROVIDER",
269
  "lbl": "Provider name for custom endpoints",
270
+ "type": "text", "ph": "custom", "tag": "advanced"
271
  },
272
  {
273
  "g": "Providers", "icon": "🔑",
274
  "k": "CUSTOM_MODEL_CONTEXT_LENGTH",
275
  "lbl": "Context length for custom model",
276
+ "type": "number", "ph": "131072", "tag": "advanced"
277
  },
278
  {
279
  "g": "Providers", "icon": "🔑",
280
  "k": "CUSTOM_MODEL_MAX_TOKENS",
281
  "lbl": "Max output tokens for custom model",
282
+ "type": "number", "ph": "8192", "tag": "advanced"
283
  },
284
 
285
  // ── Cloudflare ──
 
287
  "g": "Cloudflare", "icon": "☁️",
288
  "k": "CLOUDFLARE_WORKERS_TOKEN",
289
  "lbl": "Cloudflare Workers API token (for Telegram proxy setup)",
290
+ "type": "password", "secret": 1, "tag": "credential"
291
  },
292
  {
293
  "g": "Cloudflare", "icon": "☁️",
294
  "k": "CLOUDFLARE_PROXY_URL",
295
  "lbl": "Cloudflare proxy URL for Telegram (if already deployed)",
296
+ "type": "text", "ph": "https://your-worker.your-subdomain.workers.dev", "tag": "feature"
297
  },
298
  {
299
  "g": "Cloudflare", "icon": "☁️",
300
  "k": "CLOUDFLARE_PROXY_DEBUG",
301
  "lbl": "Enable Cloudflare proxy debug logging",
302
+ "type": "toggle", "ph": "false", "tag": "advanced"
303
  },
304
 
305
  // ── Advanced ──
 
307
  "g": "Advanced", "icon": "⚙️",
308
  "k": "WEBHOOK_URL",
309
  "lbl": "URL to POST a JSON notification on gateway (re)start",
310
+ "type": "text", "ph": "https://...", "tag": "optional"
311
+ },
312
+ {
313
+ "g": "Advanced", "icon": "⚙️",
314
+ "k": "SPACE_PRIVACY",
315
+ "lbl": "Override Space privacy detection (public/private) — skips HF API call",
316
+ "type": "select", "options": ["public", "private"], "ph": "public", "tag": "advanced"
317
  },
318
  {
319
  "g": "Advanced", "icon": "⚙️",
320
  "k": "GATEWAY_READY_TIMEOUT",
321
  "lbl": "Seconds to wait for gateway API port before failing",
322
+ "type": "number", "ph": "120", "tag": "advanced"
323
  },
324
  {
325
  "g": "Advanced", "icon": "⚙️",
326
  "k": "API_SERVER_PORT",
327
  "lbl": "Hermes gateway internal API port",
328
+ "type": "number", "ph": "8642", "tag": "advanced"
329
  },
330
  {
331
  "g": "Advanced", "icon": "⚙️",
332
  "k": "DASHBOARD_PORT",
333
  "lbl": "Hermes dashboard internal port",
334
+ "type": "number", "ph": "9119", "tag": "advanced"
335
  },
336
  {
337
  "g": "Advanced", "icon": "⚙️",
 
339
  "lbl": "Background process notification level",
340
  "type": "select",
341
  "options": ["result", "progress", "none"],
342
+ "ph": "result", "tag": "optional"
343
  },
344
  {
345
  "g": "Advanced", "icon": "⚙️",
346
  "k": "TELEGRAM_WEBHOOK_SECRET",
347
  "lbl": "Secret token for Telegram webhook validation (auto-generated if blank)",
348
+ "type": "password", "secret": 1, "tag": "credential"
349
  }
350
  ];
351
 
 
485
  </div>`;
486
  }
487
 
488
+ function tagBadgeHTML(f) {
489
+ const t = f.tag || (f.secret ? 'credential' : 'optional');
490
+ return `<span class="badge badge-${t}">${t}</span>`;
491
+ }
492
+
493
  function cardHTML(f) {
494
+ const tagStr = (f.tag || '') + ' ' + (f.secret ? 'credential' : '') + ' ' + (f.g + ' ' + f.k + ' ' + (f.lbl || '')).toLowerCase();
495
+ return `<div class="env-card" data-row data-group="${esc(f.g)}" data-tag="${esc(f.tag || '')}" data-search="${esc(tagStr.toLowerCase())}">
 
 
496
  <div class="card-top">
497
+ <input type="checkbox" class="card-check" data-check="${esc(f.k)}" ${f.common ? 'data-common="1"' : ''} ${f.tag === 'critical' ? 'data-critical="1"' : ''}>
498
  <div class="card-info">
499
  <div class="card-key">${esc(f.k)}</div>
500
  <div class="card-lbl">${esc(f.lbl || '')}</div>
501
  </div>
502
+ ${tagBadgeHTML(f)}
503
  </div>
504
  <div class="card-input">${valueControlHTML(f)}</div>
505
  </div>`;
 
560
  return obj;
561
  }
562
 
563
+ function generateBundle() {
564
  const obj = collect();
565
  const keys = Object.keys(obj).sort();
566
  const bundle = keys.length ? encodeBundle(Object.fromEntries(keys.map(k => [k, obj[k]]))) : '';
567
  $('bundleOut').value = bundle;
568
  $('envLineOut').value = bundle ? `${BUNDLE_KEY}=${bundle}` : '';
569
+ }
570
+
571
+ function refresh() {
572
+ // Refresh summary + counts — does NOT auto-regenerate bundle (requires explicit button click)
573
+ const obj = collect();
574
+ const keys = Object.keys(obj).sort();
575
  const s = $('summary');
576
  if (keys.length) {
577
  s.innerHTML = `<strong>${keys.length}</strong> variable${keys.length > 1 ? 's' : ''} selected<div class="sum-keys">${keys.map(k => `<span class="sum-key">${esc(k)}</span>`).join('')}</div>`;
 
771
 
772
  // ── Events ──
773
  $('search').oninput = filter;
774
+ $('selectRequired').onclick = () => {
775
+ document.querySelectorAll('[data-critical="1"]').forEach(c => c.checked = true);
776
+ markSelected(); refresh();
777
+ showToast('Critical fields selected ✓');
778
+ };
779
  $('selectCommon').onclick = () => {
780
  document.querySelectorAll('[data-common="1"]').forEach(c => c.checked = true);
781
  markSelected(); refresh();
 
785
  markSelected(); refresh();
786
  };
787
  $('clearAll').onclick = () => { clearForm(); markSelected(); filter(); refresh(); };
788
+ $('generateBundle').onclick = () => { generateBundle(); showToast('Bundle generated ✓'); };
789
  $('applyImport').onclick = () => {
790
  try { applyObj(parseEnv($('importText').value), true); showToast('Imported ✓'); }
791
  catch (e) { showToast('Import failed'); alert(e.message); }
health-server.js CHANGED
@@ -33,44 +33,94 @@ function deriveHfSpaceUrl() {
33
  }
34
  const HF_SPACE_URL = deriveHfSpaceUrl();
35
 
36
- let SPACE_IS_PRIVATE = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  async function detectSpacePrivacy() {
38
- if (!SPACE_ID) return;
39
- try {
40
- const token = (process.env.HF_TOKEN || "").trim();
41
- const reqOptions = {
42
- hostname: "huggingface.co",
43
- path: `/api/spaces/${SPACE_ID}`,
44
- method: "GET",
45
- headers: Object.assign(
46
- { "User-Agent": "HuggingMes/health-server" },
47
- token ? { Authorization: `Bearer ${token}` } : {}
48
- ),
49
- };
50
- await new Promise((resolve) => {
51
- const r = https.request(reqOptions, (res) => {
52
- let body = "";
53
- res.on("data", (chunk) => { body += chunk; });
54
- res.on("end", () => {
55
- try {
56
- if (res.statusCode === 200) {
57
- const data = JSON.parse(body);
58
- SPACE_IS_PRIVATE = data.private === true;
59
- } else if (res.statusCode === 404 && !token) {
60
- SPACE_IS_PRIVATE = true;
61
- }
62
- } catch {}
63
- resolve();
 
 
 
 
 
 
 
 
 
 
 
 
64
  });
 
 
 
65
  });
66
- r.on("error", resolve);
67
- r.setTimeout(5000, () => { r.destroy(); resolve(); });
68
- r.end();
69
- });
70
- console.log(`[health-server] Space privacy: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
71
- } catch {}
 
 
 
 
 
 
 
 
 
 
 
 
72
  }
73
- detectSpacePrivacy();
74
 
75
  const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
76
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
@@ -427,7 +477,14 @@ function renderPrivateRedirect(targetUrl) {
427
  <a class="btn" href="${safeUrl}">Open on Hugging Face →</a>
428
  <div class="sub">Redirecting in 3 seconds&hellip;</div>
429
  </div>
430
- <script>setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);</script>
 
 
 
 
 
 
 
431
  </body></html>`;
432
  }
433
 
@@ -632,7 +689,46 @@ function renderDashboard(data) {
632
  const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
633
  const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
634
  const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
635
- const SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
637
  const notice = document.createElement('div');
638
  notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999';
@@ -640,17 +736,6 @@ function renderDashboard(data) {
640
  document.body.appendChild(notice);
641
  setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
642
  }
643
- // Force new-tab navigation when running inside the HF App iframe or on a raw .hf.space link
644
- const openInNewTab = inEmbeddedApp || isDirectHfSpaceHost;
645
- document.querySelectorAll('a[data-space-link]').forEach((a) => {
646
- if (openInNewTab) {
647
- a.setAttribute('target', '_blank');
648
- a.setAttribute('rel', 'noopener noreferrer');
649
- } else {
650
- a.removeAttribute('target');
651
- a.removeAttribute('rel');
652
- }
653
- });
654
  </script>
655
  </body>
656
  </html>`;
@@ -660,6 +745,15 @@ const server = http.createServer(async (req, res) => {
660
  const parsed = new URL(req.url, "http://localhost");
661
  const path = parsed.pathname;
662
 
 
 
 
 
 
 
 
 
 
663
  if (path === LOGIN_PATH) {
664
  await handleLogin(req, res, parsed);
665
  return;
@@ -669,9 +763,31 @@ const server = http.createServer(async (req, res) => {
669
  // Intercepts browser HTML requests from raw .hf.space hosts when the Space is private.
670
  // /health and /status are always exempt so uptime monitors keep working.
671
  const isHtmlReq = (req.headers.accept || "").includes("text/html");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
  const isDirectHfSpaceReq = SPACE_IS_PRIVATE &&
673
  HF_SPACE_URL &&
674
  isHtmlReq &&
 
 
675
  typeof req.headers.host === "string" &&
676
  req.headers.host.endsWith(".hf.space");
677
 
 
33
  }
34
  const HF_SPACE_URL = deriveHfSpaceUrl();
35
 
36
+ // Privacy detection priority:
37
+ // 1. SPACE_PRIVACY env var ("public"/"private") — explicit override, skip API call
38
+ // 2. HF API auto-detect with retry
39
+ // 3. Fail-secure: treat as private if SPACE_ID set
40
+ const _spacPrivacyEnv = (process.env.SPACE_PRIVACY || "").trim().toLowerCase();
41
+ let SPACE_IS_PRIVATE;
42
+ let _privacyDetectionDone = false;
43
+ let _privacyDetectionResolve;
44
+ const privacyDetectionReady = new Promise((res) => { _privacyDetectionResolve = res; });
45
+
46
+ if (_spacPrivacyEnv === "public") {
47
+ SPACE_IS_PRIVATE = false;
48
+ _privacyDetectionDone = true;
49
+ console.log("[health-server] Space privacy: public (SPACE_PRIVACY env override)");
50
+ _privacyDetectionResolve();
51
+ } else if (_spacPrivacyEnv === "private") {
52
+ SPACE_IS_PRIVATE = true;
53
+ _privacyDetectionDone = true;
54
+ console.log("[health-server] Space privacy: private (SPACE_PRIVACY env override)");
55
+ _privacyDetectionResolve();
56
+ } else {
57
+ // Fail-secure default until API call resolves
58
+ SPACE_IS_PRIVATE = !!SPACE_ID;
59
+ }
60
+
61
  async function detectSpacePrivacy() {
62
+ if (_spacPrivacyEnv === "public" || _spacPrivacyEnv === "private") return;
63
+ if (!SPACE_ID) {
64
+ SPACE_IS_PRIVATE = false;
65
+ _privacyDetectionDone = true;
66
+ _privacyDetectionResolve();
67
+ return;
68
+ }
69
+ const token = (process.env.HF_TOKEN || "").trim();
70
+ const reqOptions = {
71
+ hostname: "huggingface.co",
72
+ path: `/api/spaces/${SPACE_ID}`,
73
+ method: "GET",
74
+ headers: Object.assign(
75
+ { "User-Agent": "HuggingMes/health-server" },
76
+ token ? { Authorization: `Bearer ${token}` } : {}
77
+ ),
78
+ };
79
+ const MAX_ATTEMPTS = 5;
80
+ let detected = false;
81
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
82
+ try {
83
+ const result = await new Promise((resolve) => {
84
+ const r = https.request(reqOptions, (apiRes) => {
85
+ let body = "";
86
+ apiRes.on("data", (chunk) => { body += chunk; });
87
+ apiRes.on("end", () => {
88
+ try {
89
+ if (apiRes.statusCode === 200) {
90
+ SPACE_IS_PRIVATE = JSON.parse(body).private === true;
91
+ resolve({ ok: true });
92
+ } else if (apiRes.statusCode === 401 || apiRes.statusCode === 403) {
93
+ SPACE_IS_PRIVATE = true;
94
+ resolve({ ok: true });
95
+ } else {
96
+ resolve({ ok: false });
97
+ }
98
+ } catch { resolve({ ok: false }); }
99
+ });
100
  });
101
+ r.on("error", () => resolve({ ok: false }));
102
+ r.setTimeout(8000, () => { r.destroy(); resolve({ ok: false }); });
103
+ r.end();
104
  });
105
+ console.log(`[health-server] Privacy detection attempt ${attempt}/${MAX_ATTEMPTS}: ok=${result.ok}`);
106
+ if (result.ok) { detected = true; break; }
107
+ } catch {}
108
+ const delay = Math.min(2000 * attempt, 10000);
109
+ if (attempt < MAX_ATTEMPTS) await new Promise((r) => setTimeout(r, delay));
110
+ }
111
+ if (!detected) {
112
+ console.warn(`[health-server] Privacy detection failed after ${MAX_ATTEMPTS} attempts — defaulting to ${SPACE_IS_PRIVATE ? "private" : "public"}. TIP: Set SPACE_PRIVACY=public in Space secrets to skip API detection.`);
113
+ } else {
114
+ console.log(`[health-server] Space privacy detected: ${SPACE_IS_PRIVATE ? "private" : "public"}`);
115
+ }
116
+ _privacyDetectionDone = true;
117
+ _privacyDetectionResolve();
118
+ }
119
+
120
+ if (_spacPrivacyEnv !== "public" && _spacPrivacyEnv !== "private") {
121
+ detectSpacePrivacy();
122
+ setInterval(detectSpacePrivacy, 5 * 60 * 1000);
123
  }
 
124
 
125
  const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json";
126
  const CLOUDFLARE_KEEPALIVE_STATUS_FILE =
 
477
  <a class="btn" href="${safeUrl}">Open on Hugging Face →</a>
478
  <div class="sub">Redirecting in 3 seconds&hellip;</div>
479
  </div>
480
+ <script>
481
+ // Only auto-redirect when NOT inside an iframe — navigating an iframe to
482
+ // huggingface.co is blocked by X-Frame-Options and causes "refused to connect".
483
+ const _inFrame = (() => { try { return window.top !== window.self; } catch { return true; } })();
484
+ if (!_inFrame) {
485
+ setTimeout(() => { window.location.replace(${JSON.stringify(targetUrl)}); }, 100);
486
+ }
487
+ </script>
488
  </body></html>`;
489
  }
490
 
 
689
  const inEmbeddedApp = (() => { try { return window.top !== window.self; } catch { return true; } })();
690
  const isDirectHfSpaceHost = /\.hf\.space$/i.test(window.location.hostname);
691
  const HF_SPACE_URL = ${JSON.stringify(HF_SPACE_URL)};
692
+ // Server-side value may be stale if privacy detection raced — syncPrivacy() corrects it.
693
+ let SPACE_IS_PRIVATE = ${JSON.stringify(SPACE_IS_PRIVATE)};
694
+
695
+ function applyLinkTargets() {
696
+ const openInNewTab = !SPACE_IS_PRIVATE && (inEmbeddedApp || isDirectHfSpaceHost);
697
+ document.querySelectorAll('a[data-space-link]').forEach((a) => {
698
+ if (openInNewTab) {
699
+ a.setAttribute('target', '_blank');
700
+ a.setAttribute('rel', 'noopener noreferrer');
701
+ } else {
702
+ a.removeAttribute('target');
703
+ a.removeAttribute('rel');
704
+ }
705
+ });
706
+ }
707
+ applyLinkTargets();
708
+
709
+ function syncPrivacy() {
710
+ return fetch('/api/is-private', { cache: 'no-store' })
711
+ .then(r => r.json())
712
+ .then(d => {
713
+ if (d.isPrivate !== SPACE_IS_PRIVATE) {
714
+ SPACE_IS_PRIVATE = d.isPrivate;
715
+ applyLinkTargets();
716
+ }
717
+ return d.isPrivate;
718
+ })
719
+ .catch(() => SPACE_IS_PRIVATE);
720
+ }
721
+
722
+ if (isDirectHfSpaceHost) {
723
+ syncPrivacy().then(isPrivate => {
724
+ if (isPrivate) {
725
+ setTimeout(syncPrivacy, 8000);
726
+ setTimeout(syncPrivacy, 16000);
727
+ }
728
+ });
729
+ }
730
+
731
+ // Private redirect — only when NOT in iframe (huggingface.co has X-Frame-Options: DENY)
732
  if (SPACE_IS_PRIVATE && isDirectHfSpaceHost && !inEmbeddedApp && HF_SPACE_URL) {
733
  const notice = document.createElement('div');
734
  notice.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#08080f;color:#f6f4ff;font-family:sans-serif;flex-direction:column;gap:16px;z-index:9999';
 
736
  document.body.appendChild(notice);
737
  setTimeout(() => { window.location.replace(HF_SPACE_URL); }, 300);
738
  }
 
 
 
 
 
 
 
 
 
 
 
739
  </script>
740
  </body>
741
  </html>`;
 
745
  const parsed = new URL(req.url, "http://localhost");
746
  const path = parsed.pathname;
747
 
748
+ // Lightweight endpoint for client-side privacy fallback.
749
+ // Called by dashboard JS to correct stale server-rendered SPACE_IS_PRIVATE value.
750
+ // No auth required — not sensitive.
751
+ if (path === "/api/is-private") {
752
+ if (!_privacyDetectionDone) await privacyDetectionReady;
753
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
754
+ return res.end(JSON.stringify({ isPrivate: SPACE_IS_PRIVATE }));
755
+ }
756
+
757
  if (path === LOGIN_PATH) {
758
  await handleLogin(req, res, parsed);
759
  return;
 
763
  // Intercepts browser HTML requests from raw .hf.space hosts when the Space is private.
764
  // /health and /status are always exempt so uptime monitors keep working.
765
  const isHtmlReq = (req.headers.accept || "").includes("text/html");
766
+
767
+ // RACE CONDITION FIX: await privacy detection before computing redirect logic.
768
+ // Without this, the fail-secure default (SPACE_IS_PRIVATE=true when SPACE_ID is set)
769
+ // causes public spaces to redirect during the brief window before API detection completes.
770
+ if (isHtmlReq && !_privacyDetectionDone) {
771
+ await Promise.race([
772
+ privacyDetectionReady,
773
+ new Promise((r) => setTimeout(r, 1500)),
774
+ ]);
775
+ }
776
+
777
+ // In-app navigation from same origin or HF App iframe — skip private redirect.
778
+ const referer = req.headers.referer || req.headers.referrer || "";
779
+ const isSameOriginNav = !!(referer && typeof req.headers.host === "string" &&
780
+ referer.startsWith(`https://${req.headers.host}`));
781
+ const isFromHFApp = !!(referer && (
782
+ referer.startsWith("https://huggingface.co") ||
783
+ referer.startsWith("https://hf.co")
784
+ ));
785
+
786
  const isDirectHfSpaceReq = SPACE_IS_PRIVATE &&
787
  HF_SPACE_URL &&
788
  isHtmlReq &&
789
+ !isSameOriginNav &&
790
+ !isFromHFApp &&
791
  typeof req.headers.host === "string" &&
792
  req.headers.host.endsWith(".hf.space");
793