@openparachute/hub 0.6.3 → 0.6.4-rc.2

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