@dollhousemcp/mcp-server 2.0.25 → 2.0.26

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 (62) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/auto-dollhouse/portDiscovery.d.ts.map +1 -1
  3. package/dist/auto-dollhouse/portDiscovery.js +7 -4
  4. package/dist/di/Container.d.ts.map +1 -1
  5. package/dist/di/Container.js +4 -2
  6. package/dist/generated/version.d.ts +2 -2
  7. package/dist/generated/version.js +3 -3
  8. package/dist/handlers/mcp-aql/MCPAQLHandler.d.ts.map +1 -1
  9. package/dist/handlers/mcp-aql/MCPAQLHandler.js +18 -1
  10. package/dist/handlers/mcp-aql/OperationRouter.d.ts.map +1 -1
  11. package/dist/handlers/mcp-aql/OperationRouter.js +6 -1
  12. package/dist/handlers/mcp-aql/OperationSchema.d.ts.map +1 -1
  13. package/dist/handlers/mcp-aql/OperationSchema.js +16 -1
  14. package/dist/handlers/mcp-aql/SchemaDispatcher.d.ts.map +1 -1
  15. package/dist/handlers/mcp-aql/SchemaDispatcher.js +2 -1
  16. package/dist/index.js +14 -2
  17. package/dist/server/tools/BuildInfoTools.d.ts +1 -0
  18. package/dist/server/tools/BuildInfoTools.d.ts.map +1 -1
  19. package/dist/server/tools/BuildInfoTools.js +2 -1
  20. package/dist/server/tools/MCPAQLTools.js +3 -1
  21. package/dist/services/ActivationStore.d.ts +8 -0
  22. package/dist/services/ActivationStore.d.ts.map +1 -1
  23. package/dist/services/ActivationStore.js +28 -29
  24. package/dist/services/BuildInfoService.d.ts +3 -0
  25. package/dist/services/BuildInfoService.d.ts.map +1 -1
  26. package/dist/services/BuildInfoService.js +18 -1
  27. package/dist/services/sessionIdentity.d.ts +24 -0
  28. package/dist/services/sessionIdentity.d.ts.map +1 -0
  29. package/dist/services/sessionIdentity.js +42 -0
  30. package/dist/utils/permissionAuthority.d.ts +38 -0
  31. package/dist/utils/permissionAuthority.d.ts.map +1 -0
  32. package/dist/utils/permissionAuthority.js +341 -0
  33. package/dist/utils/permissionHooks.d.ts.map +1 -1
  34. package/dist/utils/permissionHooks.js +10 -1
  35. package/dist/web/console/UnifiedConsole.d.ts +2 -0
  36. package/dist/web/console/UnifiedConsole.d.ts.map +1 -1
  37. package/dist/web/console/UnifiedConsole.js +3 -1
  38. package/dist/web/portDiscovery.d.ts +7 -0
  39. package/dist/web/portDiscovery.d.ts.map +1 -1
  40. package/dist/web/portDiscovery.js +35 -4
  41. package/dist/web/public/app.js +28 -4
  42. package/dist/web/public/index.html +2 -0
  43. package/dist/web/public/permissions.css +456 -0
  44. package/dist/web/public/permissions.js +553 -16
  45. package/dist/web/public/sessions.css +119 -0
  46. package/dist/web/public/sessions.js +95 -9
  47. package/dist/web/public/setup.js +56 -4
  48. package/dist/web/public/styles.css +21 -2
  49. package/dist/web/routes/permissionRoutes.d.ts +4 -1
  50. package/dist/web/routes/permissionRoutes.d.ts.map +1 -1
  51. package/dist/web/routes/permissionRoutes.js +118 -6
  52. package/dist/web/routes/setupRoutes.d.ts +18 -0
  53. package/dist/web/routes/setupRoutes.d.ts.map +1 -1
  54. package/dist/web/routes/setupRoutes.js +47 -26
  55. package/dist/web/server.d.ts +4 -0
  56. package/dist/web/server.d.ts.map +1 -1
  57. package/dist/web/server.js +19 -1
  58. package/package.json +4 -3
  59. package/scripts/pretooluse-dollhouse.sh +78 -12
  60. package/scripts/pretooluse-vscode.sh +6 -9
  61. package/scripts/pretooluse-windsurf.sh +6 -9
  62. package/server.json +2 -2
@@ -18,6 +18,34 @@
18
18
  let latestAggregateData = null;
19
19
  let latestSelectedData = null;
20
20
  let latestPollRequestId = 0;
