@dollhousemcp/mcp-server 2.0.11 → 2.0.12-rc.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/config/ConfigManager.d.ts +34 -0
  3. package/dist/config/ConfigManager.d.ts.map +1 -1
  4. package/dist/config/ConfigManager.js +46 -1
  5. package/dist/config/env.d.ts +18 -0
  6. package/dist/config/env.d.ts.map +1 -1
  7. package/dist/config/env.js +89 -2
  8. package/dist/di/Container.d.ts +1 -0
  9. package/dist/di/Container.d.ts.map +1 -1
  10. package/dist/di/Container.js +38 -2
  11. package/dist/elements/agents/AgentManager.js +2 -2
  12. package/dist/generated/version.d.ts +2 -2
  13. package/dist/generated/version.d.ts.map +1 -1
  14. package/dist/generated/version.js +3 -3
  15. package/dist/handlers/mcp-aql/GatekeeperSession.d.ts.map +1 -1
  16. package/dist/handlers/mcp-aql/GatekeeperSession.js +3 -2
  17. package/dist/handlers/mcp-aql/MCPAQLHandler.d.ts.map +1 -1
  18. package/dist/handlers/mcp-aql/MCPAQLHandler.js +11 -3
  19. package/dist/handlers/mcp-aql/OperationSchema.d.ts.map +1 -1
  20. package/dist/handlers/mcp-aql/OperationSchema.js +11 -6
  21. package/dist/handlers/mcp-aql/policies/ToolClassification.d.ts.map +1 -1
  22. package/dist/handlers/mcp-aql/policies/ToolClassification.js +8 -1
  23. package/dist/index.js +53 -3
  24. package/dist/security/InputNormalizer.d.ts +4 -6
  25. package/dist/security/InputNormalizer.d.ts.map +1 -1
  26. package/dist/security/InputNormalizer.js +10 -16
  27. package/dist/security/audit/config/suppressions.d.ts.map +1 -1
  28. package/dist/security/audit/config/suppressions.js +26 -1
  29. package/dist/security/constants.d.ts +7 -0
  30. package/dist/security/constants.d.ts.map +1 -1
  31. package/dist/security/constants.js +10 -1
  32. package/dist/security/contentValidator.d.ts.map +1 -1
  33. package/dist/security/contentValidator.js +14 -6
  34. package/dist/security/encryption/PatternEncryptor.d.ts.map +1 -1
  35. package/dist/security/encryption/PatternEncryptor.js +18 -10
  36. package/dist/security/securityMonitor.d.ts +1 -1
  37. package/dist/security/securityMonitor.d.ts.map +1 -1
  38. package/dist/security/securityMonitor.js +1 -1
  39. package/dist/security/tokenManager.d.ts +18 -2
  40. package/dist/security/tokenManager.d.ts.map +1 -1
  41. package/dist/security/tokenManager.js +47 -15
  42. package/dist/security/validators/unicodeValidator.d.ts +4 -5
  43. package/dist/security/validators/unicodeValidator.d.ts.map +1 -1
  44. package/dist/security/validators/unicodeValidator.js +19 -20
  45. package/dist/services/VerificationNotifier.d.ts.map +1 -1
  46. package/dist/services/VerificationNotifier.js +15 -13
  47. package/dist/services/validation/GenericElementValidator.js +5 -5
  48. package/dist/web/console/IngestRoutes.d.ts +14 -2
  49. package/dist/web/console/IngestRoutes.d.ts.map +1 -1
  50. package/dist/web/console/IngestRoutes.js +69 -5
  51. package/dist/web/console/LeaderElection.d.ts +37 -3
  52. package/dist/web/console/LeaderElection.d.ts.map +1 -1
  53. package/dist/web/console/LeaderElection.js +67 -7
  54. package/dist/web/console/LeaderForwardingSink.d.ts +19 -3
  55. package/dist/web/console/LeaderForwardingSink.d.ts.map +1 -1
  56. package/dist/web/console/LeaderForwardingSink.js +44 -9
  57. package/dist/web/console/PromotionManager.d.ts +29 -0
  58. package/dist/web/console/PromotionManager.d.ts.map +1 -0
  59. package/dist/web/console/PromotionManager.js +94 -0
  60. package/dist/web/console/SessionNames.d.ts +7 -0
  61. package/dist/web/console/SessionNames.d.ts.map +1 -1
  62. package/dist/web/console/SessionNames.js +10 -1
  63. package/dist/web/console/UnifiedConsole.d.ts +26 -1
  64. package/dist/web/console/UnifiedConsole.d.ts.map +1 -1
  65. package/dist/web/console/UnifiedConsole.js +143 -20
  66. package/dist/web/portDiscovery.d.ts +4 -3
  67. package/dist/web/portDiscovery.d.ts.map +1 -1
  68. package/dist/web/portDiscovery.js +7 -5
  69. package/dist/web/public/app.js +35 -18
  70. package/dist/web/public/consoleAuth.js +152 -0
  71. package/dist/web/public/index.html +243 -9
  72. package/dist/web/public/logs.js +1 -1
  73. package/dist/web/public/metrics.js +3 -3
  74. package/dist/web/public/permissions.js +1 -1
  75. package/dist/web/public/security.css +396 -0
  76. package/dist/web/public/security.js +393 -0
  77. package/dist/web/public/sessions.css +54 -4
  78. package/dist/web/public/sessions.js +46 -6
  79. package/dist/web/public/setup.css +439 -0
  80. package/dist/web/public/setup.js +490 -18
  81. package/dist/web/public/styles.css +10 -0
  82. package/dist/web/routes/permissionRoutes.d.ts.map +1 -1
  83. package/dist/web/routes/permissionRoutes.js +8 -3
  84. package/dist/web/routes/setupRoutes.d.ts +4 -0
  85. package/dist/web/routes/setupRoutes.d.ts.map +1 -1
  86. package/dist/web/routes/setupRoutes.js +418 -7
  87. package/dist/web/routes.d.ts.map +1 -1
  88. package/dist/web/routes.js +9 -5
  89. package/dist/web/server.d.ts +49 -3
  90. package/dist/web/server.d.ts.map +1 -1
  91. package/dist/web/server.js +282 -82
  92. package/package.json +11 -2
  93. package/server.json +2 -2
