@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.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. 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 with the
544
- * same SpawnRequest. Used by the `/api/modules/:name/restart`
545
- * handler. The on-box `parachute restart <svc>` path stays on
546
- * `commands/lifecycle.ts` — different surface, different ownership.
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 (?, ?, 'write', ?)`,
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
- * Three writes inside one transaction:
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. Bump `updated_at` so the SPA's row reflects the rotation.
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 `grants.user_id` are NOT NULL with a
567
- * non-cascading FK. Both are deleted before the users row drops.
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. Both have non-cascading FKs on user_id;
596
- // leaving rows behind would RESTRICT the users delete below.
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
- const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
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}