21
+ const AUTHORITY_MODE_REQUEST_STATES = {
22
+ idle: 'idle',
23
+ saving: 'saving',
24
+ };
25
+ const AUTHORITY_AUTHORITATIVE_HOSTS = new Set(['claude-code']);
26
+ // Authority modes are intentionally phrased in human terms in the UI:
27
+ // - off => host-controlled permissions
28
+ // - shared => both Dollhouse and the host participate
29
+ // - authoritative => Dollhouse is the source of truth
30
+ const authorityUiState = {
31
+ selectedHost: 'claude-code',
32
+ selectedMode: 'shared',
33
+ draftReason: '',
34
+ dirty: false,
35
+ requestState: AUTHORITY_MODE_REQUEST_STATES.idle,
36
+ feedback: '',
37
+ feedbackKind: 'info',
38
+ };
39
+ const AUTHORITY_HOST_META = {
40
+ 'claude-code': { label: 'Claude Code', shortLabel: 'CC', tone: 'claude' },
41
+ 'codex': { label: 'Codex', shortLabel: 'CX', tone: 'codex' },
42
+ 'cursor': { label: 'Cursor', shortLabel: 'CU', tone: 'cursor' },
43
+ 'vscode': { label: 'VS Code', shortLabel: 'VS', tone: 'vscode' },
44
+ 'windsurf': { label: 'Windsurf', shortLabel: 'WS', tone: 'windsurf' },
45
+ 'gemini': { label: 'Gemini CLI', shortLabel: 'GM', tone: 'gemini' },
46
+ 'cline': { label: 'Cline', shortLabel: 'CL', tone: 'cline' },
47
+ 'lmstudio': { label: 'LM Studio', shortLabel: 'LM', tone: 'lmstudio' },
48
+ };
21
49
 
22
50
  async function fetchPermissionStatus(sessionId) {
23
51
  const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '';
@@ -26,6 +54,17 @@
26
54
  return res.json();
27
55
  }
28
56
 
57
+ async function updatePermissionAuthority(payload) {
58
+ const res = await DollhouseAuth.apiFetch('/api/permissions/authority', {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify(payload),
62
+ });
63
+ const data = await res.json().catch(function () { return {}; });
64
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
65
+ return data;
66
+ }
67
+
29
68
  // ── Public API ─────────────────────────────────────────────
30
69
 
31
70
  window.DollhouseConsole = window.DollhouseConsole || {};
@@ -43,15 +82,31 @@
43
82
  if (tabs) {
44
83
  tabs.addEventListener('click', function (e) {
45
84
  const btn = e.target.closest('.console-tab');
46
- if (btn && btn.dataset.tab === 'permissions') {
47
- var dc = window.DollhouseConsole;
48
- if (dc && dc.permissions) {
49
- if (!initialized) dc.permissions.init();
50
- else if (dc.permissions.refresh) dc.permissions.refresh();
51
- }
85
+ if (!btn || btn.dataset.tab !== 'permissions') {
86
+ return;
87
+ }
88
+
89
+ const dc = window.DollhouseConsole;
90
+ if (!dc || !dc.permissions) {
91
+ return;
92
+ }
93
+
94
+ if (!initialized) {
95
+ dc.permissions.init();
96
+ return;
97
+ }
98
+
99
+ if (dc.permissions.refresh) {
100
+ dc.permissions.refresh();
52
101
  }
53
102
  });
54
103
  }
104
+
105
+ window.addEventListener('dollhouse:policy-debug-visibility-changed', function () {
106
+ if (initialized) {
107
+ renderFromCache();
108
+ }
109
+ });
55
110
  });
56
111
 
57
112
  // ── Initialization ─────────────────────────────────────────
@@ -65,6 +120,7 @@
65
120
 
66
121
  root.innerHTML = buildDashboardHTML();
67
122
  attachCardToggles();
123
+ attachAuthorityControls();
68
124
  poll(); // immediate first fetch
69
125
  pollTimer = setInterval(poll, POLL_INTERVAL_MS);
70
126
  }
@@ -87,13 +143,14 @@
87
143
  return;
88
144
  }
89
145
 
146
+ const visibleAggregateData = getVisibleAggregateData(aggregateData);
90
147
  const currentSessionId = window.DollhouseSessions?.getFilterSessionId?.() || '';
91
- const selectedData = deriveSelectedSessionData(aggregateData, currentSessionId);
148
+ const selectedData = deriveSelectedSessionData(visibleAggregateData, currentSessionId);
92
149
 
93
150
  latestAggregateData = aggregateData;
94
151
  latestSelectedData = selectedData;
95
152
  window.DollhouseSessions?.setPolicySessions?.(aggregateData.knownSessions || []);
96
- render(aggregateData, selectedData);
153
+ render(visibleAggregateData, selectedData);
97
154
  } catch (err) {
98
155
  renderError(err.message);
99
156
  }
@@ -106,15 +163,16 @@
106
163
  }
107
164
 
108
165
  const sessionId = window.DollhouseSessions?.getFilterSessionId?.() || '';
