@openparachute/hub 0.5.14-rc.8 → 0.6.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 (87) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -76,8 +76,9 @@ export interface RunCliWizardOpts {
76
76
  sleep?: (ms: number) => Promise<void>;
77
77
  /**
78
78
  * Non-interactive escape hatch: pre-supply the account-step answers.
79
- * Username defaults to `admin` when unset; password is required (no
80
- * default, no prompt exit-with-error). Mirrors the
79
+ * Username defaults to `owner` when unset (aligned with
80
+ * `parachute auth set-password` + the operator.token convention); password
81
+ * is required (no default, no prompt → exit-with-error). Mirrors the
81
82
  * `PARACHUTE_INITIAL_ADMIN_*` env-seed shape.
82
83
  */
83
84
  accountUsername?: string;
@@ -400,8 +401,11 @@ async function walkAccountStep(
400
401
  log(" Set up the operator account that owns this hub.");
401
402
  let username = opts.accountUsername;
402
403
  if (username === undefined) {
403
- const raw = (await opts.prompt(" username [admin]: ")).trim();
404
- username = raw === "" ? "admin" : raw;
404
+ // Default to "owner" aligns with `parachute auth set-password` and the
405
+ // operator.token convention (the earliest-created user is the operator).
406
+ // The web wizard lets the operator name it freely; so does this prompt.
407
+ const raw = (await opts.prompt(" username [owner]: ")).trim();
408
+ username = raw === "" ? "owner" : raw;
405
409
  }
406
410
  let password = opts.accountPassword;
407
411
  if (password === undefined) {
package/src/env-file.ts CHANGED
@@ -68,6 +68,16 @@ export function upsertEnvLine(lines: string[], key: string, value: string): stri
68
68
  return next;
69
69
  }
70
70
 
71
+ /**
72
+ * Drop every `KEY=…` line for `key`, preserving the order of the rest.
73
+ * Returns a new array; the input is untouched. No-op (returns a copy) when
74
+ * the key isn't present.
75
+ */
76
+ export function removeEnvLine(lines: string[], key: string): string[] {
77
+ const prefix = `${key}=`;
78
+ return lines.filter((line) => !line.startsWith(prefix));
79
+ }
80
+
71
81
  export function writeEnvFile(path: string, lines: readonly string[]): void {
72
82
  mkdirSync(dirname(path), { recursive: true });
73
83
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
package/src/help.ts CHANGED
@@ -194,7 +194,7 @@ Flags:
194
194
  http://127.0.0.1:1939). \`parachute init\`
195
195
  passes this in when chaining; standalone
196
196
  callers supply it explicitly.
197
- --account-username <name> pre-supply the admin username (default: admin)
197
+ --account-username <name> pre-supply the admin username (default: owner)
198
198
  --account-password <pw> pre-supply the admin password (required when
199
199
  non-interactive)
200
200
  --bootstrap-token <token> one-time bootstrap token when the hub is in
@@ -326,6 +326,7 @@ Usage:
326
326
  parachute expose public --tailnet
327
327
  parachute expose public --cloudflare --domain <hostname>
328
328
  parachute expose public off --cloudflare
329
+ parachute expose cloudflare --domain <hostname> # alias for the above
329
330
 
330
331
  Status:
331
332
  tailnet is the supported exposure shape. The hub's OAuth + per-module
@@ -381,6 +382,7 @@ Examples:
381
382
  parachute expose public off # stop the Funnel
382
383
  parachute expose public --cloudflare --domain vault.example.com
383
384
  # stable URL via cloudflared
385
+ parachute expose cloudflare --domain vault.example.com # alias for the line above
384
386
  parachute expose public off --cloudflare # stop the cloudflared tunnel
385
387
 
386
388
  Tailscale Funnel constraints:
package/src/hub-db.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Hub-local SQLite database. Opens `~/.parachute/hub.db` (overridable via
3
3
  * `$PARACHUTE_HOME`). Holds everything the hub owns as the ecosystem's OAuth
4
4
  * issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
5
- * clients + auth-codes + grants + browser sessions (v3).
5
+ * clients + auth-codes + grants + browser sessions (v3), and TOTP 2FA
6
+ * enrollment on the users row (v11, hub#473).
6
7
  *
7
8
  * Each open() runs `migrate()` to bring the schema up to date. A
8
9
  * `schema_version` table records every applied migration so re-opens are
@@ -320,6 +321,43 @@ const MIGRATIONS: readonly Migration[] = [
320
321
  ALTER TABLE users DROP COLUMN assigned_vault;
321
322
  `,
322
323
  },
324
+ {
325
+ version: 11,
326
+ sql: `
327
+ -- Real TOTP 2FA at the hub login layer (hub#473). Three nullable
328
+ -- columns on \`users\`:
329
+ --
330
+ -- * totp_secret (TEXT, nullable) — the base32-encoded RFC 6238 TOTP
331
+ -- secret. NULL means "2FA not enrolled" — the canonical "is 2FA on
332
+ -- for this user?" signal. Stored as the plaintext base32 string
333
+ -- (not encrypted at rest): hub.db already holds the argon2id
334
+ -- password hashes AND the OAuth signing private keys in plaintext
335
+ -- PEM (signing_keys.private_key_pem), so the TOTP secret sits at
336
+ -- the same operator-local trust boundary — encrypting one column
337
+ -- while leaving the signing key in the clear would be security
338
+ -- theatre. A future at-rest-encryption pass (hub#474 follow-up)
339
+ -- would cover all three (password hashes are already one-way; the
340
+ -- signing key + TOTP secret are the recoverable secrets).
341
+ -- * totp_backup_codes (TEXT, nullable) — JSON array of argon2id-HASHED
342
+ -- single-use recovery codes. Same hash family as passwords
343
+ -- (@node-rs/argon2). Plaintext codes are shown to the user exactly
344
+ -- once at enrollment and never stored. A code is removed from the
345
+ -- array when consumed. NULL / "[]" means "no backup codes left."
346
+ -- * totp_enrolled_at (TEXT, nullable) — ISO-8601 timestamp of the
347
+ -- last successful enrollment. NULL until first enroll; informational
348
+ -- (admin UI / account page "2FA enabled since …").
349
+ --
350
+ -- Backfill: every existing user pre-dates this migration and gets NULL
351
+ -- for all three — i.e. "2FA not enrolled." Their /login flow stays
352
+ -- password-only (the login handler only requires a TOTP step when
353
+ -- totp_secret IS NOT NULL), so existing operators keep signing in
354
+ -- exactly as before. No backfill UPDATE needed — the column default is
355
+ -- NULL.
356
+ ALTER TABLE users ADD COLUMN totp_secret TEXT;
357
+ ALTER TABLE users ADD COLUMN totp_backup_codes TEXT;
358
+ ALTER TABLE users ADD COLUMN totp_enrolled_at TEXT;
359
+ `,
360
+ },
323
361
  ];
324
362
 
325
363
  export function openHubDb(path: string = hubDbPath()): Database {
package/src/hub-server.ts CHANGED
@@ -67,11 +67,20 @@
67
67
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
68
68
  * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
69
69
  * /login (GET + POST) → operator password login
70
+ * /login/2fa (POST) → second-factor (TOTP/backup) step
71
+ * (hub#473; reached after a correct
72
+ * password for a 2FA-enrolled user)
70
73
  * /logout (POST) → end admin session
71
74
  * /account/change-password (GET + POST) → user self-service change-password
72
75
  * (force-redirect target for users
73
76
  * with password_changed=false; also
74
77
  * reachable directly to rotate)
78
+ * /account/2fa (GET + POST) → user self-service 2FA enroll/disenroll
79
+ * (hub#473; QR + backup codes)
80
+ * /account/vault-token/<name> (POST) → friend mints a scoped
81
+ * vault:<name>:read|write bearer for
82
+ * an ASSIGNED vault (headless clients;
83
+ * session + assignment + scope-capped)
75
84
  * /admin/config* → 301 → /admin/vaults (legacy
76
85
  * portal retired post-SPA-rework)
77
86
  *
@@ -108,11 +117,13 @@ import { existsSync } from "node:fs";
108
117
  import { dirname, join, resolve } from "node:path";
109
118
  import { fileURLToPath } from "node:url";
110
119
  import pkg from "../package.json" with { type: "json" };
120
+ import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
111
121
  import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
112
122
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
113
123
  import {
114
124
  handleAdminLoginGet,
115
125
  handleAdminLoginPost,
126
+ handleAdminLoginTotpPost,
116
127
  handleAdminLogoutPost,
117
128
  } from "./admin-handlers.ts";
118
129
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
@@ -199,6 +210,7 @@ import {
199
210
  } from "./setup-wizard.ts";
200
211
  import { getAllPublicKeys } from "./signing-keys.ts";
201
212
  import type { Supervisor } from "./supervisor.ts";
213
+ import { handleTwoFactorGet, handleTwoFactorPost } from "./two-factor-handlers.ts";
202
214
  import { getUserById, userCount } from "./users.ts";
203
215
  import {
204
216
  WELL_KNOWN_DIR,
@@ -2029,6 +2041,17 @@ export function hubFetch(
2029
2041
  return new Response("method not allowed", { status: 405 });
2030
2042
  }
2031
2043
 
2044
+ // /login/2fa — second-factor step (hub#473). POST-only: reached only
2045
+ // after a correct password POST for a 2FA-enrolled user handed back a
2046
+ // pending-login cookie + rendered the challenge page. A bare GET (e.g.
2047
+ // browser back button) has no form to render usefully, so 405 → the
2048
+ // operator restarts at /login.
2049
+ if (pathname === "/login/2fa") {
2050
+ if (!getDb) return dbNotConfigured();
2051
+ if (req.method === "POST") return handleAdminLoginTotpPost(getDb(), req);
2052
+ return new Response("method not allowed", { status: 405 });
2053
+ }
2054
+
2032
2055
  if (pathname === "/logout") {
2033
2056
  if (!getDb) return dbNotConfigured();
2034
2057
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
@@ -2056,6 +2079,35 @@ export function hubFetch(
2056
2079
  return new Response("method not allowed", { status: 405 });
2057
2080
  }
2058
2081
 
2082
+ // /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
2083
+ // Both GET (render state) and POST (start/confirm/disable) require an
2084
+ // active session; the handler does the session check + 302 to /login when
2085
+ // missing, same posture as /account/change-password.
2086
+ if (pathname === "/account/2fa") {
2087
+ if (!getDb) return dbNotConfigured();
2088
+ const twoFactorDeps = { db: getDb() };
2089
+ if (req.method === "GET") return handleTwoFactorGet(req, twoFactorDeps);
2090
+ if (req.method === "POST") return handleTwoFactorPost(req, twoFactorDeps);
2091
+ return new Response("method not allowed", { status: 405 });
2092
+ }
2093
+
2094
+ // /account/vault-token/<name> — friend-facing scoped vault token mint.
2095
+ // POST-only, session-gated, assignment-capped: a non-admin friend mints a
2096
+ // `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
2097
+ // scripts / headless clients that can't do browser OAuth. The handler
2098
+ // enforces session → assignment → scope-cap (never `:admin`, never a
2099
+ // vault outside the assignment, never a broader verb than the role
2100
+ // grants) + CSRF + per-user rate limit. Must precede the `/account/`
2101
+ // match below (more specific prefix). See `account-vault-token.ts`.
2102
+ if (pathname.startsWith("/account/vault-token/")) {
2103
+ if (!getDb) return dbNotConfigured();
2104
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2105
+ const vaultName = decodeURIComponent(pathname.slice("/account/vault-token/".length));
2106
+ const db = getDb();
2107
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer);
2108
+ return handleAccountVaultTokenPost(req, vaultName, { db, hubOrigin });
2109
+ }
2110
+
2059
2111
  // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
2060
2112
  // Companion to the first-admin gate on `/admin/host-admin-token`: a
2061
2113
  // signed-in non-admin (friend) lands here instead of bouncing against
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,15 +84,34 @@ 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 {
91
103
  // Inline POST form so sign-out works without JS. Submit button is
92
104
  // styled as a text link via `.auth-signout` so the visual weight
93
105
  // matches the surrounding "Signed in as <name>" text.
106
+ //
107
+ // The "Account" link is the single breadcrumb to `/account/` — the
108
+ // self-service home where any signed-in user (admin or invited
109
+ // member) can change their password, see their vault, and sign out.
110
+ // Without it, a friend who's been handed credentials has no way to
111
+ // discover the change-password surface after the first-login prompt.
94
112
  return `<div class="auth-indicator">
95
113
  <span class="muted">Signed in as <strong>${escapeHtml(displayName)}</strong></span>
114
+ <a href="/account/" class="auth-account">Account</a>
96
115
  <form method="POST" action="/logout" class="auth-signout-form">
97
116
  <input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(csrfToken)}" />
98
117
  <button type="submit" class="auth-signout">Sign out</button>
@@ -203,7 +222,7 @@ const HTML_TEMPLATE = `<!doctype html>
203
222
  margin: 0;
204
223
  display: inline;
205
224
  }
206
- .auth-signout, .auth-signin {
225
+ .auth-signout, .auth-signin, .auth-account {
207
226
  background: none;
208
227
  border: none;
209
228
  padding: 0;
@@ -214,10 +233,10 @@ const HTML_TEMPLATE = `<!doctype html>
214
233
  text-decoration-thickness: 1px;
215
234
  text-underline-offset: 2px;
216
235
  }
217
- .auth-signout:hover, .auth-signin:hover {
236
+ .auth-signout:hover, .auth-signin:hover, .auth-account:hover {
218
237
  color: var(--accent-hover);
219
238
  }
220
- a.auth-signin {
239
+ a.auth-signin, a.auth-account {
221
240
  /* Anchor needs explicit reset since the a element has its own
222
241
  color/decoration. */
223
242
  border-bottom: none;
@@ -262,6 +281,34 @@ const HTML_TEMPLATE = `<!doctype html>
262
281
  font-size: 0.92rem;
263
282
  margin: 0 0 1.25rem;
264
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
+ }
265
312
  .grid {
266
313
  display: grid;
267
314
  gap: 1.25rem;
@@ -371,7 +418,22 @@ const HTML_TEMPLATE = `<!doctype html>
371
418
  <h1>${WORDMARK_TEXT}</h1>
372
419
  <p class="tagline">${CANONICAL_TAGLINE}</p>
373
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
+ `;
374
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 = `
375
437
  <section class="section" id="get-started-section" hidden>
376
438
  <h2>Get started</h2>
377
439
  <p class="section-sub">Jump straight into what you came here for.</p>
@@ -391,12 +453,21 @@ const HTML_TEMPLATE = `<!doctype html>
391
453
  <p class="section-sub">Manage this hub — vaults, permissions, tokens.</p>
392
454
  <div class="grid" id="admin-grid"></div>
393
455
  </section>
456
+ `;
394
457
 
395
- <footer>
396
- <a href="/.well-known/parachute.json">discovery</a>
397
- </footer>
398
- </main>
399
- <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>
400
471
  (async () => {
401
472
  const servicesGrid = document.getElementById('services-grid');
402
473
  const adminGrid = document.getElementById('admin-grid');
@@ -607,7 +678,4 @@ const HTML_TEMPLATE = `<!doctype html>
607
678
 
608
679
  void loadServices();
609
680
  })();
610
- </script>
611
- </body>
612
- </html>
613
- `;
681
+ </script>`;