@@ -39,24 +39,25 @@
39
39
  const npxCmd = (tag) => `npx -y ${PKG}@${tag}`;
40
40
 
41
41
  /** Build all platform configs for a given pinned version */
42
- function buildConfigs(version) {
42
+ function buildConfigs(version, channel = 'latest') {
43
+ const ch = channel;
43
44
  const result = {};
44
45
  for (const { id, rootKey, cli, toml } of PLATFORMS) {
45
46
  const entry = {
46
47
  npx: cli
47
- ? { code: `${cli} mcp add dollhousemcp -- ${npxCmd('latest')}`, isTerminal: true }
48
- : jsonConfig(rootKey, npxCmd('latest')),
48
+ ? { code: `${cli} mcp add dollhousemcp -- ${npxCmd(ch)}`, isTerminal: true }
49
+ : jsonConfig(rootKey, npxCmd(ch)),
49
50
  global: cli
50
51
  ? { code: `${cli} mcp add dollhousemcp -- ${npxCmd(version)}`, isTerminal: true }
51
52
  : jsonConfig(rootKey, npxCmd(version)),
52
53
  };
53
54
  if (cli) {
54
- entry.npxJson = jsonConfig(rootKey, npxCmd('latest'));
55
+ entry.npxJson = jsonConfig(rootKey, npxCmd(ch));
55
56
  entry.globalJson = jsonConfig(rootKey, npxCmd(version));
56
57
  }
57
58
  if (toml) {
58
59
  const tomlBlock = (tag) => `[mcp_servers.dollhousemcp]\ncommand = "npx"\nargs = ["-y", "${PKG}@${tag}"]`;
59
- entry.npxToml = { code: tomlBlock('latest') };
60
+ entry.npxToml = { code: tomlBlock(ch) };
60
61
  entry.globalToml = { code: tomlBlock(version) };
61
62
  }
62
63
  result[id] = entry;
@@ -66,7 +67,8 @@
66
67
 
67
68
  // Start with a placeholder version, update once we fetch from server
68
69
  let pinnedVersion = 'latest';
69
- let configs = buildConfigs(pinnedVersion);
70
+ let currentChannel = 'latest';
71
+ let configs = buildConfigs(pinnedVersion, currentChannel);
70
72
 
71
73
  // ── Current method state ──────────────────────────────────────────────
72
74
 
@@ -111,6 +113,7 @@
111
113
  // Cache DOM elements queried on every toggle click
112
114
  const prereq = document.getElementById('setup-pinned-prereq');
113
115
  const mcpbSection = document.getElementById('setup-mcpb-section');
116
+ const channelToggle = document.getElementById('setup-channel-toggle');
114
117
 
115
118
  const handleToggle = (btn) => {
116
119
  const method = btn.dataset.method;
@@ -125,6 +128,7 @@
125
128
 
126
129
  if (prereq) prereq.hidden = method !== 'global';
127
130
  if (mcpbSection) mcpbSection.hidden = method !== 'global';
131
+ if (channelToggle) channelToggle.hidden = method === 'global';
128
132
 
129
133
  updateAllConfigs(method);
130
134
  updateInstallButtonLabels();
@@ -134,6 +138,34 @@
134
138
  buttons.forEach((btn) => {
135
139
  btn.addEventListener('click', () => handleToggle(btn));
136
140
  });
141
+
142
+ // Sync initial visibility — if the browser restored a non-default
143
+ // active button (e.g. pinned was selected before reload), apply
144
+ // the hidden state now without waiting for a click.
145
+ if (channelToggle) channelToggle.hidden = currentMethod === 'global';
146
+ };
147
+
148
+ // ── Channel selector ──────────────────────────────────────────────────
149
+
150
+ /** User-facing descriptions for each release channel, shown below the selector. */
151
+ const CHANNEL_HINTS = {
152
+ latest: 'Recommended for most users.',
153
+ rc: 'Preview of the next stable release. May have minor issues.',
154
+ beta: 'Cutting-edge features. May be unstable.',
155
+ };
156
+
157
+ const initChannelSelector = () => {
158
+ const select = document.getElementById('setup-channel-select');
159
+ const hint = document.getElementById('setup-channel-hint');
160
+ if (!select) return;
161
+
162
+ select.addEventListener('change', () => {
163
+ currentChannel = select.value;
164
+ if (hint) hint.textContent = CHANNEL_HINTS[currentChannel] || '';
165
+ configs = buildConfigs(pinnedVersion, currentChannel);
166
+ updateAllConfigs(currentMethod);
167
+ updateInstallButtonLabels();
168
+ });
137
169
  };
138
170
 
139
171
  /** Rewrite code blocks and copy-text for the selected method */
@@ -285,9 +317,10 @@
285
317
  const isPinned = currentMethod === 'global' && pinnedVersion && pinnedVersion !== 'latest';
286
318
 
287
319
  // Update Install buttons
320
+ const channelLabel = currentChannel === 'latest' ? '' : ` (${currentChannel})`;
288
321
  document.querySelectorAll('.setup-install-btn').forEach((btn) => {
289
322
  if (btn.classList.contains('is-success') || btn.classList.contains('is-match')) return;
290
- btn.textContent = isPinned ? `Install v${pinnedVersion}` : 'Install Now';
323
+ btn.textContent = isPinned ? `Configure v${pinnedVersion}` : `Configure Now${channelLabel}`;
291
324
  });
292
325
 
293
326
  // Update auto-install badges and descriptions
@@ -311,7 +344,7 @@
311
344
 
312
345
  // ── Install buttons ────────────────────────────────────────────────────
313
346
 
314
- /** Handle Install Now button click */
347
+ /** Handle Configure Now button click */
315
348
  const handleInstallClick = async (btn) => {
316
349
  const client = btn.dataset.installClient;
317
350
  if (!client) return;
@@ -320,7 +353,7 @@
320
353
  const originalText = btn.textContent;
321
354
 
322
355
  btn.disabled = true;
323
- btn.textContent = 'Installing...';
356
+ btn.textContent = 'Configuring...';
324
357
  btn.classList.add('is-loading');
325
358
  if (status) {
326
359
  status.textContent = '';
@@ -331,9 +364,11 @@
331
364
  const payload = { client };
332
365
  if (currentMethod === 'global' && pinnedVersion && pinnedVersion !== 'latest') {
333
366
  payload.version = pinnedVersion;
367
+ } else if (currentChannel !== 'latest') {
368
+ payload.channel = currentChannel;
334
369
  }
335
370
 
336
- const res = await fetch('/api/setup/install', {
371
+ const res = await DollhouseAuth.apiFetch('/api/setup/install', {
337
372
  method: 'POST',
338
373
  headers: { 'Content-Type': 'application/json' },
339
374
  body: JSON.stringify(payload),
@@ -490,7 +525,7 @@
490
525
  };
491
526
 
492
527
  try {
493
- const res = await fetch('/api/setup/open-config', {
528
+ const res = await DollhouseAuth.apiFetch('/api/setup/open-config', {
494
529
  method: 'POST',
495
530
  headers: { 'Content-Type': 'application/json' },
496
531
  body: JSON.stringify({ client }),
@@ -516,7 +551,7 @@
516
551
 
517
552
  const fetchVersion = async () => {
518
553
  try {
519
- const res = await fetch('/api/setup/version');
554
+ const res = await DollhouseAuth.apiFetch('/api/setup/version');
520
555
  if (!res.ok) return;
521
556
  const data = await res.json();
522
557
 
@@ -524,8 +559,8 @@
524
559
  pinnedVersion = data.latest?.version || data.running?.version || pinnedVersion;
525
560
  if (pinnedVersion === 'latest') return;
526
561
 
527
- // Rebuild configs with real version
528
- configs = buildConfigs(pinnedVersion);
562
+ // Rebuild configs with real version and current channel
563
+ configs = buildConfigs(pinnedVersion, currentChannel);
529
564
 
530
565
  // Update prereq section
531
566
  const versionLabel = document.getElementById('pinned-version-label');
@@ -680,7 +715,7 @@
680
715
  installBtn.classList.add('is-match');
681
716
  } else {
682
717
  const isPinned = currentMethod === 'global' && pinnedVersion && pinnedVersion !== 'latest';
683
- installBtn.textContent = isPinned ? `Install v${pinnedVersion}` : 'Install Now';
718
+ installBtn.textContent = isPinned ? `Configure v${pinnedVersion}` : 'Configure Now';
684
719
  installBtn.disabled = false;
685
720
  installBtn.classList.remove('is-match');
686
721
  }
@@ -727,7 +762,7 @@
727
762
  /** Fetch detection results from API and update all platform states */
728
763
  const fetchDetection = async () => {
729
764
  try {
730
- const res = await fetch('/api/setup/detect');
765
+ const res = await DollhouseAuth.apiFetch('/api/setup/detect');
731
766
  if (!res.ok) return;
732
767
  const data = await res.json();
733
768
  for (const [clientId, info] of Object.entries(data)) {
@@ -751,12 +786,12 @@
751
786
  const openBtnHtml = (openClient) =>
752
787
  openClient ? ` <button class="setup-open-btn" type="button" data-open-client="${openClient}">Open config file</button>` : '';
753
788
 
754
- /** Build the Install Now + CLI terminal command section */
789
+ /** Build the Configure Now + CLI terminal command section */
755
790
  const renderInstallSection = (p) => {
756
791
  let html = '';
757
792
  if (p.installClient) {
758
793
  html += '<div class="setup-method setup-method-primary">';
759
- html += `<div class="setup-install-row"><button class="setup-btn setup-btn-primary setup-install-btn" type="button" data-install-client="${p.installClient}">Install Now</button>`;
794
+ html += `<div class="setup-install-row"><button class="setup-btn setup-btn-primary setup-install-btn" type="button" data-install-client="${p.installClient}">Configure Now</button>`;
760
795
  html += `<span class="setup-install-status" data-install-status="${p.installClient}"></span></div>`;
761
796
  }
762
797
  if (p.cli) {
@@ -827,16 +862,453 @@
827
862
  }
828
863
  };
829
864
 
865
+ // ── License selector (#1746) ──────────────────────────────────────────
866
+
867
+ function formatActivationDate(license) {
868
+ if (license.verifiedAt) return new Date(license.verifiedAt).toLocaleString();
869
+ if (license.attestedAt) return new Date(license.attestedAt).toLocaleString();
870
+ return '—';
871
+ }
872
+
873
+ function initLicense() {
874
+ const tiers = document.getElementById('license-tiers');
875
+ if (!tiers) return;
876
+
877
+ const tierButtons = tiers.querySelectorAll('.license-tier');
878
+ const details = {
879
+ 'agpl': document.getElementById('license-detail-agpl'),
880
+ 'free-commercial': document.getElementById('license-detail-free-commercial'),
881
+ 'paid-commercial': document.getElementById('license-detail-paid-commercial'),
882
+ };
883
+ const savedBanner = document.getElementById('license-saved');
884
+ const savedText = document.getElementById('license-saved-text');
885
+
886
+ function selectTier(tier) {
887
+ // Update button states
888
+ tierButtons.forEach(btn => {
889
+ const selected = btn.dataset.tier === tier;
890
+ btn.classList.toggle('is-selected', selected);
891
+ btn.setAttribute('aria-pressed', String(selected));
892
+ });
893
+ // Show/hide detail panels. If the selected tier has an active license,
894
+ // keep the form hidden — showLicenseDetails() will display the details
895
+ // card instead (#1841).
896
+ const hideForm = activeLicense?.status === 'active' && activeLicense?.tier === tier && tier !== 'agpl';
897
+ for (const [key, el] of Object.entries(details)) {
898
+ if (el) el.hidden = key !== tier || hideForm;
899
+ }
900
+ if (hideForm) showLicenseDetails(activeLicense);
901
+ // Hide saved banner when switching
902
+ if (savedBanner) savedBanner.hidden = true;
903
+ }
904
+
905
+ // Click handlers for tier buttons
906
+ tierButtons.forEach(btn => {
907
+ btn.addEventListener('click', () => selectTier(btn.dataset.tier));
908
+ });
909
+
910
+ // Form submission: Free Commercial
911
+ const freeForm = document.getElementById('license-form-free-commercial');
912
+ if (freeForm) {
913
+ freeForm.addEventListener('submit', async (e) => {
914
+ e.preventDefault();
915
+ const status = document.getElementById('license-status-free-commercial');
916
+ const data = new FormData(freeForm);
917
+ await submitLicense({
918
+ tier: 'free-commercial',
919
+ email: data.get('email'),
920
+ telemetryAcknowledged: !!data.get('telemetry'),
921
+ attributionAcknowledged: !!data.get('attribution'),
922
+ revenueAttested: !!data.get('attestation'),
923
+ }, status, 'Commercial license activated');
924
+ });
925
+ }
926
+
927
+ // Form submission: Paid Commercial
928
+ const paidForm = document.getElementById('license-form-paid-commercial');
929
+ if (paidForm) {
930
+ paidForm.addEventListener('submit', async (e) => {
931
+ e.preventDefault();
932
+ const status = document.getElementById('license-status-paid-commercial');
933
+ const data = new FormData(paidForm);
934
+ await submitLicense({
935
+ tier: 'paid-commercial',
936
+ email: data.get('email'),
937
+ revenueScale: data.get('revenueScale'),
938
+ companyName: data.get('companyName') || undefined,
939
+ useCase: data.get('useCase') || undefined,
940
+ telemetryAcknowledged: !!data.get('telemetry'),
941
+ }, status, 'Enterprise inquiry sent — our team will reach out within 2 business days');
942
+ });
943
+ }
944
+
945
+ const verificationPanel = document.getElementById('license-verification');
946
+ const verifyForm = document.getElementById('license-verify-form');
947
+ const verifyEmailSpan = document.getElementById('license-verify-email');
948
+ const verifyStatus = document.getElementById('license-verify-status');
949
+ const verifyTimer = document.getElementById('license-verify-timer');
950
+ const resendBtn = document.getElementById('license-resend-btn');
951
+ let countdownInterval = null;
952
+
953
+ let verificationPollInterval = null;
954
+
955
+ function showVerificationUI(email) {
956
+ if (verificationPanel) verificationPanel.hidden = false;
957
+ if (verifyEmailSpan) verifyEmailSpan.textContent = email;
958
+ if (verifyStatus) { verifyStatus.textContent = ''; verifyStatus.className = 'license-form-status'; }
959
+ startCountdown(10 * 60);
960
+ startVerificationPolling();
961
+ }
962
+
963
+ function hideVerificationUI() {
964
+ if (verificationPanel) verificationPanel.hidden = true;
965
+ if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
966
+ stopVerificationPolling();
967
+ }
968
+
969
+ /** Poll license status every 3 seconds while verification is pending.
970
+ * When the user clicks the verify link in another tab, this tab
971
+ * auto-detects the activation and updates without a refresh. */
972
+ function startVerificationPolling() {
973
+ stopVerificationPolling();
974
+ verificationPollInterval = setInterval(async () => {
975
+ try {
976
+ const res = await fetch('/api/setup/license');
977
+ if (!res.ok) return;
978
+ const license = await res.json();
979
+ if (license.status === 'active') {
980
+ handleVerifySuccess(license);
981
+ }
982
+ } catch {
983
+ // Ignore polling errors
984
+ }
985
+ }, 3000);
986
+ }
987
+
988
+ function stopVerificationPolling() {
989
+ if (verificationPollInterval) {
990
+ clearInterval(verificationPollInterval);
991
+ verificationPollInterval = null;
992
+ }
993
+ }
994
+
995
+ function startCountdown(seconds) {
996
+ if (countdownInterval) clearInterval(countdownInterval);
997
+ let remaining = seconds;
998
+ function update() {
999
+ if (!verifyTimer) return;
1000
+ if (remaining <= 0) {
1001
+ verifyTimer.textContent = 'Code expired. Click "Resend code" to get a new one.';
1002
+ clearInterval(countdownInterval);
1003
+ return;
1004
+ }
1005
+ const m = Math.floor(remaining / 60);
1006
+ const s = remaining % 60;
1007
+ verifyTimer.textContent = `Code expires in ${m}:${String(s).padStart(2, '0')}`;
1008
+ remaining--;
1009
+ }
1010
+ update();
1011
+ countdownInterval = setInterval(update, 1000);
1012
+ }
1013
+
1014
+ async function submitLicense(body, statusEl, successMsg) {
1015
+ if (statusEl) {
1016
+ statusEl.textContent = '';
1017
+ statusEl.className = 'license-form-status';
1018
+ }
1019
+ try {
1020
+ const res = await fetch('/api/setup/license', {
1021
+ method: 'POST',
1022
+ headers: { 'Content-Type': 'application/json' },
1023
+ body: JSON.stringify(body),
1024
+ });
1025
+ const json = await res.json();
1026
+ if (!res.ok) {
1027
+ if (statusEl) {
1028
+ statusEl.textContent = json.error || 'Failed to save';
1029
+ statusEl.className = 'license-form-status is-error';
1030
+ }
1031
+ return;
1032
+ }
1033
+
1034
+ // Commercial tiers: show verification code input
1035
+ if (json.verificationRequired) {
1036
+ if (statusEl) {
1037
+ statusEl.textContent = 'Check your email for a verification code.';
1038
+ statusEl.className = 'license-form-status is-success';
1039
+ }
1040
+ showVerificationUI(body.email);
1041
+ return;
1042
+ }
1043
+
1044
+ // AGPL or already verified: show success
1045
+ if (statusEl) {
1046
+ statusEl.textContent = '';
1047
+ statusEl.className = 'license-form-status is-success';
1048
+ }
1049
+ hideVerificationUI();
1050
+ if (savedBanner && savedText) {
1051
+ savedText.textContent = successMsg;
1052
+ savedBanner.hidden = false;
1053
+ }
1054
+ } catch (err) {
1055
+ console.debug('License submission failed:', err);
1056
+ if (statusEl) {
1057
+ statusEl.textContent = 'Network error — is the server running?';
1058
+ statusEl.className = 'license-form-status is-error';
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ // Verification form submission
1064
+ if (verifyForm) {
1065
+ verifyForm.addEventListener('submit', async (e) => {
1066
+ e.preventDefault();
1067
+ const data = new FormData(verifyForm);
1068
+ const code = data.get('code');
1069
+ if (verifyStatus) { verifyStatus.textContent = ''; verifyStatus.className = 'license-form-status'; }
1070
+
1071
+ try {
1072
+ const res = await fetch('/api/setup/license/verify', {
1073
+ method: 'POST',
1074
+ headers: { 'Content-Type': 'application/json' },
1075
+ body: JSON.stringify({ code }),
1076
+ });
1077
+ const json = await res.json();
1078
+ if (!res.ok) {
1079
+ if (verifyStatus) {
1080
+ verifyStatus.textContent = json.error || 'Verification failed';
1081
+ verifyStatus.className = 'license-form-status is-error';
1082
+ }
1083
+ return;
1084
+ }
1085
+
1086
+ // Verification succeeded
1087
+ handleVerifySuccess(json.license);
1088
+ } catch (err) {
1089
+ console.debug('Verification failed:', err);
1090
+ if (verifyStatus) {
1091
+ verifyStatus.textContent = 'Network error — is the server running?';
1092
+ verifyStatus.className = 'license-form-status is-error';
1093
+ }
1094
+ }
1095
+ });
1096
+ }
1097
+
1098
+ // Resend verification code
1099
+ if (resendBtn) {
1100
+ resendBtn.addEventListener('click', async () => {
1101
+ resendBtn.disabled = true;
1102
+ if (verifyStatus) { verifyStatus.textContent = 'Sending new code...'; verifyStatus.className = 'license-form-status'; }
1103
+
1104
+ try {
1105
+ const res = await fetch('/api/setup/license/resend', {
1106
+ method: 'POST',
1107
+ headers: { 'Content-Type': 'application/json' },
1108
+ });
1109
+ const json = await res.json();
1110
+ if (res.ok) {
1111
+ if (verifyStatus) {
1112
+ verifyStatus.textContent = 'New code sent. Check your email.';
1113
+ verifyStatus.className = 'license-form-status is-success';
1114
+ }
1115
+ startCountdown(10 * 60);
1116
+ } else if (verifyStatus) {
1117
+ verifyStatus.textContent = json.error || 'Failed to resend';
1118
+ verifyStatus.className = 'license-form-status is-error';
1119
+ }
1120
+ } catch (err) {
1121
+ console.debug('Resend failed:', err);
1122
+ if (verifyStatus) {
1123
+ verifyStatus.textContent = 'Network error';
1124
+ verifyStatus.className = 'license-form-status is-error';
1125
+ }
1126
+ }
1127
+ setTimeout(() => { resendBtn.disabled = false; }, 5000);
1128
+ });
1129
+ }
1130
+
1131
+ // Track whether the user has an active commercial license
1132
+ let activeLicense = null;
1133
+
1134
+ // AGPL selection: confirm if downgrading from active commercial license
1135
+ tierButtons.forEach(btn => {
1136
+ if (btn.dataset.tier === 'agpl') {
1137
+ btn.addEventListener('click', async () => {
1138
+ if (activeLicense?.status === 'active' && activeLicense?.tier !== 'agpl') {
1139
+ const tierLabel = activeLicense.tier === 'free-commercial' ? 'Commercial' : 'Enterprise';
1140
+ const confirmed = confirm(
1141
+ `You have an active ${tierLabel} license. Switching to AGPL will deactivate your ${tierLabel} license.\n\nYou can reactivate your ${tierLabel} license at any time.\n\nAre you sure?`
1142
+ );
1143
+ if (!confirmed) {
1144
+ // Restore the previous tier selection (selectTier handles
1145
+ // re-hiding the form when activeLicense is set)
1146
+ selectTier(activeLicense.tier);
1147
+ return;
1148
+ }
1149
+ }
1150
+ await submitLicense({ tier: 'agpl' }, null, 'AGPL-3.0 license selected');
1151
+ activeLicense = null;
1152
+ hideLicenseDetails();
1153
+ });
1154
+ }
1155
+ });
1156
+
1157
+ const licenseDetailsPanel = document.getElementById('license-active-details');
1158
+ const licenseInfoTable = document.getElementById('license-info-tbody');
1159
+
1160
+ function showLicenseDetails(license) {
1161
+ if (!licenseDetailsPanel || !licenseInfoTable) return;
1162
+ if (license?.status !== 'active' || license?.tier === 'agpl') {
1163
+ licenseDetailsPanel.hidden = true;
1164
+ return;
1165
+ }
1166
+
1167
+ // Hide the activation form for the active tier — the details panel replaces it (#1841)
1168
+ const activeForm = details[license.tier];
1169
+ if (activeForm) activeForm.hidden = true;
1170
+
1171
+ const tierLabel = license.tier === 'free-commercial' ? 'Free Commercial' : 'Enterprise';
1172
+ const rows = [
1173
+ ['License type', tierLabel],
1174
+ ['Email', license.email || '—'],
1175
+ ['Status', 'Active'],
1176
+ ['Activated', formatActivationDate(license)],
1177
+ ['Telemetry', license.telemetryRequired ? 'Enabled (license requirement)' : 'Not required'],
1178
+ ];
1179
+ if (license.tier === 'paid-commercial') {
1180
+ if (license.revenueScale) rows.push(['Revenue scale', license.revenueScale]);
1181
+ if (license.companyName) rows.push(['Company', license.companyName]);
1182
+ if (license.useCase) rows.push(['Use case', license.useCase]);
1183
+ }
1184
+
1185
+ licenseInfoTable.innerHTML = rows
1186
+ .map(([label, value]) => `<tr><td>${label}</td><td>${value}</td></tr>`)
1187
+ .join('');
1188
+ licenseDetailsPanel.hidden = false;
1189
+ }
1190
+
1191
+ function hideLicenseDetails() {
1192
+ if (licenseDetailsPanel) licenseDetailsPanel.hidden = true;
1193
+ }
1194
+
1195
+ function prefillFreeCommercialForm(license) {
1196
+ if (freeForm && license.email) {
1197
+ freeForm.querySelector('[name="email"]').value = license.email;
1198
+ }
1199
+ }
1200
+
1201
+ function prefillEnterpriseForm(license) {
1202
+ if (!paidForm) return;
1203
+ if (license.email) paidForm.querySelector('[name="email"]').value = license.email;
1204
+ if (license.revenueScale) paidForm.querySelector('[name="revenueScale"]').value = license.revenueScale;
1205
+ if (license.companyName) paidForm.querySelector('[name="companyName"]').value = license.companyName;
1206
+ if (license.useCase) paidForm.querySelector('[name="useCase"]').value = license.useCase;
1207
+ }
1208
+
1209
+ function showSavedBanner(license) {
1210
+ if (license.tier === 'agpl' || !license.attestedAt) return;
1211
+ if (!savedBanner || !savedText) return;
1212
+ const tierLabel = license.tier === 'free-commercial' ? 'Commercial' : 'Enterprise';
1213
+ savedText.textContent = tierLabel + ' license active';
1214
+ savedBanner.hidden = false;
1215
+ showLicenseDetails(license);
1216
+ }
1217
+
1218
+ // Load saved license on page load
1219
+ async function loadSavedLicense() {
1220
+ try {
1221
+ const res = await fetch('/api/setup/license');
1222
+ if (!res.ok) return;
1223
+ const license = await res.json();
1224
+ if (!license.tier || !details[license.tier]) return;
1225
+ selectTier(license.tier);
1226
+ if (license.tier === 'free-commercial') prefillFreeCommercialForm(license);
1227
+ if (license.tier === 'paid-commercial') prefillEnterpriseForm(license);
1228
+
1229
+ // Show verification UI if license is pending
1230
+ if (license.status === 'pending' && license.email) {
1231
+ showVerificationUI(license.email);
1232
+ return;
1233
+ }
1234
+
1235
+ if (license.status === 'active') {
1236
+ activeLicense = license;
1237
+ showSavedBanner(license);
1238
+ }
1239
+ } catch (err) {
1240
+ // Default AGPL is fine — log for debugging only
1241
+ if (typeof console !== 'undefined') console.debug('License load skipped:', err);
1242
+ }
1243
+ }
1244
+
1245
+ function handleVerifySuccess(license) {
1246
+ hideVerificationUI();
1247
+ for (const el of Object.values(details)) {
1248
+ if (el) el.hidden = true;
1249
+ }
1250
+ activeLicense = license;
1251
+ if (savedBanner && savedText) {
1252
+ const tierLabel = license.tier === 'free-commercial' ? 'Commercial' : 'Enterprise';
1253
+ savedText.textContent = tierLabel + ' license verified and activated';
1254
+ savedBanner.hidden = false;
1255
+ }
1256
+ showLicenseDetails(license);
1257
+ }
1258
+
1259
+ async function handleAutoVerifyFailure(json) {
1260
+ const license = await (await fetch('/api/setup/license')).json();
1261
+ if (license.status === 'pending' && license.email) {
1262
+ showVerificationUI(license.email);
1263
+ }
1264
+ if (verifyStatus) {
1265
+ verifyStatus.textContent = json.error || 'Verification failed';
1266
+ verifyStatus.className = 'license-form-status is-error';
1267
+ }
1268
+ }
1269
+
1270
+ // Auto-verify from email click-through link (#verify=CODE)
1271
+ async function checkHashVerification() {
1272
+ const hash = globalThis.location.hash;
1273
+ const match = /^#verify=(\d{6})$/.exec(hash);
1274
+ if (!match) return;
1275
+
1276
+ const code = match[1];
1277
+ history.replaceState(null, '', globalThis.location.pathname + '#setup');
1278
+
1279
+ try {
1280
+ const res = await fetch('/api/setup/license/verify', {
1281
+ method: 'POST',
1282
+ headers: { 'Content-Type': 'application/json' },
1283
+ body: JSON.stringify({ code }),
1284
+ });
1285
+ const json = await res.json();
1286
+ if (res.ok) {
1287
+ handleVerifySuccess(json.license);
1288
+ } else {
1289
+ await handleAutoVerifyFailure(json);
1290
+ }
1291
+ } catch (err) {
1292
+ console.debug('Auto-verification failed:', err);
1293
+ }
1294
+ }
1295
+
1296
+ loadSavedLicense();
1297
+ checkHashVerification();
1298
+ }
1299
+
830
1300
  // ── Init ──────────────────────────────────────────────────────────────
831
1301
 
832
1302
  const os = detectOS();
833
1303
  renderGeneratedPanels();
834
1304
  highlightOSPaths(os);
835
1305
  initMethodToggle();
1306
+ initChannelSelector();
836
1307
  initPlatformTabs();
837
1308
  initCopyButtons();
838
1309
  initInstallButtons();
839
1310
  initOpenButtons();
840
1311
  fetchVersion();
841
1312
  fetchDetection();
1313
+ initLicense();
842
1314
  })();
@@ -1375,6 +1375,10 @@ body.modal-open { overflow: hidden; }
1375
1375
  .site-footer {
1376
1376
  border-top: 1px solid var(--line);
1377
1377
  padding: 0.85rem 0 1.35rem;
1378
+ position: sticky;
1379
+ bottom: 0;
1380
+ background: var(--paper);
1381
+ z-index: 10;
1378
1382
  }
1379
1383
 
1380
1384
  .footer-inner {
@@ -1396,6 +1400,12 @@ body.modal-open { overflow: hidden; }
1396
1400
  }
1397
1401
  .footer-link:hover { color: var(--accent); }
1398
1402
 
1403
+ .footer-copyright {
1404
+ color: var(--ink-500);
1405
+ font-size: 0.75rem;
1406
+ margin-left: auto;
1407
+ }
1408
+
1399
1409
  .footer-updated {
1400
1410
  margin-left: auto;
1401
1411
  font-family: var(--font-mono);
@@ -1 +1 @@
1
- {"version":3,"file":"permissionRoutes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/permissionRoutes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAgB,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAE1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AAkD7E;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAiGrF"}
1
+ {"version":3,"file":"permissionRoutes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/permissionRoutes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAgB,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAE1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AAkD7E;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAuGrF"}