@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
package/src/users.ts CHANGED
@@ -144,6 +144,64 @@ function readVaultsForUser(db: Database, userId: string): string[] {
144
144
  .map((r) => r.vault_name);
145
145
  }
146
146
 
147
+ /**
148
+ * The per-vault verbs a `user_vaults.role` grants. The schema's `role`
149
+ * column is `TEXT NOT NULL DEFAULT 'write'` and is reserved forward-compat
150
+ * for per-vault role granularity (see the v10 migration note in
151
+ * `hub-db.ts`). Today every assignment is created with `role = 'write'`, so
152
+ * the only live mapping is `write → {read, write}`. The function is the
153
+ * single place the verb-cap lives so a future role taxonomy (`read`-only,
154
+ * `admin`, etc.) lands here without the friend-mint path having to change.
155
+ *
156
+ * Mapping:
157
+ * - `write` (today's only value) → `["read", "write"]`
158
+ * - `read` → `["read"]`
159
+ * - anything else (unknown role) → `[]` — fail closed. An unrecognised
160
+ * role grants no minting authority rather than silently defaulting to
161
+ * write. (Defense-in-depth: a hand-edited / future row with a role this
162
+ * code doesn't understand should not be treated as broad write.)
163
+ *
164
+ * `admin` is intentionally NOT mapped to a `vault:<name>:admin` mint here —
165
+ * the friend-facing token mint is capped at read/write by design. A
166
+ * future per-vault-admin friend grant would route through the
167
+ * vault-admin-token path, not this one.
168
+ */
169
+ export type VaultVerb = "read" | "write";
170
+
171
+ export function vaultVerbsForRole(role: string): VaultVerb[] {
172
+ if (role === "write") return ["read", "write"];
173
+ if (role === "read") return ["read"];
174
+ return [];
175
+ }
176
+
177
+ /**
178
+ * Read the verbs a user may mint for one of their assigned vaults.
179
+ *
180
+ * Returns `null` when the user has NO `user_vaults` row for `vaultName` —
181
+ * i.e. the vault is not in their assignment. The caller treats `null` as a
182
+ * hard 403 (no minting for an unassigned vault). When a row exists, returns
183
+ * the verb list `vaultVerbsForRole` maps the stored role to (today always
184
+ * `["read", "write"]` since every assignment is `role = 'write'`).
185
+ *
186
+ * This reads the role column directly rather than going through
187
+ * `getUserById().assignedVaults` because that array is verb-blind — it
188
+ * names the vaults but not the role granted. The friend-mint authorization
189
+ * cap needs the role.
190
+ */
191
+ export function vaultVerbsForUserVault(
192
+ db: Database,
193
+ userId: string,
194
+ vaultName: string,
195
+ ): VaultVerb[] | null {
196
+ const row = db
197
+ .query<{ role: string }, [string, string]>(
198
+ "SELECT role FROM user_vaults WHERE user_id = ? AND vault_name = ?",
199
+ )
200
+ .get(userId, vaultName);
201
+ if (!row) return null;
202
+ return vaultVerbsForRole(row.role);
203
+ }
204
+
147
205
  export interface CreateUserOpts {
148
206
  /** Allow creating an additional user when one already exists. Off by default. */
149
207
  allowMulti?: boolean;
@@ -1,29 +1,48 @@
1
1
  /**
2
- * Read-only probe of vault's auth state, for the post-exposure preflight
3
- * nudge. We don't want to lock the DB or mutate anything — this is a
4
- * one-shot "should we warn the user their vault is wide open on the public
2
+ * Read-only probe of operator auth state, for the post-exposure preflight
3
+ * nudge. We don't want to lock anything or mutate state — this is a one-
4
+ * shot "should we warn the user their vault is wide open on the public
5
5
  * internet?" check.
6
6
  *
7
- * Two sources:
8
- * 1. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
9
- * 2. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table count
7
+ * Three sources, checked in this order:
10
8
  *
11
- * The YAML path uses line-anchored regex parsing that matches vault's own
12
- * `readGlobalConfig()` semantics (parachute-vault src/config.ts): keys are
13
- * optional, quoted scalars, and empty-string / missing-key both mean "not
14
- * configured." We mirror that rather than bringing in a YAML dependency.
9
+ * 1. ~/.parachute/hub.db → users table (authoritative since multi-user
10
+ * Phase 1, hub#252 / PRs 279–281 / 425). Hub-issued OAuth + browser
11
+ * sign-in both verify against `users.password_hash`. If any user row
12
+ * exists with a non-empty password_hash, the operator has an account
13
+ * "owner password set." The earliest-created user row is the canonical
14
+ * operator (cf. `getFirstAdminId` in src/users.ts).
15
+ * 2. ~/.parachute/vault/config.yaml → owner_password_hash + totp_secret
16
+ * (pre-multi-user Phase 1 location). Fallback for super-old installs
17
+ * whose hub.db is absent or empty.
18
+ * 3. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table
19
+ * count, summed across every vault instance.
15
20
  *
16
- * The SQLite path is best-effort: if the DB is missing, locked (vault is
17
- * writing), or the schema has drifted, `tokenCount` comes back as `null`
18
- * and the caller surfaces "token status unknown" rather than lying with a
19
- * false zero. The exposure flow has already succeeded by the time this
20
- * runs a probe failure must never block the user's happy path.
21
+ * As of hub#473 the hub.db `users` table carries TOTP columns
22
+ * (`totp_secret` et al.) and `/login` enforces a second factor when set, so
23
+ * `hasTotp` now reflects the hub.db state: true when any user has a non-empty
24
+ * `totp_secret`. The legacy vault `config.yaml` `totp_secret` (which never
25
+ * gated hub `/login`) is still read as a fallback for super-old installs whose
26
+ * hub.db is absent, but real hub-login 2FA is the hub.db signal.
21
27
  *
22
- * Schema coupling note: we read the `tokens` table by name with a bare
23
- * COUNT(*). If vault ever renames that table, that's a breaking change on
24
- * vault's side and this probe is the least of the fallout. Post-launch,
25
- * a public `/api/auth/status` endpoint on vault (tracked separately) would
26
- * let us drop this coupling entirely.
28
+ * The YAML fallback path uses line-anchored regex parsing that matches
29
+ * vault's own `readGlobalConfig()` semantics (parachute-vault src/config.ts):
30
+ * keys are optional, quoted scalars, and empty-string / missing-key both
31
+ * mean "not configured." We mirror that rather than bringing in a YAML
32
+ * dependency.
33
+ *
34
+ * The vault-token SQLite path is best-effort: if the DB is missing,
35
+ * locked (vault is writing), or the schema has drifted, `tokenCount`
36
+ * comes back as `null` and the caller surfaces "token status unknown"
37
+ * rather than lying with a false zero. The exposure flow has already
38
+ * succeeded by the time this runs — a probe failure must never block
39
+ * the user's happy path.
40
+ *
41
+ * Schema coupling note: we read the `tokens` table in each vault.db by
42
+ * name with a bare COUNT(*). If vault ever renames that table, that's a
43
+ * breaking change on vault's side and this probe is the least of the
44
+ * fallout. Post-launch, a public `/api/auth/status` endpoint on vault
45
+ * (tracked separately) would let us drop this coupling entirely.
27
46
  */
28
47
 
29
48
  import { existsSync, readFileSync, readdirSync } from "node:fs";
@@ -36,9 +55,12 @@ export interface VaultAuthStatus {
36
55
  hasTotp: boolean;
37
56
  /**
38
57
  * `null` means we couldn't read the SQLite DB — distinct from "0 tokens
39
- * exist." Callers branch the UI on this: `null` "token status unknown,
40
- * run `parachute vault tokens list` to check"; `0` loud "no auth at
41
- * all!" warning; `>0` benign.
58
+ * exist." Post the pvt_* DROP (hub#466) the expose preflight no longer
59
+ * branches on this `classify()` in expose-auth-preflight.ts gates on
60
+ * `hasOwnerPassword`, since access now flows through the owner password +
61
+ * on-demand hub JWT mint, not standing vault tokens. `tokenCount` is kept
62
+ * as a best-effort diagnostic only (these rows are vestigial); `null`
63
+ * still cleanly distinguishes "unreadable DB" from "0 rows."
42
64
  */
43
65
  tokenCount: number | null;
44
66
  /** Vault instance names discovered under data/. Empty when vault has
@@ -49,6 +71,8 @@ export interface VaultAuthStatus {
49
71
  export interface AuthStatusOpts {
50
72
  /** Override `~/.parachute/vault` for tests. */
51
73
  vaultHome?: string;
74
+ /** Override `~/.parachute/hub.db` for tests. */
75
+ hubDbPath?: string;
52
76
  /** Read a YAML file; defaults to `readFileSync(path, "utf8")`. Missing
53
77
  * file should return `undefined` (not throw) so callers can distinguish
54
78
  * "no password configured" from "IO error." */
@@ -60,13 +84,42 @@ export interface AuthStatusOpts {
60
84
  * thrown error (missing, locked, schema drift) is caught by the caller
61
85
  * and mapped to `tokenCount: null`. */
62
86
  countTokens?: (dbPath: string) => number;
87
+ /**
88
+ * Probe hub.db for "does at least one user row with a non-empty
89
+ * `password_hash` exist?" — the canonical "owner password is set"
90
+ * signal post-multi-user-Phase-1. Returning:
91
+ * - `true` → at least one user has a password_hash; hub.db is
92
+ * the source of truth, YAML fallback is skipped.
93
+ * - `false` → hub.db opened cleanly but users is empty (fresh
94
+ * install pre-wizard); fall back to YAML.
95
+ * - `undefined` → hub.db missing / unreadable / migration not yet
96
+ * applied; fall back to YAML (legacy install path).
97
+ * The split between `false` and `undefined` matters: an empty hub.db
98
+ * on a fresh wizard run should NOT be allowed to mask an owner_password_hash
99
+ * that the operator set via vault's pre-multi-user flow. Callers that
100
+ * want true "I can sign in" semantics get the OR of hub.db∪YAML.
101
+ */
102
+ probeHubDbHasUserPassword?: (dbPath: string) => boolean | undefined;
103
+ /**
104
+ * Probe hub.db for "does at least one user row have a non-empty
105
+ * `totp_secret`?" — the canonical "real hub-login 2FA is on" signal
106
+ * (hub#473). Same tri-state semantics as {@link probeHubDbHasUserPassword}:
107
+ * - `true` → at least one user has TOTP enrolled.
108
+ * - `false` → hub.db opened cleanly but no user has a secret.
109
+ * - `undefined` → hub.db missing / unreadable / column absent (pre-v11);
110
+ * caller falls back to the legacy YAML probe.
111
+ */
112
+ probeHubDbHasTotp?: (dbPath: string) => boolean | undefined;
63
113
  }
64
114
 
65
115
  interface Resolved {
66
116
  vaultHome: string;
117
+ hubDbPath: string;
67
118
  readText: (path: string) => string | undefined;
68
119
  listVaultNames: (dataDir: string) => string[];
69
120
  countTokens: (dbPath: string) => number;
121
+ probeHubDbHasUserPassword: (dbPath: string) => boolean | undefined;
122
+ probeHubDbHasTotp: (dbPath: string) => boolean | undefined;
70
123
  }
71
124
 
72
125
  function defaultVaultHome(): string {
@@ -77,6 +130,11 @@ function defaultVaultHome(): string {
77
130
  return root.length > 0 ? join(root, "vault") : join(homedir(), ".parachute", "vault");
78
131
  }
79
132
 
133
+ function defaultHubDbPath(): string {
134
+ const root = configDir();
135
+ return root.length > 0 ? join(root, "hub.db") : join(homedir(), ".parachute", "hub.db");
136
+ }
137
+
80
138
  function defaultReadText(path: string): string | undefined {
81
139
  try {
82
140
  return readFileSync(path, "utf8");
@@ -112,12 +170,93 @@ function defaultCountTokens(dbPath: string): number {
112
170
  }
113
171
  }
114
172
 
173
+ /**
174
+ * Open hub.db readonly and ask "does at least one user have a non-empty
175
+ * password_hash?" Returns `undefined` on any failure (DB missing, locked,
176
+ * schema drift, users table absent because migration v2 hasn't applied) —
177
+ * indistinguishable from "no hub.db at all," which is what the caller
178
+ * wants for the YAML-fallback branch.
179
+ *
180
+ * We deliberately do NOT open hub.db read-write here — `openHubDb()`
181
+ * would run migrations as a side effect, and this is a read probe from
182
+ * an unrelated command (`parachute expose`). `readonly: true` skips the
183
+ * WAL handshake and won't contend with the live hub server.
184
+ */
185
+ function defaultProbeHubDbHasUserPassword(dbPath: string): boolean | undefined {
186
+ if (!existsSync(dbPath)) return undefined;
187
+ const { Database } = require("bun:sqlite");
188
+ let db: { prepare: (sql: string) => { get: () => unknown }; close: () => void } | undefined;
189
+ try {
190
+ db = new Database(dbPath, { readonly: true }) as typeof db;
191
+ // COUNT(*) over users with non-empty password_hash. `length(...) > 0`
192
+ // matches the "missing/empty hash" treatment from the YAML side.
193
+ //
194
+ // Why "any user with a hash" not "first admin specifically": friend
195
+ // accounts can only be created by an already-authenticated admin
196
+ // (per api-users.ts's host:admin gate), so any user-with-hash
197
+ // implies the first admin has one too. Equivalent in practice and
198
+ // simpler than a JOIN on earliest-created-at. A future env-seed
199
+ // flow that creates friend accounts before the operator sets a
200
+ // password would need to revisit this assumption.
201
+ const row = db
202
+ ?.prepare(
203
+ "SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL AND length(password_hash) > 0",
204
+ )
205
+ .get() as { n: number } | null;
206
+ return (row?.n ?? 0) > 0;
207
+ } catch {
208
+ return undefined;
209
+ } finally {
210
+ try {
211
+ db?.close();
212
+ } catch {
213
+ // ignore
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Open hub.db readonly and ask "does at least one user have a non-empty
220
+ * `totp_secret`?" — the real hub-login 2FA signal (hub#473). Returns
221
+ * `undefined` on any failure (DB missing, locked, or the `totp_secret` column
222
+ * absent because migration v11 hasn't applied) — the caller then falls back to
223
+ * the legacy YAML probe. Readonly open (no migration side effects) mirrors
224
+ * {@link defaultProbeHubDbHasUserPassword}.
225
+ */
226
+ function defaultProbeHubDbHasTotp(dbPath: string): boolean | undefined {
227
+ if (!existsSync(dbPath)) return undefined;
228
+ const { Database } = require("bun:sqlite");
229
+ let db: { prepare: (sql: string) => { get: () => unknown }; close: () => void } | undefined;
230
+ try {
231
+ db = new Database(dbPath, { readonly: true }) as typeof db;
232
+ const row = db
233
+ ?.prepare(
234
+ "SELECT COUNT(*) AS n FROM users WHERE totp_secret IS NOT NULL AND length(totp_secret) > 0",
235
+ )
236
+ .get() as { n: number } | null;
237
+ return (row?.n ?? 0) > 0;
238
+ } catch {
239
+ // Column absent (pre-v11) or DB unreadable — indistinguishable from
240
+ // "no hub.db"; let the YAML fallback decide.
241
+ return undefined;
242
+ } finally {
243
+ try {
244
+ db?.close();
245
+ } catch {
246
+ // ignore
247
+ }
248
+ }
249
+ }
250
+
115
251
  function resolve(opts: AuthStatusOpts): Resolved {
116
252
  return {
117
253
  vaultHome: opts.vaultHome ?? defaultVaultHome(),
254
+ hubDbPath: opts.hubDbPath ?? defaultHubDbPath(),
118
255
  readText: opts.readText ?? defaultReadText,
119
256
  listVaultNames: opts.listVaultNames ?? defaultListVaultNames,
120
257
  countTokens: opts.countTokens ?? defaultCountTokens,
258
+ probeHubDbHasUserPassword: opts.probeHubDbHasUserPassword ?? defaultProbeHubDbHasUserPassword,
259
+ probeHubDbHasTotp: opts.probeHubDbHasTotp ?? defaultProbeHubDbHasTotp,
121
260
  };
122
261
  }
123
262
 
@@ -134,7 +273,12 @@ function matchQuotedKey(yaml: string, key: string): string | undefined {
134
273
  return captured;
135
274
  }
136
275
 
137
- function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: boolean } {
276
+ interface AuthSignals {
277
+ hasOwnerPassword: boolean;
278
+ hasTotp: boolean;
279
+ }
280
+
281
+ function readYamlAuth(r: Resolved): AuthSignals {
138
282
  const yaml = r.readText(join(r.vaultHome, "config.yaml"));
139
283
  if (yaml === undefined) return { hasOwnerPassword: false, hasTotp: false };
140
284
  return {
@@ -143,6 +287,37 @@ function readGlobalAuth(r: Resolved): { hasOwnerPassword: boolean; hasTotp: bool
143
287
  };
144
288
  }
145
289
 
290
+ /**
291
+ * Combine the hub.db probe + the legacy YAML probe into a single auth-signals
292
+ * read. Logic:
293
+ *
294
+ * - hub.db says yes → operator has an account in the canonical store;
295
+ * report `hasOwnerPassword: true`. TOTP is reported per the YAML probe
296
+ * (so a legacy operator who set both YAML password + YAML totp_secret,
297
+ * then migrated to a hub.db user, still surfaces "TOTP is on" — we
298
+ * don't have a hub-side TOTP column yet, hub#252 Phase 3 lands it).
299
+ * - hub.db says no AND was reachable → users table is empty, no hub
300
+ * account exists yet. Fall back to YAML for both signals — a pre-
301
+ * multi-user install would have its password in YAML.
302
+ * - hub.db unreachable → can't tell, fall back to YAML entirely.
303
+ *
304
+ * Net effect: `hasOwnerPassword` is the OR of (hub.db has a user with a
305
+ * password) ∪ (YAML has owner_password_hash). Either source counts.
306
+ */
307
+ function readAuthSignals(r: Resolved): AuthSignals {
308
+ const yaml = readYamlAuth(r);
309
+ const hubDbHasUser = r.probeHubDbHasUserPassword(r.hubDbPath);
310
+ const hasOwnerPassword = hubDbHasUser === true ? true : yaml.hasOwnerPassword;
311
+ // Real hub-login 2FA (hub#473): read hub.db's totp_secret. `undefined` (DB
312
+ // missing / pre-v11 column absent) falls back to the legacy YAML totp_secret
313
+ // — that one never gated hub `/login`, but suppressing the warning for an
314
+ // operator who set it avoids nagging. A definitive hub.db `false` (column
315
+ // present, no user enrolled) overrides a stale YAML true.
316
+ const hubDbHasTotp = r.probeHubDbHasTotp(r.hubDbPath);
317
+ const hasTotp = hubDbHasTotp === undefined ? yaml.hasTotp : hubDbHasTotp;
318
+ return { hasOwnerPassword, hasTotp };
319
+ }
320
+
146
321
  /**
147
322
  * Sum token counts across every vault instance found under data/. If any
148
323
  * probe throws (missing DB, locked, schema drift), the whole result
@@ -171,7 +346,7 @@ function readTotalTokenCount(r: Resolved, vaultNames: string[]): number | null {
171
346
 
172
347
  export function readVaultAuthStatus(opts: AuthStatusOpts = {}): VaultAuthStatus {
173
348
  const r = resolve(opts);
174
- const { hasOwnerPassword, hasTotp } = readGlobalAuth(r);
349
+ const { hasOwnerPassword, hasTotp } = readAuthSignals(r);
175
350
  const dataDir = join(r.vaultHome, "data");
176
351
  const vaultNames = r.listVaultNames(dataDir);
177
352
  const tokenCount = readTotalTokenCount(r, vaultNames);
@@ -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}