@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
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Persist the resolved hub PUBLIC origin into `<configDir>/vault/.env` so the
3
+ * launchd / systemd daemon validates hub-minted JWTs against it.
4
+ *
5
+ * The OAuth issuer-mismatch P0 (this module's reason to exist): hub stamps the
6
+ * public origin (e.g. `https://parachute-x.ts.net`) on every JWT it mints.
7
+ * Vault, as the resource server, validates the `iss` claim against
8
+ * `getHubOrigin()` (`parachute-vault/src/hub-jwt.ts`), which reads ONLY
9
+ * `PARACHUTE_HUB_ORIGIN` from its process env and falls back to loopback
10
+ * `http://127.0.0.1:1939` when unset.
11
+ *
12
+ * `parachute start|restart vault` injects `PARACHUTE_HUB_ORIGIN` into the
13
+ * *spawn* env (see `commands/lifecycle.ts`). But the real-world boot path on an
14
+ * owner-operated box is the autostart daemon: `parachute-vault init` registers
15
+ * a launchd agent (macOS) / systemd unit (Linux) with `KeepAlive`/`Restart`,
16
+ * and that daemon boots vault out-of-band via `~/.parachute/vault/start.sh`.
17
+ * The wrapper sources `~/.parachute/vault/.env` and never reads expose-state,
18
+ * and the launchd plist carries no `EnvironmentVariables`. So on every reboot
19
+ * or crash-restart, launchd starts vault with NO `PARACHUTE_HUB_ORIGIN`, vault
20
+ * falls back to loopback, and every token stamped with the public FQDN fails
21
+ * the `iss` check → `HubJwtError('issuer')` → 401 on every OAuth/MCP reconnect.
22
+ * Bricks team onboarding on any exposed deploy.
23
+ *
24
+ * The durable fix: write `PARACHUTE_HUB_ORIGIN` into `vault/.env` so the daemon
25
+ * boot path picks it up too. Mirrors `auto-wire.ts`'s vault-.env writes
26
+ * (SCRIBE_AUTH_TOKEN / SCRIBE_URL) — hub owns these keys, vault's boot wrapper
27
+ * reads them.
28
+ */
29
+ import { join } from "node:path";
30
+ import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
31
+ import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
32
+ import { HUB_ORIGIN_ENV } from "./hub-origin.ts";
33
+
34
+ /**
35
+ * Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
36
+ * local-dev fallback `deriveHubOrigin` returns when no exposure is active. We
37
+ * never *persist* one — baking a loopback value into `.env` would SHADOW a
38
+ * later exposure on the daemon boot path (which has no other source of truth),
39
+ * recreating the exact `iss` mismatch this fix prevents. Worse than absent.
40
+ */
41
+ export function isLoopbackOrigin(origin: string): boolean {
42
+ try {
43
+ // URL.hostname keeps IPv6 in bracket form (`[::1]`); strip them so the
44
+ // comparison is on the bare address.
45
+ const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "");
46
+ // `0.0.0.0` is a bind-all wildcard, not a reachable origin — `deriveHubOrigin`
47
+ // never emits it, but `--hub-origin http://0.0.0.0:1939` flows straight
48
+ // through `persistVaultHubOrigin`. Baking it into vault/.env would advertise
49
+ // a non-functional issuer and recreate the iss-mismatch class this guard
50
+ // exists to prevent. Refuse it like any other loopback.
51
+ return (
52
+ hostname === "127.0.0.1" ||
53
+ hostname === "localhost" ||
54
+ hostname === "::1" ||
55
+ hostname === "0.0.0.0"
56
+ );
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function vaultEnvPath(configDir: string): string {
63
+ return join(configDir, "vault", ".env");
64
+ }
65
+
66
+ /**
67
+ * Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
68
+ * non-loopback public origin. Idempotent — skips the write (and the log) when
69
+ * the value is already current so repeated `start`s don't churn the file.
70
+ * Returns true iff the file was written this call.
71
+ */
72
+ export function persistVaultHubOrigin(
73
+ configDir: string,
74
+ origin: string,
75
+ log: (line: string) => void,
76
+ ): boolean {
77
+ if (isLoopbackOrigin(origin)) return false;
78
+ const path = vaultEnvPath(configDir);
79
+ const parsed = parseEnvFile(path);
80
+ if (parsed.values[HUB_ORIGIN_ENV] === origin) return false;
81
+ writeEnvFile(path, upsertEnvLine(parsed.lines, HUB_ORIGIN_ENV, origin));
82
+ log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` from `vault/.env`. Called
88
+ * on `expose … off`: once exposure is torn down, a local-only hub mints tokens
89
+ * with a loopback `iss`, so a stale public origin left in `.env` would itself
90
+ * cause the mismatch. Removing the line reverts vault to its loopback default
91
+ * (`getHubOrigin`), which matches what the local hub now stamps. No-op (returns
92
+ * false) when the key isn't present. Returns true iff the file was rewritten.
93
+ */
94
+ export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
95
+ const path = vaultEnvPath(configDir);
96
+ const parsed = parseEnvFile(path);
97
+ if (parsed.values[HUB_ORIGIN_ENV] === undefined) return false;
98
+ writeEnvFile(path, removeEnvLine(parsed.lines, HUB_ORIGIN_ENV));
99
+ log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * The public origin a live exposure advertises, or undefined when no exposure
105
+ * is active. Both the Tailscale and Cloudflare expose paths populate
106
+ * `expose-state.json` with a `hubOrigin` (the URL stamped into OAuth tokens'
107
+ * `iss` claim); older state files predating Phase 0 may carry only
108
+ * `canonicalFqdn`, so we synthesize `https://<fqdn>` as a fallback. Loopback /
109
+ * empty values resolve to undefined — there's no public origin to persist.
110
+ */
111
+ export function publicOriginFromExposeState(
112
+ exposeStatePath: string = EXPOSE_STATE_PATH,
113
+ ): string | undefined {
114
+ let state: ReturnType<typeof readExposeState>;
115
+ try {
116
+ state = readExposeState(exposeStatePath);
117
+ } catch {
118
+ // A malformed expose-state must never block a vault start — treat it as
119
+ // "no exposure" and let the loopback default stand.
120
+ return undefined;
121
+ }
122
+ if (!state) return undefined;
123
+ const origin = state.hubOrigin ?? (state.canonicalFqdn ? `https://${state.canonicalFqdn}` : "");
124
+ if (!origin || isLoopbackOrigin(origin)) return undefined;
125
+ return origin.replace(/\/+$/, "");
126
+ }
127
+
128
+ /**
129
+ * Self-heal vault's persisted `PARACHUTE_HUB_ORIGIN` from `expose-state.json`.
130
+ *
131
+ * The bug this closes (the Cloudflare 401 P0): on a Cloudflare-tunnel deploy the
132
+ * expose path writes a public `hubOrigin` into `expose-state.json`, but — unlike
133
+ * the Tailscale path, which auto-restarts vault and so flows the public origin
134
+ * into `vault/.env` via `persistVaultHubOrigin` — it never wrote vault's `.env`.
135
+ * So the launchd / systemd daemon kept booting vault with NO `PARACHUTE_HUB_ORIGIN`,
136
+ * vault fell back to loopback as its expected issuer, and every hub-minted token
137
+ * (whose `iss` is the public origin) failed the `iss` check → 401 on every vault
138
+ * request → "You're not signed in to the hub."
139
+ *
140
+ * Called on `parachute start|restart vault`: when expose-state advertises a
141
+ * public origin AND vault's persisted value is unset or loopback, write the
142
+ * public origin. Existing broken deploys self-correct on the next restart, not
143
+ * just fresh ones. We deliberately do NOT overwrite a *different* non-loopback
144
+ * value already in `.env` — that could be a deliberate `--hub-origin` override;
145
+ * `persistVaultHubOrigin` (the explicit, resolved-origin path) owns that case.
146
+ *
147
+ * Returns true iff `.env` was written this call.
148
+ */
149
+ export function selfHealVaultHubOrigin(
150
+ configDir: string,
151
+ log: (line: string) => void,
152
+ exposeStatePath: string = EXPOSE_STATE_PATH,
153
+ ): boolean {
154
+ const publicOrigin = publicOriginFromExposeState(exposeStatePath);
155
+ if (!publicOrigin) return false;
156
+ const current = parseEnvFile(vaultEnvPath(configDir)).values[HUB_ORIGIN_ENV];
157
+ // Only heal the broken shapes: unset (daemon falls back to loopback) or an
158
+ // already-persisted loopback (a value that itself causes the iss mismatch).
159
+ // A current public value — including one equal to publicOrigin — is left to
160
+ // persistVaultHubOrigin's idempotent path so we don't double-log.
161
+ if (current !== undefined && !isLoopbackOrigin(current)) return false;
162
+ return persistVaultHubOrigin(configDir, publicOrigin, log);
163
+ }
@@ -0,0 +1 @@
1
+ :root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select,textarea{font:inherit;background:#fff;border:1px solid var(--border);border-radius:6px;padding:.55rem .75rem;color:var(--fg)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;flex-wrap:wrap;gap:.6rem 1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto;display:inline-flex;align-items:center;gap:.45rem;color:var(--accent);text-decoration:none}.nav .brand:hover{color:var(--accent-hover);text-decoration:none}.nav .brand-mark-icon{flex-shrink:0;line-height:0}.nav .brand-wordmark{color:var(--fg);letter-spacing:-.005em}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}.nav a.nav-link-active{color:var(--accent);font-weight:500;text-decoration:underline;text-underline-offset:.3em;text-decoration-thickness:2px}.nav .nav-divider{display:inline-block;width:1px;height:1.1em;background:var(--border);align-self:center}.nav .nav-dropdown{position:relative}.nav .nav-dropdown-summary{list-style:none;cursor:pointer;color:var(--fg-muted);font-size:.95rem;-webkit-user-select:none;user-select:none}.nav .nav-dropdown-summary::-webkit-details-marker{display:none}.nav .nav-dropdown-summary:hover{color:var(--fg)}.nav .nav-dropdown[open]>.nav-dropdown-summary{color:var(--fg)}.nav .nav-dropdown-summary:after{content:" ▾";font-size:.7em;color:var(--fg-dim)}.nav .nav-dropdown-panel{position:absolute;top:calc(100% + .4rem);left:0;z-index:10;min-width:12rem;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 12px #00000014;padding:.4rem 0;display:flex;flex-direction:column}.nav .nav-dropdown-item{padding:.4rem .85rem;color:var(--fg);font-size:.9rem;text-decoration:none}.nav .nav-dropdown-item:hover{background:var(--bg-soft);color:var(--fg);text-decoration:none}.nav .nav-dropdown-item-disabled{color:var(--fg-dim);cursor:not-allowed}.nav .nav-dropdown-item-disabled:hover{background:transparent;color:var(--fg-dim)}.nav .auth-spa{font-size:.85rem;color:var(--fg-muted)}.nav .auth-spa strong{font-weight:600;color:var(--fg)}.nav .auth-spa-signout{background:none;border:none;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}.nav .auth-spa-signout:hover:not(:disabled){color:var(--accent-hover)}.nav .auth-spa-signout:disabled{color:var(--fg-dim);cursor:not-allowed}h1{margin:0 0 .5rem;font-family:var(--font-serif);font-size:1.85rem;font-weight:400;letter-spacing:-.01em;line-height:1.2;color:var(--fg)}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}@keyframes pc-loading-pulse{0%,to{opacity:.55}50%{opacity:1}}[data-loading=true]{animation:pc-loading-pulse 1.4s ease-in-out infinite}.user-table tbody tr,.tokens-table tbody tr{transition:background-color .12s ease}.user-table tbody tr:hover,.tokens-table tbody tr:hover{background:var(--bg-soft)}@keyframes pc-route-fade-up{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}[data-route-content]{animation:pc-route-fade-up .32s ease forwards}@media(prefers-reduced-motion:reduce){[data-loading=true],[data-route-content]{animation:none}}.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;background:linear-gradient(to right,var(--card-bg),var(--card-bg)) left center / 20px 100% no-repeat,linear-gradient(to right,#2c2a2614,#2c2a2600) left center / 8px 100% no-repeat,linear-gradient(to left,var(--card-bg),var(--card-bg)) right center / 20px 100% no-repeat,linear-gradient(to left,#2c2a2614,#2c2a2600) right center / 8px 100% no-repeat;background-attachment:local,scroll,local,scroll}.table-scroll>table{min-width:100%}.empty-rich{text-align:left;padding:2rem 1.75rem;background:#fff;border:1px solid var(--border)}.empty-rich .empty-headline{font-size:1.05rem;color:var(--fg);margin:0 0 .5rem;font-weight:500}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h1,.list-header h2{margin:0}.tag{display:inline-block;padding:.1em .55em;background:var(--accent-soft);color:var(--accent);border-radius:4px;font-size:.78rem;font-weight:500}.tag.muted{background:var(--bg-soft);color:var(--fg-muted)}.tag.source-oauth{background:#4a7cc61f;color:#3b6aa6}.tag.source-operator{background:#c6984a24;color:#8a5e1f}.tag.source-cli{background:#4a7c5924;color:#2f5a3f}.tag.source-unknown{background:var(--bg-soft);color:var(--fg-muted)}@media(prefers-color-scheme:dark){.tag.source-oauth{background:#7a9cdc24;color:#9bb6d8}.tag.source-operator{background:#dcb46e24;color:#d4b27a}.tag.source-cli{background:#7ab08a24;color:#8fc49e}.tag.source-unknown{background:#e8e4dc0f;color:#a8a49a}}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .url{margin-top:.25rem;word-break:break-all}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}.vault-row-group{margin-bottom:.5rem}.vault-row-group .vault-row{margin-bottom:0}.vault-row-actions{display:flex;gap:.5rem;align-items:center;flex-shrink:0}.mcp-connect-card{background:var(--bg-soft);border:1px solid var(--border);border-radius:8px;padding:1.1rem 1.25rem;margin:0 0 .5rem}.mcp-connect-card-embedded{background:#fff;margin-bottom:0}.mcp-connect-card h3{margin:0 0 .4rem;font-size:1rem}.mcp-connect-card>p{margin-top:0}.mcp-connect-card .token-box{display:flex;align-items:center;gap:.5rem;margin:.35rem 0 .25rem}.mcp-connect-card .token-box code{flex:1;font-size:.85rem;padding:.55rem .7rem;background:#fff;border:1px solid var(--border);border-radius:6px;word-break:break-all;-webkit-user-select:all;user-select:all}.mcp-field{margin-top:.9rem}.mcp-field-label{display:block;font-size:.82rem;font-weight:600;color:var(--fg-muted)}.mcp-field .dim{margin:.3rem 0 0}.mcp-token-path{margin-top:1rem;border-top:1px solid var(--border);padding-top:.75rem}.mcp-token-path>summary{cursor:pointer;font-size:.9rem;color:var(--fg-muted)}.mcp-token-path>summary:hover{color:var(--accent)}.mcp-token-path .mint-banner{margin-top:.75rem;margin-bottom:0}.mcp-docs-link{margin:.9rem 0 0}form .row{margin-bottom:1rem}form label{display:block;font-size:.9rem;color:var(--fg-muted);margin-bottom:.3rem;font-weight:500}form input[type=text]{width:100%}form .actions{display:flex;gap:.6rem;align-items:center;margin-top:1rem}form .field-hint{margin-top:.35rem;font-size:.82rem;color:var(--fg-dim)}form .field-error{margin-top:.35rem;font-size:.85rem;color:var(--error)}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem;display:flex;gap:.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}.channel-toggle{margin:1.25rem 0 1.5rem;padding:.75rem 1rem;border:1px solid var(--border, #ddd);border-radius:6px;background:var(--bg-soft, #fafafa)}.channel-toggle legend{padding:0 .25rem;font-weight:600;font-size:.95rem}.channel-toggle label{display:inline-flex;align-items:center;gap:.4rem;margin-right:1.5rem;cursor:pointer;font-size:.95rem}.channel-toggle label input[type=radio]:disabled+*{opacity:.5}.channel-toggle code{font-size:.85em}.channel-toggle p.muted{margin:.4rem 0 0;font-size:.85rem}.module-config{display:flex;flex-direction:column;gap:1.25rem}.module-config-header h1{margin-bottom:.35rem}.module-config-form fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:1rem}.module-config-form .field{display:flex;flex-direction:column;gap:.25rem}.module-config-form .field input,.module-config-form .field select,.module-config-form .field textarea{width:100%}.module-config-form .field-inline{flex-direction:row;align-items:center;flex-wrap:wrap;gap:.5rem}.module-config-form .field-inline label{display:inline-flex;align-items:center;gap:.5rem}.module-config-form .field-inline .field-hint{flex-basis:100%;margin-left:1.6rem}.module-config-form .field-invalid input,.module-config-form .field-invalid select,.module-config-form .field-invalid textarea{border-color:var(--error)}.module-config-form .actions{display:flex;gap:.6rem;align-items:center;margin-top:.5rem}.module-config-form .actions button.destructive{background:#fff;color:var(--fg);border:1px solid var(--border)}.module-config-form .actions button.destructive:hover{background:var(--bg-soft)}.module-config-form .banner{margin:0;padding:.75rem 1rem;border-radius:6px;border:1px solid transparent;font-size:.9rem}.module-config-form .banner-success{background:var(--success-soft);border-color:var(--success);color:var(--success)}.module-config-form .banner-success p,.module-config-form .banner-success ul{margin:.4rem 0 0}.module-config-form .banner-error{background:var(--error-soft, rgba(163, 57, 43, .08));border-color:var(--error);color:var(--error)}.modules-installed,.modules-installable{margin-top:1.75rem}.modules-installed>h2,.modules-installable>h2{font-size:1.15rem;font-weight:600;margin:0 0 .75rem;color:var(--fg)}.modules-installed>p.muted,.modules-installable>p.muted{margin:0 0 .5rem}.install-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.6rem}.install-card{display:flex;flex-direction:row;align-items:center;gap:1rem;flex-wrap:wrap;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease}.install-card:hover{border-color:var(--accent)}.install-card-body{flex:1 1 0;min-width:0}.install-card-body h3{margin:0 0 .2rem;font-size:1rem;font-weight:600;color:var(--fg)}.install-card-body .tagline{margin:0 0 .35rem;color:var(--fg-muted);font-size:.92rem}.install-card-meta{margin:0;font-size:.82rem}.install-card-actions{flex:0 0 auto}.install-card .error{flex-basis:100%;margin-top:.5rem;color:var(--error);font-size:.85rem}.module-row .actions .btn,a.btn{display:inline-block;font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease;text-decoration:none}.module-row .actions .btn:hover,a.btn:hover{background:var(--accent-hover);text-decoration:none}.module-uis{margin:.5rem 0 0;padding:.5rem 0 0;border-top:1px solid var(--border-light)}.module-uis>summary{cursor:pointer;font-size:.88rem;color:var(--fg-muted);font-weight:500;padding:.15rem 0;list-style:revert}.module-uis>summary:hover{color:var(--fg)}.ui-sub-units{list-style:none;padding:0;margin:.5rem 0 0 1.1rem;display:flex;flex-direction:column;gap:.35rem}.ui-sub-unit{display:flex;flex-direction:row;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg-soft);border:1px solid var(--border-light);border-radius:6px;transition:border-color .15s ease,background .15s ease}.ui-sub-unit:hover{border-color:var(--accent);background:#fff}.ui-icon{flex:0 0 auto;width:20px;height:20px;border-radius:4px;object-fit:contain}.ui-sub-unit-body{flex:1 1 0;min-width:0}.ui-sub-unit-link{color:var(--fg);font-size:.95rem;text-decoration:none}.ui-sub-unit-link:hover{color:var(--accent);text-decoration:underline}.ui-sub-unit-link strong{font-weight:600}.ui-sub-unit .tagline{margin:.2rem 0 0;font-size:.82rem;color:var(--fg-muted)}.status{flex:0 0 auto;display:inline-block;padding:.1em .55em;background:var(--bg-soft);color:var(--fg-muted);border-radius:4px;font-size:.78rem;font-weight:500;white-space:nowrap}.status-active{background:var(--success-soft);color:var(--success)}.status-pending{background:var(--warn-soft);color:var(--warn)}.status-inactive{background:var(--bg-soft);color:var(--fg-dim)}.status-failing{background:var(--error-soft);color:var(--error)}.status-absent{background:var(--bg-soft);color:var(--fg-dim)}.status-pending-oauth{background:var(--warn-soft);color:var(--warn)}.status-disabled{background:var(--bg-soft);color:var(--fg-dim)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.hub-version-badge{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border-light);display:flex;flex-direction:column;align-items:flex-start;gap:.75rem;color:var(--fg-muted);font-size:.8rem}.hub-version-badge-summary{background:transparent;border:0;padding:0;margin:0;color:var(--fg-muted);font:inherit;cursor:pointer;text-align:left;border-radius:4px}.hub-version-badge-summary:hover{color:var(--fg);background:transparent}.hub-version-badge-summary strong{color:var(--fg);font-weight:600}.hub-version-badge-source{font-variant:small-caps;letter-spacing:.04em}.hub-version-badge-panel{background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:.85rem 1rem;font-size:.85rem;color:var(--fg);width:100%;max-width:28rem}.hub-version-badge-panel dl{margin:0 0 .75rem;display:grid;grid-template-columns:max-content 1fr;gap:.3rem .85rem}.hub-version-badge-panel dt{color:var(--fg-muted);font-size:.78rem;text-transform:uppercase;letter-spacing:.06em;padding-top:.1rem}.hub-version-badge-panel dd{margin:0;color:var(--fg);word-break:break-all}.hub-version-badge-refresh{font-size:.8rem;padding:.35rem .85rem}.depcard-wrap{margin-top:.6rem}.depcard{border:1px solid var(--warn);background:var(--warn-soft);border-radius:8px;padding:.9rem 1rem}.depcard-heading{margin:0 0 .25rem;font-size:1rem}.depcard-why{margin:0 0 .75rem;font-size:.9rem}.depcard-installs-label{margin:0 0 .4rem;font-size:.85rem;font-weight:600}.depcard-install{margin-bottom:.55rem}.depcard-install.preferred .depcard-os{color:var(--accent);font-weight:600}.depcard-os{display:block;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted);margin-bottom:.2rem}.depcard-cmd{display:flex;align-items:stretch;gap:.4rem}.depcard-cmd-text{flex:1;margin:0;padding:.45rem .6rem;background:var(--card-bg, #fff);border:1px solid var(--border);border-radius:6px;font-size:.82rem;white-space:pre-wrap;overflow-x:auto}.depcard-copy{flex:0 0 auto;font-size:.8rem;padding:.35rem .7rem;align-self:flex-start}.depcard-docs{margin:.5rem 0 .4rem;font-size:.88rem}.depcard-hint{margin:0;font-size:.82rem}.depcard-fallback{color:var(--error);font-size:.9rem}