109
- latestSelectedData = deriveSelectedSessionData(latestAggregateData, sessionId);
110
- renderPolicySources(latestAggregateData, latestSelectedData);
111
- renderSelectedSessionDetail(latestSelectedData);
166
+ const visibleAggregateData = getVisibleAggregateData(latestAggregateData);
167
+ latestSelectedData = deriveSelectedSessionData(visibleAggregateData, sessionId);
168
+ render(visibleAggregateData, latestSelectedData);
112
169
  }
113
170
 
114
171
  // ── Rendering ──────────────────────────────────────────────
115
172
 
116
173
  function render(data, selectedData) {
117
174
  renderStatusBar(data);
175
+ renderAuthorityMode(data);
118
176
  renderSummaryStats(data);
119
177
  renderAdvisory(data);
120
178
  renderPolicySources(data, selectedData);
@@ -196,6 +254,119 @@
196
254
  setText('perm-stat-asked', asked);
197
255
  }
198
256
 
257
+ function renderAuthorityMode(data) {
258
+ const card = document.getElementById('perm-authority-card');
259
+ const saveButton = document.getElementById('perm-authority-save-btn');
260
+ const message = document.getElementById('perm-authority-message');
261
+ const currentHostList = document.getElementById('perm-authority-current-host-list');
262
+ const selectedHostHeading = document.getElementById('perm-authority-selected-host');
263
+ const reasonInput = document.getElementById('perm-authority-reason');
264
+ const note = document.getElementById('perm-authority-note');
265
+ const authoritativeNote = document.getElementById('perm-authority-authoritative-note');
266
+ const dirtyState = document.getElementById('perm-authority-dirty-state');
267
+ const saveCopy = document.getElementById('perm-authority-save-copy');
268
+ const saveShell = document.getElementById('perm-authority-save-shell');
269
+
270
+ if (!card || !saveButton || !message || !currentHostList || !selectedHostHeading || !reasonInput || !note || !authoritativeNote || !dirtyState || !saveCopy || !saveShell) {
271
+ return;
272
+ }
273
+
274
+ const supportedHosts = Array.isArray(data?.authoritySupportedHosts)
275
+ ? data.authoritySupportedHosts
276
+ : ['claude-code'];
277
+ const authority = data?.authority || { defaultMode: 'shared', hosts: {} };
278
+
279
+ if (supportedHosts.length === 0) {
280
+ currentHostList.innerHTML = '<div class="perm-pattern-empty">No installed permission hosts detected yet.</div>';
281
+ selectedHostHeading.textContent = 'No installed hosts';
282
+ note.textContent = 'Install Dollhouse permission hooks for a host before changing permission authority mode here.';
283
+ authoritativeNote.hidden = true;
284
+ authoritativeNote.textContent = '';
285
+ dirtyState.hidden = true;
286
+ dirtyState.textContent = '';
287
+ message.hidden = true;
288
+ message.textContent = '';
289
+ message.dataset.kind = 'info';
290
+ saveCopy.textContent = 'Once a host is installed and configured, it will appear on the left for editing.';
291
+ saveShell.dataset.dirty = 'false';
292
+ saveShell.dataset.busy = 'false';
293
+ card.dataset.authorityDirty = 'false';
294
+ card.setAttribute('aria-busy', 'false');
295
+ reasonInput.value = authorityUiState.draftReason;
296
+ setAuthorityRadioState('perm-authority-mode-off', false, true);
297
+ setAuthorityRadioState('perm-authority-mode-shared', false, true);
298
+ setAuthorityRadioState('perm-authority-mode-authoritative', false, true);
299
+ saveButton.disabled = true;
300
+ saveButton.textContent = 'No Installed Hosts Yet';
301
+ saveButton.setAttribute('aria-busy', 'false');
302
+ card.hidden = false;
303
+ return;
304
+ }
305
+
306
+ if (!supportedHosts.includes(authorityUiState.selectedHost)) {
307
+ authorityUiState.selectedHost = supportedHosts[0];
308
+ authorityUiState.dirty = false;
309
+ }
310
+
311
+ const serverMode = getAuthorityModeForHost(authority, authorityUiState.selectedHost);
312
+ if (!authorityUiState.dirty) {
313
+ authorityUiState.selectedMode = serverMode;
314
+ }
315
+
316
+ reasonInput.value = authorityUiState.draftReason;
317
+
318
+ const authoritativeSupported = AUTHORITY_AUTHORITATIVE_HOSTS.has(authorityUiState.selectedHost);
319
+ const desiredMode = authoritativeSupported ? authorityUiState.selectedMode : fallbackAuthorityMode(authorityUiState.selectedMode);
320
+ const dirty = desiredMode !== serverMode;
321
+
322
+ setAuthorityRadioState('perm-authority-mode-off', desiredMode === 'off', false);
323
+ setAuthorityRadioState('perm-authority-mode-shared', desiredMode === 'shared', false);
324
+ setAuthorityRadioState('perm-authority-mode-authoritative', desiredMode === 'authoritative', !authoritativeSupported);
325
+
326
+ currentHostList.innerHTML = renderAuthorityCurrentHostList(authority, supportedHosts, authorityUiState.selectedHost);
327
+ selectedHostHeading.textContent = formatAuthorityHost(authorityUiState.selectedHost);
328
+ note.textContent = 'Human-only control. AI can read authority mode but cannot change it through MCP.';
329
+ authoritativeNote.hidden = authoritativeSupported;
330
+ authoritativeNote.textContent = authoritativeSupported
331
+ ? ''
332
+ : 'Claude Code only for now. Other hosts can use Host-Controlled or Shared Permissioning mode.';
333
+ dirtyState.hidden = !dirty;
334
+ dirtyState.textContent = dirty
335
+ ? `Unsaved change: ${formatAuthorityHost(authorityUiState.selectedHost)} will move from ${formatAuthorityMode(serverMode)} to ${formatAuthorityMode(desiredMode)} after you save.`
336
+ : '';
337
+ saveCopy.textContent = buildAuthoritySaveCopy({
338
+ host: authorityUiState.selectedHost,
339
+ currentMode: serverMode,
340
+ desiredMode,
341
+ dirty,
342
+ saving: authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving,
343
+ });
344
+ saveShell.dataset.dirty = dirty ? 'true' : 'false';
345
+ saveShell.dataset.busy = authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving ? 'true' : 'false';
346
+ card.dataset.authorityDirty = dirty ? 'true' : 'false';
347
+ card.setAttribute('aria-busy', authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving ? 'true' : 'false');
348
+
349
+ if (authorityUiState.feedback) {
350
+ message.hidden = false;
351
+ message.textContent = authorityUiState.feedback;
352
+ message.dataset.kind = authorityUiState.feedbackKind;
353
+ } else {
354
+ message.hidden = true;
355
+ message.textContent = '';
356
+ message.dataset.kind = 'info';
357
+ }
358
+
359
+ saveButton.disabled = authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving || !dirty;
360
+ saveButton.textContent = authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving
361
+ ? `Saving ${formatAuthorityMode(desiredMode)}...`
362
+ : dirty
363
+ ? `Save ${formatAuthorityMode(desiredMode)} Mode for ${formatAuthorityHost(authorityUiState.selectedHost)}`
364
+ : `Saved for ${formatAuthorityHost(authorityUiState.selectedHost)}`;
365
+ saveButton.dataset.dirty = dirty ? 'true' : 'false';
366
+ saveButton.setAttribute('aria-busy', authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving ? 'true' : 'false');
367
+ card.hidden = false;
368
+ }
369
+
199
370
  function renderAdvisory(data) {
200
371
  const advisory = document.getElementById('perm-all-sessions-advisory');
201
372
  if (!advisory) return;
@@ -500,6 +671,60 @@
500
671
  };
