@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) 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-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Parachute Hub</title>
7
7
  <meta name="description" content="Manage vaults registered with this Parachute hub." />
8
- <script type="module" crossorigin src="/admin/assets/index-tRmPbbC7.js"></script>
9
- <link rel="stylesheet" crossorigin href="/admin/assets/index-7DtAXz7y.css">
8
+ <script type="module" crossorigin src="/admin/assets/index-CIN3mnmf.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/admin/assets/index-BiBlvEaj.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -1,183 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { runVaultTokensCreateInteractive } from "../commands/vault-tokens-create-interactive.ts";
3
-
4
- interface Harness {
5
- logs: string[];
6
- prompts: string[];
7
- promptAnswers: string[];
8
- commands: string[][];
9
- exitCode: number;
10
- }
11
-
12
- function makeHarness(
13
- answers: string[],
14
- opts: { exitCode?: number } = {},
15
- ): {
16
- harness: Harness;
17
- wire: {
18
- log: (l: string) => void;
19
- prompt: (q: string) => Promise<string>;
20
- interactiveRunner: (cmd: readonly string[]) => Promise<number>;
21
- };
22
- } {
23
- const harness: Harness = {
24
- logs: [],
25
- prompts: [],
26
- promptAnswers: [...answers],
27
- commands: [],
28
- exitCode: opts.exitCode ?? 0,
29
- };
30
- let i = 0;
31
- return {
32
- harness,
33
- wire: {
34
- log: (line) => harness.logs.push(line),
35
- prompt: async (q) => {
36
- harness.prompts.push(q);
37
- const a = harness.promptAnswers[i++];
38
- if (a === undefined) throw new Error(`prompt exhausted at: ${q}`);
39
- return a;
40
- },
41
- interactiveRunner: async (cmd) => {
42
- harness.commands.push([...cmd]);
43
- return harness.exitCode;
44
- },
45
- },
46
- };
47
- }
48
-
49
- describe("runVaultTokensCreateInteractive — scope picker", () => {
50
- test("Enter defaults to read (safer choice)", async () => {
51
- const { harness, wire } = makeHarness(["", ""]); // scope=default, label=skip
52
- const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
53
- expect(code).toBe(0);
54
- expect(harness.commands).toHaveLength(1);
55
- const cmd = harness.commands[0]!;
56
- expect(cmd).toEqual(["parachute-vault", "tokens", "create", "--read"]);
57
- });
58
-
59
- test("'1' also selects read", async () => {
60
- const { harness, wire } = makeHarness(["1", ""]);
61
- await runVaultTokensCreateInteractive({ args: [], ...wire });
62
- expect(harness.commands[0]).toContain("--read");
63
- });
64
-
65
- test("'2' selects write (vault:write scope)", async () => {
66
- const { harness, wire } = makeHarness(["2", ""]);
67
- await runVaultTokensCreateInteractive({ args: [], ...wire });
68
- const cmd = harness.commands[0]!;
69
- expect(cmd).toEqual(["parachute-vault", "tokens", "create", "--scope", "vault:write"]);
70
- });
71
-
72
- test("'3' selects admin (vault:admin scope)", async () => {
73
- const { harness, wire } = makeHarness(["3", ""]);
74
- await runVaultTokensCreateInteractive({ args: [], ...wire });
75
- const cmd = harness.commands[0]!;
76
- expect(cmd).toEqual(["parachute-vault", "tokens", "create", "--scope", "vault:admin"]);
77
- });
78
-
79
- test("'4' cancels with exit 0 and no subprocess", async () => {
80
- const { harness, wire } = makeHarness(["4"]);
81
- const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
82
- expect(code).toBe(0);
83
- expect(harness.commands).toHaveLength(0);
84
- expect(harness.logs.join("\n")).toContain("Cancelled");
85
- });
86
-
87
- test("'q' also cancels", async () => {
88
- const { harness, wire } = makeHarness(["q"]);
89
- const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
90
- expect(code).toBe(0);
91
- expect(harness.commands).toHaveLength(0);
92
- });
93
-
94
- test("word aliases accepted case-insensitively", async () => {
95
- for (const [answer, expected] of [
96
- ["READ", "--read"],
97
- ["Write", "vault:write"],
98
- ["admin", "vault:admin"],
99
- ] as const) {
100
- const { harness, wire } = makeHarness([answer, ""]);
101
- await runVaultTokensCreateInteractive({ args: [], ...wire });
102
- expect(harness.commands[0]!.join(" ")).toContain(expected);
103
- }
104
- });
105
-
106
- test("garbage input reprompts, keeping the scope picker tight", async () => {
107
- const { harness, wire } = makeHarness(["bogus", "5", "2", ""]);
108
- await runVaultTokensCreateInteractive({ args: [], ...wire });
109
- expect(harness.commands).toHaveLength(1);
110
- expect(harness.commands[0]).toContain("vault:write");
111
- // Three scope prompts were asked (bogus, 5, then the valid 2); one label.
112
- expect(harness.prompts.filter((p) => p.startsWith("Choice"))).toHaveLength(3);
113
- });
114
- });
115
-
116
- describe("runVaultTokensCreateInteractive — label prompt", () => {
117
- test("blank label = no --label flag forwarded", async () => {
118
- const { harness, wire } = makeHarness(["1", ""]);
119
- await runVaultTokensCreateInteractive({ args: [], ...wire });
120
- expect(harness.commands[0]!.includes("--label")).toBe(false);
121
- });
122
-
123
- test("non-blank label is appended verbatim", async () => {
124
- const { harness, wire } = makeHarness(["1", "n8n-sync"]);
125
- await runVaultTokensCreateInteractive({ args: [], ...wire });
126
- const cmd = harness.commands[0]!;
127
- expect(cmd).toContain("--label");
128
- expect(cmd[cmd.indexOf("--label") + 1]).toBe("n8n-sync");
129
- });
130
-
131
- test("label with spaces is passed as a single arg (not re-split)", async () => {
132
- const { harness, wire } = makeHarness(["1", "pendant prototype"]);
133
- await runVaultTokensCreateInteractive({ args: [], ...wire });
134
- const cmd = harness.commands[0]!;
135
- expect(cmd[cmd.indexOf("--label") + 1]).toBe("pendant prototype");
136
- });
137
-
138
- test("pre-supplied --label skips the label prompt entirely", async () => {
139
- const { harness, wire } = makeHarness(["1"]); // ONLY the scope prompt
140
- await runVaultTokensCreateInteractive({
141
- args: ["--label", "existing"],
142
- ...wire,
143
- });
144
- // Prompts only include the scope picker, not a label prompt.
145
- expect(harness.prompts.some((p) => p.toLowerCase().includes("label"))).toBe(false);
146
- // The user-supplied --label stays in place; we didn't double-append.
147
- const cmd = harness.commands[0]!;
148
- const labelIdxs: number[] = [];
149
- cmd.forEach((a, idx) => {
150
- if (a === "--label") labelIdxs.push(idx);
151
- });
152
- expect(labelIdxs).toHaveLength(1);
153
- expect(cmd[labelIdxs[0]! + 1]).toBe("existing");
154
- });
155
- });
156
-
157
- describe("runVaultTokensCreateInteractive — passthrough of pre-supplied args", () => {
158
- test("--vault / --expires forwarded verbatim, scope appended", async () => {
159
- const { harness, wire } = makeHarness(["1", ""]);
160
- await runVaultTokensCreateInteractive({
161
- args: ["--vault", "work", "--expires", "30d"],
162
- ...wire,
163
- });
164
- const cmd = harness.commands[0]!;
165
- // Original argv stays in order, scope flag appended after.
166
- expect(cmd).toEqual([
167
- "parachute-vault",
168
- "tokens",
169
- "create",
170
- "--vault",
171
- "work",
172
- "--expires",
173
- "30d",
174
- "--read",
175
- ]);
176
- });
177
-
178
- test("subprocess exit code is returned to the caller", async () => {
179
- const { wire } = makeHarness(["1", ""], { exitCode: 5 });
180
- const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
181
- expect(code).toBe(5);
182
- });
183
- });
@@ -1,143 +0,0 @@
1
- /**
2
- * `parachute vault tokens create` (no narrowing flags, in a TTY) — guided
3
- * token creation. Same command with any of `--scope` / `--read` /
4
- * `--permission`, or running under a non-TTY, bypasses this module and passes
5
- * through to `parachute-vault tokens create` unchanged.
6
- *
7
- * Two prompts:
8
- *
9
- * 1. Scope — read / write / admin / cancel. Default is `read` on Enter:
10
- * the two-factor reasoning is (a) a read-only token is the least
11
- * dangerous thing to mint by mistake, and (b) most callers of this
12
- * command interactively are plumbing in a new read-only consumer
13
- * (hooks, dashboards, n8n triggers). Users who actually want admin can
14
- * type "3" in ~1 second.
15
- *
16
- * 2. Label — free-form string, blank skips the prompt entirely (vault's
17
- * own `--label` default of "default" then applies). Skipped outright
18
- * if the user already supplied `--label …` on the command line.
19
- *
20
- * The resolved flags are appended to the original argv and forwarded to
21
- * `parachute-vault tokens create` via an inherit-stdio subprocess so the
22
- * generated token and its usage block print directly to the user's terminal.
23
- *
24
- * Shape mirrors `expose-interactive.ts`: every side-effectful edge is an
25
- * injectable seam so the full prompt tree is testable without spawning.
26
- */
27
-
28
- import { createInterface } from "node:readline/promises";
29
-
30
- export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
31
-
32
- const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
33
- // Inherit env so the child (parachute-vault subprocess) sees PATH, HOME,
34
- // PARACHUTE_HOME, etc. Bun.spawn defaults to empty env — see
35
- // api-modules-ops.ts:defaultRun.
36
- const proc = Bun.spawn([...cmd], {
37
- stdio: ["inherit", "inherit", "inherit"],
38
- env: process.env,
39
- });
40
- return await proc.exited;
41
- };
42
-
43
- async function defaultPrompt(question: string): Promise<string> {
44
- const rl = createInterface({ input: process.stdin, output: process.stdout });
45
- try {
46
- return await rl.question(question);
47
- } finally {
48
- rl.close();
49
- }
50
- }
51
-
52
- export interface VaultTokensCreateInteractiveOpts {
53
- /**
54
- * Original argv after `vault tokens create`. Flags the user already
55
- * supplied (`--vault <name>`, `--expires <dur>`, `--label <x>`) are
56
- * forwarded verbatim to `parachute-vault`; only the scope dimension is
57
- * resolved interactively.
58
- */
59
- args: readonly string[];
60
- prompt?: (question: string) => Promise<string>;
61
- interactiveRunner?: InteractiveRunner;
62
- log?: (line: string) => void;
63
- }
64
-
65
- interface Resolved {
66
- args: readonly string[];
67
- prompt: (question: string) => Promise<string>;
68
- interactiveRunner: InteractiveRunner;
69
- log: (line: string) => void;
70
- }
71
-
72
- function resolve(opts: VaultTokensCreateInteractiveOpts): Resolved {
73
- return {
74
- args: opts.args,
75
- prompt: opts.prompt ?? defaultPrompt,
76
- interactiveRunner: opts.interactiveRunner ?? defaultInteractiveRunner,
77
- log: opts.log ?? ((line) => console.log(line)),
78
- };
79
- }
80
-
81
- type ScopeChoice = "read" | "write" | "admin" | "cancel";
82
-
83
- async function promptScope(r: Resolved): Promise<ScopeChoice> {
84
- r.log("Scope for this token?");
85
- r.log(" [1] read — query-only (safer default)");
86
- r.log(" [2] write — read + create/update");
87
- r.log(" [3] admin — full access (token + config management)");
88
- r.log(" [4] cancel");
89
- while (true) {
90
- const raw = (await r.prompt("Choice [1]: ")).trim().toLowerCase();
91
- if (raw === "" || raw === "1" || raw === "read") return "read";
92
- if (raw === "2" || raw === "write") return "write";
93
- if (raw === "3" || raw === "admin") return "admin";
94
- if (raw === "4" || raw === "cancel" || raw === "q") return "cancel";
95
- r.log(`(didn't understand "${raw}" — please pick 1, 2, 3, or 4)`);
96
- }
97
- }
98
-
99
- async function promptLabel(r: Resolved): Promise<string | undefined> {
100
- r.log("");
101
- const raw = (await r.prompt('Label for this token (e.g. "n8n-sync", blank to skip): ')).trim();
102
- return raw === "" ? undefined : raw;
103
- }
104
-
105
- /**
106
- * Map the scope choice to the CLI flag sequence vault expects. We pass the
107
- * canonical form for each level so anyone inspecting the spawned argv can
108
- * see exactly what got minted — `--read` reads clearer than `--scope
109
- * vault:read` for the common case, and `vault:write`/`vault:admin` are the
110
- * canonical OAuth-style scope names for the other two.
111
- */
112
- function scopeFlagsFor(choice: "read" | "write" | "admin"): string[] {
113
- if (choice === "read") return ["--read"];
114
- if (choice === "write") return ["--scope", "vault:write"];
115
- return ["--scope", "vault:admin"];
116
- }
117
-
118
- export async function runVaultTokensCreateInteractive(
119
- opts: VaultTokensCreateInteractiveOpts,
120
- ): Promise<number> {
121
- const r = resolve(opts);
122
-
123
- const scope = await promptScope(r);
124
- if (scope === "cancel") {
125
- r.log("Cancelled — no token created.");
126
- return 0;
127
- }
128
-
129
- const hasLabelFlag = r.args.includes("--label");
130
- const label = hasLabelFlag ? undefined : await promptLabel(r);
131
-
132
- const forwarded: string[] = [
133
- "parachute-vault",
134
- "tokens",
135
- "create",
136
- ...r.args,
137
- ...scopeFlagsFor(scope),
138
- ];
139
- if (label !== undefined) forwarded.push("--label", label);
140
-
141
- r.log("");
142
- return await r.interactiveRunner(forwarded);
143
- }
@@ -1 +0,0 @@
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 .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}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}