@openparachute/hub 0.5.14-rc.14 → 0.5.14-rc.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.14",
3
+ "version": "0.5.14-rc.15",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -57,6 +57,23 @@ describe("renderAccountHome", () => {
57
57
  expect(html).not.toContain("Authorization: Bearer");
58
58
  // Copy-button progressive-enhancement script is present.
59
59
  expect(html).toContain("navigator.clipboard");
60
+ // Friendlier framing: the block leads with "connect your AI assistant"
61
+ // rather than MCP jargon up top.
62
+ expect(html).toContain('data-testid="connect-ai-heading"');
63
+ expect(html).toContain("Connect your AI");
64
+ // BOTH connect methods render as distinct, labelled blocks.
65
+ expect(html).toContain('data-testid="connect-method-claude-code"');
66
+ expect(html).toContain("Claude Code");
67
+ expect(html).toContain('data-testid="connect-method-claude-ai"');
68
+ expect(html).toContain("Claude.ai");
69
+ // The Claude.ai path mirrors the install.njk canonical phrasing
70
+ // (Settings → Connectors → Add custom connector, paste the endpoint).
71
+ expect(html).toContain("Connectors");
72
+ expect(html).toContain("Add custom connector");
73
+ // A brief "any other MCP client" line is present (no bloat — just one).
74
+ expect(html).toContain('data-testid="connect-any-client-hint"');
75
+ // Notes CTA still present, now framed as the browser-UI option.
76
+ expect(html).toContain('data-testid="open-notes-cta"');
60
77
  });
61
78
 
62
79
  test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
@@ -112,11 +129,16 @@ describe("renderAccountHome", () => {
112
129
  twoFactorEnabled: false,
113
130
  });
114
131
  expect(html).toContain("Welcome, ghost");
115
- expect(html).toContain("Ask the hub operator");
132
+ // The message explains WHY there's nothing to connect (no vault yet) and
133
+ // gives a clear next step — not just a bare "ask your admin".
134
+ expect(html).toContain("Ask the hub operator to assign you a vault");
135
+ expect(html).toContain("don't have a vault yet");
116
136
  // No /admin/ link in this branch — they have no admin role.
117
137
  expect(html).not.toContain('href="/admin/"');
118
138
  // No Notes CTA.
119
139
  expect(html).not.toContain("notes.parachute.computer/add");
140
+ // No connect block — you can't connect a vault you don't have.
141
+ expect(html).not.toContain('data-testid="mcp-connect"');
120
142
  });
121
143
 
