@openparachute/hub 0.6.3 → 0.6.4-rc.2
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/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/scope-attenuation.ts
CHANGED
|
@@ -83,3 +83,22 @@ export function hasMintingAuthority(bearerScopes: string[]): boolean {
|
|
|
83
83
|
bearerScopes.some((s) => isVaultAdminScope(s))
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is this an *operator* bearer — i.e. does it hold host-level minting authority
|
|
89
|
+
* (`parachute:host:auth` or `parachute:host:admin`)?
|
|
90
|
+
*
|
|
91
|
+
* The distinction matters for the `subject` override on the mint endpoint: an
|
|
92
|
+
* operator bearer is the on-box host administrator (or a service account it
|
|
93
|
+
* delegated to), so it may legitimately mint a token whose `sub` names a
|
|
94
|
+
* service account other than its own — the documented service-account override.
|
|
95
|
+
* A merely vault-scoped bearer (`vault:<N>:admin` only) has NO host authority,
|
|
96
|
+
* so letting it set an arbitrary `sub` is audit-attribution forgery: it could
|
|
97
|
+
* mint a token that the registry + revocation list attribute to a foreign
|
|
98
|
+
* subject. Non-operator bearers are therefore pinned to their own `sub`.
|
|
99
|
+
*/
|
|
100
|
+
export function isOperatorBearer(bearerScopes: string[]): boolean {
|
|
101
|
+
return (
|
|
102
|
+
bearerScopes.includes(MINT_HOST_AUTH_SCOPE) || bearerScopes.includes(MINT_HOST_ADMIN_SCOPE)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -270,7 +270,15 @@ export function isNonRequestableScope(scope: string): boolean {
|
|
|
270
270
|
// consent path and through `canGrant` rule 1, capped to the consenting
|
|
271
271
|
// user's held authority at the `issueAuthCodeRedirect` choke-point. Only
|
|
272
272
|
// the host-level operator scopes stay non-requestable here.
|
|
273
|
-
|
|
273
|
+
//
|
|
274
|
+
// Item C — case-insensitive guard. The membership check is exact-string,
|
|
275
|
+
// but Parachute scope tokens are canonically lowercase. A casing variant
|
|
276
|
+
// like `PARACHUTE:HOST:AUTH` would slip past a raw `Set.has` and be treated
|
|
277
|
+
// as requestable — minting a junk-but-harmless token today (consumers are
|
|
278
|
+
// case-sensitive + anchored, so it grants nothing), but a backstop against
|
|
279
|
+
// a future consumer that case-folds. Normalize to lowercase before the
|
|
280
|
+
// membership check so every casing of a host-level scope is refused.
|
|
281
|
+
return NON_REQUESTABLE_SCOPES.has(scope.toLowerCase());
|
|
274
282
|
}
|
|
275
283
|
|
|
276
284
|
/** True when the scope can appear in a public `/oauth/authorize` request. */
|
package/src/service-spec.ts
CHANGED
|
@@ -39,9 +39,14 @@ import type { ServiceEntry } from "./services-manifest.ts";
|
|
|
39
39
|
*
|
|
40
40
|
* Operator override is now "edit services.json" (or `parachute config`
|
|
41
41
|
* once that lands), not "edit `.env`". Pre-#206 stale `.env` PORT lines on
|
|
42
|
-
* existing operator machines stay where they are — harmless
|
|
43
|
-
*
|
|
44
|
-
* PORT env tier — and future installs no longer touch them.
|
|
42
|
+
* existing operator machines stay where they are — harmless to the module's
|
|
43
|
+
* own resolvePort ladder, which reads services.json before falling through to
|
|
44
|
+
* the bare PORT env tier — and future installs no longer touch them. (Caveat,
|
|
45
|
+
* hub#537: the supervisor's spawn-env builder used to echo a stale `.env` PORT
|
|
46
|
+
* into the injected `PORT`, and its readiness probe trusted that — so a `.env`
|
|
47
|
+
* PORT disagreeing with services.json yielded a false `started_but_unbound`.
|
|
48
|
+
* Fixed by making `entry.port` authoritative: `buildModuleSpawnRequest` drops a
|
|
49
|
+
* `.env` PORT.)
|
|
45
50
|
*
|
|
46
51
|
* **No speculative reservations.** Future first-party modules claim a slot
|
|
47
52
|
* the moment they ship, not before — pre-reservation for unbuilt things has
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH enrichment for spawned modules + the hub's own boot env.
|
|
3
|
+
*
|
|
4
|
+
* The hub-as-supervisor unit bakes a hardcoded base PATH (see
|
|
5
|
+
* `enrichedUnitPath` below), and `Bun.spawn` defaults to an empty env, so
|
|
6
|
+
* supervised children only ever see the PATH the unit handed the hub. On a
|
|
7
|
+
* launchd-managed Mac that PATH omits the two dirs operator tools actually live
|
|
8
|
+
* in — `$HOME/.local/bin` (scribe's `parakeet-mlx`) and the Homebrew bin
|
|
9
|
+
* (`ffmpeg`, `/opt/homebrew/bin` on Apple Silicon). The result: scribe's
|
|
10
|
+
* `Bun.which("ffmpeg")` / `Bun.which("parakeet-mlx")` probes come up empty and
|
|
11
|
+
* transcription is dead on canonical installs.
|
|
12
|
+
*
|
|
13
|
+
* `enrichedPath` is the shared fix. It takes a base env (defaults to
|
|
14
|
+
* `process.env`), keeps whatever PATH was inherited, and APPENDS the operator-
|
|
15
|
+
* tool dirs that exist on disk and aren't already present. Inherited PATH wins
|
|
16
|
+
* (append, not prepend) so an operator's explicit PATH ordering is never
|
|
17
|
+
* reordered out from under them. `PARACHUTE_EXTRA_PATH` (colon-joined) is
|
|
18
|
+
* PREPENDED so an operator can intentionally shadow a system binary.
|
|
19
|
+
*
|
|
20
|
+
* Two consumers:
|
|
21
|
+
* 1. The spawn side (`buildModuleSpawnRequest` in `commands/serve-boot.ts` +
|
|
22
|
+
* `spawnSupervised` in `api-modules-ops.ts`) injects `enrichedPath()` into
|
|
23
|
+
* every supervised child's env. This is the PRIMARY fix — it self-heals
|
|
24
|
+
* every existing install at the next hub restart, no re-init needed.
|
|
25
|
+
* 2. The hub's own `serve` startup enriches `process.env.PATH` so the hub's
|
|
26
|
+
* own `Bun.which` probes (cloudflared / tailscale detection, etc.) also
|
|
27
|
+
* see brew + `.local`.
|
|
28
|
+
*
|
|
29
|
+
* The unit generator (`enrichedUnitPath`, used by `hub-unit.ts` +
|
|
30
|
+
* `commands/migrate-cutover.ts`) bakes the same dirs into the launchd/systemd
|
|
31
|
+
* unit PATH as belt-and-suspenders for fresh installs.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
|
|
37
|
+
/** Injectable seams so tests don't touch the real fs / os / platform. */
|
|
38
|
+
export interface EnrichedPathDeps {
|
|
39
|
+
/** Operator home dir. */
|
|
40
|
+
homeDir: () => string;
|
|
41
|
+
/** True when `path` exists on disk. */
|
|
42
|
+
exists: (path: string) => boolean;
|
|
43
|
+
/** `process.platform` value (e.g. "darwin", "linux"). */
|
|
44
|
+
platform: NodeJS.Platform;
|
|
45
|
+
/** `process.arch` value (e.g. "arm64", "x64"). */
|
|
46
|
+
arch: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const defaultEnrichedPathDeps: EnrichedPathDeps = {
|
|
50
|
+
homeDir: () => homedir(),
|
|
51
|
+
exists: (path) => existsSync(path),
|
|
52
|
+
platform: process.platform,
|
|
53
|
+
arch: process.arch,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The Homebrew bin dir for the current platform/arch, or null when there
|
|
58
|
+
* isn't a canonical one (non-darwin — Linux brew is opt-in + non-canonical, so
|
|
59
|
+
* we only contribute `$HOME/.local/bin` there).
|
|
60
|
+
*
|
|
61
|
+
* Apple Silicon brew installs under `/opt/homebrew`; Intel macOS brew under
|
|
62
|
+
* `/usr/local`.
|
|
63
|
+
*/
|
|
64
|
+
function brewBinDir(platform: NodeJS.Platform, arch: string): string | null {
|
|
65
|
+
if (platform !== "darwin") return null;
|
|
66
|
+
return arch === "arm64" ? "/opt/homebrew/bin" : "/usr/local/bin";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Operator-tool dirs to APPEND (in this order): `$HOME/.local/bin` (pipx /
|
|
71
|
+
* `pip install --user` — scribe's `parakeet-mlx`), the platform brew bin
|
|
72
|
+
* (`ffmpeg`), and `$HOME/.bun/bin` (bun-linked binaries on a cold boot). Order
|
|
73
|
+
* is deterministic and stable. Pure of fs — the runtime enrichment filters by
|
|
74
|
+
* existence, the unit generator includes them unconditionally.
|
|
75
|
+
*/
|
|
76
|
+
export function operatorToolDirs(home: string, platform: NodeJS.Platform, arch: string): string[] {
|
|
77
|
+
const dirs = [`${home}/.local/bin`];
|
|
78
|
+
const brew = brewBinDir(platform, arch);
|
|
79
|
+
if (brew) dirs.push(brew);
|
|
80
|
+
dirs.push(`${home}/.bun/bin`);
|
|
81
|
+
return dirs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function candidateDirs(deps: EnrichedPathDeps): string[] {
|
|
85
|
+
return operatorToolDirs(deps.homeDir(), deps.platform, deps.arch);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function dedupe(entries: string[]): string[] {
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const out: string[] = [];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (seen.has(entry)) continue;
|
|
93
|
+
seen.add(entry);
|
|
94
|
+
out.push(entry);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The PATH baked into the launchd/systemd hub unit at generation time.
|
|
101
|
+
*
|
|
102
|
+
* `${bunInstall}/bin` first (so supervised children resolve a bun-linked binary
|
|
103
|
+
* on cold boot, R20), then the usual system dirs, then the operator-tool dirs
|
|
104
|
+
* (`$HOME/.local/bin`, the platform brew bin) so a managed hub — and the modules
|
|
105
|
+
* it spawns — can find scribe's `parakeet-mlx` + `ffmpeg`. `PARACHUTE_EXTRA_PATH`
|
|
106
|
+
* (if set at generation time) is PREPENDED for intentional operator shadowing.
|
|
107
|
+
* Deduped end-to-end.
|
|
108
|
+
*
|
|
109
|
+
* Unlike `enrichedPath`, the unit-side dirs are included UNCONDITIONALLY (no
|
|
110
|
+
* existence check): the unit is generated once at install/migrate time but
|
|
111
|
+
* brew/tools may be installed afterward, and a non-existent PATH entry is simply
|
|
112
|
+
* skipped by the OS — so baking them in is the robust choice for fresh installs.
|
|
113
|
+
*
|
|
114
|
+
* Shared by `hub-unit.ts` (init bringup) + `commands/migrate-cutover.ts`
|
|
115
|
+
* (`--to-supervised` cutover) so the two unit-generation paths can't drift.
|
|
116
|
+
*/
|
|
117
|
+
export function enrichedUnitPath(
|
|
118
|
+
bunInstall: string,
|
|
119
|
+
home: string,
|
|
120
|
+
platform: NodeJS.Platform = process.platform,
|
|
121
|
+
arch: string = process.arch,
|
|
122
|
+
extraPath: string | undefined = process.env.PARACHUTE_EXTRA_PATH,
|
|
123
|
+
): string {
|
|
124
|
+
const extra = (extraPath ?? "").split(":").filter((e) => e.length > 0);
|
|
125
|
+
const base = [`${bunInstall}/bin`, "/usr/local/bin", "/usr/bin", "/bin"];
|
|
126
|
+
return dedupe([...extra, ...base, ...operatorToolDirs(home, platform, arch)]).join(":");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a PATH that enriches `env.PATH` with operator-tool dirs.
|
|
131
|
+
*
|
|
132
|
+
* Ordering: `PARACHUTE_EXTRA_PATH` (prepended) : inherited PATH : appended
|
|
133
|
+
* operator-tool dirs that exist and aren't already present. Deduped end-to-end
|
|
134
|
+
* (first occurrence wins, so an inherited entry is never reordered).
|
|
135
|
+
*/
|
|
136
|
+
export function enrichedPath(
|
|
137
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
138
|
+
deps: EnrichedPathDeps = defaultEnrichedPathDeps,
|
|
139
|
+
): string {
|
|
140
|
+
const inherited = (env.PATH ?? "").split(":").filter((e) => e.length > 0);
|
|
141
|
+
const extra = (env.PARACHUTE_EXTRA_PATH ?? "").split(":").filter((e) => e.length > 0);
|
|
142
|
+
const appended = candidateDirs(deps).filter((d) => deps.exists(d));
|
|
143
|
+
|
|
144
|
+
// PARACHUTE_EXTRA_PATH first (intentional operator shadow), then the
|
|
145
|
+
// inherited PATH (inherited wins over our appended defaults), then our
|
|
146
|
+
// appended operator-tool dirs.
|
|
147
|
+
return dedupe([...extra, ...inherited, ...appended]).join(":");
|
|
148
|
+
}
|
package/src/supervisor.ts
CHANGED
|
@@ -196,6 +196,19 @@ export interface SupervisorOpts {
|
|
|
196
196
|
readonly startReadyMs?: number;
|
|
197
197
|
/** Poll interval while waiting for the port to bind, in ms. Default 200. */
|
|
198
198
|
readonly startReadyPollMs?: number;
|
|
199
|
+
/**
|
|
200
|
+
* How long the background late-bind watch keeps re-probing AFTER the
|
|
201
|
+
* readiness window elapsed with the port unbound, in ms. Heavy modules
|
|
202
|
+
* (vault — SQLite + git mirror + well-known init) can legitimately take
|
|
203
|
+
* longer than `startReadyMs` to bind; without a re-probe the recorded
|
|
204
|
+
* `started_but_unbound` note sticks for the module's whole lifetime and
|
|
205
|
+
* `parachute status` shows a perpetual "failed to start" on a healthy
|
|
206
|
+
* module. The watch clears the note once the port binds. Default 60s;
|
|
207
|
+
* `0` disables the watch (the note then behaves as before).
|
|
208
|
+
*/
|
|
209
|
+
readonly lateBindWatchMs?: number;
|
|
210
|
+
/** Poll interval for the late-bind watch, in ms. Default 1000. */
|
|
211
|
+
readonly lateBindPollMs?: number;
|
|
199
212
|
/**
|
|
200
213
|
* PATH-resolution seam for the pre-spawn `ensureExecutable` preflight
|
|
201
214
|
* (`@openparachute/depcheck`). Production uses the real `Bun.which`; a
|
|
@@ -242,6 +255,8 @@ const DEFAULT_KILL_TIMEOUT_MS = 5_000;
|
|
|
242
255
|
const DEFAULT_LOG_BUFFER_BYTES = 64 * 1024;
|
|
243
256
|
const DEFAULT_START_READY_MS = 4_000;
|
|
244
257
|
const DEFAULT_START_READY_POLL_MS = 200;
|
|
258
|
+
const DEFAULT_LATE_BIND_WATCH_MS = 60_000;
|
|
259
|
+
const DEFAULT_LATE_BIND_POLL_MS = 1_000;
|
|
245
260
|
|
|
246
261
|
/**
|
|
247
262
|
* Bounded, line-oriented ring buffer (§6.5). Holds the most-recent lines of a
|
|
@@ -318,6 +333,8 @@ export class Supervisor {
|
|
|
318
333
|
startReadyMs:
|
|
319
334
|
opts.startReadyMs ?? (isProductionPath || readinessOptedIn ? DEFAULT_START_READY_MS : 0),
|
|
320
335
|
startReadyPollMs: opts.startReadyPollMs ?? DEFAULT_START_READY_POLL_MS,
|
|
336
|
+
lateBindWatchMs: opts.lateBindWatchMs ?? DEFAULT_LATE_BIND_WATCH_MS,
|
|
337
|
+
lateBindPollMs: opts.lateBindPollMs ?? DEFAULT_LATE_BIND_POLL_MS,
|
|
321
338
|
which: opts.which ?? (isProductionPath ? Bun.which : () => "/stub/bin/preflight-skipped"),
|
|
322
339
|
};
|
|
323
340
|
}
|
|
@@ -453,6 +470,44 @@ export class Supervisor {
|
|
|
453
470
|
at: new Date(this.opts.now()).toISOString(),
|
|
454
471
|
},
|
|
455
472
|
};
|
|
473
|
+
// Keep watching in the background: heavy modules (vault) routinely bind
|
|
474
|
+
// a moment after the window. Without the re-probe the note above would
|
|
475
|
+
// stick for the module's whole lifetime — `parachute status` then shows
|
|
476
|
+
// a perpetual "failed to start" on a healthy module. Fire-and-forget so
|
|
477
|
+
// `start()`'s latency stays bounded by `startReadyMs`.
|
|
478
|
+
if (this.opts.lateBindWatchMs > 0) {
|
|
479
|
+
void this.lateBindWatch(entry, port).catch(() => {});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Background re-probe after `awaitPortReadiness` recorded a
|
|
486
|
+
* `started_but_unbound` note: poll (slower cadence, bounded window) and
|
|
487
|
+
* clear the note once the port binds. Exits early when the module stops,
|
|
488
|
+
* crashes, or the note is replaced by a different startError (a later
|
|
489
|
+
* crash-restart's missing-dependency note must not be wiped).
|
|
490
|
+
*
|
|
491
|
+
* Restart safety: a crash-auto-restart reuses the same `entry` object and
|
|
492
|
+
* clears `startError` on respawn — this watch's `error_type` guard then sees
|
|
493
|
+
* `undefined` and exits, so a stale watch from spawn-1 never clobbers
|
|
494
|
+
* spawn-2's state. If spawn-2 also misses its window, its own gate records a
|
|
495
|
+
* fresh note and launches its own watch; two live watches clearing the same
|
|
496
|
+
* note is idempotent. `stop()`/teardown set `stopRequested` before any entry
|
|
497
|
+
* removal, so a watch holding a stale entry ref exits cleanly.
|
|
498
|
+
*/
|
|
499
|
+
private async lateBindWatch(entry: ModuleEntry, port: number): Promise<void> {
|
|
500
|
+
const deadline = this.opts.now() + this.opts.lateBindWatchMs;
|
|
501
|
+
while (this.opts.now() < deadline) {
|
|
502
|
+
await this.opts.sleep(this.opts.lateBindPollMs);
|
|
503
|
+
if (entry.stopRequested || entry.state.status !== "running") return;
|
|
504
|
+
// Only OUR note is clearable — anything else was recorded after us.
|
|
505
|
+
if (entry.state.startError?.error_type !== "started_but_unbound") return;
|
|
506
|
+
if (await this.opts.portListening(port)) {
|
|
507
|
+
const { startError: _drop, ...rest } = entry.state;
|
|
508
|
+
entry.state = rest;
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
456
511
|
}
|
|
457
512
|
}
|
|
458
513
|
|
|
@@ -540,21 +595,43 @@ export class Supervisor {
|
|
|
540
595
|
}
|
|
541
596
|
|
|
542
597
|
/**
|
|
543
|
-
* Restart a supervised module: stop, wait for exit, start
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
598
|
+
* Restart a supervised module: stop, wait for exit, start again. Used by
|
|
599
|
+
* the `/api/modules/:name/restart` handler. The on-box
|
|
600
|
+
* `parachute restart <svc>` path stays on `commands/lifecycle.ts` —
|
|
601
|
+
* different surface, different ownership.
|
|
602
|
+
*
|
|
603
|
+
* `nextReq` (hub#532): when the caller supplies a freshly-rebuilt
|
|
604
|
+
* SpawnRequest (current `PARACHUTE_HUB_ORIGIN` / enriched PATH /
|
|
605
|
+
* re-resolved cwd), the re-spawn uses it AND it becomes the entry's new
|
|
606
|
+
* `req` — so subsequent CRASH-restarts (`handleExit` → `spawnAndWatch`,
|
|
607
|
+
* which reuse `entry.req`) also carry the refreshed env, not the original
|
|
608
|
+
* first-start snapshot. When omitted, the prior `entry.req` is replayed
|
|
609
|
+
* (legacy behavior, e.g. an internal restart with no state change).
|
|
610
|
+
*
|
|
611
|
+
* `nextReq.short` MUST match `short`: `start(req)` keys the supervisor map
|
|
612
|
+
* on `req.short`, so a mismatch would silently register the restarted
|
|
613
|
+
* module under the WRONG key (orphaning the original entry + breaking every
|
|
614
|
+
* subsequent `get`/`stop`/`restart` lookup). Throws on mismatch rather than
|
|
615
|
+
* trusting the caller — a one-line invariant that turns a silent
|
|
616
|
+
* state-corruption bug into a loud one.
|
|
547
617
|
*/
|
|
548
|
-
async restart(short: string): Promise<ModuleState | undefined> {
|
|
618
|
+
async restart(short: string, nextReq?: SpawnRequest): Promise<ModuleState | undefined> {
|
|
619
|
+
if (nextReq && nextReq.short !== short) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
`restart(${short}): nextReq.short is "${nextReq.short}" — it must match the restarted short or the module re-registers under the wrong key`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
549
624
|
const entry = this.modules.get(short);
|
|
550
625
|
if (!entry) return undefined;
|
|
551
|
-
const req = entry.req;
|
|
626
|
+
const req = nextReq ?? entry.req;
|
|
552
627
|
entry.state = { ...entry.state, status: "restarting" };
|
|
553
628
|
// stop() now awaits the prior process's exit (with SIGKILL
|
|
554
629
|
// escalation) before returning, so the fresh spawn below doesn't
|
|
555
630
|
// race on EADDRINUSE — no separate await needed here.
|
|
556
631
|
await this.stop(short);
|
|
557
|
-
// Drop the entry so `start` treats this as a clean spawn.
|
|
632
|
+
// Drop the entry so `start` treats this as a clean spawn. `start` stores
|
|
633
|
+
// `req` as the new entry's `req`, so a refreshed `nextReq` propagates to
|
|
634
|
+
// the crash-restart path too.
|
|
558
635
|
this.modules.delete(short);
|
|
559
636
|
return this.start(req);
|
|
560
637
|
}
|
package/src/users.ts
CHANGED
|
@@ -232,6 +232,26 @@ export interface CreateUserOpts {
|
|
|
232
232
|
* each name against `services.json` before passing through.
|
|
233
233
|
*/
|
|
234
234
|
assignedVaults?: string[];
|
|
235
|
+
/**
|
|
236
|
+
* The `user_vaults.role` to write for every entry in `assignedVaults`.
|
|
237
|
+
* Default `'write'` (= owner; `vaultVerbsForRole('write')` grants the
|
|
238
|
+
* full read/write/admin triple). The invite-redeem path passes the
|
|
239
|
+
* invite's baked-in role so a future shared-into-existing-vault invite
|
|
240
|
+
* can land a narrower `'read'` role without a second migration. All
|
|
241
|
+
* existing call sites omit it and keep the historical `'write'` default.
|
|
242
|
+
*/
|
|
243
|
+
role?: string;
|
|
244
|
+
/**
|
|
245
|
+
* Optional hook run INSIDE the same transaction as the user + user_vaults
|
|
246
|
+
* inserts, after them, with the new user's id. Throwing from it rolls the
|
|
247
|
+
* whole insert back (no orphan user row). The invite-redeem path uses this
|
|
248
|
+
* to atomically re-check + consume a single-use invite together with the
|
|
249
|
+
* account creation — so two concurrent redeems of one invite can't both
|
|
250
|
+
* create an account (the loser throws here and its user insert rolls back),
|
|
251
|
+
* while a failure still leaves the invite re-usable (nothing committed).
|
|
252
|
+
* Must be synchronous — bun:sqlite transactions can't await.
|
|
253
|
+
*/
|
|
254
|
+
withinTx?: (userId: string) => void;
|
|
235
255
|
}
|
|
236
256
|
|
|
237
257
|
export async function createUser(
|
|
@@ -268,14 +288,18 @@ export async function createUser(
|
|
|
268
288
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
269
289
|
).run(id, username, passwordHash, stamp, stamp, passwordChanged);
|
|
270
290
|
if (assignedVaults.length > 0) {
|
|
291
|
+
const role = opts.role ?? "write";
|
|
271
292
|
const insertVault = db.prepare(
|
|
272
293
|
`INSERT INTO user_vaults (user_id, vault_name, role, created_at)
|
|
273
|
-
VALUES (?, ?,
|
|
294
|
+
VALUES (?, ?, ?, ?)`,
|
|
274
295
|
);
|
|
275
296
|
for (const vaultName of assignedVaults) {
|
|
276
|
-
insertVault.run(id, vaultName, stamp);
|
|
297
|
+
insertVault.run(id, vaultName, role, stamp);
|
|
277
298
|
}
|
|
278
299
|
}
|
|
300
|
+
// In-transaction hook (e.g. consume a single-use invite). Throwing here
|
|
301
|
+
// rolls back the user + user_vaults inserts above — no orphan row.
|
|
302
|
+
opts.withinTx?.(id);
|
|
279
303
|
})();
|
|
280
304
|
} catch (err) {
|
|
281
305
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -468,7 +492,7 @@ export async function setPassword(
|
|
|
468
492
|
* only Phase-1 recovery was delete+recreate, which is destructive-feeling
|
|
469
493
|
* even though it's safe (vaults are independent of accounts).
|
|
470
494
|
*
|
|
471
|
-
*
|
|
495
|
+
* Four writes inside one transaction:
|
|
472
496
|
*
|
|
473
497
|
* 1. Rotate `password_hash` to the new argon2id hash and flip
|
|
474
498
|
* `password_changed` back to 0 so the user is force-redirected
|
|
@@ -482,7 +506,15 @@ export async function setPassword(
|
|
|
482
506
|
* would defeat the purpose. We keep the rows (don't NULL `user_id`
|
|
483
507
|
* like `deleteUser` does) because the audit trail naturally re-
|
|
484
508
|
* anchors to the still-existing user row.
|
|
485
|
-
* 3.
|
|
509
|
+
* 3. Delete every active SESSION for the user (item G). Revoking tokens
|
|
510
|
+
* alone left a live session cookie valid — an attacker who already had
|
|
511
|
+
* a session (the very "old password / stolen device" shape this reset
|
|
512
|
+
* recovers from) kept browsing post-reset until the session aged out.
|
|
513
|
+
* Killing sessions in the same transaction makes the reset a true cut:
|
|
514
|
+
* the user (and any attacker) must re-authenticate with the new
|
|
515
|
+
* password. Sessions carry no audit value (unlike tokens), so we hard-
|
|
516
|
+
* delete — same shape as `deleteUser`'s `DELETE FROM sessions`.
|
|
517
|
+
* 4. Bump `updated_at` so the SPA's row reflects the rotation.
|
|
486
518
|
*
|
|
487
519
|
* Hash OUTSIDE the transaction — argon2id is async and `db.transaction()`
|
|
488
520
|
* on bun:sqlite is sync; doing it inside silently breaks atomicity (same
|
|
@@ -547,6 +579,12 @@ export async function resetUserPassword(
|
|
|
547
579
|
stamp,
|
|
548
580
|
userId,
|
|
549
581
|
);
|
|
582
|
+
// Item G — also kill active sessions in the same transaction. A token
|
|
583
|
+
// revoke alone left a live session cookie valid; an admin reset must
|
|
584
|
+
// force re-auth with the new password (the "old password leaked / stolen
|
|
585
|
+
// device" recovery shape). Sessions carry no audit value, so hard-delete
|
|
586
|
+
// (same shape as deleteUser).
|
|
587
|
+
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
|
550
588
|
})();
|
|
551
589
|
return updated;
|
|
552
590
|
}
|
|
@@ -59,6 +59,34 @@ export function isLoopbackOrigin(origin: string): boolean {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Canonicalize a candidate public origin for use as the hub's OAuth issuer:
|
|
64
|
+
* strip trailing slashes, then accept it only when it parses as an absolute
|
|
65
|
+
* `http(s)` URL that is NOT loopback. Returns the canonical origin or
|
|
66
|
+
* undefined when it fails any check.
|
|
67
|
+
*
|
|
68
|
+
* Shared by the two expose-state issuer fallbacks (`resolveStartupIssuer` in
|
|
69
|
+
* commands/serve.ts and `exposeIssuerOrigin` in hub-server.ts) so the guard
|
|
70
|
+
* — never let a non-http(s) or loopback value pin the issuer — stays
|
|
71
|
+
* identical on both origin-resolution chokepoints (#531). The loopback
|
|
72
|
+
* rejection is defensive: expose-state.hubOrigin should always be the public
|
|
73
|
+
* origin, but a stray loopback value would re-pin the degraded
|
|
74
|
+
* request-origin mode the fix exists to escape.
|
|
75
|
+
*/
|
|
76
|
+
export function sanitizePublicOrigin(raw: string | undefined): string | undefined {
|
|
77
|
+
const trimmed = raw?.replace(/\/+$/, "");
|
|
78
|
+
if (!trimmed) return undefined;
|
|
79
|
+
let proto: string;
|
|
80
|
+
try {
|
|
81
|
+
proto = new URL(trimmed).protocol;
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
if (proto !== "http:" && proto !== "https:") return undefined;
|
|
86
|
+
if (isLoopbackOrigin(trimmed)) return undefined;
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
|
|
62
90
|
function vaultEnvPath(configDir: string): string {
|
|
63
91
|
return join(configDir, "vault", ".env");
|
|
64
92
|
}
|
package/src/vault-name.ts
CHANGED
|
@@ -25,7 +25,19 @@
|
|
|
25
25
|
* `/vault/list` endpoint.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Canonical vault-name charset: lowercase alphanumerics + hyphen/underscore.
|
|
30
|
+
* Exported as the single source of truth for the hub edge sites that mint /
|
|
31
|
+
* create vaults (item I) — `admin-vaults.ts`, `account-vault-token.ts`,
|
|
32
|
+
* `admin-vault-admin-token.ts` historically accepted `[a-zA-Z0-9_-]`, a
|
|
33
|
+
* superset of what vault's init enforces. The case drift was a real bug class:
|
|
34
|
+
* a hub-side `Work` would never match vault's URL-derived `work`, so the minted
|
|
35
|
+
* token's audience (`vault.Work`) wouldn't validate, and a created vault name
|
|
36
|
+
* could diverge from what vault persisted. Pinning every hub edge to THIS
|
|
37
|
+
* lowercase-only regex closes the drift.
|
|
38
|
+
*/
|
|
39
|
+
export const VAULT_NAME_CHARSET_RE = /^[a-z0-9_-]+$/;
|
|
40
|
+
const VAULT_NAME_RE = VAULT_NAME_CHARSET_RE;
|
|
29
41
|
const VAULT_NAME_MIN_LEN = 2;
|
|
30
42
|
const VAULT_NAME_MAX_LEN = 32;
|
|
31
43
|
|
|
@@ -1 +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}.hub-upgrade-card{border-left:3px solid var(--accent);margin-top:1.25rem}.hub-upgrade-card .warn-banner,.hub-upgrade-card .error-banner{flex-basis:100%;margin:.5rem 0 0;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}
|
|
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}.hub-upgrade-card{border-left:3px solid var(--accent);margin-top:1.25rem}.hub-upgrade-card .warn-banner,.hub-upgrade-card .error-banner{flex-basis:100%;margin:.5rem 0 0;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-redeemed{background:var(--success-soft);color:var(--success)}.status-expired,.status-revoked{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}
|