@openparachute/hub 0.6.5-rc.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
@@ -1,421 +0,0 @@
1
- /**
2
- * `/api/modules/:short/config[/schema]` — admin SPA's module-config surface.
3
- *
4
- * The admin SPA renders a generic per-module config form at
5
- * `/admin/modules/<short>/config`. It fetches three things off this hub-side
6
- * endpoint:
7
- *
8
- * - `GET /api/modules/:short/config/schema` → the module's draft-07 JSON
9
- * Schema (`{type:"object", properties:{...}, required:[...]}`).
10
- * - `GET /api/modules/:short/config` → the module's current
11
- * resolved values (keys present in the schema; `writeOnly` keys omitted).
12
- * - `PUT /api/modules/:short/config` → write new values; module
13
- * validates against its own schema and 4xx's on shape errors.
14
- *
15
- * Hub doesn't own the schema or the values — it just proxies to the module's
16
- * own runtime endpoints (`/.parachute/config/schema`, `/.parachute/config`).
17
- * Two reasons to wrap rather than expose the proxy directly:
18
- *
19
- * 1. **Scope translation (Option A).** Modules enforce per-module scopes
20
- * on `/.parachute/config*` (e.g. scribe requires `scribe:admin`). The
21
- * admin SPA's session-derived bearer carries `parachute:host:admin`,
22
- * not `<short>:admin`. We mint a fresh short-lived `<short>:admin`
23
- * JWT at proxy time so the upstream auth gate is satisfied without
24
- * handing the operator a permanent module-scoped bearer.
25
- * 2. **Curated set + clean errors.** We restrict the surface to
26
- * `CURATED_MODULES` (vault / notes / scribe) and surface a clean
27
- * "module not installed" / "module has no config schema" empty state
28
- * rather than the upstream's raw 404. The admin UI gets a consistent
29
- * contract across modules even if individual modules drift on shape.
30
- *
31
- * Bearer-gated on `parachute:host:admin` (same scope as install / upgrade —
32
- * config writes are destructive operator-only state changes). A read-only
33
- * `parachute:host:auth` token gets 403 here. The SPA's host-admin mint at
34
- * `/admin/host-admin-token` carries both scopes so the SPA path works.
35
- *
36
- * Option A vs B trade-off (Aaron's hub#260 brief): hub mints a one-shot
37
- * `<short>:admin` JWT (audience = module short, ttl = 60s) and proxies
38
- * the request with that bearer. The alternative (modules accept
39
- * `parachute:host:admin` as a master scope) would centralize the override
40
- * in the wrong place — each module would need to know hub's scope vocabulary
41
- * and the master-scope concept would creep into module auth surfaces. The
42
- * mint-and-forward shape keeps every module ignorant of hub's session model
43
- * — they enforce their own scope as if a real `<short>:admin` token came
44
- * over the wire, which is exactly what hub gave them.
45
- */
46
-
47
- import type { Database } from "bun:sqlite";
48
- import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
49
- import { signAccessToken, validateAccessToken } from "./jwt-sign.ts";
50
- import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
51
- import { readManifestLenient } from "./services-manifest.ts";
52
-
53
- /**
54
- * Resolve a curated short to its services.json `manifestName` key. Consults
55
- * both FIRST_PARTY_FALLBACKS (notes / channel) and KNOWN_MODULES
56
- * (vault / scribe / runner — post-FALLBACK retirement, hub#310). Returns
57
- * undefined when the short is unknown (shouldn't happen in this file — the
58
- * parsing layer restricts to CURATED_MODULES).
59
- */
60
- function manifestNameForShort(short: string): string | undefined {
61
- return FIRST_PARTY_FALLBACKS[short]?.manifest.manifestName ?? KNOWN_MODULES[short]?.manifestName;
62
- }
63
-
64
- /**
65
- * Vendored fallback paths for the FIRST_PARTY_FALLBACKS shorts (notes /
66
- * channel). KNOWN_MODULES shorts (vault / scribe / runner) don't carry
67
- * vendored paths — they self-register and services.json is authoritative;
68
- * absent a services.json entry the module is "not installed."
69
- */
70
- function fallbackPathsForShort(short: string): readonly string[] | undefined {
71
- return FIRST_PARTY_FALLBACKS[short]?.manifest.paths;
72
- }
73
-
74
- function fallbackStripPrefixForShort(short: string): boolean | undefined {
75
- return FIRST_PARTY_FALLBACKS[short]?.manifest.stripPrefix;
76
- }
77
-
78
- /** Scope required on the SPA's bearer to call any of these endpoints. */
79
- export const API_MODULES_CONFIG_REQUIRED_SCOPE = "parachute:host:admin";
80
-
81
- /** TTL on the minted module-scoped JWT we forward upstream. */
82
- export const MODULE_CONFIG_PROXY_TOKEN_TTL_SECONDS = 60;
83
-
84
- /** client_id stamped on the minted proxy token. Audit-friendly. */
85
- export const MODULE_CONFIG_PROXY_CLIENT_ID = "parachute-hub-module-config-proxy";
86
-
87
- export interface ApiModulesConfigDeps {
88
- db: Database;
89
- /** Hub origin — sets `iss` on the minted proxy token AND validates the SPA bearer. */
90
- issuer: string;
91
- /** services.json path. Module-mount + port come from here. */
92
- manifestPath: string;
93
- /**
94
- * Loopback fetch — production calls `fetch()`; tests inject a fake that
95
- * returns a canned Response without binding a port. Defaults to global
96
- * `fetch`.
97
- */
98
- upstreamFetch?: (url: string, init: RequestInit) => Promise<Response>;
99
- /** Test seam over wall-clock — passed through to `signAccessToken`. */
100
- now?: () => Date;
101
- }
102
-
103
- interface PathMatch {
104
- short: CuratedModuleShort;
105
- /** `""` for `/api/modules/<short>/config`, `"schema"` for `.../schema`. */
106
- suffix: "" | "schema";
107
- }
108
-
109
- /**
110
- * Parse `/api/modules/<short>/config` or `/api/modules/<short>/config/schema`.
111
- * Returns undefined for any other shape so the caller can fall through to
112
- * other `/api/modules/...` handlers (install / upgrade / etc.).
113
- */
114
- export function parseModulesConfigPath(pathname: string): PathMatch | undefined {
115
- const prefix = "/api/modules/";
116
- if (!pathname.startsWith(prefix)) return undefined;
117
- const tail = pathname.slice(prefix.length);
118
- // Accept exactly `<short>/config` or `<short>/config/schema`.
119
- const m = tail.match(/^([a-z][a-z0-9-]*)\/config(\/schema)?$/);
120
- if (!m) return undefined;
121
- const short = m[1];
122
- if (!CURATED_MODULES.includes(short as CuratedModuleShort)) return undefined;
123
- return {
124
- short: short as CuratedModuleShort,
125
- suffix: m[2] ? "schema" : "",
126
- };
127
- }
128
-
129
- /**
130
- * Look up a module's upstream `http://127.0.0.1:<port>/<mount>` base URL.
131
- * Returns `{installed: false}` when the module isn't in services.json —
132
- * the SPA renders an empty state pointing the operator at /admin/modules
133
- * to install it first.
134
- *
135
- * `hostsBareParachute` is true when the module declares `/.parachute` in
136
- * its `paths[]`, meaning it serves the universal module-protocol endpoints
137
- * at the bare URL (no module-name prefix) — runner is the first example.
138
- * This is independent of `stripPrefix`: runner ships
139
- * `paths: ["/runner", "/.parachute"]` with `stripPrefix: false` because its
140
- * `/runner/jobs` admin endpoints want the literal `/runner` prefix, but
141
- * `/.parachute/config` is hosted bare. See `buildUpstreamPath`.
142
- */
143
- function resolveUpstream(
144
- short: CuratedModuleShort,
145
- manifestPath: string,
146
- ):
147
- | {
148
- installed: true;
149
- port: number;
150
- mount: string;
151
- stripPrefix: boolean;
152
- hostsBareParachute: boolean;
153
- }
154
- | { installed: false } {
155
- const manifestName = manifestNameForShort(short);
156
- if (!manifestName) return { installed: false };
157
- // Lenient — see hub#406.
158
- const manifest = readManifestLenient(manifestPath);
159
- const entry = manifest.services.find((s) => s.name === manifestName);
160
- if (!entry) return { installed: false };
161
- // Mount = the first path the service registers (canonical convention
162
- // matches `findServiceUpstream` in hub-server.ts). Strip prefix mirrors
163
- // the proxy's `stripPrefixFor` — explicit on-entry wins, fallback supplies
164
- // the default. We compute it here rather than threading the proxy helper
165
- // because we're constructing the upstream URL ourselves, not piggy-backing
166
- // on `proxyRequest`.
167
- //
168
- // KNOWN_MODULES shorts (vault / scribe / runner) self-register their
169
- // canonical `paths` + `stripPrefix` into the entry on boot, so the
170
- // fallback-paths consultation below is a no-op for them — only notes /
171
- // channel still need the vendored fallback shape.
172
- const fbPaths = fallbackPathsForShort(short);
173
- const fbStripPrefix = fallbackStripPrefixForShort(short);
174
- const mount = entry.paths[0] ?? fbPaths?.[0] ?? "/";
175
- const stripPrefix =
176
- entry.stripPrefix !== undefined ? entry.stripPrefix : (fbStripPrefix ?? false);
177
- // Check both the live services.json entry (operator-authoritative) and the
178
- // vendored fallback (so a `bun link` install without a written entry still
179
- // routes correctly for notes / channel). Match a trailing slash too —
180
- // `["/.parachute/"]` is the same intent as `["/.parachute"]`.
181
- const isBareParachute = (p: string): boolean =>
182
- p === "/.parachute" || p === "/.parachute/" || p.startsWith("/.parachute/");
183
- const hostsBareParachute =
184
- entry.paths.some(isBareParachute) || (fbPaths?.some(isBareParachute) ?? false);
185
- return { installed: true, port: entry.port, mount, stripPrefix, hostsBareParachute };
186
- }
187
-
188
- /**
189
- * Build the upstream URL for `.parachute/config[/schema]`.
190
- *
191
- * The `/.parachute/*` endpoints (info, config, config/schema, clear-credential)
192
- * are the **universal module protocol** — every module speaks them, and the
193
- * shape they take depends on how the module exposes its mount(s):
194
- *
195
- * 1. **Module declares `/.parachute` in its `paths[]`** (runner-shape):
196
- * the module hosts the bare URL `/.parachute/config[/schema]` directly
197
- * and the proxy forwards there with no prefix, regardless of
198
- * `stripPrefix`. This is the explicit "I serve the universal endpoints
199
- * at the bare URL" declaration.
200
- * 2. **`stripPrefix: true`** (scribe-shape): the proxy strips the module
201
- * mount on every request, so the bare `/.parachute/config[/schema]`
202
- * is what the module sees on the wire — same result as case 1.
203
- * 3. **`stripPrefix: false` and no `/.parachute` in paths** (vault/notes-
204
- * shape): the proxy preserves the mount prefix
205
- * (`/vault/default/.parachute/config`). Vault routes its
206
- * `.parachute/config` per-vault, scoped under the `/vault/<name>` mount,
207
- * so it explicitly NEEDS the prefix to know which vault the request
208
- * targets.
209
- *
210
- * Case 1 was the gap that hub#307 fixed: runner ships
211
- * `paths: ["/runner", "/.parachute"]` with `stripPrefix: false`. Before this
212
- * fix, the proxy built `/runner/.parachute/config` because it only saw
213
- * `paths[0]` and the stripPrefix flag — runner's HTTP server matches
214
- * `/.parachute/config` literally and 404'd. Detecting the `/.parachute`
215
- * declaration in `paths[]` lets runner (and any future module with the same
216
- * shape) route correctly without affecting vault.
217
- */
218
- function buildUpstreamPath(
219
- mount: string,
220
- stripPrefix: boolean,
221
- hostsBareParachute: boolean,
222
- suffix: "" | "schema",
223
- ): string {
224
- const inner = suffix === "schema" ? "/.parachute/config/schema" : "/.parachute/config";
225
- // Universal-protocol short-circuit: a module that declares `/.parachute`
226
- // in its paths[] hosts the bare URL — same upstream path whether
227
- // stripPrefix is true or false.
228
- if (hostsBareParachute) return inner;
229
- if (stripPrefix) return inner;
230
- // Normalize trailing slash (mirrors `findServiceUpstream`'s normalization
231
- // so a `paths: ["/scribe/"]` entry doesn't double-slash).
232
- const norm = mount.replace(/\/+$/, "") || "";
233
- return `${norm}${inner}`;
234
- }
235
-
236
- /**
237
- * Validate the SPA's bearer + extract its scopes. Returns either an error
238
- * response or the parsed sub.
239
- */
240
- async function authorize(
241
- req: Request,
242
- deps: ApiModulesConfigDeps,
243
- ): Promise<Response | { sub: string }> {
244
- const auth = req.headers.get("authorization");
245
- if (!auth || !auth.startsWith("Bearer ")) {
246
- return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
247
- }
248
- const bearer = auth.slice("Bearer ".length).trim();
249
- if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
250
- try {
251
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
252
- const sub = validated.payload.sub;
253
- if (typeof sub !== "string" || sub.length === 0) {
254
- return jsonError(401, "unauthenticated", "bearer token has no sub claim");
255
- }
256
- const scopes =
257
- typeof validated.payload.scope === "string"
258
- ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
259
- : [];
260
- if (!scopes.includes(API_MODULES_CONFIG_REQUIRED_SCOPE)) {
261
- return jsonError(
262
- 403,
263
- "insufficient_scope",
264
- `bearer token lacks ${API_MODULES_CONFIG_REQUIRED_SCOPE}`,
265
- );
266
- }
267
- return { sub };
268
- } catch (err) {
269
- const msg = err instanceof Error ? err.message : String(err);
270
- return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
271
- }
272
- }
273
-
274
- /**
275
- * Mint a short-lived `<short>:admin` JWT to forward upstream. Re-uses the
276
- * existing `signAccessToken` plumbing (active signing key from the DB,
277
- * RS256, iss-stamped); audience = `short` so the module's audience check
278
- * passes. The token is NOT recorded in the tokens registry — it's a
279
- * one-shot proxy artifact, dies on its own in 60s, and we never hand it
280
- * to a caller.
281
- */
282
- async function mintProxyToken(
283
- short: CuratedModuleShort,
284
- sub: string,
285
- deps: ApiModulesConfigDeps,
286
- ): Promise<string> {
287
- const opts: Parameters<typeof signAccessToken>[1] = {
288
- sub,
289
- scopes: [`${short}:admin`],
290
- audience: short,
291
- clientId: MODULE_CONFIG_PROXY_CLIENT_ID,
292
- issuer: deps.issuer,
293
- ttlSeconds: MODULE_CONFIG_PROXY_TOKEN_TTL_SECONDS,
294
- };
295
- if (deps.now) opts.now = deps.now;
296
- const signed = await signAccessToken(deps.db, opts);
297
- return signed.token;
298
- }
299
-
300
- /**
301
- * Top-level dispatcher for `/api/modules/:short/config[/schema]`.
302
- *
303
- * - `GET /api/modules/:short/config/schema` → upstream `/.parachute/config/schema`
304
- * - `GET /api/modules/:short/config` → upstream `/.parachute/config`
305
- * - `PUT /api/modules/:short/config` → upstream `/.parachute/config` (PUT)
306
- *
307
- * Other verbs return 405.
308
- */
309
- export async function handleApiModulesConfig(
310
- req: Request,
311
- match: PathMatch,
312
- deps: ApiModulesConfigDeps,
313
- ): Promise<Response> {
314
- // Method gate per route. PUT only valid on the bare `config` path.
315
- if (match.suffix === "schema") {
316
- if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
317
- } else {
318
- if (req.method !== "GET" && req.method !== "PUT") {
319
- return jsonError(405, "method_not_allowed", "use GET or PUT");
320
- }
321
- }
322
-
323
- // Auth.
324
- const authOut = await authorize(req, deps);
325
- if (authOut instanceof Response) return authOut;
326
- const { sub } = authOut;
327
-
328
- // Resolve upstream from services.json. Not-installed = clean empty state
329
- // so the SPA can prompt the operator to install first.
330
- const upstream = resolveUpstream(match.short, deps.manifestPath);
331
- if (!upstream.installed) {
332
- return jsonError(
333
- 404,
334
- "module_not_installed",
335
- `module "${match.short}" is not installed; visit /admin/modules to install it first`,
336
- );
337
- }
338
-
339
- // Mint the per-request `<short>:admin` proxy token (Option A).
340
- let proxyToken: string;
341
- try {
342
- proxyToken = await mintProxyToken(match.short, sub, deps);
343
- } catch (err) {
344
- const msg = err instanceof Error ? err.message : String(err);
345
- return jsonError(500, "mint_failed", `failed to mint proxy token — ${msg}`);
346
- }
347
-
348
- // Build upstream URL.
349
- const path = buildUpstreamPath(
350
- upstream.mount,
351
- upstream.stripPrefix,
352
- upstream.hostsBareParachute,
353
- match.suffix,
354
- );
355
- const url = `http://127.0.0.1:${upstream.port}${path}`;
356
-
357
- // Forward. We carry method + body through. The SPA's Authorization
358
- // header is dropped (it's the host-admin scope, not what the module
359
- // wants); we substitute the freshly-minted module-scoped JWT.
360
- const init: RequestInit & { duplex?: "half" } = {
361
- method: req.method,
362
- headers: {
363
- authorization: `Bearer ${proxyToken}`,
364
- // Preserve content-type on PUT — modules parse JSON bodies based on
365
- // it. Default to application/json so a SPA that forgot the header
366
- // doesn't accidentally hit a text/plain body path upstream.
367
- "content-type": req.headers.get("content-type") ?? "application/json",
368
- accept: req.headers.get("accept") ?? "application/json",
369
- },
370
- redirect: "manual",
371
- };
372
- if (req.method === "PUT") {
373
- init.body = req.body;
374
- init.duplex = "half";
375
- }
376
-
377
- let upstreamRes: Response;
378
- try {
379
- const fetchFn = deps.upstreamFetch ?? fetch;
380
- upstreamRes = await fetchFn(url, init);
381
- } catch (err) {
382
- const msg = err instanceof Error ? err.message : String(err);
383
- return jsonError(
384
- 502,
385
- "upstream_unreachable",
386
- `module "${match.short}" upstream unreachable: ${msg}`,
387
- );
388
- }
389
-
390
- // Special case: a module GET-schema that returns 404 means the module
391
- // is up but doesn't expose `.parachute/config/schema`. Surface a
392
- // distinguishable error so the SPA can render "this module has no
393
- // operator-editable config" rather than a generic 404. Same for the
394
- // bare `/.parachute/config` GET (some module versions may ship one
395
- // without the other; we treat both upstream 404s as "no schema").
396
- if (upstreamRes.status === 404 && req.method === "GET") {
397
- return jsonError(
398
- 404,
399
- "no_config_schema",
400
- `module "${match.short}" does not expose a config schema at /.parachute/config/schema`,
401
- );
402
- }
403
-
404
- // For all other responses, forward verbatim — body, status, and
405
- // content-type. Modules already shape their own error bodies (scribe
406
- // uses `{error, message, errors[]}` on a 400 validation fail); the
407
- // SPA renders the module's message inline.
408
- const body = await upstreamRes.text();
409
- const headers = new Headers();
410
- const ct = upstreamRes.headers.get("content-type");
411
- if (ct) headers.set("content-type", ct);
412
- else headers.set("content-type", "application/json");
413
- return new Response(body, { status: upstreamRes.status, headers });
414
- }
415
-
416
- function jsonError(status: number, code: string, description: string): Response {
417
- return new Response(JSON.stringify({ error: code, error_description: description }), {
418
- status,
419
- headers: { "content-type": "application/json" },
420
- });
421
- }
@@ -1 +0,0 @@
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}