@openparachute/hub 0.6.3 → 0.6.4-rc.10
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 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- 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 +236 -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 +195 -3
- 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__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -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__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- 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 +36 -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 +118 -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 +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- 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/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -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 +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- 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/supervisor.ts
CHANGED
|
@@ -34,14 +34,55 @@
|
|
|
34
34
|
* child state to disk (transient — re-derived from services.json on every boot).
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
37
38
|
import {
|
|
38
39
|
MissingDependencyError,
|
|
39
40
|
type MissingDependencyWire,
|
|
40
41
|
ensureExecutable,
|
|
41
42
|
rethrowIfMissing,
|
|
42
43
|
} from "@openparachute/depcheck";
|
|
44
|
+
import { defaultPidOnPort } from "./hub-control.ts";
|
|
43
45
|
import { type PortListeningFn, defaultPortListening } from "./port-probe.ts";
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Which pid (if any) holds a TCP LISTEN on `port`. Production wires
|
|
49
|
+
* `hub-control.ts:defaultPidOnPort` (an `lsof -ti :<port> -sTCP:LISTEN`
|
|
50
|
+
* shell-out, macOS + Linux); a box without `lsof` / on an unsupported platform
|
|
51
|
+
* returns undefined → the squatter check degrades gracefully (falls back to the
|
|
52
|
+
* existing started-but-unbound error). Injectable so tests stay deterministic.
|
|
53
|
+
*/
|
|
54
|
+
export type PidOnPortFn = (port: number) => number | undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort command line of a pid (the squatter-surfacing detail). Returns
|
|
58
|
+
* undefined when it can't be read; the message then omits the cmdline.
|
|
59
|
+
*/
|
|
60
|
+
export type OwnerProbeFn = (pid: number) => string | undefined;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Production `ownerOfPid`: `ps -o command= -p <pid>` → the process's full argv
|
|
64
|
+
* (one line). Mirrors `migrate-cutover.ts:defaultOwnerOfPid` (inlined rather
|
|
65
|
+
* than imported to keep the supervisor off the heavy command-module graph).
|
|
66
|
+
* Any failure (no `ps`, pid gone, permission, garbage) → undefined, so the
|
|
67
|
+
* squatter message degrades to "command line unavailable".
|
|
68
|
+
*/
|
|
69
|
+
export const defaultOwnerOfPid: OwnerProbeFn = (pid) => {
|
|
70
|
+
try {
|
|
71
|
+
const result = spawnSync("ps", ["-o", "command=", "-p", String(pid)], {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
timeout: 2000,
|
|
74
|
+
});
|
|
75
|
+
if (result.status !== 0) return undefined;
|
|
76
|
+
const line = result.stdout
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((s) => s.trim())
|
|
79
|
+
.find((s) => s.length > 0);
|
|
80
|
+
return line === undefined || line.length === 0 ? undefined : line;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
45
86
|
export type ModuleStatus = "starting" | "running" | "stopped" | "crashed" | "restarting";
|
|
46
87
|
|
|
47
88
|
/**
|
|
@@ -196,6 +237,19 @@ export interface SupervisorOpts {
|
|
|
196
237
|
readonly startReadyMs?: number;
|
|
197
238
|
/** Poll interval while waiting for the port to bind, in ms. Default 200. */
|
|
198
239
|
readonly startReadyPollMs?: number;
|
|
240
|
+
/**
|
|
241
|
+
* How long the background late-bind watch keeps re-probing AFTER the
|
|
242
|
+
* readiness window elapsed with the port unbound, in ms. Heavy modules
|
|
243
|
+
* (vault — SQLite + git mirror + well-known init) can legitimately take
|
|
244
|
+
* longer than `startReadyMs` to bind; without a re-probe the recorded
|
|
245
|
+
* `started_but_unbound` note sticks for the module's whole lifetime and
|
|
246
|
+
* `parachute status` shows a perpetual "failed to start" on a healthy
|
|
247
|
+
* module. The watch clears the note once the port binds. Default 60s;
|
|
248
|
+
* `0` disables the watch (the note then behaves as before).
|
|
249
|
+
*/
|
|
250
|
+
readonly lateBindWatchMs?: number;
|
|
251
|
+
/** Poll interval for the late-bind watch, in ms. Default 1000. */
|
|
252
|
+
readonly lateBindPollMs?: number;
|
|
199
253
|
/**
|
|
200
254
|
* PATH-resolution seam for the pre-spawn `ensureExecutable` preflight
|
|
201
255
|
* (`@openparachute/depcheck`). Production uses the real `Bun.which`; a
|
|
@@ -208,6 +262,29 @@ export interface SupervisorOpts {
|
|
|
208
262
|
* Tests exercising the missing-binary branch inject `which: () => null`.
|
|
209
263
|
*/
|
|
210
264
|
readonly which?: (cmd: string) => string | null;
|
|
265
|
+
/**
|
|
266
|
+
* Pre-spawn port-squatter detection (#580 item 4). Returns the pid holding a
|
|
267
|
+
* TCP LISTEN on the module's port, or undefined when the port is free /
|
|
268
|
+
* undetectable. Before spawning a module, the supervisor checks whether the
|
|
269
|
+
* declared port is already held by a pid it does NOT own (not one of its live
|
|
270
|
+
* children). If so it records a structured `port_squatter` start-error with
|
|
271
|
+
* an actionable message and DOES NOT spawn — so a rogue process holding the
|
|
272
|
+
* port (the #580 field signature: a bare `vault/src/server.ts` outside the
|
|
273
|
+
* supervisor on :1940) surfaces in `status` instead of the supervised child
|
|
274
|
+
* EADDRINUSE-crash-looping into a bare `supervisor: crashed`.
|
|
275
|
+
*
|
|
276
|
+
* Detection ONLY — never auto-kills (that's an operator's unrelated process).
|
|
277
|
+
* Defaults to `hub-control.ts:defaultPidOnPort` on the production path; the
|
|
278
|
+
* stub-spawner test path defaults to "no squatter" (returns undefined) so
|
|
279
|
+
* existing fake-proc tests are unaffected unless they inject this explicitly.
|
|
280
|
+
*/
|
|
281
|
+
readonly pidOnPort?: PidOnPortFn;
|
|
282
|
+
/**
|
|
283
|
+
* Best-effort cmdline probe for the squatter pid (the actionable message
|
|
284
|
+
* detail). Defaults to {@link defaultOwnerOfPid} on the production path; the
|
|
285
|
+
* stub-spawner test path defaults to "unknown" (returns undefined).
|
|
286
|
+
*/
|
|
287
|
+
readonly ownerOfPid?: OwnerProbeFn;
|
|
211
288
|
}
|
|
212
289
|
|
|
213
290
|
/**
|
|
@@ -242,6 +319,8 @@ const DEFAULT_KILL_TIMEOUT_MS = 5_000;
|
|
|
242
319
|
const DEFAULT_LOG_BUFFER_BYTES = 64 * 1024;
|
|
243
320
|
const DEFAULT_START_READY_MS = 4_000;
|
|
244
321
|
const DEFAULT_START_READY_POLL_MS = 200;
|
|
322
|
+
const DEFAULT_LATE_BIND_WATCH_MS = 60_000;
|
|
323
|
+
const DEFAULT_LATE_BIND_POLL_MS = 1_000;
|
|
245
324
|
|
|
246
325
|
/**
|
|
247
326
|
* Bounded, line-oriented ring buffer (§6.5). Holds the most-recent lines of a
|
|
@@ -318,7 +397,15 @@ export class Supervisor {
|
|
|
318
397
|
startReadyMs:
|
|
319
398
|
opts.startReadyMs ?? (isProductionPath || readinessOptedIn ? DEFAULT_START_READY_MS : 0),
|
|
320
399
|
startReadyPollMs: opts.startReadyPollMs ?? DEFAULT_START_READY_POLL_MS,
|
|
400
|
+
lateBindWatchMs: opts.lateBindWatchMs ?? DEFAULT_LATE_BIND_WATCH_MS,
|
|
401
|
+
lateBindPollMs: opts.lateBindPollMs ?? DEFAULT_LATE_BIND_POLL_MS,
|
|
321
402
|
which: opts.which ?? (isProductionPath ? Bun.which : () => "/stub/bin/preflight-skipped"),
|
|
403
|
+
// Squatter detection (#580 item 4): real probes on the production path;
|
|
404
|
+
// the stub-spawner test path defaults to "no squatter / unknown owner" so
|
|
405
|
+
// fake-proc tests (which never hold a real port) aren't tripped. Tests
|
|
406
|
+
// opt in by injecting `pidOnPort` / `ownerOfPid`.
|
|
407
|
+
pidOnPort: opts.pidOnPort ?? (isProductionPath ? defaultPidOnPort : () => undefined),
|
|
408
|
+
ownerOfPid: opts.ownerOfPid ?? (isProductionPath ? defaultOwnerOfPid : () => undefined),
|
|
322
409
|
};
|
|
323
410
|
}
|
|
324
411
|
|
|
@@ -372,6 +459,25 @@ export class Supervisor {
|
|
|
372
459
|
}
|
|
373
460
|
}
|
|
374
461
|
|
|
462
|
+
// Pre-spawn port-squatter detection (#580 item 4). If the module's declared
|
|
463
|
+
// port is already held by a process the supervisor does NOT own (not one of
|
|
464
|
+
// its live children), spawning would EADDRINUSE-crash-loop the child into a
|
|
465
|
+
// bare `supervisor: crashed` with no clue why. Detect the foreign holder and
|
|
466
|
+
// record a structured, actionable `port_squatter` start-error INSTEAD of
|
|
467
|
+
// spawning — the operator sees the offending pid + cmdline + a copy-paste
|
|
468
|
+
// recovery in `status` / the SPA. Detection only: we never kill someone
|
|
469
|
+
// else's process (it may be the operator's unrelated dev server).
|
|
470
|
+
const squatter = this.detectPortSquatter(entry);
|
|
471
|
+
if (squatter) {
|
|
472
|
+
entry.state = {
|
|
473
|
+
...entry.state,
|
|
474
|
+
status: "crashed",
|
|
475
|
+
pid: undefined,
|
|
476
|
+
startError: squatter,
|
|
477
|
+
};
|
|
478
|
+
return entry.state;
|
|
479
|
+
}
|
|
480
|
+
|
|
375
481
|
// Belt-and-suspenders for a spawn that slips past the preflight (binary
|
|
376
482
|
// removed between check + spawn, or a path that didn't preflight): a
|
|
377
483
|
// not-found spawn throw becomes the same structured MissingDependencyError
|
|
@@ -411,6 +517,65 @@ export class Supervisor {
|
|
|
411
517
|
return entry.state;
|
|
412
518
|
}
|
|
413
519
|
|
|
520
|
+
/**
|
|
521
|
+
* The set of pids the supervisor currently owns AND that are still alive — its
|
|
522
|
+
* live children's pids. Used by the squatter check to decide whether a process
|
|
523
|
+
* holding a module's port is "ours" (a re-probe of our own just-spawned child,
|
|
524
|
+
* or a sibling) vs a foreign rogue.
|
|
525
|
+
*
|
|
526
|
+
* Liveness guard (N1): `entry.proc` is NEVER cleared on exit (`handleExit`
|
|
527
|
+
* only updates `entry.state`), so a recycled OS pid could otherwise be
|
|
528
|
+
* misclassified as "our own child" and wrongly excused from the squatter
|
|
529
|
+
* check. We therefore only count an entry whose child is actually running —
|
|
530
|
+
* `state.status` is `running` or `starting`. A `crashed` / `restarting` /
|
|
531
|
+
* `stopped` module's recorded pid is stale (the process is gone or being
|
|
532
|
+
* replaced) and must not vouch for whoever now holds the port. An entry with
|
|
533
|
+
* no `proc` (never spawned) contributes no pid either.
|
|
534
|
+
*/
|
|
535
|
+
private supervisedPids(): Set<number> {
|
|
536
|
+
const pids = new Set<number>();
|
|
537
|
+
for (const entry of this.modules.values()) {
|
|
538
|
+
if (entry.state.status !== "running" && entry.state.status !== "starting") continue;
|
|
539
|
+
const pid = entry.proc?.pid;
|
|
540
|
+
if (typeof pid === "number" && pid > 0) pids.add(pid);
|
|
541
|
+
}
|
|
542
|
+
return pids;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Pre-spawn port-squatter check (#580 item 4). Returns a structured
|
|
547
|
+
* `port_squatter` start-error when the module's declared port is held by a
|
|
548
|
+
* process the supervisor does NOT own; undefined when the port is free, the
|
|
549
|
+
* holder is one of our own children, or detection isn't available on this
|
|
550
|
+
* platform (no `lsof` → `pidOnPort` returns undefined → we degrade to the
|
|
551
|
+
* existing started-but-unbound path post-spawn).
|
|
552
|
+
*
|
|
553
|
+
* Ownership precedent mirrors `migrate-cutover.ts:sweepOrphanOnPort`'s "is
|
|
554
|
+
* this mine?" check — here the discriminant is "is the holder one of my live
|
|
555
|
+
* children's pids?". We deliberately do NOT kill the holder (detection only):
|
|
556
|
+
* a foreign pid on a module port may be the operator's unrelated process.
|
|
557
|
+
*/
|
|
558
|
+
private detectPortSquatter(entry: ModuleEntry): ModuleStartError | undefined {
|
|
559
|
+
const portStr = entry.req.env?.PORT;
|
|
560
|
+
const port = portStr ? Number(portStr) : Number.NaN;
|
|
561
|
+
if (!Number.isFinite(port) || port <= 0) return undefined; // No declared port.
|
|
562
|
+
|
|
563
|
+
const holder = this.opts.pidOnPort(port);
|
|
564
|
+
if (holder === undefined) return undefined; // Port free, or detection unavailable.
|
|
565
|
+
if (this.supervisedPids().has(holder)) return undefined; // Our own child.
|
|
566
|
+
|
|
567
|
+
const cmdline = this.opts.ownerOfPid(holder);
|
|
568
|
+
const who = cmdline ? `pid ${holder} (${cmdline})` : `pid ${holder}`;
|
|
569
|
+
const short = entry.req.short;
|
|
570
|
+
return {
|
|
571
|
+
error_type: "port_squatter",
|
|
572
|
+
error_description:
|
|
573
|
+
`port ${port} is held by ${who} outside the supervisor — ` +
|
|
574
|
+
`kill it and retry: kill ${holder} && parachute start ${short}`,
|
|
575
|
+
at: new Date(this.opts.now()).toISOString(),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
414
579
|
/**
|
|
415
580
|
* Poll the module's port until it binds or `startReadyMs` elapses (§6.5).
|
|
416
581
|
* Skipped when the gate is disabled (stub-spawner test path) or the request
|
|
@@ -453,6 +618,44 @@ export class Supervisor {
|
|
|
453
618
|
at: new Date(this.opts.now()).toISOString(),
|
|
454
619
|
},
|
|
455
620
|
};
|
|
621
|
+
// Keep watching in the background: heavy modules (vault) routinely bind
|
|
622
|
+
// a moment after the window. Without the re-probe the note above would
|
|
623
|
+
// stick for the module's whole lifetime — `parachute status` then shows
|
|
624
|
+
// a perpetual "failed to start" on a healthy module. Fire-and-forget so
|
|
625
|
+
// `start()`'s latency stays bounded by `startReadyMs`.
|
|
626
|
+
if (this.opts.lateBindWatchMs > 0) {
|
|
627
|
+
void this.lateBindWatch(entry, port).catch(() => {});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Background re-probe after `awaitPortReadiness` recorded a
|
|
634
|
+
* `started_but_unbound` note: poll (slower cadence, bounded window) and
|
|
635
|
+
* clear the note once the port binds. Exits early when the module stops,
|
|
636
|
+
* crashes, or the note is replaced by a different startError (a later
|
|
637
|
+
* crash-restart's missing-dependency note must not be wiped).
|
|
638
|
+
*
|
|
639
|
+
* Restart safety: a crash-auto-restart reuses the same `entry` object and
|
|
640
|
+
* clears `startError` on respawn — this watch's `error_type` guard then sees
|
|
641
|
+
* `undefined` and exits, so a stale watch from spawn-1 never clobbers
|
|
642
|
+
* spawn-2's state. If spawn-2 also misses its window, its own gate records a
|
|
643
|
+
* fresh note and launches its own watch; two live watches clearing the same
|
|
644
|
+
* note is idempotent. `stop()`/teardown set `stopRequested` before any entry
|
|
645
|
+
* removal, so a watch holding a stale entry ref exits cleanly.
|
|
646
|
+
*/
|
|
647
|
+
private async lateBindWatch(entry: ModuleEntry, port: number): Promise<void> {
|
|
648
|
+
const deadline = this.opts.now() + this.opts.lateBindWatchMs;
|
|
649
|
+
while (this.opts.now() < deadline) {
|
|
650
|
+
await this.opts.sleep(this.opts.lateBindPollMs);
|
|
651
|
+
if (entry.stopRequested || entry.state.status !== "running") return;
|
|
652
|
+
// Only OUR note is clearable — anything else was recorded after us.
|
|
653
|
+
if (entry.state.startError?.error_type !== "started_but_unbound") return;
|
|
654
|
+
if (await this.opts.portListening(port)) {
|
|
655
|
+
const { startError: _drop, ...rest } = entry.state;
|
|
656
|
+
entry.state = rest;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
456
659
|
}
|
|
457
660
|
}
|
|
458
661
|
|
|
@@ -540,21 +743,43 @@ export class Supervisor {
|
|
|
540
743
|
}
|
|
541
744
|
|
|
542
745
|
/**
|
|
543
|
-
* Restart a supervised module: stop, wait for exit, start
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
746
|
+
* Restart a supervised module: stop, wait for exit, start again. Used by
|
|
747
|
+
* the `/api/modules/:name/restart` handler. The on-box
|
|
748
|
+
* `parachute restart <svc>` path stays on `commands/lifecycle.ts` —
|
|
749
|
+
* different surface, different ownership.
|
|
750
|
+
*
|
|
751
|
+
* `nextReq` (hub#532): when the caller supplies a freshly-rebuilt
|
|
752
|
+
* SpawnRequest (current `PARACHUTE_HUB_ORIGIN` / enriched PATH /
|
|
753
|
+
* re-resolved cwd), the re-spawn uses it AND it becomes the entry's new
|
|
754
|
+
* `req` — so subsequent CRASH-restarts (`handleExit` → `spawnAndWatch`,
|
|
755
|
+
* which reuse `entry.req`) also carry the refreshed env, not the original
|
|
756
|
+
* first-start snapshot. When omitted, the prior `entry.req` is replayed
|
|
757
|
+
* (legacy behavior, e.g. an internal restart with no state change).
|
|
758
|
+
*
|
|
759
|
+
* `nextReq.short` MUST match `short`: `start(req)` keys the supervisor map
|
|
760
|
+
* on `req.short`, so a mismatch would silently register the restarted
|
|
761
|
+
* module under the WRONG key (orphaning the original entry + breaking every
|
|
762
|
+
* subsequent `get`/`stop`/`restart` lookup). Throws on mismatch rather than
|
|
763
|
+
* trusting the caller — a one-line invariant that turns a silent
|
|
764
|
+
* state-corruption bug into a loud one.
|
|
547
765
|
*/
|
|
548
|
-
async restart(short: string): Promise<ModuleState | undefined> {
|
|
766
|
+
async restart(short: string, nextReq?: SpawnRequest): Promise<ModuleState | undefined> {
|
|
767
|
+
if (nextReq && nextReq.short !== short) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`restart(${short}): nextReq.short is "${nextReq.short}" — it must match the restarted short or the module re-registers under the wrong key`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
549
772
|
const entry = this.modules.get(short);
|
|
550
773
|
if (!entry) return undefined;
|
|
551
|
-
const req = entry.req;
|
|
774
|
+
const req = nextReq ?? entry.req;
|
|
552
775
|
entry.state = { ...entry.state, status: "restarting" };
|
|
553
776
|
// stop() now awaits the prior process's exit (with SIGKILL
|
|
554
777
|
// escalation) before returning, so the fresh spawn below doesn't
|
|
555
778
|
// race on EADDRINUSE — no separate await needed here.
|
|
556
779
|
await this.stop(short);
|
|
557
|
-
// Drop the entry so `start` treats this as a clean spawn.
|
|
780
|
+
// Drop the entry so `start` treats this as a clean spawn. `start` stores
|
|
781
|
+
// `req` as the new entry's `req`, so a refreshed `nextReq` propagates to
|
|
782
|
+
// the crash-restart path too.
|
|
558
783
|
this.modules.delete(short);
|
|
559
784
|
return this.start(req);
|
|
560
785
|
}
|
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
|
}
|
|
@@ -563,8 +601,10 @@ export async function resetUserPassword(
|
|
|
563
601
|
* parent users row. The audit trail survives via the `subject`
|
|
564
602
|
* column we backfill from the username plus the existing
|
|
565
603
|
* `created_at`, `scopes`, `client_id`, `revoked_at` fields.
|
|
566
|
-
* - `sessions.user_id` and `
|
|
567
|
-
* non-cascading FK.
|
|
604
|
+
* - `sessions.user_id`, `grants.user_id`, and `auth_codes.user_id` are
|
|
605
|
+
* NOT NULL with a non-cascading (RESTRICT) FK. All three are deleted
|
|
606
|
+
* before the users row drops — auth_codes are ephemeral OAuth codes
|
|
607
|
+
* (60s TTL, no audit value), so a hard-delete is correct (hub#559).
|
|
568
608
|
* - `user_vaults.user_id` has `ON DELETE CASCADE` (migration v10), so
|
|
569
609
|
* vault assignments are dropped automatically when the parent row
|
|
570
610
|
* goes. No explicit cleanup needed.
|
|
@@ -592,10 +632,16 @@ export function deleteUser(db: Database, userId: string): boolean {
|
|
|
592
632
|
db.prepare(
|
|
593
633
|
"UPDATE tokens SET subject = COALESCE(subject, ?), user_id = NULL WHERE user_id = ?",
|
|
594
634
|
).run(row.username, userId);
|
|
595
|
-
// 2. Drop sessions + grants.
|
|
596
|
-
// leaving rows behind
|
|
635
|
+
// 2. Drop sessions + grants + auth_codes. All have NOT-NULL, non-cascading
|
|
636
|
+
// (RESTRICT) FKs on user_id; leaving rows behind blocks the users delete
|
|
637
|
+
// below with SQLITE_CONSTRAINT_FOREIGNKEY. auth_codes are short-lived
|
|
638
|
+
// (60s TTL) OAuth authorization codes with no audit value — hard-delete,
|
|
639
|
+
// same as sessions. (Omitting this 500'd a real delete of a user who had
|
|
640
|
+
// completed an OAuth authorize: the code row outlived its TTL but still
|
|
641
|
+
// pinned the FK. hub#559.)
|
|
597
642
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
|
598
643
|
db.prepare("DELETE FROM grants WHERE user_id = ?").run(userId);
|
|
644
|
+
db.prepare("DELETE FROM auth_codes WHERE user_id = ?").run(userId);
|
|
599
645
|
// 3. Drop the user row itself.
|
|
600
646
|
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
|
601
647
|
})();
|
|
@@ -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
|
|
package/src/well-known.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR } from "./config.ts";
|
|
4
4
|
import { type ModuleManifest, readModuleManifest } from "./module-manifest.ts";
|
|
5
|
+
import { SEED_VERSION } from "./service-spec.ts";
|
|
5
6
|
import {
|
|
6
7
|
type ServiceEntry,
|
|
7
8
|
type UiSubUnit,
|
|
@@ -315,6 +316,18 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
|
|
|
315
316
|
}
|
|
316
317
|
doc.services.push(entry);
|
|
317
318
|
if (isVault) {
|
|
319
|
+
// hub#577: don't fabricate a phantom vault row from a SEED placeholder.
|
|
320
|
+
// `parachute init` installs the vault MODULE without creating an
|
|
321
|
+
// instance (hub#168 Cut 1: `noCreate`), seeding a services.json entry
|
|
322
|
+
// at SEED_VERSION with the canonical `/vault/default` mount. Vault's
|
|
323
|
+
// own boot overwrites that entry with the real instance path(s) once a
|
|
324
|
+
// vault is actually created. Until then, emitting a `vaults[]` row here
|
|
325
|
+
// makes the management page show a `default` vault that doesn't exist —
|
|
326
|
+
// it vanishes the moment a real vault registers. Keep the `services`
|
|
327
|
+
// entry (so the SPA knows the module IS installed and offers "New
|
|
328
|
+
// vault" rather than "Install module"), but suppress the vault row so
|
|
329
|
+
// the list honestly reads "No vaults yet."
|
|
330
|
+
if (s.version === SEED_VERSION) continue;
|
|
318
331
|
const managementUrl = opts.managementUrlFor?.(s);
|
|
319
332
|
const entry: WellKnownVaultEntry = {
|
|
320
333
|
name: vaultInstanceNameFor(s.name, path),
|
|
@@ -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}
|