@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
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 +4 -11
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/src/oauth-ui.ts
CHANGED
|
@@ -147,6 +147,46 @@ export interface ConsentViewProps {
|
|
|
147
147
|
* the user on an error page. Defaults to authorizable when omitted.
|
|
148
148
|
*/
|
|
149
149
|
userCanAuthorizeRequest?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* hub#689 — owner-on-own-vault verb selector. Set when the consenting user
|
|
152
|
+
* OWNS (holds admin on) every vault they could pick AND the client requested
|
|
153
|
+
* an unnamed `vault:read`/`vault:write` verb. Renders a read/write/admin
|
|
154
|
+
* radio group, pre-selected to admin, so the owner can grant the level their
|
|
155
|
+
* AI client actually needs in-flow (the requested-scope shape was the
|
|
156
|
+
* blocker, not the user's authority) — or transparently downgrade.
|
|
157
|
+
*
|
|
158
|
+
* The submitted `verb_select` is an UNTRUSTED hint: the consent-submit
|
|
159
|
+
* handler re-derives, server-side, whether the user actually owns the picked
|
|
160
|
+
* vault before widening, and `capScopesToUserAuthority` remains the backstop
|
|
161
|
+
* that drops any verb the user doesn't hold. The selector only ever WIDENS
|
|
162
|
+
* the unnamed verb(s) on the picked vault; it never touches any other scope.
|
|
163
|
+
*/
|
|
164
|
+
ownerVerbSelector?: OwnerVerbSelector;
|
|
165
|
+
/**
|
|
166
|
+
* hub#314 — same-hub vs external trust marker. True when the requesting
|
|
167
|
+
* client was registered through this hub's own flow / first-party install
|
|
168
|
+
* (`OAuthClient.sameHub` — bearer `hub:admin` OR session-cookie +
|
|
169
|
+
* same-origin DCR). False for a third-party Dynamic Client Registration
|
|
170
|
+
* (an external app, e.g. Claude.ai, that self-registered). Drives a small
|
|
171
|
+
* trust badge in the consent card header so the operator knows the trust
|
|
172
|
+
* level of the app they're approving before they click Approve.
|
|
173
|
+
*
|
|
174
|
+
* Omitted / undefined → no badge (provenance unknown; only the GET-handler
|
|
175
|
+
* call site, which always has the client row, populates it). Provenance is
|
|
176
|
+
* a clean DB-backed signal — see the `same_hub` column on `clients` and the
|
|
177
|
+
* `consentProps` call site in `oauth-handlers.ts`.
|
|
178
|
+
*/
|
|
179
|
+
sameHub?: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface OwnerVerbSelector {
|
|
183
|
+
/**
|
|
184
|
+
* The unnamed read/write verb(s) the client requested. Only `read`/`write`
|
|
185
|
+
* are upgradeable here — an unnamed `vault:admin` request already renders
|
|
186
|
+
* with the admin badge and needs no selector. Used to word the selector
|
|
187
|
+
* help text ("the app asked for write access").
|
|
188
|
+
*/
|
|
189
|
+
requestedVerbs: string[];
|
|
150
190
|
}
|
|
151
191
|
|
|
152
192
|
export interface VaultPicker {
|
|
@@ -328,6 +368,8 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
328
368
|
staleAssignedVault,
|
|
329
369
|
blockApproveForStaleAssignment,
|
|
330
370
|
userCanAuthorizeRequest,
|
|
371
|
+
ownerVerbSelector,
|
|
372
|
+
sameHub,
|
|
331
373
|
} = props;
|
|
332
374
|
// Substitute unnamed `vault:<verb>` rows with the resolved named form so
|
|
333
375
|
// the operator sees the scope shape that will appear in the token. Raw
|
|
@@ -339,6 +381,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
339
381
|
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
340
382
|
: displayedScopes.map(renderScopeRow).join("\n");
|
|
341
383
|
const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
|
|
384
|
+
const verbSelectorSection = ownerVerbSelector ? renderOwnerVerbSelector(ownerVerbSelector) : "";
|
|
342
385
|
// Approve is disabled when the picker can't yield a valid vault. The
|
|
343
386
|
// empty-vault branch (no vaults registered) is the original case. A
|
|
344
387
|
// locked-vault picker (multi-user Phase 1) always has a valid value via
|
|
@@ -391,6 +434,24 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
391
434
|
before authorizing vault access.
|
|
392
435
|
</p>`
|
|
393
436
|
: "";
|
|
437
|
+
// hub#314 — same-hub vs external trust marker. `sameHub === true` means the
|
|
438
|
+
// client was registered through this hub's own flow (first-party / operator-
|
|
439
|
+
// authenticated DCR); `false` means a third-party app self-registered via
|
|
440
|
+
// public Dynamic Client Registration. `undefined` → no badge (provenance
|
|
441
|
+
// unknown to the caller). The badge sits in the header so the operator sees
|
|
442
|
+
// the trust level before reading the scope list.
|
|
443
|
+
const trustMarker =
|
|
444
|
+
sameHub === undefined
|
|
445
|
+
? ""
|
|
446
|
+
: sameHub
|
|
447
|
+
? `<p class="trust-marker trust-marker-same-hub">
|
|
448
|
+
<span class="badge badge-trust-same-hub">First-party</span>
|
|
449
|
+
<span class="trust-marker-text">Registered through this hub.</span>
|
|
450
|
+
</p>`
|
|
451
|
+
: `<p class="trust-marker trust-marker-external">
|
|
452
|
+
<span class="badge badge-trust-external">External</span>
|
|
453
|
+
<span class="trust-marker-text">A third-party app that registered itself. Approve only if you recognise it.</span>
|
|
454
|
+
</p>`;
|
|
394
455
|
const body = `
|
|
395
456
|
<div class="card">
|
|
396
457
|
<div class="card-header">
|
|
@@ -402,6 +463,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
402
463
|
<p class="subtitle">
|
|
403
464
|
This app is requesting access to your Parachute account.
|
|
404
465
|
</p>
|
|
466
|
+
${trustMarker}
|
|
405
467
|
<p class="client-meta">
|
|
406
468
|
<span class="client-meta-label">client_id</span>
|
|
407
469
|
<code>${escapeHtml(clientId)}</code>
|
|
@@ -418,6 +480,7 @@ export function renderConsent(props: ConsentViewProps): string {
|
|
|
418
480
|
${renderCsrfHiddenInput(csrfToken)}
|
|
419
481
|
${renderHiddenInputs(params)}
|
|
420
482
|
${pickerSection}
|
|
483
|
+
${verbSelectorSection}
|
|
421
484
|
<div class="button-row">
|
|
422
485
|
<button type="submit" name="approve" value="yes" class="btn btn-primary"${approveDisabled}>Approve</button>
|
|
423
486
|
<button type="submit" name="approve" value="no" class="btn btn-secondary">Deny</button>
|
|
@@ -492,6 +555,60 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
492
555
|
</section>`;
|
|
493
556
|
}
|
|
494
557
|
|
|
558
|
+
/**
|
|
559
|
+
* hub#689 — owner-on-own-vault verb selector. Rendered only when the
|
|
560
|
+
* consenting user owns (holds admin on) every vault they could pick and the
|
|
561
|
+
* client requested an unnamed `vault:read`/`vault:write` verb. Three radios
|
|
562
|
+
* (read / write / admin), pre-selected to **admin** so the common case (the
|
|
563
|
+
* owner's own AI client that needs full access) is one click — but the owner
|
|
564
|
+
* sees and submits the choice, and can downgrade.
|
|
565
|
+
*
|
|
566
|
+
* The `admin` option keeps the `.scope-admin` red border + admin badge so an
|
|
567
|
+
* admin grant stays visibly flagged even when pre-selected. The submitted
|
|
568
|
+
* `verb_select` is an untrusted hint re-checked server-side (ownership
|
|
569
|
+
* re-derivation in `handleConsentSubmit` + `capScopesToUserAuthority` backstop);
|
|
570
|
+
* this template only renders the choice.
|
|
571
|
+
*/
|
|
572
|
+
function renderOwnerVerbSelector(selector: OwnerVerbSelector): string {
|
|
573
|
+
const requested = selector.requestedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`);
|
|
574
|
+
const requestedList =
|
|
575
|
+
requested.length === 1
|
|
576
|
+
? requested[0]
|
|
577
|
+
: `${requested.slice(0, -1).join(", ")} and ${requested.at(-1)}`;
|
|
578
|
+
const option = (
|
|
579
|
+
verb: "read" | "write" | "admin",
|
|
580
|
+
title: string,
|
|
581
|
+
desc: string,
|
|
582
|
+
checked: boolean,
|
|
583
|
+
): string => {
|
|
584
|
+
const isAdmin = verb === "admin";
|
|
585
|
+
const cls = `verb-option${isAdmin ? " verb-option-admin scope-admin" : ""}`;
|
|
586
|
+
const badge = isAdmin ? `<span class="badge badge-admin">admin</span>` : "";
|
|
587
|
+
return `
|
|
588
|
+
<label class="${cls}">
|
|
589
|
+
<input type="radio" name="verb_select" value="${verb}"${checked ? " checked" : ""} />
|
|
590
|
+
<span class="verb-option-body">
|
|
591
|
+
<span class="verb-option-head">
|
|
592
|
+
<span class="verb-option-title">${escapeHtml(title)}</span>
|
|
593
|
+
${badge}
|
|
594
|
+
</span>
|
|
595
|
+
<span class="verb-option-desc">${escapeHtml(desc)}</span>
|
|
596
|
+
</span>
|
|
597
|
+
</label>`;
|
|
598
|
+
};
|
|
599
|
+
return `
|
|
600
|
+
<section class="verb-selector">
|
|
601
|
+
<h2 class="scopes-title">Access level</h2>
|
|
602
|
+
<p class="picker-help">
|
|
603
|
+
This app asked for ${requestedList} access to your vault. Because you own
|
|
604
|
+
this vault, you can grant a different level — admin is selected so your app
|
|
605
|
+
can do everything it might need; lower it if you'd rather not.
|
|
606
|
+
</p>
|
|
607
|
+
<div class="verb-options">${option("read", "Read only", "View notes, tags, attachments, and config.", false)}${option("write", "Read & write", "Create, edit, and delete notes, tags, and attachments.", false)}${option("admin", "Admin", "Full access plus config, triggers/automation, GitHub backup, and minting tokens.", true)}
|
|
608
|
+
</div>
|
|
609
|
+
</section>`;
|
|
610
|
+
}
|
|
611
|
+
|
|
495
612
|
/**
|
|
496
613
|
* "App not yet approved" page (#74). Two branches:
|
|
497
614
|
*
|
|
@@ -1282,6 +1399,47 @@ const STYLES = `
|
|
|
1282
1399
|
font-size: 0.88rem;
|
|
1283
1400
|
color: ${PALETTE.fg};
|
|
1284
1401
|
}
|
|
1402
|
+
/* hub#689 — owner-on-own-vault verb selector. Same card shell as the
|
|
1403
|
+
vault picker; the admin option carries the .scope-admin red border so an
|
|
1404
|
+
admin grant stays visibly flagged even when pre-selected. */
|
|
1405
|
+
.verb-selector {
|
|
1406
|
+
margin: 0 0 1.25rem;
|
|
1407
|
+
padding: 0.75rem 0.85rem;
|
|
1408
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
1409
|
+
border-radius: 6px;
|
|
1410
|
+
background: ${PALETTE.bgSoft};
|
|
1411
|
+
}
|
|
1412
|
+
.verb-selector .scopes-title { margin-bottom: 0.4rem; }
|
|
1413
|
+
.verb-options {
|
|
1414
|
+
display: flex;
|
|
1415
|
+
flex-direction: column;
|
|
1416
|
+
gap: 0.4rem;
|
|
1417
|
+
}
|
|
1418
|
+
.verb-option {
|
|
1419
|
+
display: flex;
|
|
1420
|
+
align-items: flex-start;
|
|
1421
|
+
gap: 0.5rem;
|
|
1422
|
+
padding: 0.5rem 0.65rem;
|
|
1423
|
+
border: 1px solid ${PALETTE.border};
|
|
1424
|
+
border-radius: 6px;
|
|
1425
|
+
background: ${PALETTE.cardBg};
|
|
1426
|
+
cursor: pointer;
|
|
1427
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
1428
|
+
}
|
|
1429
|
+
.verb-option:hover { border-color: ${PALETTE.accent}; }
|
|
1430
|
+
.verb-option input[type=radio] { margin-top: 0.25rem; }
|
|
1431
|
+
.verb-option input[type=radio]:focus { outline: 2px solid ${PALETTE.accent}; outline-offset: 2px; }
|
|
1432
|
+
.verb-option-body { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
1433
|
+
.verb-option-head {
|
|
1434
|
+
display: flex;
|
|
1435
|
+
align-items: center;
|
|
1436
|
+
gap: 0.4rem;
|
|
1437
|
+
flex-wrap: wrap;
|
|
1438
|
+
}
|
|
1439
|
+
.verb-option-title { font-weight: 500; color: ${PALETTE.fg}; font-size: 0.9rem; }
|
|
1440
|
+
.verb-option-desc { font-size: 0.82rem; color: ${PALETTE.fgMuted}; }
|
|
1441
|
+
.verb-option-admin .verb-option-title { color: ${PALETTE.danger}; }
|
|
1442
|
+
|
|
1285
1443
|
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
1286
1444
|
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
1287
1445
|
.vault-picker-locked .picker-help { color: ${PALETTE.fgMuted}; }
|
|
@@ -1456,6 +1614,22 @@ const STYLES = `
|
|
|
1456
1614
|
.badge-send { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
|
|
1457
1615
|
.badge-admin { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
|
|
1458
1616
|
|
|
1617
|
+
/* hub#314 — same-hub vs external trust marker on the consent header. The
|
|
1618
|
+
first-party badge uses the accent (calm/trusted); external uses the danger
|
|
1619
|
+
tint so a third-party DCR client stands out without being alarmist. */
|
|
1620
|
+
.trust-marker {
|
|
1621
|
+
display: flex;
|
|
1622
|
+
align-items: baseline;
|
|
1623
|
+
gap: 0.45rem;
|
|
1624
|
+
flex-wrap: wrap;
|
|
1625
|
+
margin: 0.75rem 0 0;
|
|
1626
|
+
font-size: 0.85rem;
|
|
1627
|
+
color: ${PALETTE.fgMuted};
|
|
1628
|
+
}
|
|
1629
|
+
.trust-marker-text { flex: 1; min-width: 12rem; }
|
|
1630
|
+
.badge-trust-same-hub { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
|
|
1631
|
+
.badge-trust-external { background: ${PALETTE.dangerSoft}; color: ${PALETTE.danger}; }
|
|
1632
|
+
|
|
1459
1633
|
@media (max-width: 480px) {
|
|
1460
1634
|
main { padding: 0.75rem; }
|
|
1461
1635
|
.card { padding: 1.5rem 1.25rem; border-radius: 10px; }
|
package/src/rate-limit.ts
CHANGED
|
@@ -87,6 +87,21 @@ export const CHANGE_PASSWORD_WINDOW_MS = 5 * 60 * 1000;
|
|
|
87
87
|
* cookie attacker shouldn't get a 5-shot grind window.
|
|
88
88
|
*/
|
|
89
89
|
export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
|
|
90
|
+
/**
|
|
91
|
+
* `/api/account/2fa/confirm` (TOTP enrollment seal) window: 15 minutes. This is
|
|
92
|
+
* the SELF-only, already-session-authenticated enrollment step — the operator is
|
|
93
|
+
* typing the first live code off their own authenticator while it drifts into
|
|
94
|
+
* sync, so legitimate mistypes are common and must not be punished. The threat
|
|
95
|
+
* is only a hijacked session grinding the (client-held, not-yet-persisted)
|
|
96
|
+
* in-flight secret, which the 10^6 code space + replay cache already make
|
|
97
|
+
* effectively non-exploitable — so this is defense-in-depth, deliberately MORE
|
|
98
|
+
* generous than the 3/5-min change-password bucket. NOT the `/login/2fa` bucket:
|
|
99
|
+
* that one is the strict, pre-auth brute-force door (5/15-min); enrollment is a
|
|
100
|
+
* different, lower-risk surface and gets its own lenient bucket.
|
|
101
|
+
*/
|
|
102
|
+
export const TOTP_ENROLL_CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
|
103
|
+
/** `/api/account/2fa/confirm` attempts allowed per window. 11th is denied. */
|
|
104
|
+
export const TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS = 10;
|
|
90
105
|
/**
|
|
91
106
|
* `/login/2fa` window length: 15 minutes — same as `/login`. The second-
|
|
92
107
|
* factor step (hub#473) sits behind a verified password + a short-lived
|
|
@@ -289,6 +304,18 @@ export const changePasswordRateLimiter = new RateLimiter(
|
|
|
289
304
|
*/
|
|
290
305
|
export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
|
|
291
306
|
|
|
307
|
+
/**
|
|
308
|
+
* `/api/account/2fa/confirm` enrollment-seal bucket. Lenient (10 / 15 min),
|
|
309
|
+
* keyed by `user.id` (the session already establishes identity). Separate from
|
|
310
|
+
* `totpRateLimiter` so an enrollment mistype and a `/login/2fa` failure never
|
|
311
|
+
* share a window — different surfaces, different threat models (see the const
|
|
312
|
+
* docs above).
|
|
313
|
+
*/
|
|
314
|
+
export const totpEnrollConfirmRateLimiter = new RateLimiter(
|
|
315
|
+
TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS,
|
|
316
|
+
TOTP_ENROLL_CONFIRM_WINDOW_MS,
|
|
317
|
+
);
|
|
318
|
+
|
|
292
319
|
/**
|
|
293
320
|
* Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
|
|
294
321
|
* IP ONLY. Shared by all interactive auth doors (`/login`, the
|
|
@@ -342,6 +369,7 @@ export function __resetForTests(): void {
|
|
|
342
369
|
loginRateLimiter.reset();
|
|
343
370
|
changePasswordRateLimiter.reset();
|
|
344
371
|
totpRateLimiter.reset();
|
|
372
|
+
totpEnrollConfirmRateLimiter.reset();
|
|
345
373
|
vaultTokenMintRateLimiter.reset();
|
|
346
374
|
signupRateLimiter.reset();
|
|
347
375
|
authIpCeilingRateLimiter.reset();
|
|
@@ -42,7 +42,8 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
42
42
|
level: "write",
|
|
43
43
|
},
|
|
44
44
|
"vault:admin": {
|
|
45
|
-
label:
|
|
45
|
+
label:
|
|
46
|
+
"Read and write everything, plus admin: config & settings, triggers & automation, GitHub backup, and minting access tokens.",
|
|
46
47
|
level: "admin",
|
|
47
48
|
},
|
|
48
49
|
// Optional-module scopes (scribe / agent). These are in FIRST_PARTY_SCOPES
|
package/src/setup-wizard.ts
CHANGED
|
@@ -1592,14 +1592,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1592
1592
|
// poll on the auth the wizard already carries.
|
|
1593
1593
|
const opId = url.searchParams.get("op");
|
|
1594
1594
|
if (opId) {
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1595
|
+
// hub#618: post-setup this JSON `?op=` surface is unauth-reachable —
|
|
1596
|
+
// `/admin/setup` is always lockout-exempt (the dispatcher's
|
|
1597
|
+
// `shouldGateForSetup` lets it through so a stale bookmark resolves), and
|
|
1598
|
+
// the snapshot is read BEFORE any session check. The leak is small (an
|
|
1599
|
+
// in-memory op's status + install-progress log lines, behind an
|
|
1600
|
+
// unguessable UUID), but it's still a post-setup admin surface, so gate
|
|
1601
|
+
// it once setup is COMPLETE. During setup (no admin yet) the surface
|
|
1602
|
+
// stays OPEN: the unauth CLI wizard (`parachute init`) AND the brand-new-
|
|
1603
|
+
// operator browser both poll this `?op=` snapshot mid-setup before any
|
|
1604
|
+
// session exists — gating then would break first-boot vault
|
|
1605
|
+
// provisioning. Loopback always passes (same on-box trust as the
|
|
1606
|
+
// `bootstrapToken` branch below); a valid session also passes.
|
|
1607
|
+
const setupComplete = state.hasAdmin && state.hasVault && state.hasExposeMode;
|
|
1608
|
+
const opSnapshotAllowed =
|
|
1609
|
+
!setupComplete ||
|
|
1610
|
+
deps.requestIsLoopback === true ||
|
|
1611
|
+
findActiveSession(deps.db, req) !== null;
|
|
1612
|
+
if (opSnapshotAllowed) {
|
|
1613
|
+
const op = deps.registry?.get(opId);
|
|
1614
|
+
if (op) {
|
|
1615
|
+
envelope.operation = {
|
|
1616
|
+
id: op.id,
|
|
1617
|
+
status: op.status,
|
|
1618
|
+
log: op.log,
|
|
1619
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1603
1622
|
}
|
|
1604
1623
|
}
|
|
1605
1624
|
// hub#576: hand the actual token to a LOOPBACK caller only. The on-box
|
|
@@ -2325,19 +2344,19 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
2325
2344
|
if (registry) {
|
|
2326
2345
|
// hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
|
|
2327
2346
|
// vault's first-boot path (vault#342) names the created vault
|
|
2328
|
-
// accordingly.
|
|
2329
|
-
// field blank — vault's `resolveFirstBootVaultName` defaults to
|
|
2330
|
-
// `default` on absent env vars, so this preserves the prior
|
|
2331
|
-
// behaviour for the empty-input case.
|
|
2347
|
+
// accordingly.
|
|
2332
2348
|
//
|
|
2333
|
-
//
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2349
|
+
// #478 Part 2: ALWAYS set `PARACHUTE_VAULT_NAME`, even when the name
|
|
2350
|
+
// is "default". Once vault removes its silent auto-create-on-missing-env
|
|
2351
|
+
// behavior, vault's first-boot will require the env var to know which
|
|
2352
|
+
// vault to create — a missing `PARACHUTE_VAULT_NAME` will mean "no vault
|
|
2353
|
+
// to create" rather than "create one named default". Passing it
|
|
2354
|
+
// explicitly for every path (including the default) is correct and safe:
|
|
2355
|
+
// vault's `resolveFirstBootVaultName` accepts "default" as a valid name
|
|
2356
|
+
// and behaves identically to the prior implicit default.
|
|
2357
|
+
const spawnEnv: Record<string, string> = {
|
|
2358
|
+
PARACHUTE_VAULT_NAME: vaultName,
|
|
2359
|
+
};
|
|
2341
2360
|
// Capture importParams + deps in the runInstall promise chain — when
|
|
2342
2361
|
// mode === "import", run the vault-side `/.parachute/mirror/import`
|
|
2343
2362
|
// POST as a follow-up step once the supervised vault has come up
|
|
@@ -2358,7 +2377,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
|
|
|
2358
2377
|
registry,
|
|
2359
2378
|
...(deps.run ? { run: deps.run } : {}),
|
|
2360
2379
|
...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
|
|
2361
|
-
|
|
2380
|
+
spawnEnv,
|
|
2362
2381
|
})
|
|
2363
2382
|
.then(async () => {
|
|
2364
2383
|
if (!importToRun) return;
|
package/src/supervisor.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { spawnSync } from "node:child_process";
|
|
|
38
38
|
import {
|
|
39
39
|
MissingDependencyError,
|
|
40
40
|
type MissingDependencyWire,
|
|
41
|
+
NonExecutableError,
|
|
41
42
|
ensureExecutable,
|
|
42
43
|
rethrowIfMissing,
|
|
43
44
|
} from "@openparachute/depcheck";
|
|
@@ -263,6 +264,14 @@ export interface SupervisorOpts {
|
|
|
263
264
|
* Tests exercising the missing-binary branch inject `which: () => null`.
|
|
264
265
|
*/
|
|
265
266
|
readonly which?: (cmd: string) => string | null;
|
|
267
|
+
/**
|
|
268
|
+
* #634 secondary-probe seam for `ensureExecutable`: when `which` returns null,
|
|
269
|
+
* walk PATH IGNORING X_OK to detect a present-but-non-executable binary (a
|
|
270
|
+
* `bin` that lost its +x bit). Production leaves this undefined so depcheck's
|
|
271
|
+
* real PATH walk runs (gated to the real `Bun.which`); tests inject it to
|
|
272
|
+
* exercise the non-executable preflight branch through a stubbed `which`.
|
|
273
|
+
*/
|
|
274
|
+
readonly findNonExecutable?: (binary: string) => string | null;
|
|
266
275
|
/**
|
|
267
276
|
* Pre-spawn port-squatter detection (#580 item 4). Returns the pid holding a
|
|
268
277
|
* TCP LISTEN on the module's port, or undefined when the port is free /
|
|
@@ -427,8 +436,11 @@ export class LogRingBuffer {
|
|
|
427
436
|
* boot and threads it into the API handlers.
|
|
428
437
|
*/
|
|
429
438
|
export class Supervisor {
|
|
430
|
-
private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
|
|
439
|
+
private readonly opts: Required<Omit<SupervisorOpts, "spawnFn" | "findNonExecutable">> & {
|
|
431
440
|
readonly spawnFn: SpawnFn;
|
|
441
|
+
// Optional #634 probe seam — undefined on the production path so depcheck's
|
|
442
|
+
// own real PATH walk runs (gated to the real `Bun.which`).
|
|
443
|
+
readonly findNonExecutable?: (binary: string) => string | null;
|
|
432
444
|
};
|
|
433
445
|
private readonly modules = new Map<string, ModuleEntry>();
|
|
434
446
|
|
|
@@ -459,6 +471,9 @@ export class Supervisor {
|
|
|
459
471
|
lateBindWatchMs: opts.lateBindWatchMs ?? DEFAULT_LATE_BIND_WATCH_MS,
|
|
460
472
|
lateBindPollMs: opts.lateBindPollMs ?? DEFAULT_LATE_BIND_POLL_MS,
|
|
461
473
|
which: opts.which ?? (isProductionPath ? Bun.which : () => "/stub/bin/preflight-skipped"),
|
|
474
|
+
// #634: undefined on production so depcheck's real PATH walk runs (its
|
|
475
|
+
// gate keys on the real `Bun.which`); tests inject it to drive the branch.
|
|
476
|
+
findNonExecutable: opts.findNonExecutable,
|
|
462
477
|
// Squatter detection (#580 item 4): real probes on the production path;
|
|
463
478
|
// the stub-spawner test path defaults to "no squatter / unknown owner" so
|
|
464
479
|
// fake-proc tests (which never hold a real port) aren't tripped. Tests
|
|
@@ -509,7 +524,9 @@ export class Supervisor {
|
|
|
509
524
|
const startBinary = req.cmd[0];
|
|
510
525
|
if (startBinary) {
|
|
511
526
|
try {
|
|
512
|
-
ensureExecutable
|
|
527
|
+
const ensureOpts: Parameters<typeof ensureExecutable>[1] = { which: this.opts.which };
|
|
528
|
+
if (this.opts.findNonExecutable) ensureOpts.findNonExecutable = this.opts.findNonExecutable;
|
|
529
|
+
ensureExecutable(startBinary, ensureOpts);
|
|
513
530
|
} catch (err) {
|
|
514
531
|
if (err instanceof MissingDependencyError) {
|
|
515
532
|
entry.state = {
|
|
@@ -520,6 +537,18 @@ export class Supervisor {
|
|
|
520
537
|
};
|
|
521
538
|
return entry.state;
|
|
522
539
|
}
|
|
540
|
+
// #634: the binary IS present but not executable (a `bin` that lost its
|
|
541
|
+
// +x bit). Record the actionable chmod hint instead of a misleading
|
|
542
|
+
// "not installed" — and never throw out of `start`.
|
|
543
|
+
if (err instanceof NonExecutableError) {
|
|
544
|
+
entry.state = {
|
|
545
|
+
...entry.state,
|
|
546
|
+
status: "crashed",
|
|
547
|
+
pid: undefined,
|
|
548
|
+
startError: nonExecutableStartError(err, this.opts.now),
|
|
549
|
+
};
|
|
550
|
+
return entry.state;
|
|
551
|
+
}
|
|
523
552
|
throw err;
|
|
524
553
|
}
|
|
525
554
|
}
|
|
@@ -1243,6 +1272,21 @@ function startErrorFromWire(wire: MissingDependencyWire, now: () => number): Mod
|
|
|
1243
1272
|
};
|
|
1244
1273
|
}
|
|
1245
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* #634: map a `NonExecutableError` (binary present on PATH but not +x) onto the
|
|
1277
|
+
* `ModuleStartError` shape. `error_type: "non_executable"` so a UI can branch;
|
|
1278
|
+
* `error_description` is the formatted `chmod +x` block. No install card — the
|
|
1279
|
+
* fix is a permission flip, not a reinstall.
|
|
1280
|
+
*/
|
|
1281
|
+
function nonExecutableStartError(err: NonExecutableError, now: () => number): ModuleStartError {
|
|
1282
|
+
return {
|
|
1283
|
+
error_type: err.errorType,
|
|
1284
|
+
error_description: err.message,
|
|
1285
|
+
binary: err.binary,
|
|
1286
|
+
at: new Date(now()).toISOString(),
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1246
1290
|
/**
|
|
1247
1291
|
* Production group-aware kill (hub#88). Sends `signal` to the entire process
|
|
1248
1292
|
* group rooted at `pid` (the negative-pid syscall) so a wrapped startCmd's
|
package/src/vault-names.ts
CHANGED
|
@@ -23,8 +23,17 @@
|
|
|
23
23
|
* Walks both manifest shapes: single-entry-multi-path (`parachute-vault`
|
|
24
24
|
* with `paths: ["/vault/work", "/vault/personal"]`) and per-vault entries
|
|
25
25
|
* (`parachute-vault-work`) by delegating each (name, path) pair to
|
|
26
|
-
* `vaultInstanceNameFor`.
|
|
27
|
-
*
|
|
26
|
+
* `vaultInstanceNameFor`.
|
|
27
|
+
*
|
|
28
|
+
* #478: an empty-paths vault row (e.g. `parachute-vault` with `paths: []`,
|
|
29
|
+
* which vault's self-register emits at zero vaults) is "installed but no
|
|
30
|
+
* servable vault instance" and is SKIPPED entirely — it must not synthesize a
|
|
31
|
+
* name (the bare `parachute-vault` would otherwise resolve to a phantom
|
|
32
|
+
* "default"). This mirrors the empty-paths `continue` in `admin-vaults.ts`'s
|
|
33
|
+
* `findExistingVault`/`listVaultInstanceNames`, so every read path agrees: a
|
|
34
|
+
* vault instance is named only by a real `/vault/<name>` mount path. This
|
|
35
|
+
* supersedes the prior hub#143 manifest-suffix fallback for path-less entries
|
|
36
|
+
* — a registered vault carries its mount path once a vault exists.
|
|
28
37
|
*/
|
|
29
38
|
import { type ServicesManifest, readManifestLenient } from "./services-manifest.ts";
|
|
30
39
|
import { isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
@@ -39,8 +48,10 @@ export function listVaultNames(manifest: ServicesManifest): string[] {
|
|
|
39
48
|
const names = new Set<string>();
|
|
40
49
|
for (const svc of manifest.services) {
|
|
41
50
|
if (!isVaultEntry(svc)) continue;
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
// #478: an empty-paths vault row means "installed but no servable vault
|
|
52
|
+
// instance" — skip it so it never synthesizes a phantom "default".
|
|
53
|
+
if (svc.paths.length === 0) continue;
|
|
54
|
+
for (const path of svc.paths) {
|
|
44
55
|
names.add(vaultInstanceNameFor(svc.name, path));
|
|
45
56
|
}
|
|
46
57
|
}
|
package/src/well-known.ts
CHANGED
|
@@ -247,7 +247,16 @@ export function buildWellKnown(opts: BuildWellKnownOpts): WellKnownDocument {
|
|
|
247
247
|
// multi-path on those is treated as aliases rather than separate
|
|
248
248
|
// installs.
|
|
249
249
|
const isVault = isVaultEntry(s);
|
|
250
|
-
|
|
250
|
+
// #478: an empty-paths VAULT row means "installed but no servable vault
|
|
251
|
+
// instance" — vault's self-register emits `paths: []` at zero vaults.
|
|
252
|
+
// Skip it entirely: emitting `["/"]` here would fabricate a phantom vault
|
|
253
|
+
// entry at root in both the `services` catalog and the `vaults` array.
|
|
254
|
+
// This mirrors the empty-paths `continue` in admin-vaults.ts / vault-names.ts
|
|
255
|
+
// so every read path agrees: a vault instance exists only by a real
|
|
256
|
+
// `/vault/<name>` mount path. Non-vault services keep the `paths[0] ?? "/"`
|
|
257
|
+
// fallback (a path-less non-vault row legitimately mounts at root).
|
|
258
|
+
if (isVault && s.paths.length === 0) continue;
|
|
259
|
+
const pathsToEmit = isVault ? s.paths : [s.paths[0] ?? "/"];
|
|
251
260
|
for (const path of pathsToEmit) {
|
|
252
261
|
const url = new URL(path, `${base}/`).toString();
|
|
253
262
|
const infoUrl = new URL(joinInfoPath(path), `${base}/`).toString();
|
|
@@ -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,.channel-table tbody tr{transition:background-color .12s ease}.user-table tbody tr:hover,.tokens-table tbody tr:hover,.channel-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}.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}.modules-experimental{margin-top:1.25rem;padding-top:.75rem;border-top:1px dashed var(--border, #d0d0d0);opacity:.78;transition:opacity .15s ease}.modules-experimental:hover,.modules-experimental:focus-within{opacity:1}.experimental-heading{font-size:.95rem;font-weight:600;margin:0 0 .6rem;text-transform:uppercase;letter-spacing:.04em}.experimental-heading>span.muted{text-transform:none;letter-spacing:normal;font-weight:400}.modules-deprecated{margin-top:1.25rem;padding-top:.75rem;border-top:1px dashed var(--border, #d0d0d0);opacity:.65;transition:opacity .15s ease}.modules-deprecated:hover,.modules-deprecated:focus-within{opacity:1}.deprecated-heading{font-size:.95rem;font-weight:600;margin:0 0 .6rem;text-transform:uppercase;letter-spacing:.04em}.deprecated-heading>span.muted{text-transform:none;letter-spacing:normal;font-weight:400}.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-approved{background:var(--success-soft);color:var(--success)}.error-inline{color:var(--error);font-size:.85rem}.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}.admin-home>.muted{margin-bottom:1.75rem}.home-group{margin-bottom:2.25rem}.home-group-head{display:flex;align-items:baseline;gap:.6rem;margin-bottom:.25rem}.home-group-head h2{margin:0;font-size:1.25rem}.home-group-sub{margin:0 0 1rem}.home-group-tag{display:inline-block;padding:.1em .55em;border-radius:4px;font-size:.72rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase}.home-group-tag-hub{background:var(--accent-soft);color:var(--accent)}.home-group-tag-module{background:var(--bg-soft);color:var(--fg-muted)}.home-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.85rem}.home-card{display:flex;flex-direction:column;gap:.3rem;padding:.95rem 1.1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:10px;text-decoration:none;color:inherit;transition:border-color .15s ease,box-shadow .15s ease,transform .12s ease}.home-card:hover{text-decoration:none;border-color:var(--accent);box-shadow:0 3px 10px #0000000d;transform:translateY(-1px)}.home-card-module,.home-card-surface{background:var(--bg-soft)}.home-card-disabled{cursor:default;opacity:.65}.home-card-disabled:hover{border-color:var(--border);box-shadow:none;transform:none}.home-card-title{font-weight:600;font-size:1rem;color:var(--fg)}.home-card-desc{font-size:.86rem;color:var(--fg-muted);line-height:1.4}.home-card-owner{margin-top:.15rem;font-size:.76rem;color:var(--accent);font-weight:500}.home-card-owner-empty{color:var(--fg-dim);font-weight:400}.ext-mark{font-size:.85em;color:var(--accent);font-weight:600}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.lock-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);padding:1.5rem}.lock-card{width:100%;max-width:22rem;display:flex;flex-direction:column;align-items:center;text-align:center;gap:.35rem;padding:2rem 1.75rem;background:var(--bg-soft);border:1px solid var(--border);border-radius:14px}.lock-brand-mark{margin-bottom:.4rem}.lock-title{font-size:1.2rem;margin:0}.lock-sub{margin:0 0 .6rem;font-size:.9rem}.lock-form{display:flex;flex-direction:column;gap:.6rem;width:100%}.lock-pin-input{width:100%;text-align:center;letter-spacing:.45em;font-size:1.3rem;padding:.7rem .5rem;border:1px solid var(--border);border-radius:9px;background:var(--bg);color:var(--fg)}.lock-pin-input:focus{outline:none;border-color:var(--accent)}.lock-error{margin-top:.6rem;color:var(--error);font-size:.88rem}.lock-settings-form{display:flex;flex-direction:column;gap:.7rem;max-width:28rem}.lock-settings-form label{display:flex;flex-direction:column;gap:.3rem;font-size:.9rem}.lock-status-pill{display:inline-block;padding:.1rem .5rem;border-radius:999px;font-size:.78rem;font-weight:500}.lock-status-on{background:var(--accent-soft);color:var(--accent)}.lock-status-off{background:var(--bg-soft);color:var(--fg-muted)}
|
|
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,.channel-table tbody tr{transition:background-color .12s ease}.user-table tbody tr:hover,.tokens-table tbody tr:hover,.channel-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}.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}.modules-experimental{margin-top:1.25rem;padding-top:.75rem;border-top:1px dashed var(--border, #d0d0d0);opacity:.78;transition:opacity .15s ease}.modules-experimental:hover,.modules-experimental:focus-within{opacity:1}.experimental-heading{font-size:.95rem;font-weight:600;margin:0 0 .6rem;text-transform:uppercase;letter-spacing:.04em}.experimental-heading>span.muted{text-transform:none;letter-spacing:normal;font-weight:400}.modules-deprecated{margin-top:1.25rem;padding-top:.75rem;border-top:1px dashed var(--border, #d0d0d0);opacity:.65;transition:opacity .15s ease}.modules-deprecated:hover,.modules-deprecated:focus-within{opacity:1}.deprecated-heading{font-size:.95rem;font-weight:600;margin:0 0 .6rem;text-transform:uppercase;letter-spacing:.04em}.deprecated-heading>span.muted{text-transform:none;letter-spacing:normal;font-weight:400}.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-approved{background:var(--success-soft);color:var(--success)}.error-inline{color:var(--error);font-size:.85rem}.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}.admin-home>.muted{margin-bottom:1.75rem}.home-group{margin-bottom:2.25rem}.home-group-head{display:flex;align-items:baseline;gap:.6rem;margin-bottom:.25rem}.home-group-head h2{margin:0;font-size:1.25rem}.home-group-sub{margin:0 0 1rem}.home-group-tag{display:inline-block;padding:.1em .55em;border-radius:4px;font-size:.72rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase}.home-group-tag-hub{background:var(--accent-soft);color:var(--accent)}.home-group-tag-module{background:var(--bg-soft);color:var(--fg-muted)}.home-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.85rem}.home-card{display:flex;flex-direction:column;gap:.3rem;padding:.95rem 1.1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:10px;text-decoration:none;color:inherit;transition:border-color .15s ease,box-shadow .15s ease,transform .12s ease}.home-card:hover{text-decoration:none;border-color:var(--accent);box-shadow:0 3px 10px #0000000d;transform:translateY(-1px)}.home-card-module,.home-card-surface{background:var(--bg-soft)}.home-card-disabled{cursor:default;opacity:.65}.home-card-disabled:hover{border-color:var(--border);box-shadow:none;transform:none}.home-card-title{font-weight:600;font-size:1rem;color:var(--fg)}.home-card-desc{font-size:.86rem;color:var(--fg-muted);line-height:1.4}.home-card-owner{margin-top:.15rem;font-size:.76rem;color:var(--accent);font-weight:500}.home-card-owner-empty{color:var(--fg-dim);font-weight:400}.ext-mark{font-size:.85em;color:var(--accent);font-weight:600}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.lock-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:var(--bg);padding:1.5rem}.lock-card{width:100%;max-width:22rem;display:flex;flex-direction:column;align-items:center;text-align:center;gap:.35rem;padding:2rem 1.75rem;background:var(--bg-soft);border:1px solid var(--border);border-radius:14px}.lock-brand-mark{margin-bottom:.4rem}.lock-title{font-size:1.2rem;margin:0}.lock-sub{margin:0 0 .6rem;font-size:.9rem}.lock-form{display:flex;flex-direction:column;gap:.6rem;width:100%}.lock-pin-input{width:100%;text-align:center;letter-spacing:.45em;font-size:1.3rem;padding:.7rem .5rem;border:1px solid var(--border);border-radius:9px;background:var(--bg);color:var(--fg)}.lock-pin-input:focus{outline:none;border-color:var(--accent)}.lock-error{margin-top:.6rem;color:var(--error);font-size:.88rem}.lock-settings-form{display:flex;flex-direction:column;gap:.7rem;max-width:28rem}.lock-settings-form label{display:flex;flex-direction:column;gap:.3rem;font-size:.9rem}.lock-status-pill{display:inline-block;padding:.1rem .5rem;border-radius:999px;font-size:.78rem;font-weight:500}.lock-status-on{background:var(--accent-soft);color:var(--accent)}.lock-status-off{background:var(--bg-soft);color:var(--fg-muted)}.backup-codes{list-style:none;padding:0;margin:.5rem 0;display:grid;grid-template-columns:repeat(auto-fill,minmax(8rem,1fr));gap:.35rem}.backup-codes li code{font-size:.95rem;letter-spacing:.02em}
|