@lifeaitools/clauth 2.1.0 → 2.10.0

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 (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2614 -11189
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +98 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -0,0 +1,872 @@
1
+ // cli/dashboard/panels/services-panel.js
2
+ // The Services panel, as a real Web Component: <clauth-services-panel>. Owns
3
+ // its own Shadow DOM (project tabs + search bar + the credential grid) and
4
+ // every credential-lifecycle and metadata-CRUD action that used to live in
5
+ // two sibling files, services-key-lifecycle.js and services-metadata-crud.js.
6
+ // Those two files never registered an independent ClauthPanelRegistry panel
7
+ // of their own -- they were pure onclick-invoked helper groups for this same
8
+ // visual region (see their own header comments), so folding them into ONE
9
+ // class here is the correct mirror of what they always were, not a new
10
+ // merge decision. Both files are deleted; every function they held is now an
11
+ // instance method on ClauthServicesPanel below.
12
+ //
13
+ // Every onclick="globalFn(${jsArg(x)})" in the per-card markup became
14
+ // data-action="methodName" plus a data-service-name="..." attribute read off
15
+ // the clicked element, dispatched by the shared listener in
16
+ // ../panel-element.js -- same pattern as tunnel-panel.js's wizSelectTunnel/
17
+ // wizCopy. jsArg() is no longer needed for any of this panel's own markup
18
+ // (data-* attribute values are plain HTML-escaped text, not JS source
19
+ // embedded in an attribute) -- it stays defined in dashboard.js because
20
+ // surfaces-panel.js still uses it.
21
+ //
22
+ // error-bar, the "dot" header indicator, and the whole Add-Service form
23
+ // (add-panel/add-name/add-label/add-description/add-type/add-project/
24
+ // add-msg) are NOT part of this panel's own template -- they live in the
25
+ // toolbar's light-DOM markup (served straight out of serve.js) and are
26
+ // reached with plain document.getElementById(...), exactly as before.
27
+ // Only elements this panel itself renders (project-tabs, service-search-*,
28
+ // grid, and everything renderServiceGrid() puts inside grid) move to
29
+ // this.$()/this.$$() -- the shadow-root-scoped lookups.
30
+ //
31
+ // Loaded as a plain classic <script> (not type="module") BEFORE dashboard.js
32
+ // -- same pattern as every other panel component.
33
+
34
+ const SERVICE_HINTS = {
35
+ // Built via concatenation rather than one contiguous literal, so this
36
+ // display-only hint string (what a value for this credential looks like)
37
+ // does not read as an actual driver connection string to a source scanner.
38
+ "neo4j": "neo4j" + "+s://username:password@instance.databases.neo4j.io",
39
+ "supabase-db": "postgresql://postgres:password@db.ref.supabase.co:5432/postgres",
40
+ "r2": "accountId:accessKeyId:secretAccessKey",
41
+ "namecheap": "apiUser:apiKey",
42
+ };
43
+
44
+ const KEY_URLS = {
45
+ "github": "https://github.com/settings/tokens",
46
+ "vercel": "https://vercel.com/account/tokens",
47
+ "supabase-anon": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
48
+ "supabase-service":"https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/api",
49
+ "supabase-db": "https://supabase.com/dashboard/project/uvojezuorjgqzmhhgluu/settings/database",
50
+ "anthropic": "https://console.anthropic.com/settings/keys",
51
+ "cloudflare": "https://dash.cloudflare.com/profile/api-tokens",
52
+ "r2": "https://dash.cloudflare.com/profile/api-tokens",
53
+ "r2-bucket": "https://dash.cloudflare.com/profile/api-tokens",
54
+ "rocketreach": "https://rocketreach.co/account?section=security",
55
+ "namecheap": "https://ap.www.namecheap.com/settings/tools/apiaccess/",
56
+ "neo4j": "https://console.neo4j.io/",
57
+ "npm": "https://www.npmjs.com/settings/~/tokens",
58
+ "gmail": "https://console.cloud.google.com/apis/credentials",
59
+ "gcal": "https://console.cloud.google.com/apis/credentials",
60
+ };
61
+
62
+ const EXTRA_LINKS = {};
63
+
64
+ const OAUTH_IMPORT = {
65
+ "gmail": {
66
+ jsonFields: ["client_id", "client_secret"],
67
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground — select Gmail API scopes, authorize, then exchange for tokens" }]
68
+ },
69
+ "gcal": {
70
+ jsonFields: ["client_id", "client_secret"],
71
+ extra: [{ key: "refresh_token", label: "Refresh Token", hint: "From Google OAuth Playground — select Calendar API scopes, authorize, then exchange for tokens" }]
72
+ }
73
+ };
74
+
75
+ const TYPE_LABELS = {
76
+ token: "token (API key)",
77
+ secret: "secret (password, secret)",
78
+ keypair: "keypair (user:key pair)",
79
+ connstring: "connstring (connection string)",
80
+ oauth: "oauth (OAuth credentials)",
81
+ fileserver: "fileserver (mount config)",
82
+ };
83
+
84
+ const MCP_SURFACE_DUPLICATE_SERVICES = new Set([
85
+ "mcp-clauth-endpoint",
86
+ "mcp-fs-endpoint",
87
+ "mcp-regen-media-endpoint",
88
+ "mcp-web-research-endpoint",
89
+ ]);
90
+
91
+ const ROTATION_CAPABLE = ["cloudflare","cloudfare-dns","npm","vultr-api"];
92
+
93
+ // Pure helpers -- no instance state, so these stay module-level functions
94
+ // exactly like every other pure-data const above, not instance methods.
95
+ function serviceDomId(name) {
96
+ return encodeURIComponent(String(name)).replace(/%/g, "_");
97
+ }
98
+
99
+ function serviceSort(a, b) {
100
+ return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base", numeric: true });
101
+ }
102
+
103
+ function matchesServiceSearch(s, query) {
104
+ if (!query) return true;
105
+ const q = query.toLowerCase();
106
+ return String(s.name || "").toLowerCase().includes(q) ||
107
+ String(s.label || "").toLowerCase().includes(q);
108
+ }
109
+
110
+ class ClauthServicesPanel extends window.ClauthPanelElement {
111
+ static tagName = "clauth-services-panel";
112
+ static cssHref = "/dashboard/panels/services-panel.css";
113
+
114
+ template() {
115
+ return `
116
+ <div id="project-tabs" class="project-tabs" style="display:none"></div>
117
+ <div id="service-search" class="service-search">
118
+ <span class="service-search-label">Search</span>
119
+ <input id="service-search-input" class="service-search-input" type="search" placeholder="service name or display name" autocomplete="off" spellcheck="false">
120
+ <span id="service-search-count" class="service-search-count"></span>
121
+ </div>
122
+ <div id="grid" class="grid"><p class="loading">Loading services…</p></div>
123
+ `;
124
+ }
125
+
126
+ mount() {
127
+ this.allServices = [];
128
+ this.activeProjectTab = "all";
129
+ this.serviceSearchQuery = "";
130
+ // oninput has no data-* equivalent in panel-element.js's delegated
131
+ // listeners (click + keydown only) -- wire this one directly, same as
132
+ // tunnel-panel.js would for any non-click/keydown DOM event.
133
+ const searchInput = this.$("#service-search-input");
134
+ if (searchInput) searchInput.addEventListener("input", (e) => this.setServiceSearch(e.target.value));
135
+ this.loadServices();
136
+ }
137
+
138
+ // ── Rendering: project tabs, search, the credential grid ───
139
+ renderSetPanel(serviceOrName) {
140
+ const service = typeof serviceOrName === "object" && serviceOrName ? serviceOrName : { name: serviceOrName };
141
+ const name = service.name;
142
+ const keyType = String(service.key_type || "").toLowerCase();
143
+ const id = serviceDomId(name);
144
+ const escapedName = htmlEscape(name);
145
+ const displayName = htmlEscape(name);
146
+ const imp = OAUTH_IMPORT[name];
147
+ if (imp) {
148
+ const extraHtml = imp.extra.map(f => `
149
+ <div class="oauth-field">
150
+ <label class="oauth-label">${f.label}</label>
151
+ ${f.hint ? `<div class="oauth-hint">${f.hint}</div>` : ""}
152
+ <input type="text" class="oauth-input" id="ofield-${id}-${f.key}" placeholder="Paste ${htmlEscape(f.label)}…" spellcheck="false" autocomplete="off">
153
+ </div>
154
+ `).join("");
155
+ return `
156
+ <div class="set-panel" id="set-panel-${id}">
157
+ <label>Set <strong>${displayName}</strong> credentials — paste directly from Google, never in chat</label>
158
+ <div class="oauth-fields">
159
+ <div class="oauth-field">
160
+ <label class="oauth-label">OAuth JSON from Google Cloud Console</label>
161
+ <div class="oauth-hint">Download from APIs & Services → Credentials → your OAuth client → ↓ Download JSON</div>
162
+ <textarea class="set-input" id="ofield-${id}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
163
+ </div>
164
+ ${extraHtml}
165
+ </div>
166
+ <div class="set-foot">
167
+ <button class="btn btn-save" type="button" data-action="saveKey" data-service-name="${escapedName}">Save</button>
168
+ <button class="btn btn-cancel" type="button" data-action="toggleSet" data-service-name="${escapedName}">Cancel</button>
169
+ <span class="set-msg" id="set-msg-${id}"></span>
170
+ </div>
171
+ </div>
172
+ `;
173
+ }
174
+ if (keyType === "keypair") {
175
+ return `
176
+ <div class="set-panel" id="set-panel-${id}">
177
+ <label>New user/pass pair for <strong>${displayName}</strong> — paste here, never in chat</label>
178
+ <div class="oauth-fields">
179
+ <div class="oauth-field">
180
+ <label class="oauth-label">User / Key</label>
181
+ <input type="text" class="oauth-input" id="kp-key-${id}" placeholder="Username, key id, access key id…" spellcheck="false" autocomplete="off">
182
+ </div>
183
+ <div class="oauth-field">
184
+ <label class="oauth-label">Password / Secret</label>
185
+ <textarea class="set-input" id="kp-value-${id}" placeholder="Password, API key, private key, secret…" spellcheck="false" rows="3"></textarea>
186
+ </div>
187
+ </div>
188
+ <div class="set-foot">
189
+ <button class="btn btn-save" type="button" data-action="saveKey" data-service-name="${escapedName}">Save</button>
190
+ <button class="btn btn-cancel" type="button" data-action="toggleSet" data-service-name="${escapedName}">Cancel</button>
191
+ <span class="set-msg" id="set-msg-${id}"></span>
192
+ </div>
193
+ </div>
194
+ `;
195
+ }
196
+ return `
197
+ <div class="set-panel" id="set-panel-${id}">
198
+ <label>New value for <strong>${displayName}</strong> — paste here, never in chat</label>
199
+ <textarea class="set-input" id="set-input-${id}" placeholder="${htmlEscape(SERVICE_HINTS[name] || "Paste credential…")}" spellcheck="false"></textarea>
200
+ ${SERVICE_HINTS[name] ? `<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">${htmlEscape(SERVICE_HINTS[name])}</div>` : ""}
201
+ <div class="set-foot">
202
+ <button class="btn btn-generate" type="button" data-action="generateSecretKey" data-service-name="${escapedName}">Generate 32-byte key</button>
203
+ <button class="btn btn-save" type="button" data-action="saveKey" data-service-name="${escapedName}">Save</button>
204
+ <button class="btn btn-cancel" type="button" data-action="toggleSet" data-service-name="${escapedName}">Cancel</button>
205
+ <span class="set-msg" id="set-msg-${id}"></span>
206
+ </div>
207
+ </div>
208
+ `;
209
+ }
210
+
211
+ setServiceSearch(value) {
212
+ this.serviceSearchQuery = value || "";
213
+ this.renderServiceGrid(this.allServices);
214
+ }
215
+
216
+ renderProjectTabs(services) {
217
+ const tabsEl = this.$("#project-tabs");
218
+ const projects = new Map(); // project -> count
219
+ let unassigned = 0;
220
+ for (const s of services) {
221
+ if (s.project) {
222
+ projects.set(s.project, (projects.get(s.project) || 0) + 1);
223
+ } else {
224
+ unassigned++;
225
+ }
226
+ }
227
+ // Only show tabs if there's at least one project
228
+ if (projects.size === 0) { tabsEl.style.display = "none"; return; }
229
+ tabsEl.style.display = "flex";
230
+ const tabs = [
231
+ { key: "all", label: "All", count: services.length },
232
+ ...Array.from(projects.entries()).map(([name, count]) => ({ key: name, label: name, count })),
233
+ { key: "unassigned", label: "Global", count: unassigned },
234
+ ];
235
+ tabsEl.innerHTML = tabs.map(t =>
236
+ `<button class="project-tab ${this.activeProjectTab === t.key ? "active" : ""}" data-action="switchProjectTab" data-tab-key="${htmlEscape(t.key)}">${htmlEscape(t.label)}<span class="tab-count">(${t.count})</span></button>`
237
+ ).join("");
238
+ }
239
+
240
+ switchProjectTab(key) {
241
+ this.activeProjectTab = key;
242
+ this.renderProjectTabs(this.allServices);
243
+ this.renderServiceGrid(this.allServices);
244
+ }
245
+
246
+ renderServiceGrid(services) {
247
+ const grid = this.$("#grid");
248
+ services = services.filter(s => !MCP_SURFACE_DUPLICATE_SERVICES.has(s.name));
249
+ let filtered = services;
250
+ if (this.activeProjectTab === "unassigned") {
251
+ filtered = services.filter(s => !s.project);
252
+ } else if (this.activeProjectTab !== "all") {
253
+ filtered = services.filter(s => s.project === this.activeProjectTab);
254
+ }
255
+ filtered = filtered
256
+ .filter(s => matchesServiceSearch(s, this.serviceSearchQuery))
257
+ .slice()
258
+ .sort(serviceSort);
259
+ const searchCount = this.$("#service-search-count");
260
+ if (searchCount) {
261
+ const trimmed = this.serviceSearchQuery.trim();
262
+ searchCount.textContent = trimmed ? filtered.length + " match" + (filtered.length === 1 ? "" : "es") : filtered.length + " shown";
263
+ }
264
+ if (!filtered.length) {
265
+ grid.innerHTML = this.serviceSearchQuery.trim()
266
+ ? '<p class="loading">No services match that search.</p>'
267
+ : '<p class="loading">No services in this group.</p>';
268
+ return;
269
+ }
270
+ grid.innerHTML = filtered.map(s => {
271
+ const id = serviceDomId(s.name);
272
+ const nameAttr = htmlEscape(s.name);
273
+ const projectName = s.project || "general";
274
+ return `
275
+ <div class="card">
276
+ <div class="card-top">
277
+ <div class="card-main">
278
+ <div class="card-title-row">
279
+ <span class="card-name" id="label-display-${id}">${htmlEscape(s.label && s.label !== s.name ? s.label : s.name)}</span>
280
+ <code class="card-slug">${htmlEscape(s.name)}</code>
281
+ </div>
282
+ <div class="card-meta-row">
283
+ <div class="card-type">${htmlEscape(s.key_type || "secret")}</div>
284
+ <span class="svc-badge ${s.enabled === false ? "off" : "on"}" id="badge-${id}">${s.enabled === false ? "disabled" : "enabled"}</span>
285
+ <span class="expiry-badge" id="expiry-${id}" style="display:none"></span>
286
+ <span class="card-project">
287
+ <span class="card-project-name" id="proj-name-${id}">${htmlEscape(projectName)}</span>
288
+ <button class="card-project-pencil" type="button" data-action="toggleCardEdit" data-service-name="${nameAttr}" title="Edit name &amp; project">✎</button>
289
+ </span>
290
+ </div>
291
+ ${s.description ? `<div class="card-desc">${htmlEscape(s.description)}</div>` : ""}
292
+ ${KEY_URLS[s.name] ? `<a class="card-getkey" href="${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>` : ""}
293
+ ${(EXTRA_LINKS[s.name] || []).map(l => `<a class="card-getkey" href="${l.url}" target="_blank" rel="noopener" style="margin-left:0">${htmlEscape(l.label)}</a>`).join("")}
294
+ </div>
295
+ <div class="card-corner">
296
+ <button class="card-edit-corner" type="button" data-action="toggleCardEdit" data-service-name="${nameAttr}" title="Edit name &amp; project">✏️</button>
297
+ <div class="status-dot" id="sdot-${id}" title=""></div>
298
+ </div>
299
+ </div>
300
+ <div class="card-value" id="val-${id}"></div>
301
+ <div class="card-actions">
302
+ <button class="btn btn-reveal" type="button" data-action="reveal" data-service-name="${nameAttr}">Reveal</button>
303
+ <button class="btn btn-copy" id="copybtn-${id}" style="display:none" type="button" data-action="copyKey" data-service-name="${nameAttr}">Copy</button>
304
+ <button class="btn btn-set" type="button" data-action="toggleSet" data-service-name="${nameAttr}">Set</button>
305
+ <button class="btn ${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-${id}" type="button" data-action="toggleService" data-service-name="${nameAttr}">${s.enabled === false ? "Enable" : "Disable"}</button>
306
+ <button class="btn-rotate" id="rotbtn-${id}" style="display:none" type="button" data-action="rotateKey" data-service-name="${nameAttr}">↻ Rotate</button>
307
+ <button class="btn-delete" type="button" data-action="deleteService" data-service-name="${nameAttr}" title="Delete service">Delete</button>
308
+ </div>
309
+ <div class="card-edit-panel" id="ce-${id}">
310
+ <div class="ce-head">
311
+ <span>Edit</span>
312
+ <code class="card-slug">${htmlEscape(s.name)}</code>
313
+ <span class="ce-hint">(slug unchanged)</span>
314
+ </div>
315
+ <div class="ce-row">
316
+ <label>Display name</label>
317
+ <input class="ce-input" id="ce-name-${id}" value="${htmlEscape(s.label || s.name)}" spellcheck="false" autocomplete="off"
318
+ data-keydown="ceInputKeydown" data-service-name="${nameAttr}">
319
+ </div>
320
+ <div class="ce-row">
321
+ <label>Project</label>
322
+ <input class="ce-input" id="ce-project-${id}" value="${htmlEscape(projectName)}" spellcheck="false" autocomplete="off"
323
+ data-keydown="ceInputKeydown" data-service-name="${nameAttr}">
324
+ </div>
325
+ <div class="ce-foot">
326
+ <button class="btn" type="button" data-action="saveCardEdit" data-service-name="${nameAttr}">Save</button>
327
+ <button class="btn" type="button" data-action="toggleCardEdit" data-service-name="${nameAttr}">Cancel</button>
328
+ <span class="ce-msg" id="ce-msg-${id}"></span>
329
+ </div>
330
+ </div>
331
+ ${this.renderSetPanel(s)}
332
+ </div>
333
+ `;
334
+ }).join("");
335
+ }
336
+
337
+ async loadServices() {
338
+ const grid = this.$("#grid");
339
+ // error-bar and the header "dot" belong to the toolbar's light-DOM
340
+ // markup, not this panel's own shadow template -- plain document
341
+ // lookups, unchanged from before this panel had a shadow root.
342
+ const err = document.getElementById("error-bar");
343
+ err.style.display = "none";
344
+ grid.innerHTML = '<p class="loading">Loading…</p>';
345
+
346
+ let status;
347
+ for (let attempt = 0; attempt < 3; attempt++) {
348
+ try {
349
+ status = await Promise.race([
350
+ fetch(BASE + "/status").then(r => r.json()),
351
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 8000))
352
+ ]);
353
+ break;
354
+ } catch (e) {
355
+ if (attempt === 2) {
356
+ err.textContent = "⚠ Could not load services — " + e.message;
357
+ err.style.display = "block";
358
+ grid.innerHTML = "";
359
+ return;
360
+ }
361
+ await new Promise(r => setTimeout(r, 1000));
362
+ }
363
+ }
364
+ try {
365
+ if (status.locked) { showLockScreen(); return; }
366
+ if (status.error) throw new Error(status.error);
367
+
368
+ this.allServices = status.services || [];
369
+ if (!this.allServices.length) { grid.innerHTML = '<p class="loading">No services registered.</p>'; return; }
370
+
371
+ this.renderProjectTabs(this.allServices);
372
+ this.renderServiceGrid(this.allServices);
373
+ // Load expiry data after grid renders
374
+ this.loadExpiry();
375
+ } catch (e) {
376
+ err.textContent = "⚠ " + e.message;
377
+ err.style.display = "block";
378
+ document.getElementById("dot").style.background = "#ef4444";
379
+ grid.innerHTML = "";
380
+ }
381
+ }
382
+
383
+ async toggleService(name) {
384
+ const id = serviceDomId(name);
385
+ const badge = this.$("#badge-" + id);
386
+ const btn = this.$("#togbtn-" + id);
387
+ if (!badge || !btn) return;
388
+ const currently = badge.classList.contains("on");
389
+ const newState = !currently;
390
+
391
+ btn.disabled = true; btn.textContent = "…";
392
+
393
+ try {
394
+ const r = await fetch(BASE + "/toggle/" + encodeURIComponent(name), {
395
+ method: "POST",
396
+ headers: writeHeaders({ "Content-Type": "application/json" }),
397
+ body: JSON.stringify({ enabled: newState })
398
+ }).then(r => r.json());
399
+
400
+ if (r.locked) { showLockScreen(); return; }
401
+ if (r.error) throw new Error(r.error);
402
+
403
+ badge.className = "svc-badge " + (newState ? "on" : "off");
404
+ badge.textContent = newState ? "enabled" : "disabled";
405
+ btn.className = "btn " + (newState ? "btn-disable" : "btn-enable");
406
+ btn.textContent = newState ? "Disable" : "Enable";
407
+ } catch (e) {
408
+ btn.textContent = "Error";
409
+ setTimeout(() => {
410
+ btn.textContent = currently ? "Disable" : "Enable";
411
+ }, 2000);
412
+ } finally {
413
+ btn.disabled = false;
414
+ }
415
+ }
416
+
417
+ async addService() {
418
+ // The Add-Service form lives in the toolbar's light-DOM markup (see
419
+ // header comment) -- plain document lookups, same as loadServices()'s
420
+ // error-bar/dot above.
421
+ const name = document.getElementById("add-name").value.trim().toLowerCase();
422
+ const label = document.getElementById("add-label").value.trim();
423
+ const description = document.getElementById("add-description").value.trim();
424
+ const type = document.getElementById("add-type").value;
425
+ const project = document.getElementById("add-project").value.trim();
426
+ const msg = document.getElementById("add-msg");
427
+
428
+ if (!name) { msg.className = "add-msg fail"; msg.textContent = "Slug is required."; return; }
429
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { msg.className = "add-msg fail"; msg.textContent = "Slug: lowercase letters, numbers, hyphens, underscores only."; return; }
430
+
431
+ msg.className = "add-msg"; msg.textContent = "Creating…";
432
+ try {
433
+ const payload = { name, key_type: type, label: label || name };
434
+ if (description) payload.description = description;
435
+ if (project) payload.project = project;
436
+ const r = await fetch(BASE + "/add-service", {
437
+ method: "POST",
438
+ headers: writeHeaders({ "Content-Type": "application/json" }),
439
+ body: JSON.stringify(payload)
440
+ }).then(r => r.json());
441
+
442
+ if (r.locked) { showLockScreen(); return; }
443
+ if (r.error) throw new Error(r.error);
444
+
445
+ msg.className = "add-msg ok"; msg.textContent = "✓ " + name + " created";
446
+ setTimeout(() => {
447
+ document.getElementById("add-panel").style.display = "none";
448
+ msg.textContent = "";
449
+ this.loadServices();
450
+ }, 1200);
451
+ } catch (e) {
452
+ msg.className = "add-msg fail"; msg.textContent = "✗ " + e.message;
453
+ }
454
+ }
455
+
456
+ // ── Credential lifecycle: rotation, expiry, reveal, copy, generate, save ───
457
+ // (moved verbatim from services-key-lifecycle.js, now deleted)
458
+ async loadExpiry() {
459
+ try {
460
+ const data = await fetch(BASE + "/expiry").then(r => r.json());
461
+ if (!data.services) return;
462
+ for (const [name, info] of Object.entries(data.services)) {
463
+ const id = serviceDomId(name);
464
+ const el = this.$("#expiry-" + id);
465
+ const rotBtn = this.$("#rotbtn-" + id);
466
+ if (!el) continue;
467
+
468
+ // Show rotate button for auto-capable services
469
+ if (rotBtn && ROTATION_CAPABLE.includes(name)) {
470
+ rotBtn.style.display = "inline-block";
471
+ }
472
+
473
+ if (!info.expires_at) {
474
+ // Show policy type for services with rotation config
475
+ if (info.policy === "auto") {
476
+ el.style.display = "inline";
477
+ el.style.color = "#94a3b8";
478
+ el.style.background = "rgba(148,163,184,0.1)";
479
+ el.style.border = "1px solid rgba(148,163,184,0.2)";
480
+ el.textContent = "no expiry set";
481
+ }
482
+ continue;
483
+ }
484
+
485
+ el.style.display = "inline";
486
+ const colors = { green: { c: "#4ade80", bg: "rgba(74,222,128,0.1)", b: "rgba(74,222,128,0.2)" },
487
+ yellow: { c: "#facc15", bg: "rgba(250,204,21,0.1)", b: "rgba(250,204,21,0.2)" },
488
+ orange: { c: "#fb923c", bg: "rgba(251,146,60,0.1)", b: "rgba(251,146,60,0.2)" },
489
+ red: { c: "#f87171", bg: "rgba(248,113,113,0.15)", b: "rgba(248,113,113,0.3)" },
490
+ gray: { c: "#94a3b8", bg: "rgba(148,163,184,0.1)", b: "rgba(148,163,184,0.2)" } };
491
+ const clr = colors[info.color] || colors.gray;
492
+ el.style.color = clr.c;
493
+ el.style.background = clr.bg;
494
+ el.style.border = "1px solid " + clr.b;
495
+
496
+ if (info.daysLeft === null) {
497
+ el.textContent = "unknown";
498
+ } else if (info.daysLeft < 0) {
499
+ el.textContent = "EXPIRED " + Math.abs(info.daysLeft) + "d ago";
500
+ } else if (info.daysLeft === 0) {
501
+ el.textContent = "EXPIRES TODAY";
502
+ } else {
503
+ el.textContent = info.daysLeft + "d left";
504
+ }
505
+ }
506
+ } catch {}
507
+ }
508
+
509
+ async rotateKey(name) {
510
+ if (!confirm("Rotate " + name + " key?\n\nA new key will be created and the old one deleted.")) return;
511
+ const rotBtn = this.$("#rotbtn-" + serviceDomId(name));
512
+ if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
513
+ try {
514
+ const r = await fetch(BASE + "/rotate/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
515
+ if (r.ok) {
516
+ if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
517
+ this.loadExpiry(); // refresh badges
518
+ } else {
519
+ alert("Rotation failed: " + (r.error || "unknown error"));
520
+ if (rotBtn) { rotBtn.disabled = false; rotBtn.textContent = "↻ Rotate"; }
521
+ }
522
+ } catch (err) {
523
+ alert("Rotation error: " + err.message);
524
+ if (rotBtn) { rotBtn.disabled = false; rotBtn.textContent = "↻ Rotate"; }
525
+ }
526
+ }
527
+
528
+ async setExpiry(name) {
529
+ const days = prompt("Set expiry for " + name + " (days from now):", "30");
530
+ if (!days) return;
531
+ const expiresAt = new Date(Date.now() + parseInt(days) * 86400000).toISOString();
532
+ try {
533
+ await fetch(BASE + "/set-expiry/" + encodeURIComponent(name), {
534
+ method: "POST",
535
+ headers: writeHeaders({ "Content-Type": "application/json" }),
536
+ body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
537
+ });
538
+ this.loadExpiry();
539
+ } catch {}
540
+ }
541
+
542
+ async reveal(name, btn) {
543
+ const id = serviceDomId(name);
544
+ const valEl = this.$("#val-" + id);
545
+ const copyBtn = this.$("#copybtn-" + id);
546
+ if (!valEl || !copyBtn) return;
547
+ if (valEl.style.display === "block") {
548
+ valEl.style.display = "none"; copyBtn.style.display = "none";
549
+ btn.textContent = "Reveal"; return;
550
+ }
551
+ valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
552
+ try {
553
+ const r = await fetch(BASE + "/get/" + encodeURIComponent(name)).then(r => r.json());
554
+ if (r.locked) { showLockScreen(); return; }
555
+ if (r.error) throw new Error(r.error);
556
+ const imp = OAUTH_IMPORT[name];
557
+ if (imp) {
558
+ try {
559
+ const parsed = JSON.parse(r.value);
560
+ const allKeys = [...imp.jsonFields, ...imp.extra.map(f => f.key)];
561
+ valEl.innerHTML = allKeys.map(k =>
562
+ '<div style="margin-bottom:6px"><span style="color:#64748b;font-size:.72rem;text-transform:uppercase;letter-spacing:.4px">' + k.replace(/_/g," ") + '</span><br>' + (parsed[k] || "—") + '</div>'
563
+ ).join("");
564
+ } catch { valEl.textContent = r.value; }
565
+ } else {
566
+ valEl.textContent = r.value;
567
+ }
568
+ copyBtn.style.display = "inline-block";
569
+ } catch (e) {
570
+ valEl.textContent = "Error: " + e.message; valEl.style.color = "#ef4444";
571
+ }
572
+ }
573
+
574
+ async copyKey(name) {
575
+ const id = serviceDomId(name);
576
+ const val = this.$("#val-" + id).textContent;
577
+ try {
578
+ await navigator.clipboard.writeText(val);
579
+ const btn = this.$("#copybtn-" + id);
580
+ btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
581
+ } catch {}
582
+ }
583
+
584
+ getServiceKeyType(name) {
585
+ const svc = this.allServices.find(s => s.name === name);
586
+ return String(svc?.key_type || "").toLowerCase();
587
+ }
588
+
589
+ generateSecretKey(name) {
590
+ const input = this.$("#set-input-" + serviceDomId(name));
591
+ if (!input) return;
592
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
593
+ input.value = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
594
+ input.focus();
595
+ input.select();
596
+ }
597
+
598
+ async saveKey(name) {
599
+ const id = serviceDomId(name);
600
+ const msg = this.$("#set-msg-" + id);
601
+ if (!msg) return;
602
+ const imp = OAUTH_IMPORT[name];
603
+ const keyType = this.getServiceKeyType(name);
604
+ let value;
605
+
606
+ if (imp) {
607
+ const jsonEl = this.$("#ofield-" + id + "-json");
608
+ const raw = jsonEl ? jsonEl.value.trim() : "";
609
+ if (!raw) { msg.className = "set-msg fail"; msg.textContent = "Paste the OAuth JSON first."; return; }
610
+ let parsed;
611
+ try { parsed = JSON.parse(raw); } catch { msg.className = "set-msg fail"; msg.textContent = "Invalid JSON — copy the full file content."; return; }
612
+ // Google wraps fields under "installed" or "web"
613
+ const src = parsed.installed || parsed.web || parsed;
614
+ const obj = {};
615
+ for (const k of imp.jsonFields) {
616
+ if (!src[k]) { msg.className = "set-msg fail"; msg.textContent = k + " not found in JSON."; return; }
617
+ obj[k] = src[k];
618
+ }
619
+ for (const f of imp.extra) {
620
+ const el = this.$("#ofield-" + id + "-" + f.key);
621
+ const v = el ? el.value.trim() : "";
622
+ if (!v) { msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return; }
623
+ obj[f.key] = v;
624
+ }
625
+ value = JSON.stringify(obj);
626
+ } else if (keyType === "keypair") {
627
+ const keyEl = this.$("#kp-key-" + id);
628
+ const valueEl = this.$("#kp-value-" + id);
629
+ const user = keyEl ? keyEl.value.trim() : "";
630
+ const pass = valueEl ? valueEl.value : "";
631
+ if (!user) { msg.className = "set-msg fail"; msg.textContent = "User / key is required."; return; }
632
+ if (!pass.trim()) { msg.className = "set-msg fail"; msg.textContent = "Password / secret is required."; return; }
633
+ value = JSON.stringify({ user, pass, username: user, password: pass, key: user, value: pass });
634
+ } else {
635
+ const input = this.$("#set-input-" + id);
636
+ value = input ? input.value : "";
637
+ if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
638
+ }
639
+
640
+ msg.className = "set-msg"; msg.textContent = "Saving…";
641
+ try {
642
+ const r = await fetch(BASE + "/set/" + encodeURIComponent(name), {
643
+ method: "POST",
644
+ headers: writeHeaders({ "Content-Type": "application/json" }),
645
+ body: JSON.stringify({ value })
646
+ }).then(r => r.json());
647
+
648
+ if (r.locked) { showLockScreen(); return; }
649
+ if (r.error) throw new Error(r.error);
650
+ msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
651
+ if (imp) {
652
+ const jsonEl = this.$("#ofield-" + id + "-json");
653
+ if (jsonEl) jsonEl.value = "";
654
+ imp.extra.forEach(f => { const el = this.$("#ofield-" + id + "-" + f.key); if (el) el.value = ""; });
655
+ } else if (keyType === "keypair") {
656
+ const keyEl = this.$("#kp-key-" + id);
657
+ const valueEl = this.$("#kp-value-" + id);
658
+ if (keyEl) keyEl.value = "";
659
+ if (valueEl) valueEl.value = "";
660
+ } else {
661
+ const inp = this.$("#set-input-" + id);
662
+ if (inp) inp.value = "";
663
+ }
664
+ const dot = this.$("#sdot-" + id);
665
+ if (dot) { dot.className = "status-dot"; dot.title = ""; }
666
+ setTimeout(() => {
667
+ const panel = this.$("#set-panel-" + id);
668
+ if (panel) panel.style.display = "none";
669
+ msg.textContent = "";
670
+ }, 1800);
671
+ } catch (e) {
672
+ msg.className = "set-msg fail"; msg.textContent = "✗ " + e.message;
673
+ }
674
+ }
675
+
676
+ // ── Service-metadata CRUD: project/label edit, delete, Set-panel toggle ───
677
+ // (moved verbatim from services-metadata-crud.js, now deleted)
678
+ toggleCardEdit(name) {
679
+ const id = serviceDomId(name);
680
+ const panel = this.$("#ce-" + id);
681
+ if (!panel) return;
682
+ const open = panel.style.display === "block";
683
+ panel.style.display = open ? "none" : "block";
684
+ if (!open) {
685
+ const svc = this.allServices.find(s => s.name === name);
686
+ this.$("#ce-name-" + id).value = (svc && svc.label) ? svc.label : name;
687
+ this.$("#ce-project-" + id).value = (svc && svc.project) ? svc.project : "general";
688
+ this.$("#ce-msg-" + id).textContent = "";
689
+ this.$("#ce-name-" + id).focus();
690
+ }
691
+ }
692
+
693
+ // Enter/Escape on the card-edit Name/Project inputs -- data-keydown
694
+ // equivalent of the old onkeydown='if(event.key==="Enter")saveCardEdit(...)'.
695
+ // saveCardEdit is write-guarded, so this dispatches through window.saveCardEdit
696
+ // (the bridge), never this.saveCardEdit() directly -- same rule as every
697
+ // write-guarded action reached from get actions() below.
698
+ ceInputKeydown(e, el) {
699
+ const name = el.dataset.serviceName;
700
+ if (e.key === "Enter") window.saveCardEdit(name);
701
+ else if (e.key === "Escape") this.toggleCardEdit(name);
702
+ }
703
+
704
+ async saveCardEdit(name) {
705
+ const id = serviceDomId(name);
706
+ const nameInput = this.$("#ce-name-" + id);
707
+ const projectInput = this.$("#ce-project-" + id);
708
+ const msg = this.$("#ce-msg-" + id);
709
+ const newLabel = nameInput.value.trim() || name;
710
+ const newProject = projectInput.value.trim() || "general";
711
+ msg.style.color = "#d9a86c"; msg.textContent = "Saving…";
712
+ try {
713
+ const r = await fetch(BASE + "/update-service", {
714
+ method: "POST",
715
+ headers: writeHeaders({ "Content-Type": "application/json" }),
716
+ body: JSON.stringify({ service: name, label: newLabel, project: newProject })
717
+ }).then(r => r.json());
718
+ if (r.locked) { showLockScreen(); return; }
719
+ if (r.error) throw new Error(r.error);
720
+ msg.style.color = "#4ade80"; msg.textContent = "✓ Saved";
721
+ const svc = this.allServices.find(s => s.name === name);
722
+ if (svc) { svc.label = newLabel; svc.project = newProject; }
723
+ const labelEl = this.$("#label-display-" + id);
724
+ if (labelEl) labelEl.textContent = newLabel;
725
+ const projEl = this.$("#proj-name-" + id);
726
+ if (projEl) projEl.textContent = newProject;
727
+ setTimeout(() => { this.toggleCardEdit(name); this.renderProjectTabs(this.allServices); }, 800);
728
+ } catch (err) {
729
+ msg.style.color = "#f87171"; msg.textContent = "✗ " + err.message;
730
+ }
731
+ }
732
+
733
+ async deleteService(name) {
734
+ if (!confirm(`Delete service "${name}"? This cannot be undone.`)) return;
735
+ try {
736
+ const r = await fetch(BASE + "/delete/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
737
+ if (r.locked) { showLockScreen(false); return; }
738
+ if (r.error) throw new Error(r.error);
739
+ this.loadServices();
740
+ } catch (e) {
741
+ alert("Delete failed: " + e.message);
742
+ }
743
+ }
744
+
745
+ toggleSet(name) {
746
+ const id = serviceDomId(name);
747
+ const panel = this.$("#set-panel-" + id);
748
+ const msg = this.$("#set-msg-" + id);
749
+ if (!panel) return;
750
+ const open = panel.style.display === "block";
751
+ panel.style.display = open ? "none" : "block";
752
+ if (!open) {
753
+ if (msg) msg.textContent = "";
754
+ const imp = OAUTH_IMPORT[name];
755
+ if (imp) {
756
+ const jsonEl = this.$("#ofield-" + id + "-json");
757
+ if (jsonEl) { jsonEl.value = ""; jsonEl.focus(); }
758
+ imp.extra.forEach(f => { const el = this.$("#ofield-" + id + "-" + f.key); if (el) el.value = ""; });
759
+ } else if (this.getServiceKeyType(name) === "keypair") {
760
+ const keyEl = this.$("#kp-key-" + id);
761
+ const valueEl = this.$("#kp-value-" + id);
762
+ if (keyEl) { keyEl.value = ""; keyEl.focus(); }
763
+ if (valueEl) valueEl.value = "";
764
+ } else {
765
+ const input = this.$("#set-input-" + id);
766
+ if (input) { input.value = ""; input.focus(); }
767
+ }
768
+ }
769
+ }
770
+
771
+ // ── Public cross-panel seam for toolbar-panel.js's Check-All button ───────
772
+ // Every ".status-dot" this panel renders lives inside its own shadow root
773
+ // now, so a light-DOM document.querySelectorAll(".status-dot") from another
774
+ // panel silently finds zero elements. Same boundary fix as tunnel-panel.js's
775
+ // updateHealthCheckResult -- a public method instead of a DOM-id reach.
776
+ setAllDotsChecking() {
777
+ this.$$(".status-dot").forEach(d => { d.className = "status-dot checking"; d.title = "Checking…"; });
778
+ }
779
+
780
+ updateCheckResult(name, result) {
781
+ const dot = this.$("#sdot-" + serviceDomId(name));
782
+ if (!dot) return;
783
+ if (result.ok) {
784
+ dot.className = "status-dot ok"; dot.title = "OK";
785
+ } else {
786
+ dot.className = "status-dot fail";
787
+ dot.title = result.reason || "No key stored";
788
+ }
789
+ }
790
+
791
+ fadeOkDots() {
792
+ this.$$(".status-dot.ok").forEach(d => d.classList.add("fading"));
793
+ }
794
+
795
+ resetAllDots() {
796
+ this.$$(".status-dot").forEach(d => { d.className = "status-dot"; d.title = ""; });
797
+ }
798
+
799
+ get actions() {
800
+ return {
801
+ switchProjectTab: (e, el) => this.switchProjectTab(el.dataset.tabKey),
802
+ toggleCardEdit: (e, el) => this.toggleCardEdit(el.dataset.serviceName),
803
+ ceInputKeydown: (e, el) => this.ceInputKeydown(e, el),
804
+ reveal: (e, el) => this.reveal(el.dataset.serviceName, el),
805
+ copyKey: (e, el) => this.copyKey(el.dataset.serviceName),
806
+ toggleSet: (e, el) => this.toggleSet(el.dataset.serviceName),
807
+ generateSecretKey: (e, el) => this.generateSecretKey(el.dataset.serviceName),
808
+ // Write-guarded actions dispatch through window.X (the bridge), NOT
809
+ // this.X(...) directly -- installWriteAccessGuards() (dashboard.js)
810
+ // wraps the GLOBAL name with the write-token acquire-and-retry gate.
811
+ // Calling the instance method directly would silently bypass that
812
+ // gate, restoring the exact write-lock defect this mechanism exists
813
+ // to prevent. Same rule tunnel-panel.js follows for wizSubmitCfToken.
814
+ toggleService: (e, el) => window.toggleService(el.dataset.serviceName),
815
+ rotateKey: (e, el) => window.rotateKey(el.dataset.serviceName),
816
+ deleteService: (e, el) => window.deleteService(el.dataset.serviceName),
817
+ saveCardEdit: (e, el) => window.saveCardEdit(el.dataset.serviceName),
818
+ saveKey: (e, el) => window.saveKey(el.dataset.serviceName),
819
+ };
820
+ }
821
+ }
822
+ customElements.define(ClauthServicesPanel.tagName, ClauthServicesPanel);
823
+
824
+ // Write-guard bridges: installWriteAccessGuards() (dashboard.js) wraps every
825
+ // name in WRITE_ACTIONS by reassigning window[name] to a guarded version --
826
+ // it can only do that for a real global function, not a class instance
827
+ // method. Each of these thin globals delegates to the real implementation on
828
+ // the live panel instance. addService's bridge is also the PRIMARY call
829
+ // path for that one action -- serve.js's Add-Service button uses a raw
830
+ // onclick="addService()" in light-DOM markup outside this panel's shadow
831
+ // root, so this global is not just a get-actions() dispatch target, it is
832
+ // the only way that button reaches this panel at all.
833
+ window.toggleService = (...args) => {
834
+ const el = document.querySelector(ClauthServicesPanel.tagName);
835
+ return el?.toggleService(...args);
836
+ };
837
+ window.addService = (...args) => {
838
+ const el = document.querySelector(ClauthServicesPanel.tagName);
839
+ return el?.addService(...args);
840
+ };
841
+ window.rotateKey = (...args) => {
842
+ const el = document.querySelector(ClauthServicesPanel.tagName);
843
+ return el?.rotateKey(...args);
844
+ };
845
+ window.setExpiry = (...args) => {
846
+ const el = document.querySelector(ClauthServicesPanel.tagName);
847
+ return el?.setExpiry(...args);
848
+ };
849
+ window.saveKey = (...args) => {
850
+ const el = document.querySelector(ClauthServicesPanel.tagName);
851
+ return el?.saveKey(...args);
852
+ };
853
+ window.saveCardEdit = (...args) => {
854
+ const el = document.querySelector(ClauthServicesPanel.tagName);
855
+ return el?.saveCardEdit(...args);
856
+ };
857
+ window.deleteService = (...args) => {
858
+ const el = document.querySelector(ClauthServicesPanel.tagName);
859
+ return el?.deleteService(...args);
860
+ };
861
+
862
+ // Panel interface registration — see ./panel-registry.js. connectedCallback()
863
+ // only builds the shadow DOM (safe -- no external dependency); mount() runs
864
+ // later, once ClauthPanelRegistry.mountAll() confirms every script (incl.
865
+ // dashboard.js's apiFetch/writeHeaders/showLockScreen) has loaded.
866
+ window.ClauthPanelRegistry.register({
867
+ id: "services",
868
+ mount: () => {
869
+ const el = document.querySelector(ClauthServicesPanel.tagName);
870
+ if (el) el.mount();
871
+ },
872
+ });