@openparachute/hub 0.7.3-rc.5 → 0.7.3-rc.7
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__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup.test.ts +129 -15
- package/src/api-modules.ts +47 -13
- package/src/api-vault-caps.ts +206 -0
- package/src/commands/setup.ts +31 -6
- package/src/hub-server.ts +27 -0
- package/src/module-manifest.ts +23 -9
- package/src/service-spec.ts +31 -12
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-B5AUE359.js +0 -61
package/src/api-modules.ts
CHANGED
|
@@ -15,11 +15,21 @@
|
|
|
15
15
|
* / `crashed` / `starting` / `restarting`) + pid. Absent when the
|
|
16
16
|
* hub is in CLI mode (no supervisor injected through HubFetchDeps).
|
|
17
17
|
*
|
|
18
|
-
* `focus` ("core" | "experimental") comes from each module's
|
|
19
|
-
* when declared, else `focusForShort`'s default map. The SPA
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* `focus` ("core" | "experimental" | "deprecated") comes from each module's
|
|
19
|
+
* `module.json` when declared, else `focusForShort`'s default map. The SPA
|
|
20
|
+
* groups core first, de-emphasizes experimental, and de-emphasizes
|
|
21
|
+
* `deprecated` (notes-daemon / runner) further — it NEVER hides an installed
|
|
22
|
+
* module (so an existing operator can still manage / uninstall a deprecated
|
|
23
|
+
* one). This is what makes a running, self-registered module (channel) visible
|
|
24
|
+
* + installable; the old `CURATED_MODULES = ["vault","scribe"]` whitelist made
|
|
25
|
+
* it invisible.
|
|
26
|
+
*
|
|
27
|
+
* `available_to_install` is the fresh-install OFFER (2026-06-25): a module the
|
|
28
|
+
* hub will push as a new install. It's `available && focus !== "deprecated"`
|
|
29
|
+
* — so a `deprecated` module that is ALREADY installed still surfaces (via the
|
|
30
|
+
* `modules` union + `installed: true`) and is manageable, but a deprecated
|
|
31
|
+
* module is never offered as a fresh install. `agent` (`experimental`) stays
|
|
32
|
+
* offered.
|
|
23
33
|
*
|
|
24
34
|
* Bearer-gated on `parachute:host:auth` to match the rest of `/api/auth/*`
|
|
25
35
|
* and `/api/grants` — the admin SPA mints this scope via
|
|
@@ -199,14 +209,33 @@ interface ModuleWireShape {
|
|
|
199
209
|
display_name: string;
|
|
200
210
|
tagline: string;
|
|
201
211
|
/**
|
|
202
|
-
* Discovery tier (2026-06-09 modular-UI architecture
|
|
203
|
-
* in the headline group; `experimental`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
212
|
+
* Discovery tier (2026-06-09 modular-UI architecture; `deprecated` added
|
|
213
|
+
* 2026-06-25). `core` modules render in the headline group; `experimental`
|
|
214
|
+
* modules render in a de-emphasized "Experimental" group; `deprecated`
|
|
215
|
+
* modules (notes-daemon / runner) render in a further-de-emphasized
|
|
216
|
+
* "Deprecated" group — never hidden when installed. Resolved from the
|
|
217
|
+
* module's `module.json` `focus` when declared, else `focusForShort`'s
|
|
218
|
+
* default map (vault/scribe/hub/surface → core, notes/runner → deprecated,
|
|
219
|
+
* others → experimental).
|
|
207
220
|
*/
|
|
208
221
|
focus: ModuleFocus;
|
|
222
|
+
/**
|
|
223
|
+
* True iff the hub knows how to install this module (`package`/`manifest`
|
|
224
|
+
* resolvable). Historically "in the curated install catalog". Still set for
|
|
225
|
+
* every known module; a purely third-party services.json row is false.
|
|
226
|
+
* NOTE: this is NOT the fresh-install OFFER — a `deprecated` module is still
|
|
227
|
+
* `available: true` (it's installable for back-compat / re-install) but is
|
|
228
|
+
* excluded from `available_to_install`.
|
|
229
|
+
*/
|
|
209
230
|
available: boolean;
|
|
231
|
+
/**
|
|
232
|
+
* The fresh-install OFFER (2026-06-25): whether the hub presents this module
|
|
233
|
+
* in the "available to install fresh" set. `available && focus !==
|
|
234
|
+
* "deprecated"`. The SPA's "Install a module" catalog filters on this so
|
|
235
|
+
* notes-daemon / runner aren't pushed on a fresh box; an already-installed
|
|
236
|
+
* deprecated module still surfaces (in the Installed section) for management.
|
|
237
|
+
*/
|
|
238
|
+
available_to_install: boolean;
|
|
210
239
|
installed: boolean;
|
|
211
240
|
installed_version: string | null;
|
|
212
241
|
latest_version: string | null;
|
|
@@ -562,8 +591,8 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
562
591
|
// module's manifest-declared `focus` (when installed + declared) wins; else
|
|
563
592
|
// the `focusForShort` default map. Sort: `core` group first (with the
|
|
564
593
|
// CURATED_MODULES recommended-install order floated to the top of that
|
|
565
|
-
// group), then `experimental
|
|
566
|
-
// never hides
|
|
594
|
+
// group), then `experimental`, then `deprecated` (notes / runner) last — the
|
|
595
|
+
// SPA renders the groups; `focus` never hides an installed module.
|
|
567
596
|
const recommendedOrder = new Map<string, number>(
|
|
568
597
|
(CURATED_MODULES as readonly string[]).map((s, i) => [s, i]),
|
|
569
598
|
);
|
|
@@ -585,6 +614,11 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
585
614
|
// known modules; a purely third-party services.json row (no install
|
|
586
615
|
// package) is not hub-installable → false.
|
|
587
616
|
available: m !== undefined,
|
|
617
|
+
// Fresh-install OFFER (2026-06-25): installable AND not deprecated. A
|
|
618
|
+
// deprecated short (notes / runner) stays `available` (re-installable
|
|
619
|
+
// for back-compat) but is dropped from the offer set so the SPA's
|
|
620
|
+
// "Install a module" catalog doesn't push it on a fresh box.
|
|
621
|
+
available_to_install: m !== undefined && focus !== "deprecated",
|
|
588
622
|
installed: installed !== undefined,
|
|
589
623
|
installed_version: installed?.version ?? null,
|
|
590
624
|
latest_version: latestByShort.get(short) ?? null,
|
|
@@ -598,7 +632,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
598
632
|
};
|
|
599
633
|
return row;
|
|
600
634
|
});
|
|
601
|
-
const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : 1);
|
|
635
|
+
const focusRank = (f: ModuleFocus): number => (f === "core" ? 0 : f === "experimental" ? 1 : 2);
|
|
602
636
|
rows.sort((a, b) => {
|
|
603
637
|
if (a.focus !== b.focus) return focusRank(a.focus) - focusRank(b.focus);
|
|
604
638
|
const ai = recommendedOrder.get(a.short) ?? Number.POSITIVE_INFINITY;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/vault-caps*` — admin visibility + edit for per-vault storage caps
|
|
3
|
+
* (DEMO-PREP-2026-06-25 Workstream B5 / D-slice).
|
|
4
|
+
*
|
|
5
|
+
* PR #686 shipped the `vault_caps` table + `setVaultCap`/`getVaultCapBytes`
|
|
6
|
+
* (provision-time persistence) and a separate Phase-2 PR reads + ENFORCES the
|
|
7
|
+
* cap at upload time. This file is the OPERATOR-FACING seam in between: the
|
|
8
|
+
* admin SPA needs to SEE who has what cap and EDIT a cap live in the demo.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces:
|
|
11
|
+
*
|
|
12
|
+
* GET /api/vault-caps list every vault (from services.json) joined
|
|
13
|
+
* with its persisted cap (host:admin)
|
|
14
|
+
* PUT /api/vault-caps/:name set/update a vault's cap to N bytes (host:admin)
|
|
15
|
+
*
|
|
16
|
+
* The list is the JOIN of "what vaults exist" (services.json, the canonical
|
|
17
|
+
* vault-name source) and "what caps are persisted" (`vault_caps`). A vault
|
|
18
|
+
* with no cap row appears with `cap_bytes: null` (uncapped) — the same
|
|
19
|
+
* "uncapped = no row" contract the Phase-2 enforcement reader relies on.
|
|
20
|
+
*
|
|
21
|
+
* Wire shape is snake_case (matches `/api/users`, `/api/invites`). Auth: same
|
|
22
|
+
* `parachute:host:admin` Bearer gate as every other `/api/*` admin surface;
|
|
23
|
+
* the SPA mints it from the session cookie via `/admin/host-admin-token`.
|
|
24
|
+
*
|
|
25
|
+
* Scope discipline: PUT only sets a cap on a vault that is REGISTERED in
|
|
26
|
+
* services.json (rejects a stale / typo name with 400 `vault_not_found`) so an
|
|
27
|
+
* operator can't seed a cap row for a vault that doesn't exist. Additive only —
|
|
28
|
+
* this never changes the provision-time cap-persistence from #686; it's a
|
|
29
|
+
* read + targeted-edit layer on the same table.
|
|
30
|
+
*/
|
|
31
|
+
import type { Database } from "bun:sqlite";
|
|
32
|
+
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
33
|
+
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
34
|
+
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
35
|
+
import { getVaultCap, setVaultCap } from "./vault-caps.ts";
|
|
36
|
+
import { listVaultNamesFromPath } from "./vault-names.ts";
|
|
37
|
+
|
|
38
|
+
export interface ApiVaultCapsDeps {
|
|
39
|
+
db: Database;
|
|
40
|
+
/** Hub origin — JWT `iss` validation. */
|
|
41
|
+
issuer: string;
|
|
42
|
+
/** Override services.json path. Defaults to `~/.parachute/services.json`. */
|
|
43
|
+
manifestPath?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One row in the `GET /api/vault-caps` response: a vault name + its cap. A
|
|
48
|
+
* vault with no persisted cap carries `cap_bytes: null` (uncapped); `created_at`
|
|
49
|
+
* / `updated_at` are null too in that case.
|
|
50
|
+
*/
|
|
51
|
+
export interface VaultCapWireShape {
|
|
52
|
+
vault_name: string;
|
|
53
|
+
cap_bytes: number | null;
|
|
54
|
+
created_at: string | null;
|
|
55
|
+
updated_at: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
59
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
60
|
+
status,
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GET /api/vault-caps — every vault registered in services.json, joined with
|
|
67
|
+
* its persisted cap (null = uncapped). Ordered by vault name.
|
|
68
|
+
*/
|
|
69
|
+
export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps): Promise<Response> {
|
|
70
|
+
if (req.method !== "GET") {
|
|
71
|
+
return jsonError(405, "method_not_allowed", "use GET");
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
77
|
+
}
|
|
78
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
79
|
+
const names = listVaultNamesFromPath(manifestPath);
|
|
80
|
+
const caps: VaultCapWireShape[] = names.map((vaultName) => {
|
|
81
|
+
const cap = getVaultCap(deps.db, vaultName);
|
|
82
|
+
return {
|
|
83
|
+
vault_name: vaultName,
|
|
84
|
+
cap_bytes: cap?.capBytes ?? null,
|
|
85
|
+
created_at: cap?.createdAt ?? null,
|
|
86
|
+
updated_at: cap?.updatedAt ?? null,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
return new Response(JSON.stringify({ vault_caps: caps }), {
|
|
90
|
+
status: 200,
|
|
91
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface SetCapBody {
|
|
96
|
+
cap_bytes: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ParseErr {
|
|
100
|
+
ok: false;
|
|
101
|
+
status: number;
|
|
102
|
+
error: string;
|
|
103
|
+
description: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function parseSetCapBody(req: Request): Promise<{ ok: true; body: SetCapBody } | ParseErr> {
|
|
107
|
+
const ctype = req.headers.get("content-type") ?? "";
|
|
108
|
+
if (!ctype.toLowerCase().includes("application/json")) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
status: 400,
|
|
112
|
+
error: "invalid_request",
|
|
113
|
+
description: "Content-Type must be application/json",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
let raw: unknown;
|
|
117
|
+
try {
|
|
118
|
+
raw = await req.json();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 400,
|
|
124
|
+
error: "invalid_request",
|
|
125
|
+
description: `invalid JSON body: ${msg}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (!raw || typeof raw !== "object") {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
status: 400,
|
|
132
|
+
error: "invalid_request",
|
|
133
|
+
description: "request body must be a JSON object",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const obj = raw as Record<string, unknown>;
|
|
137
|
+
const capBytes = obj.cap_bytes;
|
|
138
|
+
// Positive integer only — the table's CHECK (cap_bytes > 0) is the at-rest
|
|
139
|
+
// backstop; this is the edge validation so the operator gets a clean 400
|
|
140
|
+
// instead of a sqlite constraint error. A fractional byte count (which a byte
|
|
141
|
+
// count can never be) is rejected rather than silently floored downstream.
|
|
142
|
+
if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 400,
|
|
146
|
+
error: "invalid_request",
|
|
147
|
+
description: '"cap_bytes" must be a positive integer number of bytes',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, body: { cap_bytes: capBytes } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* PUT /api/vault-caps/:name — set or update a vault's storage cap.
|
|
155
|
+
*
|
|
156
|
+
* Order of checks (mirrors the /api/users handlers):
|
|
157
|
+
*
|
|
158
|
+
* 1. Method gate (405 on non-PUT).
|
|
159
|
+
* 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
|
|
160
|
+
* 3. Parse + validate body (400 on shape / non-positive cap).
|
|
161
|
+
* 4. Vault is registered in services.json (400 `vault_not_found`) — refuse
|
|
162
|
+
* to seed a cap for a vault that doesn't exist.
|
|
163
|
+
* 5. `setVaultCap` (upsert) — overwrites the cap, bumps `updated_at`,
|
|
164
|
+
* preserves the original `created_at`.
|
|
165
|
+
*
|
|
166
|
+
* Response on success: `200 { vault_cap: <wire shape> }`.
|
|
167
|
+
*/
|
|
168
|
+
export async function handleSetVaultCap(
|
|
169
|
+
req: Request,
|
|
170
|
+
vaultName: string,
|
|
171
|
+
deps: ApiVaultCapsDeps,
|
|
172
|
+
): Promise<Response> {
|
|
173
|
+
if (req.method !== "PUT") {
|
|
174
|
+
return jsonError(405, "method_not_allowed", "use PUT");
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
180
|
+
}
|
|
181
|
+
const parsed = await parseSetCapBody(req);
|
|
182
|
+
if (!parsed.ok) {
|
|
183
|
+
return jsonError(parsed.status, parsed.error, parsed.description);
|
|
184
|
+
}
|
|
185
|
+
const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
186
|
+
const known = new Set(listVaultNamesFromPath(manifestPath));
|
|
187
|
+
if (!known.has(vaultName)) {
|
|
188
|
+
return jsonError(
|
|
189
|
+
400,
|
|
190
|
+
"vault_not_found",
|
|
191
|
+
`vault "${vaultName}" is not registered in services.json`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const cap = setVaultCap(deps.db, vaultName, parsed.body.cap_bytes);
|
|
195
|
+
console.log(`vault cap set: vault=${vaultName} cap_bytes=${cap.capBytes}`);
|
|
196
|
+
const wire: VaultCapWireShape = {
|
|
197
|
+
vault_name: cap.vaultName,
|
|
198
|
+
cap_bytes: cap.capBytes,
|
|
199
|
+
created_at: cap.createdAt,
|
|
200
|
+
updated_at: cap.updatedAt,
|
|
201
|
+
};
|
|
202
|
+
return new Response(JSON.stringify({ vault_cap: wire }), {
|
|
203
|
+
status: 200,
|
|
204
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
KNOWN_MODULES,
|
|
12
12
|
type ServiceSpec,
|
|
13
13
|
composeServiceSpec,
|
|
14
|
+
focusForShort,
|
|
14
15
|
knownServices,
|
|
15
16
|
} from "../service-spec.ts";
|
|
16
17
|
import type { ServiceEntry } from "../services-manifest.ts";
|
|
@@ -108,10 +109,14 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
|
-
* Survey
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
112
|
+
* Survey ALL known first-party shortnames (vault / notes / scribe / agent /
|
|
113
|
+
* runner / surface) regardless of tier — `installed` is true when the service
|
|
114
|
+
* has a row in services.json. The fresh-install OFFER is narrowed downstream
|
|
115
|
+
* by `isOfferable` (drops already-installed + `deprecated`-tier shorts —
|
|
116
|
+
* notes / runner); agent (`experimental`) is flagged exploratory in its blurb
|
|
117
|
+
* but stays offered. Surveying everything keeps `installed` detection complete
|
|
118
|
+
* (the "already installed" banner still lists a deprecated module an operator
|
|
119
|
+
* has on disk).
|
|
115
120
|
*
|
|
116
121
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
117
122
|
* shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
|
|
@@ -149,10 +154,30 @@ function surveyServices(manifestPath: string): ServiceChoice[] {
|
|
|
149
154
|
});
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A surveyed service is OFFERED on a fresh setup iff it is not already
|
|
159
|
+
* installed AND its discovery tier is not `deprecated` (2026-06-25). The
|
|
160
|
+
* deprecated tier (notes-daemon, runner) stays resolvable + manageable for an
|
|
161
|
+
* existing install — it just isn't pushed on a fresh box. `agent`
|
|
162
|
+
* (`experimental`) is still offered. Exported so the setup tests can pin the
|
|
163
|
+
* exclusion directly.
|
|
164
|
+
*/
|
|
165
|
+
export function isOfferable(choice: { short: string; installed: boolean }): boolean {
|
|
166
|
+
if (choice.installed) return false;
|
|
167
|
+
// No `declared` arg: the survey fires PRE-install, before any module.json is
|
|
168
|
+
// on disk to read a self-declared `focus` from — so the static default map is
|
|
169
|
+
// the only signal available + the right one here.
|
|
170
|
+
return focusForShort(choice.short) !== "deprecated";
|
|
171
|
+
}
|
|
172
|
+
|
|
152
173
|
const BLURBS: Record<string, string> = {
|
|
153
174
|
vault: "knowledge graph (MCP) — your owner-authenticated note + tag store",
|
|
175
|
+
surface: "Parachute UI host — auto-installs Notes on first boot (the recommended UI path)",
|
|
176
|
+
// `app` is the pre-2026-05-27 name for `surface`; kept for any legacy survey row.
|
|
154
177
|
app: "Parachute UI host — auto-installs Notes on first boot (recommended over notes-daemon)",
|
|
155
|
-
notes
|
|
178
|
+
// notes / runner are `deprecated` (not offered on a fresh setup) — these
|
|
179
|
+
// blurbs only render if a legacy install surfaces them in the survey.
|
|
180
|
+
notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `surface`)",
|
|
156
181
|
scribe: "audio transcription for dictation + recordings",
|
|
157
182
|
runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
|
|
158
183
|
agent:
|
|
@@ -306,7 +331,7 @@ export async function setup(opts: SetupOpts = {}): Promise<number> {
|
|
|
306
331
|
|
|
307
332
|
const survey = surveyServices(manifestPath);
|
|
308
333
|
const installed = survey.filter((s) => s.installed);
|
|
309
|
-
const offered = survey.filter(
|
|
334
|
+
const offered = survey.filter(isOfferable);
|
|
310
335
|
|
|
311
336
|
if (installed.length > 0) {
|
|
312
337
|
log("Already installed:");
|
package/src/hub-server.ts
CHANGED
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
* /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
|
|
97
97
|
* /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
|
|
98
98
|
* /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
|
|
99
|
+
* /api/vault-caps (GET) → list vaults + persisted storage caps (host:admin)
|
|
100
|
+
* /api/vault-caps/<name> (PUT) → set/update a vault's storage cap (host:admin)
|
|
99
101
|
* /login (GET + POST) → operator password login
|
|
100
102
|
* /login/2fa (POST) → second-factor (TOTP/backup) step
|
|
101
103
|
* (hub#473; reached after a correct
|
|
@@ -221,6 +223,7 @@ import {
|
|
|
221
223
|
handleResetUserPassword,
|
|
222
224
|
handleUpdateUserVaults,
|
|
223
225
|
} from "./api-users.ts";
|
|
226
|
+
import { handleListVaultCaps, handleSetVaultCap } from "./api-vault-caps.ts";
|
|
224
227
|
import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
|
|
225
228
|
import {
|
|
226
229
|
CHROME_OPT_OUT_PREFIXES,
|
|
@@ -3351,6 +3354,30 @@ export function hubFetch(
|
|
|
3351
3354
|
});
|
|
3352
3355
|
}
|
|
3353
3356
|
|
|
3357
|
+
// Per-vault storage caps (B5 admin visibility / D-slice). GET lists every
|
|
3358
|
+
// vault from services.json joined with its persisted cap; PUT /:name
|
|
3359
|
+
// sets/updates a cap. host:admin-gated, same gate flavor as /api/users.
|
|
3360
|
+
if (pathname === "/api/vault-caps") {
|
|
3361
|
+
if (!getDb) return dbNotConfigured();
|
|
3362
|
+
return handleListVaultCaps(req, {
|
|
3363
|
+
db: getDb(),
|
|
3364
|
+
issuer: oauthDeps(req).issuer,
|
|
3365
|
+
manifestPath,
|
|
3366
|
+
});
|
|
3367
|
+
}
|
|
3368
|
+
if (pathname.startsWith("/api/vault-caps/")) {
|
|
3369
|
+
if (!getDb) return dbNotConfigured();
|
|
3370
|
+
const name = decodeURIComponent(pathname.slice("/api/vault-caps/".length));
|
|
3371
|
+
if (!name || name.includes("/")) {
|
|
3372
|
+
return new Response("not found", { status: 404 });
|
|
3373
|
+
}
|
|
3374
|
+
return handleSetVaultCap(req, name, {
|
|
3375
|
+
db: getDb(),
|
|
3376
|
+
issuer: oauthDeps(req).issuer,
|
|
3377
|
+
manifestPath,
|
|
3378
|
+
});
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3354
3381
|
// Canonical login/logout. The handlers themselves are unchanged from
|
|
3355
3382
|
// when they lived at /admin/login + /admin/logout; the rename surfaced
|
|
3356
3383
|
// via #231-followup so the URL reflects the surface's actual scope
|
package/src/module-manifest.ts
CHANGED
|
@@ -66,16 +66,26 @@ export interface ConfigSchema {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Discovery tier (2026-06-09 modular-UI architecture).
|
|
70
|
-
* product surface (vault / scribe / hub / surface)
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* Discovery tier (2026-06-09 modular-UI architecture). Three tiers today:
|
|
70
|
+
* - `core` — the product surface (vault / scribe / hub / surface).
|
|
71
|
+
* - `experimental` — legit previews (agent) that render in a de-emphasized
|
|
72
|
+
* group but ARE offered on a fresh install.
|
|
73
|
+
* - `deprecated` — modules retained for back-compat (notes-daemon, runner)
|
|
74
|
+
* that stay resolvable + shown-if-installed (so an existing operator can
|
|
75
|
+
* still manage / uninstall them) but are NOT offered on a fresh setup
|
|
76
|
+
* (2026-06-25). Distinct from a fully retired module (`RETIRED_MODULES`),
|
|
77
|
+
* whose services.json row is GC'd on load.
|
|
78
|
+
*
|
|
79
|
+
* **Show all installed; never hide** — `focus` only sorts + labels for an
|
|
80
|
+
* installed/known module. The one behavioral lever it pulls is the fresh-install
|
|
81
|
+
* OFFER: `deprecated` shorts are dropped from the setup wizard + the admin SPA's
|
|
82
|
+
* "available to install" set.
|
|
73
83
|
*
|
|
74
84
|
* Absent in a `module.json` ⇒ the hub falls back to its default map (see
|
|
75
85
|
* `service-spec.focusForShort`), which defaults unlisted modules to
|
|
76
86
|
* `experimental`.
|
|
77
87
|
*/
|
|
78
|
-
export type ModuleFocus = "core" | "experimental";
|
|
88
|
+
export type ModuleFocus = "core" | "experimental" | "deprecated";
|
|
79
89
|
|
|
80
90
|
/**
|
|
81
91
|
* An event a module EMITS — the left-hand side of a Connection (2026-06-09
|
|
@@ -324,8 +334,10 @@ export interface ModuleManifest {
|
|
|
324
334
|
* Discovery tier (2026-06-09 modular-UI architecture). When a module
|
|
325
335
|
* declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
|
|
326
336
|
* falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
|
|
327
|
-
* `core`, everything else → `experimental`).
|
|
328
|
-
* `focus`
|
|
337
|
+
* `core`, notes/runner → `deprecated`, everything else → `experimental`).
|
|
338
|
+
* **Show all installed; never hide** — `focus` groups + de-emphasizes; the
|
|
339
|
+
* one behavioral lever is the fresh-install OFFER, which excludes
|
|
340
|
+
* `deprecated`. Additive + back-compatible.
|
|
329
341
|
*/
|
|
330
342
|
readonly focus?: ModuleFocus;
|
|
331
343
|
/**
|
|
@@ -711,12 +723,14 @@ function asCredentials(v: unknown, where: string): readonly ModuleCredential[] |
|
|
|
711
723
|
});
|
|
712
724
|
}
|
|
713
725
|
|
|
714
|
-
const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
|
|
726
|
+
const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental", "deprecated"]);
|
|
715
727
|
|
|
716
728
|
function asFocus(v: unknown, where: string): ModuleFocus | undefined {
|
|
717
729
|
if (v === undefined) return undefined;
|
|
718
730
|
if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
|
|
719
|
-
throw new ModuleManifestError(
|
|
731
|
+
throw new ModuleManifestError(
|
|
732
|
+
`${where}: "focus" must be "core" | "experimental" | "deprecated" if present`,
|
|
733
|
+
);
|
|
720
734
|
}
|
|
721
735
|
return v as ModuleFocus;
|
|
722
736
|
}
|
package/src/service-spec.ts
CHANGED
|
@@ -637,12 +637,22 @@ export function knownServices(): string[] {
|
|
|
637
637
|
}
|
|
638
638
|
|
|
639
639
|
/**
|
|
640
|
-
* Default discovery tier per short name (2026-06-09 modular-UI architecture
|
|
641
|
-
* The hub PREFERS a module's
|
|
642
|
-
* fallback when `module.json` omits
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* `
|
|
640
|
+
* Default discovery tier per short name (2026-06-09 modular-UI architecture;
|
|
641
|
+
* `deprecated` tier added 2026-06-25). The hub PREFERS a module's
|
|
642
|
+
* manifest-declared `focus`; this map is the fallback when `module.json` omits
|
|
643
|
+
* it (and the bootstrap value before a module is installed).
|
|
644
|
+
*
|
|
645
|
+
* - `core` — vault / scribe / hub / surface (the product surface).
|
|
646
|
+
* - `experimental` — agent (legit preview; still OFFERED on a fresh install)
|
|
647
|
+
* + any unlisted third-party short.
|
|
648
|
+
* - `deprecated` — notes (notes-daemon deprecated 2026-05-22; notes-ui moved
|
|
649
|
+
* into parachute-surface) + runner (per Aaron 2026-06-25: not for new
|
|
650
|
+
* installs). Still RESOLVABLE (discoverableShorts unchanged) and
|
|
651
|
+
* SHOWN-IF-INSTALLED so an existing operator can manage/uninstall, but NOT
|
|
652
|
+
* OFFERED on a fresh setup.
|
|
653
|
+
*
|
|
654
|
+
* **Show all installed; never hide** — `focus` groups + labels; the one
|
|
655
|
+
* behavioral lever is the fresh-install OFFER, which drops `deprecated` shorts.
|
|
646
656
|
*/
|
|
647
657
|
const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
648
658
|
vault: "core",
|
|
@@ -650,8 +660,8 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
|
650
660
|
hub: "core",
|
|
651
661
|
surface: "core",
|
|
652
662
|
agent: "experimental",
|
|
653
|
-
runner: "
|
|
654
|
-
notes: "
|
|
663
|
+
runner: "deprecated",
|
|
664
|
+
notes: "deprecated",
|
|
655
665
|
};
|
|
656
666
|
|
|
657
667
|
/**
|
|
@@ -659,6 +669,13 @@ const FOCUS_DEFAULTS: Record<string, ModuleFocus> = {
|
|
|
659
669
|
* `module.json` `focus`) is present it wins; otherwise fall back to
|
|
660
670
|
* `FOCUS_DEFAULTS`, defaulting any unlisted short to `experimental`. Never
|
|
661
671
|
* returns undefined — the Modules screen always has a tier to group by.
|
|
672
|
+
*
|
|
673
|
+
* Tier semantics: `core`/`experimental` are both OFFERED on a fresh install;
|
|
674
|
+
* `deprecated` (notes / runner) is NOT offered on a fresh setup but stays
|
|
675
|
+
* resolvable + shown-if-installed (the `isKnownModuleShort` /
|
|
676
|
+
* `discoverableShorts` resolution surface is unchanged). The fresh-install
|
|
677
|
+
* filters in `setup.ts` + `api-modules.ts` consult this tier to drop
|
|
678
|
+
* `deprecated` shorts from the "available to install" set.
|
|
662
679
|
*/
|
|
663
680
|
export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocus {
|
|
664
681
|
if (declared !== undefined) return declared;
|
|
@@ -674,10 +691,12 @@ export function focusForShort(short: string, declared?: ModuleFocus): ModuleFocu
|
|
|
674
691
|
* regardless of `focus` tier. Deduped, with FIRST_PARTY_FALLBACKS shorts first
|
|
675
692
|
* (notes) then KNOWN_MODULES (vault / scribe / runner / agent / surface).
|
|
676
693
|
*
|
|
677
|
-
* `notes`
|
|
678
|
-
* for legacy installs;
|
|
679
|
-
*
|
|
680
|
-
*
|
|
694
|
+
* `notes` (and `runner`) are intentionally included — still resolvable
|
|
695
|
+
* (vendored fallback / KNOWN_MODULES) for legacy installs; they surface as
|
|
696
|
+
* `deprecated` (2026-06-25) and aren't OFFERED on a fresh install. The
|
|
697
|
+
* fresh-install OFFER (setup wizard + admin SPA) filters by tier
|
|
698
|
+
* (`focus !== "deprecated"`); `discoverableShorts` itself stays the full
|
|
699
|
+
* resolution surface so existing installs keep working.
|
|
681
700
|
*/
|
|
682
701
|
export function discoverableShorts(): string[] {
|
|
683
702
|
const seen = new Set<string>();
|
|
@@ -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}.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)}
|