@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.
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +34 -0
- package/src/__tests__/account-vault-admin-token.test.ts +35 -3
- package/src/__tests__/admin-channel-token.test.ts +173 -0
- package/src/__tests__/admin-connections.test.ts +1154 -0
- package/src/__tests__/admin-csrf-belt.test.ts +346 -0
- package/src/__tests__/admin-module-token.test.ts +311 -0
- package/src/__tests__/admin-vaults.test.ts +590 -0
- package/src/__tests__/api-modules-ops.test.ts +70 -5
- package/src/__tests__/api-modules.test.ts +262 -79
- package/src/__tests__/hub-db-liveness.test.ts +12 -7
- package/src/__tests__/hub-server.test.ts +319 -21
- package/src/__tests__/invites.test.ts +27 -0
- package/src/__tests__/module-manifest.test.ts +305 -8
- package/src/__tests__/serve-boot.test.ts +133 -2
- package/src/__tests__/service-spec-discovery.test.ts +109 -0
- package/src/__tests__/setup-gate.test.ts +13 -7
- package/src/__tests__/setup-wizard.test.ts +228 -1
- package/src/__tests__/vault-name.test.ts +20 -5
- package/src/__tests__/well-known.test.ts +44 -8
- package/src/account-vault-admin-token.ts +43 -14
- package/src/admin-channel-token.ts +135 -0
- package/src/admin-connections.ts +980 -0
- package/src/admin-module-token.ts +197 -0
- package/src/admin-vaults.ts +390 -12
- package/src/api-hub-upgrade.ts +4 -3
- package/src/api-modules-ops.ts +41 -16
- package/src/api-modules.ts +238 -116
- package/src/api-tokens.ts +8 -5
- package/src/commands/serve-boot.ts +80 -3
- package/src/commands/setup.ts +4 -4
- package/src/connections-store.ts +161 -0
- package/src/grants.ts +50 -0
- package/src/hub-db-liveness.ts +33 -17
- package/src/hub-server.ts +354 -61
- package/src/invites.ts +22 -0
- package/src/jwt-sign.ts +41 -1
- package/src/module-manifest.ts +429 -23
- package/src/origin-check.ts +106 -0
- package/src/proxy-error-ui.ts +1 -1
- package/src/service-spec.ts +132 -41
- package/src/setup-wizard.ts +68 -6
- package/src/users.ts +11 -0
- package/src/vault-name.ts +27 -7
- package/src/well-known.ts +41 -33
- package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
- package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/api-modules-config.test.ts +0 -882
- package/src/api-modules-config.ts +0 -421
- package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
- 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}
|