@openparachute/hub 0.5.14-rc.9 → 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.
- package/README.md +23 -0
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-modules-ops.ts +49 -11
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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;
|
package/src/vault/auth-status.ts
CHANGED
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* 3. ~/.parachute/vault/data/<name>/vault.db (SQLite) → tokens table
|
|
19
19
|
* count, summed across every vault instance.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
26
27
|
*
|
|
27
28
|
* The YAML fallback path uses line-anchored regex parsing that matches
|
|
28
29
|
* vault's own `readGlobalConfig()` semantics (parachute-vault src/config.ts):
|
|
@@ -54,9 +55,12 @@ export interface VaultAuthStatus {
|
|
|
54
55
|
hasTotp: boolean;
|
|
55
56
|
/**
|
|
56
57
|
* `null` means we couldn't read the SQLite DB — distinct from "0 tokens
|
|
57
|
-
* exist."
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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."
|
|
60
64
|
*/
|
|
61
65
|
tokenCount: number | null;
|
|
62
66
|
/** Vault instance names discovered under data/. Empty when vault has
|
|
@@ -96,6 +100,16 @@ export interface AuthStatusOpts {
|
|
|
96
100
|
* want true "I can sign in" semantics get the OR of hub.db∪YAML.
|
|
97
101
|
*/
|
|
98
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;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
interface Resolved {
|
|
@@ -105,6 +119,7 @@ interface Resolved {
|
|
|
105
119
|
listVaultNames: (dataDir: string) => string[];
|
|
106
120
|
countTokens: (dbPath: string) => number;
|
|
107
121
|
probeHubDbHasUserPassword: (dbPath: string) => boolean | undefined;
|
|
122
|
+
probeHubDbHasTotp: (dbPath: string) => boolean | undefined;
|
|
108
123
|
}
|
|
109
124
|
|
|
110
125
|
function defaultVaultHome(): string {
|
|
@@ -183,11 +198,46 @@ function defaultProbeHubDbHasUserPassword(dbPath: string): boolean | undefined {
|
|
|
183
198
|
// simpler than a JOIN on earliest-created-at. A future env-seed
|
|
184
199
|
// flow that creates friend accounts before the operator sets a
|
|
185
200
|
// password would need to revisit this assumption.
|
|
186
|
-
const row = db
|
|
187
|
-
|
|
188
|
-
|
|
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;
|
|
189
237
|
return (row?.n ?? 0) > 0;
|
|
190
238
|
} catch {
|
|
239
|
+
// Column absent (pre-v11) or DB unreadable — indistinguishable from
|
|
240
|
+
// "no hub.db"; let the YAML fallback decide.
|
|
191
241
|
return undefined;
|
|
192
242
|
} finally {
|
|
193
243
|
try {
|
|
@@ -205,8 +255,8 @@ function resolve(opts: AuthStatusOpts): Resolved {
|
|
|
205
255
|
readText: opts.readText ?? defaultReadText,
|
|
206
256
|
listVaultNames: opts.listVaultNames ?? defaultListVaultNames,
|
|
207
257
|
countTokens: opts.countTokens ?? defaultCountTokens,
|
|
208
|
-
probeHubDbHasUserPassword:
|
|
209
|
-
|
|
258
|
+
probeHubDbHasUserPassword: opts.probeHubDbHasUserPassword ?? defaultProbeHubDbHasUserPassword,
|
|
259
|
+
probeHubDbHasTotp: opts.probeHubDbHasTotp ?? defaultProbeHubDbHasTotp,
|
|
210
260
|
};
|
|
211
261
|
}
|
|
212
262
|
|
|
@@ -258,12 +308,14 @@ function readAuthSignals(r: Resolved): AuthSignals {
|
|
|
258
308
|
const yaml = readYamlAuth(r);
|
|
259
309
|
const hubDbHasUser = r.probeHubDbHasUserPassword(r.hubDbPath);
|
|
260
310
|
const hasOwnerPassword = hubDbHasUser === true ? true : yaml.hasOwnerPassword;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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 };
|
|
267
319
|
}
|
|
268
320
|
|
|
269
321
|
/**
|
|
@@ -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}
|