501
672
  }
502
673
 
674
+ /**
675
+ * Persisted policy state rows are primarily a debugging aid, so the
676
+ * dashboard treats them as opt-in and mirrors the explicit sessions UI flag.
677
+ */
678
+ function shouldShowPersistedPolicyDebug() {
679
+ return window.DollhouseSessions?.isPolicyDebugVisible?.() === true;
680
+ }
681
+
682
+ function getVisibleAggregateData(data) {
683
+ if (!data || shouldShowPersistedPolicyDebug()) {
684
+ return data;
685
+ }
686
+
687
+ const hiddenPolicySessions = Array.isArray(data.knownSessions) ? data.knownSessions : [];
688
+ if (hiddenPolicySessions.length === 0) {
689
+ return data;
690
+ }
691
+
692
+ const hiddenPolicySessionIds = new Set(
693
+ hiddenPolicySessions
694
+ .map(function (session) { return session && typeof session.sessionId === 'string' ? session.sessionId : ''; })
695
+ .filter(Boolean),
696
+ );
697
+
698
+ const filteredElements = ((data.elements || []).filter(function (element) {
699
+ const sessionIds = Array.isArray(element?.sessionIds) ? element.sessionIds.filter(function (sessionId) {
700
+ return typeof sessionId === 'string' && sessionId !== '';
701
+ }) : [];
702
+ if (sessionIds.length === 0) {
703
+ return true;
704
+ }
705
+ return sessionIds.some(function (sessionId) {
706
+ return !hiddenPolicySessionIds.has(sessionId);
707
+ });
708
+ }));
709
+
710
+ return {
711
+ ...data,
712
+ activeElementCount: filteredElements.length,
713
+ hasAllowlist: filteredElements.some(function (element) {
714
+ return (Array.isArray(element.allowRules) && element.allowRules.length > 0)
715
+ || (Array.isArray(element.allowPatterns) && element.allowPatterns.length > 0);
716
+ }),
717
+ elements: filteredElements,
718
+ knownSessions: [],
719
+ denyPatterns: flattenElementPatterns(filteredElements, 'denyPatterns'),
720
+ allowPatterns: flattenElementPatterns(filteredElements, 'allowPatterns'),
721
+ confirmPatterns: flattenElementPatterns(filteredElements, 'confirmPatterns'),
722
+ denyRules: flattenElementPatterns(filteredElements, 'denyRules'),
723
+ allowRules: flattenElementPatterns(filteredElements, 'allowRules'),
724
+ confirmRules: flattenElementPatterns(filteredElements, 'confirmRules'),
725
+ };
726
+ }
727
+
503
728
  function flattenElementPatterns(elements, key) {
504
729
  return elements.flatMap(function (element) {
505
730
  return Array.isArray(element[key]) ? element[key] : [];
@@ -608,6 +833,80 @@
608
833
  </div>
609
834
  </div>
610
835
 
836
+ <div class="perm-card perm-card--full" data-collapsed="false" id="perm-authority-card">
837
+ <div class="perm-card-header" role="button" tabindex="0" aria-expanded="true">
838
+ <h3 class="perm-card-title">Permission Authority Mode</h3>
839
+ <span class="perm-card-toggle" aria-hidden="true">&#9662;</span>
840
+ </div>
841
+ <div class="perm-card-body">
842
+ <div class="perm-selected-header perm-selected-header--compact">
843
+ <div>
844
+ <div class="perm-selected-title">Human-Only Permission Authority</div>
845
+ <div class="perm-selected-subtitle">Choose whether the host's native permission system or DollhouseMCP is the final authority for tool approvals.</div>
846
+ </div>
847
+ </div>
848
+
849
+ <div class="perm-selected-grid">
850
+ <div class="perm-selected-panel">
851
+ <h4 class="perm-selected-panel-title">Current Permission State</h4>
852
+ <div class="perm-authority-current-list" id="perm-authority-current-host-list"></div>
853
+ </div>
854
+
855
+ <div class="perm-selected-panel">
856
+ <h4 class="perm-selected-panel-title">Change Permission Mode</h4>
857
+ <div class="perm-authority-selected-host" id="perm-authority-selected-host">Claude Code</div>
858
+ <div class="perm-selected-subtitle">Choose how this host should handle permission decisions.</div>
859
+
860
+ <div class="perm-authority-options" role="radiogroup" aria-label="Authority mode">
861
+ <label class="perm-authority-option" id="perm-authority-option-off">
862
+ <span class="perm-authority-option-main">
863
+ <input type="radio" name="perm-authority-mode" id="perm-authority-mode-off" value="off">
864
+ <span class="perm-authority-option-copy">
865
+ <span class="perm-authority-option-title">Host-Controlled Permissions</span>
866
+ <span class="perm-authority-option-description">Dollhouse steps out of the way. The host's own permission system handles approvals by itself.</span>
867
+ </span>
868
+ </span>
869
+ </label>
870
+ <label class="perm-authority-option" id="perm-authority-option-shared">
871
+ <span class="perm-authority-option-main">
872
+ <input type="radio" name="perm-authority-mode" id="perm-authority-mode-shared" value="shared">
873
+ <span class="perm-authority-option-copy">
874
+ <span class="perm-authority-option-title">Shared Permissioning</span>
875
+ <span class="perm-authority-option-description">Dollhouse stays active, but the host permission system can still be more restrictive.</span>
876
+ </span>
877
+ </span>
878
+ </label>
879
+ <label class="perm-authority-option perm-authority-option--authoritative" id="perm-authority-option-authoritative">
880
+ <span class="perm-authority-option-main">
881
+ <input type="radio" name="perm-authority-mode" id="perm-authority-mode-authoritative" value="authoritative">
882
+ <span class="perm-authority-option-copy">
883
+ <span class="perm-authority-option-title-row">
884
+ <span class="perm-authority-option-title">Dollhouse-Controlled Permissions</span>
885
+ <span class="perm-authority-inline-note" id="perm-authority-authoritative-note" hidden></span>
886
+ </span>
887
+ <span class="perm-authority-option-description">Dollhouse becomes the permission authority. It syncs Dollhouse allow, ask, and deny rules into the host so Dollhouse decides conflicts instead of the host's own approval flow.</span>
888
+ </span>
889
+ </span>
890
+ </label>
891
+ </div>
892
+
893
+ <label class="perm-selected-subtitle perm-authority-field-label" for="perm-authority-reason">Reason (optional)</label>
894
+ <input id="perm-authority-reason" class="perm-authority-reason-input" type="text" maxlength="200" placeholder="Why are you changing the permission authority mode?">
895
+
896
+ <p class="perm-selected-subtitle perm-authority-human-note" id="perm-authority-note"></p>
897
+ <div class="perm-authority-save-shell" id="perm-authority-save-shell" data-dirty="false">
898
+ <div class="perm-authority-dirty-state" id="perm-authority-dirty-state" hidden></div>
899
+ <div class="perm-inline-warning" id="perm-authority-message" hidden></div>
900
+ <div class="perm-authority-actions">
901
+ <div class="perm-authority-save-copy" id="perm-authority-save-copy"></div>
902
+ <button type="button" class="perm-panel-action perm-authority-save-btn" id="perm-authority-save-btn">Save Authority Mode</button>
903
+ </div>
904
+ </div>
905
+ </div>
906
+ </div>
907
+ </div>
908
+ </div>
909
+
611
910
  <!-- Selected Session Detail -->
612
911
  <div class="perm-card perm-card--full" data-collapsed="false" id="perm-selected-card" hidden>
613
912
  <div class="perm-card-header" role="button" tabindex="0" aria-expanded="true">
@@ -710,8 +1009,10 @@
710
1009
  <span>Aggregate decision log across all sessions</span>
711
1010
  <span id="perm-audit-modal-count">0 captured entries</span>
712
1011
  </div>
713
- <button type="button" class="modal-action-btn" id="perm-audit-copy-btn">Copy Markdown</button>
714
- <button type="button" class="modal-close" id="perm-audit-modal-close" aria-label="Close audit view">✕</button>
1012
+ <div class="modal-header-actions">
1013
+ <button type="button" class="modal-action-btn" id="perm-audit-copy-btn">Copy Markdown</button>
1014
+ <button type="button" class="modal-close" id="perm-audit-modal-close" aria-label="Close audit view">✕</button>
1015
+ </div>
715
1016
  </header>
716
1017
  <div class="modal-body">
717
1018
  <div class="perm-feed perm-feed--modal" id="perm-audit-modal-feed" role="log" aria-live="polite" aria-label="Full permission decision audit feed">
@@ -775,6 +1076,52 @@
775
1076
  }
776
1077
  }
777
1078
 
1079
+ function attachAuthorityControls() {
1080
+ const currentHostList = document.getElementById('perm-authority-current-host-list');
1081
+ const reasonInput = document.getElementById('perm-authority-reason');
1082
+ const saveButton = document.getElementById('perm-authority-save-btn');
1083
+
1084
+ if (currentHostList) {
1085
+ currentHostList.addEventListener('click', function (event) {
1086
+ const row = event.target.closest('.perm-authority-current-host[data-host]');
1087
+ if (!row) {
1088
+ return;
1089
+ }
1090
+ const host = row.getAttribute('data-host');
1091
+ if (!host || host === authorityUiState.selectedHost) {
1092
+ return;
1093
+ }
1094
+ authorityUiState.selectedHost = host;
1095
+ authorityUiState.feedback = '';
1096
+ authorityUiState.feedbackKind = 'info';
1097
+ authorityUiState.dirty = false;
1098
+ renderAuthorityMode(latestAggregateData);
1099
+ });
1100
+ }
1101
+
1102
+ document.querySelectorAll('input[name="perm-authority-mode"]').forEach(function (input) {
1103
+ input.addEventListener('change', function (event) {
1104
+ authorityUiState.selectedMode = event.target.value;
1105
+ authorityUiState.feedback = '';
1106
+ authorityUiState.feedbackKind = 'info';
1107
+ authorityUiState.dirty = true;
1108
+ renderAuthorityMode(latestAggregateData);
1109
+ });
1110
+ });
1111
+
1112
+ if (reasonInput) {
1113
+ reasonInput.addEventListener('input', function (event) {
1114
+ authorityUiState.draftReason = event.target.value;
1115
+ });
1116
+ }
1117
+
1118
+ if (saveButton) {
1119
+ saveButton.addEventListener('click', function () {
1120
+ saveAuthorityMode();
1121
+ });
1122
+ }
1123
+ }
1124
+
778
1125
  function openAuditModal() {
779
1126
  const auditModal = document.getElementById('perm-audit-modal');
780
1127
  if (!auditModal) return;
@@ -818,10 +1165,52 @@
818
1165
 
819
1166
  async function copyTextToClipboard(text) {
820
1167
  if (navigator.clipboard?.writeText) {
821
- await navigator.clipboard.writeText(text);
822
- return;
1168
+ try {
1169
+ await navigator.clipboard.writeText(text);
1170
+ return;
1171
+ } catch {
1172
+ // Fall through to the user-gesture fallback below. Some embedded browsers
1173
+ // expose the clipboard API but still reject writes in modal contexts.
1174
+ }
1175
+ }
1176
+
1177
+ copyTextWithSelectionFallback(text);
1178
+ }
1179
+
1180
+ function copyTextWithSelectionFallback(text) {
1181
+ const textarea = document.createElement('textarea');
1182
+ let handled = false;
1183
+
1184
+ function handleCopy(event) {
1185
+ if (!event.clipboardData) {
1186
+ return;
1187
+ }
1188
+ event.clipboardData.setData('text/plain', text);
1189
+ event.preventDefault();
1190
+ handled = true;
1191
+ }
1192
+
1193
+ textarea.value = text;
1194
+ textarea.setAttribute('readonly', '');
1195
+ textarea.setAttribute('aria-hidden', 'true');
1196
+ textarea.style.position = 'fixed';
1197
+ textarea.style.top = '-9999px';
1198
+ textarea.style.left = '-9999px';
1199
+ textarea.style.opacity = '0';
1200
+ document.body.appendChild(textarea);
1201
+ document.addEventListener('copy', handleCopy, true);
1202
+ textarea.focus();
1203
+ textarea.select();
1204
+ textarea.setSelectionRange(0, textarea.value.length);
1205
+
1206
+ try {
1207
+ if (!document.execCommand || !document.execCommand('copy') || !handled) {
1208
+ throw new Error('Clipboard copy command unavailable');
1209
+ }
1210
+ } finally {
1211
+ document.removeEventListener('copy', handleCopy, true);
1212
+ textarea.remove();
823
1213
  }
824
- throw new Error('Clipboard API unavailable');
825
1214
  }
826
1215
 
827
1216
  function setText(id, value) {
@@ -848,6 +1237,154 @@
848
1237
  return str.length > len ? str.slice(0, len) + '...' : str;
849
1238
  }
850
1239
 
1240
+ function getAuthorityModeForHost(authority, host) {
1241
+ return authority?.hosts?.[host]?.mode || authority?.defaultMode || 'shared';
1242
+ }
1243
+
1244
+ function formatAuthorityHost(host) {
1245
+ const meta = AUTHORITY_HOST_META[String(host || '')];
1246
+ if (meta?.label) {
1247
+ return meta.label;
1248
+ }
1249
+ return String(host || '')
1250
+ .split('-')
1251
+ .map(function (part) { return part ? part.charAt(0).toUpperCase() + part.slice(1) : part; })
1252
+ .join(' ');
1253
+ }
1254
+
1255
+ function formatAuthorityMode(mode) {
1256
+ return mode === 'off'
1257
+ ? 'Host-Controlled Permissions'
1258
+ : mode === 'authoritative'
1259
+ ? 'Dollhouse-Controlled Permissions'
1260
+ : 'Shared Permissioning';
1261
+ }
1262
+
1263
+ function buildAuthorityExplanation(mode, authoritativeSupported) {
1264
+ if (mode === 'off') {
1265
+ return 'Dollhouse is not participating in approvals here. The host handles permissions on its own.';
1266
+ }
1267
+ if (mode === 'authoritative') {
1268
+ return 'Dollhouse is the source of truth for permissions here. The host follows Dollhouse-synced allow, ask, and deny rules, while user-authored entries outside Dollhouse-managed settings are still preserved.';
1269
+ }
1270
+ return authoritativeSupported
1271
+ ? 'Dollhouse participates in permission checks, but the host can still be more restrictive.'
1272
+ : 'This host currently supports shared/advisory mode while authoritative settings sync is still being added.';
1273
+ }
1274
+
1275
+ function fallbackAuthorityMode(mode) {
1276
+ return mode === 'authoritative' ? 'shared' : mode;
1277
+ }
1278
+
1279
+ function setAuthorityRadioState(id, checked, disabled) {
1280
+ const radio = document.getElementById(id);
1281
+ if (!radio) return;
1282
+ radio.checked = checked;
1283
+ radio.disabled = disabled;
1284
+ const option = radio.closest('.perm-authority-option');
1285
+ if (option) {
1286
+ option.dataset.checked = checked ? 'true' : 'false';
1287
+ option.dataset.disabled = disabled ? 'true' : 'false';
1288
+ }
1289
+ }
1290
+
1291
+ function buildAuthoritySaveCopy(state) {
1292
+ if (state.saving) {
1293
+ return `Applying ${formatAuthorityMode(state.desiredMode)} mode for ${formatAuthorityHost(state.host)}...`;
1294
+ }
1295
+ if (state.dirty) {
1296
+ return `Review the change and save to apply ${formatAuthorityMode(state.desiredMode)} mode for ${formatAuthorityHost(state.host)}.`;
1297
+ }
1298
+ return `${formatAuthorityHost(state.host)} is currently saved in ${formatAuthorityMode(state.currentMode)} mode.`;
1299
+ }
1300
+
1301
+ function renderAuthorityCurrentHostList(authority, supportedHosts, selectedHost) {
1302
+ const explicitHosts = Object.keys(authority?.hosts || {});
1303
+ const hostIds = Array.from(new Set(explicitHosts.concat(supportedHosts || [])));
1304
+ const orderedHosts = hostIds.sort(function (left, right) {
1305
+ return formatAuthorityHost(left).localeCompare(formatAuthorityHost(right));
1306
+ });
1307
+
1308
+ if (orderedHosts.length === 0) {
1309
+ return '<div class="perm-pattern-empty">No host authority settings saved yet.</div>';
1310
+ }
1311
+
1312
+ return orderedHosts.map(function (host) {
1313
+ const mode = getAuthorityModeForHost(authority, host);
1314
+ const meta = AUTHORITY_HOST_META[host] || { shortLabel: formatAuthorityHost(host).slice(0, 2).toUpperCase(), tone: 'generic' };
1315
+ const selectedAttr = host === selectedHost ? 'true' : 'false';
1316
+ return `
1317
+ <button type="button" class="perm-authority-current-host" data-selected="${selectedAttr}" data-host="${esc(host)}" aria-pressed="${selectedAttr}">
1318
+ <span class="perm-authority-host-mark perm-authority-host-mark--${esc(meta.tone || 'generic')}" aria-hidden="true">${esc(meta.shortLabel || 'DH')}</span>
1319
+ <span class="perm-authority-current-host-copy">
1320
+ <span class="perm-authority-current-host-name">${esc(formatAuthorityHost(host))}</span>
1321
+ <span class="perm-authority-current-host-mode">${esc(formatAuthorityMode(mode))}</span>
1322
+ </span>
1323
+ </button>
1324
+ `;
1325
+ }).join('');
1326
+ }
1327
+
1328
+ async function saveAuthorityMode() {
1329
+ if (!latestAggregateData || authorityUiState.requestState === AUTHORITY_MODE_REQUEST_STATES.saving) {
1330
+ return;
1331
+ }
1332
+
1333
+ const authority = latestAggregateData.authority || { defaultMode: 'shared', hosts: {} };
1334
+ const currentMode = getAuthorityModeForHost(authority, authorityUiState.selectedHost);
1335
+ const requestedMode = AUTHORITY_AUTHORITATIVE_HOSTS.has(authorityUiState.selectedHost)
1336
+ ? authorityUiState.selectedMode
1337
+ : fallbackAuthorityMode(authorityUiState.selectedMode);
1338
+
1339
+ if (requestedMode === currentMode) {
1340
+ authorityUiState.feedback = 'No authority-mode change to save.';
1341
+ authorityUiState.feedbackKind = 'info';
1342
+ renderAuthorityMode(latestAggregateData);
1343
+ return;
1344
+ }
1345
+
1346
+ const confirmMessage = [
1347
+ `Change ${formatAuthorityHost(authorityUiState.selectedHost)} from ${formatAuthorityMode(currentMode)} to ${formatAuthorityMode(requestedMode)}?`,
1348
+ '',
1349
+ requestedMode === 'authoritative'
1350
+ ? 'Dollhouse will take a backup and write managed host permission settings.'
1351
+ : requestedMode === 'off'
1352
+ ? 'Dollhouse hooks will no-op and the host permission system will become the only gate.'
1353
+ : 'Dollhouse will stay active, but the host will remain authoritative on conflicts.',
1354
+ ].join('\n');
1355
+
1356
+ if (!window.confirm(confirmMessage)) {
1357
+ return;
1358
+ }
1359
+
1360
+ authorityUiState.requestState = AUTHORITY_MODE_REQUEST_STATES.saving;
1361
+ authorityUiState.feedback = '';
1362
+ renderAuthorityMode(latestAggregateData);
1363
+
1364
+ try {
1365
+ const response = await updatePermissionAuthority({
1366
+ host: authorityUiState.selectedHost,
1367
+ mode: requestedMode,
1368
+ reason: authorityUiState.draftReason.trim() || undefined,
1369
+ });
1370
+
1371
+ latestAggregateData = {
1372
+ ...latestAggregateData,
1373
+ authority: response.authority,
1374
+ };
1375
+ authorityUiState.selectedMode = requestedMode;
1376
+ authorityUiState.dirty = false;
1377
+ authorityUiState.feedback = `Saved ${formatAuthorityMode(requestedMode)} mode for ${formatAuthorityHost(authorityUiState.selectedHost)}.`;
1378
+ authorityUiState.feedbackKind = 'success';
1379
+ } catch (error) {
1380
+ authorityUiState.feedback = error instanceof Error ? error.message : 'Failed to update permission authority.';
1381
+ authorityUiState.feedbackKind = 'error';
1382
+ } finally {
1383
+ authorityUiState.requestState = AUTHORITY_MODE_REQUEST_STATES.idle;
1384
+ renderAuthorityMode(latestAggregateData);
1385
+ }
1386
+ }
1387
+
851
1388
  function dataAdvisoryPlaceholder() {
852
1389
  return '<span id="perm-all-sessions-advisory" class="perm-inline-advisory" hidden></span>';
853
1390
  }