122
144
  test("account card — change-password link and sign-out form are present", () => {
@@ -5,7 +5,15 @@ import { join } from "node:path";
5
5
  import { renderHub, writeHubFile } from "../hub.ts";
6
6
 
7
7
  describe("renderHub", () => {
8
- const html = renderHub();
8
+ // The verbose discovery body (Get started / Services / Admin) + its
9
+ // data-loading script render only for a signed-in visitor (the signed-out
10
+ // landing is slimmed — see the "signed-out slimming" describe block below).
11
+ // Assertions about that verbose body therefore run against a signed-in
12
+ // render; assertions about the page shell (doctype, styles, brand) hold for
13
+ // both and use whichever render is convenient.
14
+ const html = renderHub({
15
+ session: { displayName: "operator", csrfToken: "csrf-shell" },
16
+ });
9
17
 
10
18
  test("is a self-contained HTML document with inline styles and script", () => {
11
19
  expect(html).toStartWith("<!doctype html>");
@@ -162,12 +170,72 @@ describe("renderHub", () => {
162
170
  });
163
171
 
164
172
  test("default render (no session) emits the 'Sign in' affordance", () => {
165
- expect(html).toContain('class="auth-indicator"');
166
- expect(html).toContain("Sign in");
167
- expect(html).toContain('href="/login?next=/"');
173
+ const out = renderHub();
174
+ expect(out).toContain('class="auth-indicator"');
175
+ expect(out).toContain("Sign in");
176
+ expect(out).toContain('href="/login?next=/"');
168
177
  // No POST form, no CSRF input — those only appear when signed in.
169
- expect(html).not.toContain('action="/logout"');
170
- expect(html).not.toContain("__csrf");
178
+ expect(out).not.toContain('action="/logout"');
179
+ expect(out).not.toContain("__csrf");
180
+ });
181
+ });
182
+
183
+ describe("renderHub — signed-out slimming (operator feedback)", () => {
184
+ // A signed-out visitor should see a clean, minimal landing: brand +
185
+ // tagline (in the header) + a single clear "Sign in" call. The hub's
186
+ // internal detail — the service catalog, vault listings, admin surfaces,
187
+ // and the well-known-driven loading script — must NOT render until the
188
+ // visitor authenticates. The signed-in render is unchanged.
189
+ const signedOut = renderHub();
190
+ const signedIn = renderHub({
191
+ session: { displayName: "operator", csrfToken: "csrf-xyz" },
192
+ });
193
+
194
+ test("signed-out: brand wordmark + tagline still render (the slim landing keeps the brand)", () => {
195
+ expect(signedOut).toContain("<h1>Parachute</h1>");
196
+ expect(signedOut).toContain("Truly personal computing. Your knowledge belongs with you.");
197
+ });
198
+
199
+ test("signed-out: a clear 'Sign in' call is the primary affordance", () => {
200
+ expect(signedOut).toContain('data-testid="signed-out-signin"');
201
+ expect(signedOut).toContain('href="/login?next=/"');
202
+ expect(signedOut).toContain("Sign in");
203
+ });
204
+
205
+ test("signed-out: the verbose Services / Admin / Get started sections are absent", () => {
206
+ expect(signedOut).not.toContain('id="services-section"');
207
+ expect(signedOut).not.toContain('id="admin-section"');
208
+ expect(signedOut).not.toContain('id="get-started-section"');
209
+ expect(signedOut).not.toContain("<h2>Services</h2>");
210
+ expect(signedOut).not.toContain("<h2>Admin</h2>");
211
+ // Admin links / token surface must not be exposed pre-auth.
212
+ expect(signedOut).not.toContain("/admin/vaults");
213
+ expect(signedOut).not.toContain("/admin/tokens");
214
+ });
215
+
216
+ test("signed-out: the well-known service-catalog loading script is not emitted", () => {
217
+ // No data-driven discovery body to populate when signed out → no script.
218
+ // (The brand mark is an inline SVG, not a <script>; assert on the IIFE's
219
+ // load function rather than a blanket "no <script>".) The footer's
220
+ // public "discovery" anchor → /.well-known/parachute.json stays — it's a
221
+ // plain link, not the catalog-fetching script — so assert on the fetch
222
+ // call + the loader function, not the URL string.
223
+ expect(signedOut).not.toContain("loadServices");
224
+ expect(signedOut).not.toContain("renderServices");
225
+ expect(signedOut).not.toContain("fetch('/.well-known/parachute.json'");
226
+ expect(signedOut).not.toContain("<script>");
227
+ });
228
+
229
+ test("signed-in: the verbose sections + loading script DO render (signed-in view unchanged)", () => {
230
+ expect(signedIn).toContain('id="services-section"');
231
+ expect(signedIn).toContain('id="admin-section"');
232
+ expect(signedIn).toContain('id="get-started-section"');
233
+ expect(signedIn).toContain("/admin/vaults");
234
+ expect(signedIn).toContain("/.well-known/parachute.json");
235
+ expect(signedIn).toContain("loadServices");
236
+ // And the signed-out lede / standalone Sign-in CTA is gone (the
237
+ // auth-indicator carries sign-out instead).
238
+ expect(signedIn).not.toContain('data-testid="signed-out-signin"');
171
239
  });
172
240
  });
173
241
 
@@ -186,14 +186,20 @@ function renderVaultCard(opts: VaultCardOpts): string {
186
186
 
187
187
  if (assignedVaults.length > 0) {
188
188
  // One vault tile per assignment (multi-user Phase 2 PR 2). Each tile
189
- // carries the Notes "Open" CTA AND a server-rendered MCP connect block
190
- // (endpoint + `claude mcp add` command, each with a copy button). The
191
- // connect command is the OAuth path no token, so a non-admin friend
192
- // who can't run the SPA's host-admin mint still gets a working
193
- // connect affordance (the first `claude mcp add` use opens a browser,
194
- // signs them in, and approves the scope). This closes the multi-user
195
- // gap where the friend tile only offered the external Notes link + a
196
- // bare hub-origin string.
189
+ // leads with a friendly "connect your AI assistant to this vault" block
190
+ // that covers BOTH connect paths a non-technical friend is likely to
191
+ // use Claude Code (the `claude mcp add` CLI command) and Claude.ai on
192
+ // the web (Settings Connectors Add custom connector, pointed at the
193
+ // endpoint). Both are the OAuth path no token to paste, the first
194
+ // connection opens a browser to sign in + approve. The Notes "Open" CTA
195
+ // sits alongside as the browser-UI option. Phrasing mirrors
196
+ // parachute.computer/install.njk's #connect-mcp-clients section so the
197
+ // operator docs and the friend's account page stay consistent.
198
+ //
199
+ // This closes the multi-user gap where the friend tile read as MCP
200
+ // jargon ("Connect an MCP client") rather than "here's how to connect
201
+ // this to your AI" — and where the web (Claude.ai) path was entirely
202
+ // missing, only the Claude Code CLI command was offered.
197
203
  const heading = assignedVaults.length === 1 ? "<h2>Your vault</h2>" : "<h2>Your vaults</h2>";
198
204
  const tiles = assignedVaults
199
205
  .map((vaultName) => {
@@ -206,42 +212,56 @@ function renderVaultCard(opts: VaultCardOpts): string {
206
212
  return `
207
213
  <div class="vault-tile" data-testid="vault-tile" data-vault-name="${safeVault}">
208
214
  <p class="vault-name"><strong>${safeVault}</strong></p>
209
- <p>
210
- <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
211
- target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
212
- </p>
213
215
  <div class="mcp-connect" data-testid="mcp-connect">
214
- <p class="mcp-connect-label">Connect an MCP client (Claude Code, Claude.ai)</p>
215
- <div class="mcp-field">
216
- <span class="mcp-field-label">Endpoint</span>
217
- <div class="copy-row">
218
- <code data-testid="mcp-endpoint">${safeEndpoint}</code>
219
- <button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
220
- data-testid="copy-mcp-endpoint">Copy</button>
221
- </div>
222
- </div>
223
- <div class="mcp-field">
224
- <span class="mcp-field-label">Claude Code</span>
216
+ <p class="mcp-connect-label" data-testid="connect-ai-heading">Connect your AI
217
+ assistant to this vault</p>
218
+ <p class="mcp-connect-intro">Two common ways. Both sign you in to this hub over
219
+ HTTPS and ask you to approve access the first time — no token to copy.</p>
220
+
221
+ <div class="mcp-method" data-testid="connect-method-claude-code">
222
+ <p class="mcp-method-title">Claude Code (terminal)</p>
223
+ <p class="mcp-method-sub">Run this in your terminal:</p>
225
224
  <div class="copy-row">
226
225
  <code data-testid="mcp-add-command">${safeAddCmd}</code>
227
226
  <button type="button" class="btn btn-copy" data-copy="${safeAddCmd}"
228
227
  data-testid="copy-mcp-add-command">Copy</button>
229
228
  </div>
230
229
  </div>
231
- <p class="mcp-connect-hint">No token needed — the command opens a browser to
232
- sign you in to this hub and approve access on first use.</p>
230
+
231
+ <div class="mcp-method" data-testid="connect-method-claude-ai">
232
+ <p class="mcp-method-title">Claude.ai (web)</p>
233
+ <p class="mcp-method-sub">In Claude.ai, open <strong>Settings → Connectors</strong>,
234
+ choose <strong>Add custom connector</strong>, and paste this endpoint:</p>
235
+ <div class="copy-row">
236
+ <code data-testid="mcp-endpoint">${safeEndpoint}</code>
237
+ <button type="button" class="btn btn-copy" data-copy="${safeEndpoint}"
238
+ data-testid="copy-mcp-endpoint">Copy</button>
239
+ </div>
240
+ <p class="mcp-method-note">Claude.ai then redirects you here to sign in and
241
+ approve. (Your hub must be reachable from the web for this.)</p>
242
+ </div>
243
+
244
+ <p class="mcp-connect-hint" data-testid="connect-any-client-hint">Any other MCP
245
+ client (Codex, Goose, Cursor, your own agent): point it at the same endpoint
246
+ above over HTTP.</p>
233
247
  </div>
248
+ <p class="vault-notes-cta">
249
+ <a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
250
+ target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
251
+ <span class="vault-notes-cta-sub">Prefer a browser UI? Open Notes to browse +
252
+ capture in this vault.</span>
253
+ </p>
234
254
  </div>`;
235
255
  })
236
256
  .join("");
237
257
  return `
238
258
  <section class="section" data-testid="vault-card">
239
259
  ${heading}
240
- <p>Open Notes the canonical browser UI for your vault${
260
+ <p>Connect Claude (or any AI assistant) to your vault${
241
261
  assignedVaults.length === 1 ? "" : "s"
242
- } — or connect an MCP client
243
- (Claude Code, Claude.ai) with the command below. Either way you sign in to your
244
- hub over HTTPS and approve access on the first connection.</p>
262
+ } — pick Claude Code or
263
+ Claude.ai below or open Notes for a browser UI. The first connection signs you in
264
+ to your hub over HTTPS and asks you to approve access.</p>
245
265
  <div class="vault-tiles">${tiles}
246
266
  </div>
247
267
  </section>${COPY_SCRIPT}`;
@@ -262,8 +282,11 @@ function renderVaultCard(opts: VaultCardOpts): string {
262
282
  return `
263
283
  <section class="section" data-testid="no-vault-card">
264
284
  <h2>Your vault</h2>
265
- <p>Your account isn't assigned to a vault yet. Ask the hub operator
266
- to assign one.</p>
285
+ <p>You don't have a vault yet, so there's nothing to connect to. A vault
286
+ is your personal knowledge store on this hub — once the operator
287
+ assigns you one, this page will show you how to connect Claude (or
288
+ any AI assistant) to it.</p>
289
+ <p><strong>Ask the hub operator to assign you a vault.</strong></p>
267
290
  </section>`;
268
291
  }
269
292
 
@@ -433,15 +456,40 @@ const STYLES = `
433
456
  .vault-tile p:last-child { margin-top: 0.5rem; }
434
457
 
435
458
  .mcp-connect {
436
- margin-top: 0.75rem;
437
- padding-top: 0.6rem;
438
- border-top: 1px solid ${PALETTE.borderLight};
459
+ margin-bottom: 0.75rem;
439
460
  }
440
461
  .mcp-connect-label {
462
+ font-family: ${FONT_SERIF};
463
+ font-size: 1.05rem;
464
+ font-weight: 400;
465
+ color: ${PALETTE.fg};
466
+ margin: 0 0 0.3rem;
467
+ }
468
+ .mcp-connect-intro {
441
469
  font-size: 0.85rem;
442
- font-weight: 500;
470
+ color: ${PALETTE.fgMuted};
471
+ margin: 0 0 0.75rem;
472
+ }
473
+ .mcp-method {
474
+ margin: 0.75rem 0;
475
+ padding-top: 0.6rem;
476
+ border-top: 1px solid ${PALETTE.borderLight};
477
+ }
478
+ .mcp-method-title {
479
+ font-size: 0.9rem;
480
+ font-weight: 600;
443
481
  color: ${PALETTE.fg};
444
- margin: 0 0 0.5rem;
482
+ margin: 0 0 0.15rem;
483
+ }
484
+ .mcp-method-sub {
485
+ font-size: 0.82rem;
486
+ color: ${PALETTE.fgMuted};
487
+ margin: 0 0 0.4rem;
488
+ }
489
+ .mcp-method-note {
490
+ font-size: 0.78rem;
491
+ color: ${PALETTE.fgMuted};
492
+ margin: 0.35rem 0 0;
445
493
  }
446
494
  .mcp-field { margin: 0.5rem 0; }
447
495
  .mcp-field-label {
@@ -453,6 +501,20 @@ const STYLES = `
453
501
  font-family: ${FONT_MONO};
454
502
  margin-bottom: 0.2rem;
455
503
  }
504
+ .vault-notes-cta {
505
+ margin: 0.9rem 0 0;
506
+ padding-top: 0.6rem;
507
+ border-top: 1px solid ${PALETTE.borderLight};
508
+ display: flex;
509
+ align-items: center;
510
+ flex-wrap: wrap;
511
+ gap: 0.5rem 0.75rem;
512
+ }
513
+ .vault-notes-cta-sub {
514
+ font-size: 0.82rem;
515
+ color: ${PALETTE.fgMuted};
516
+ flex: 1 1 12rem;
517
+ }
456
518
  .copy-row {
457
519
  display: flex;
458
520
  align-items: center;
@@ -564,11 +626,14 @@ const STYLES = `
564
626
  body { background: #1a1815; color: #e8e4dc; }
565
627
  .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
566
628
  h1, h2 { color: #f0ece4; }
567
- .subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint { color: #a8a29a; }
568
- .vault-name strong, .mcp-connect-label { color: #f0ece4; }
629
+ .subtitle, .kv dt, .mcp-field-label, .mcp-connect-hint,
630
+ .mcp-connect-intro, .mcp-method-sub, .mcp-method-note,
631
+ .vault-notes-cta-sub { color: #a8a29a; }
632
+ .vault-name strong, .mcp-connect-label, .mcp-method-title { color: #f0ece4; }
569
633
  code { background: #1f1c18; color: #e8e4dc; }
570
634
  .copy-row code { background: transparent; }
571
- .section, .mcp-connect { border-top-color: #3a362f; }
635
+ .section { border-top-color: #3a362f; }
636
+ .mcp-method, .vault-notes-cta { border-top-color: #3a362f; }
572
637
  .brand-tag { border-color: #3a362f; color: #a8a29a; }
573
638
  .copy-row { background: #1f1c18; border-color: #3a362f; }
574
639
  .btn-secondary, .btn-copy { color: #e8e4dc; border-color: #3a362f; }
package/src/hub.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { brandMarkSvg, CANONICAL_TAGLINE, WORDMARK_TEXT } from "./brand.ts";
3
+ import { CANONICAL_TAGLINE, WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
4
4
  import { CONFIG_DIR } from "./config.ts";
5
5
  import { CSRF_FIELD_NAME } from "./csrf.ts";
6
6
 
@@ -84,7 +84,19 @@ function buildHtml({ session }: RenderHubOpts): string {
84
84
  const authBlock = session
85
85
  ? renderSignedIn(session.displayName, session.csrfToken)
86
86
  : renderSignedOut();
87
- return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock);
87
+ // Gate the verbose discovery sections (Get started / Services / Admin)
88
+ // and their data-loading script on auth state. A signed-out visitor sees
89
+ // a clean, minimal landing — brand + tagline + a clear "Sign in" call —
90
+ // not the hub's service catalog, vault listings, or admin links. The
91
+ // detail un-gates the moment they sign in (the server already knows auth
92
+ // state from the session cookie, so this stays a no-JS-required,
93
+ // session-aware render). Operator feedback from a live multi-user deploy:
94
+ // the signed-out page exposed too much to anonymous visitors.
95
+ const body = session ? SIGNED_IN_BODY : SIGNED_OUT_BODY;
96
+ const script = session ? DISCOVERY_SCRIPT : "";
97
+ return HTML_TEMPLATE.replace("<!--AUTH-INDICATOR-->", authBlock)
98
+ .replace("<!--DISCOVERY-BODY-->", body)
99
+ .replace("<!--DISCOVERY-SCRIPT-->", script);
88
100
  }
89
101
 
90
102
  function renderSignedIn(displayName: string, csrfToken: string): string {
@@ -269,6 +281,34 @@ const HTML_TEMPLATE = `<!doctype html>
269
281
  font-size: 0.92rem;
270
282
  margin: 0 0 1.25rem;
271
283
  }
284
+ /* Signed-out landing: a single centered "Sign in" call under the brand.
285
+ Minimal by design — the service catalog + admin surfaces stay hidden
286
+ until the visitor authenticates. */
287
+ .signed-out-cta {
288
+ text-align: center;
289
+ margin-bottom: 0;
290
+ }
291
+ .signed-out-lede {
292
+ color: var(--fg-muted);
293
+ font-size: 1.05rem;
294
+ margin: 0 0 1.5rem;
295
+ }
296
+ .btn-signin {
297
+ display: inline-block;
298
+ background: var(--accent);
299
+ color: var(--card-bg);
300
+ font-family: var(--sans);
301
+ font-size: 1rem;
302
+ font-weight: 500;
303
+ text-decoration: none;
304
+ padding: 0.65rem 1.6rem;
305
+ border-radius: 8px;
306
+ transition: background 0.15s ease, transform 0.15s ease;
307
+ }
308
+ .btn-signin:hover {
309
+ background: var(--accent-hover);
310
+ transform: translateY(-1px);
311
+ }
272
312
  .grid {
273
313
  display: grid;
274
314
  gap: 1.25rem;
@@ -378,7 +418,22 @@ const HTML_TEMPLATE = `<!doctype html>
378
418
  <h1>${WORDMARK_TEXT}</h1>
379
419
  <p class="tagline">${CANONICAL_TAGLINE}</p>
380
420
  </header>
421
+ <!--DISCOVERY-BODY-->
422
+ <footer>
423
+ <a href="/.well-known/parachute.json">discovery</a>
424
+ </footer>
425
+ </main>
426
+ <!--DISCOVERY-SCRIPT-->
427
+ </body>
428
+ </html>
429
+ `;
381
430
 
431
+ // The verbose discovery body — the service catalog, admin surfaces, and the
432
+ // "Get started" CTA. Rendered ONLY for a signed-in visitor (`buildHtml`
433
+ // selects this vs SIGNED_OUT_BODY on `session`). Anonymous visitors get the
434
+ // slim landing below instead, so the hub's internal surface isn't exposed
435
+ // pre-auth.
436
+ const SIGNED_IN_BODY = `
382
437
  <section class="section" id="get-started-section" hidden>
383
438
  <h2>Get started</h2>
384
439
  <p class="section-sub">Jump straight into what you came here for.</p>
@@ -398,12 +453,21 @@ const HTML_TEMPLATE = `<!doctype html>
398
453
  <p class="section-sub">Manage this hub — vaults, permissions, tokens.</p>
399
454
  <div class="grid" id="admin-grid"></div>
400
455
  </section>
456
+ `;
401
457
 
402
- <footer>
403
- <a href="/.well-known/parachute.json">discovery</a>
404
- </footer>
405
- </main>
406
- <script>
458
+ // The slim signed-out landing. Brand + tagline (in the header above) plus a
459
+ // single clear "Sign in" call — no service catalog, no vault listings, no
460
+ // admin links. Keep it tasteful and minimal; the detail un-gates on sign-in.
461
+ const SIGNED_OUT_BODY = `
462
+ <section class="section signed-out-cta" id="signed-out-cta">
463
+ <p class="signed-out-lede">Sign in to reach your vault and the services on this hub.</p>
464
+ <a href="/login?next=/" class="btn-signin" data-testid="signed-out-signin">Sign in →</a>
465
+ </section>
466
+ `;
467
+
468
+ // The data-loading script for the signed-in discovery body. Emitted only
469
+ // when signed in (the signed-out body has nothing for it to populate).
470
+ const DISCOVERY_SCRIPT = `<script>
407
471
  (async () => {
408
472
  const servicesGrid = document.getElementById('services-grid');
409
473
  const adminGrid = document.getElementById('admin-grid');
@@ -614,7 +678,4 @@ const HTML_TEMPLATE = `<!doctype html>
614
678
 
615
679
  void loadServices();
616
680
  })();
617
- </script>
618
- </body>
619
- </html>
620
- `;
681
+ </script>`;