@openparachute/hub 0.7.4-rc.8 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-auth.test.ts +128 -0
  3. package/src/__tests__/admin-clients.test.ts +103 -1
  4. package/src/__tests__/admin-handlers.test.ts +28 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +58 -1
  6. package/src/__tests__/admin-lock.test.ts +33 -1
  7. package/src/__tests__/admin-vaults.test.ts +52 -9
  8. package/src/__tests__/api-account-2fa.test.ts +453 -0
  9. package/src/__tests__/api-mint-token.test.ts +75 -0
  10. package/src/__tests__/api-modules.test.ts +143 -0
  11. package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
  12. package/src/__tests__/auth.test.ts +336 -0
  13. package/src/__tests__/clients.test.ts +298 -0
  14. package/src/__tests__/cors.test.ts +138 -1
  15. package/src/__tests__/doctor.test.ts +755 -0
  16. package/src/__tests__/hub-command.test.ts +69 -2
  17. package/src/__tests__/hub-settings.test.ts +188 -0
  18. package/src/__tests__/jwt-sign.test.ts +27 -0
  19. package/src/__tests__/oauth-handlers.test.ts +276 -21
  20. package/src/__tests__/oauth-ui.test.ts +52 -0
  21. package/src/__tests__/scope-explanations.test.ts +20 -9
  22. package/src/__tests__/sessions.test.ts +80 -0
  23. package/src/__tests__/setup-gate.test.ts +111 -3
  24. package/src/__tests__/vault-remove.test.ts +40 -19
  25. package/src/__tests__/well-known.test.ts +37 -2
  26. package/src/account-setup.ts +2 -0
  27. package/src/admin-agent-grants.ts +16 -1
  28. package/src/admin-auth.ts +13 -4
  29. package/src/admin-clients.ts +66 -5
  30. package/src/admin-grants.ts +11 -2
  31. package/src/admin-handlers.ts +2 -0
  32. package/src/admin-host-admin-token.ts +24 -1
  33. package/src/admin-lock.ts +16 -0
  34. package/src/admin-vaults.ts +70 -15
  35. package/src/api-account-2fa.ts +395 -0
  36. package/src/api-admin-lock.ts +7 -0
  37. package/src/api-hub-upgrade.ts +14 -1
  38. package/src/api-hub.ts +10 -1
  39. package/src/api-invites.ts +18 -3
  40. package/src/api-me.ts +11 -2
  41. package/src/api-mint-token.ts +16 -1
  42. package/src/api-modules.ts +119 -1
  43. package/src/api-revoke-token.ts +14 -1
  44. package/src/api-settings-hub-origin.ts +14 -1
  45. package/src/api-settings-root-redirect.ts +201 -0
  46. package/src/api-tokens.ts +14 -1
  47. package/src/api-users.ts +15 -6
  48. package/src/api-vault-caps.ts +11 -2
  49. package/src/cli.ts +29 -0
  50. package/src/clients.ts +164 -0
  51. package/src/commands/auth.ts +263 -1
  52. package/src/commands/doctor.ts +1250 -0
  53. package/src/commands/hub.ts +102 -1
  54. package/src/commands/vault-remove.ts +16 -24
  55. package/src/cors.ts +7 -3
  56. package/src/help.ts +53 -0
  57. package/src/hub-db.ts +14 -0
  58. package/src/hub-server.ts +123 -19
  59. package/src/hub-settings.ts +163 -1
  60. package/src/jwt-sign.ts +25 -6
  61. package/src/oauth-handlers.ts +25 -5
  62. package/src/oauth-ui.ts +51 -0
  63. package/src/rate-limit.ts +28 -0
  64. package/src/scope-explanations.ts +23 -9
  65. package/src/sessions.ts +43 -2
  66. package/src/setup-wizard.ts +2 -0
  67. package/src/well-known.ts +10 -1
  68. package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
  69. package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
  70. package/web/ui/dist/index.html +2 -2
  71. package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { Database } from "bun:sqlite";
25
25
  import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
26
+ import { recordLoginUnlock } from "./admin-lock.ts";
26
27
  import { renderTotpChallenge } from "./admin-login-ui.ts";
27
28
  import {
28
29
  AuthCodeExpiredError,
@@ -294,8 +295,11 @@ export function buildServicesCatalog(
294
295
  if (audiences.has("vault")) {
295
296
  for (const entry of manifest.services) {
296
297
  if (!isVaultEntry(entry)) continue;
297
- const paths = entry.paths.length > 0 ? entry.paths : ["/"];
298
- for (const path of paths) {
298
+ // #478: an empty-paths vault row is "installed but no servable instance"
299
+ // — skip it so it never counts toward (or, below, fabricates) a phantom
300
+ // vault. Mirrors the continue in well-known.ts / admin-vaults.ts.
301
+ if (entry.paths.length === 0) continue;
302
+ for (const path of entry.paths) {
299
303
  const instance = vaultInstanceNameFor(entry.name, path);
300
304
  if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
301
305
  }
@@ -308,12 +312,16 @@ export function buildServicesCatalog(
308
312
  for (const entry of manifest.services) {
309
313
  if (isVaultEntry(entry)) {
310
314
  if (!audiences.has("vault")) continue;
315
+ // #478: an empty-paths vault row is "installed but no servable instance"
316
+ // — skip it so the catalog never offers a phantom `vault` / `vault:default`
317
+ // entry pointing at root before any vault exists. Mirrors well-known.ts /
318
+ // admin-vaults.ts / vault-names.ts.
319
+ if (entry.paths.length === 0) continue;
311
320
  // Walk every path the row exposes. Real multi-vault on the hub is a
312
321
  // single `parachute-vault` row with N paths (one per vault instance);
313
322
  // legacy per-vault rows (`parachute-vault-<name>`) are handled by the
314
323
  // same loop because each contributes one path.
315
- const paths = entry.paths.length > 0 ? entry.paths : ["/"];
316
- for (const path of paths) {
324
+ for (const path of entry.paths) {
317
325
  const instance = vaultInstanceNameFor(entry.name, path);
318
326
  const admit = broadVaultScope || namedVaults.has(instance);
319
327
  if (!admit) continue;
@@ -1494,6 +1502,7 @@ async function handleLoginSubmit(
1494
1502
  }
1495
1503
 
1496
1504
  const session = createSession(db, { userId: user.id });
1505
+ recordLoginUnlock(db, session.id);
1497
1506
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1498
1507
  secure: isHttpsRequest(req),
1499
1508
  });
@@ -2699,7 +2708,13 @@ export async function handleRegister(
2699
2708
  let sameHub = false;
2700
2709
  if (req.headers.get("authorization")) {
2701
2710
  try {
2702
- await requireScope(db, req, "hub:admin", deps.issuer);
2711
+ // Validate the operator bearer's `iss` against the SET of origins the
2712
+ // hub answers on (`deps.hubBoundOrigins` — loopback ∪ expose-state ∪
2713
+ // platform ∪ per-request issuer), not just the single per-request
2714
+ // `issuer`, so a host-admin credential minted under a still-valid prior
2715
+ // origin keeps auto-approving across an origin switch (hub#516 parity).
2716
+ // Falls back to `[deps.issuer]` when no set getter is wired (tests).
2717
+ await requireScope(db, req, "hub:admin", deps.hubBoundOrigins?.() ?? deps.issuer);
2703
2718
  status = "approved";
2704
2719
  sameHub = true;
2705
2720
  } catch (err) {
@@ -2977,6 +2992,11 @@ function consentProps(
2977
2992
  blockApproveForStaleAssignment:
2978
2993
  staleAssignedVault !== undefined && (unnamedVerbs.length > 0 || hasNamedStaleVaultScope),
2979
2994
  userCanAuthorizeRequest,
2995
+ // hub#314 — surface the client's provenance (same-hub first-party vs
2996
+ // external third-party DCR) so the operator sees the trust level on the
2997
+ // consent screen. Clean DB-backed signal: the `same_hub` column written
2998
+ // at DCR time (bearer hub:admin / same-origin session → true).
2999
+ sameHub: client.sameHub,
2980
3000
  };
2981
3001
  }
2982
3002
 
package/src/oauth-ui.ts CHANGED
@@ -162,6 +162,21 @@ export interface ConsentViewProps {
162
162
  * the unnamed verb(s) on the picked vault; it never touches any other scope.
163
163
  */
164
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;
165
180
  }
166
181
 
167
182
  export interface OwnerVerbSelector {
@@ -354,6 +369,7 @@ export function renderConsent(props: ConsentViewProps): string {
354
369
  blockApproveForStaleAssignment,
355
370
  userCanAuthorizeRequest,
356
371
  ownerVerbSelector,
372
+ sameHub,
357
373
  } = props;
358
374
  // Substitute unnamed `vault:<verb>` rows with the resolved named form so
359
375
  // the operator sees the scope shape that will appear in the token. Raw
@@ -418,6 +434,24 @@ export function renderConsent(props: ConsentViewProps): string {
418
434
  before authorizing vault access.
419
435
  </p>`
420
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>`;
421
455
  const body = `
422
456
  <div class="card">
423
457
  <div class="card-header">
@@ -429,6 +463,7 @@ export function renderConsent(props: ConsentViewProps): string {
429
463
  <p class="subtitle">
430
464
  This app is requesting access to your Parachute account.
431
465
  </p>
466
+ ${trustMarker}
432
467
  <p class="client-meta">
433
468
  <span class="client-meta-label">client_id</span>
434
469
  <code>${escapeHtml(clientId)}</code>
@@ -1579,6 +1614,22 @@ const STYLES = `
1579
1614
  .badge-send { background: ${PALETTE.accentSoft}; color: ${PALETTE.accent}; }
1580
1615
  .badge-admin { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
1581
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
+
1582
1633
  @media (max-width: 480px) {
1583
1634
  main { padding: 0.75rem; }
1584
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();
@@ -127,13 +127,23 @@ export const FIRST_PARTY_SCOPES = Object.keys(SCOPE_EXPLANATIONS).sort();
127
127
  * RFC 8414 §2 says `scopes_supported` is the list a client *can*
128
128
  * request, so omitting these is the spec-compliant call.
129
129
  *
130
- * Why `parachute:host:admin` is on this list and `hub:admin` is not:
131
- * `parachute:host:admin` provisions and destroys vaults cross-vault
132
- * data sovereignty that the operator alone owns. `hub:admin` is service
133
- * management (signing keys, registered clients, user accounts) which an
134
- * operator may legitimately delegate to a tooling app. The asymmetry is
135
- * intentional: the blast radius of compromised cross-vault admin doesn't
136
- * justify third-party requestability.
130
+ * Service-admin scopes (`hub:admin`, `scribe:admin`) are on this list as of
131
+ * 2026-06-30. They read as "delegable to a tooling app," but in practice a
132
+ * vault MCP connector (e.g. Claude) is pointed at the hub-level authorization
133
+ * server by the vault's protected-resource metadata, so the full hub catalog —
134
+ * including `hub:admin` (manage signing keys, registered clients, user
135
+ * accounts) gets advertised on its consent screen and, if approved, minted
136
+ * into its token. That's wildly over-privileged for a vault reader (cf.
137
+ * hub#671, where the agent-grants client had to hardcode least-privilege to
138
+ * dodge exactly this). The scope-narrowing that should strip it only fires
139
+ * when the client echoes a resolvable RFC 8707 vault `resource`, which MCP
140
+ * clients often don't. Every legitimate hub-admin / scribe-admin use is
141
+ * operator-bearer or session based (operator token, DCR self-registration via
142
+ * `requireScope`, the admin SPA host-admin token) — none route through
143
+ * `/oauth/authorize` — so making these non-requestable fails closed against
144
+ * third-party requests without breaking any first-party operator path.
145
+ * `parachute:host:*` remains for the original reason: it provisions/destroys
146
+ * vaults (cross-vault sovereignty the operator alone owns).
137
147
  */
138
148
  export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
139
149
  "parachute:host:admin",
@@ -142,6 +152,9 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
142
152
  "parachute:host:expose",
143
153
  "parachute:host:auth",
144
154
  "parachute:host:vault",
155
+ // Service-admin scopes: operator-only, never requestable via /oauth/authorize.
156
+ "hub:admin",
157
+ "scribe:admin",
145
158
  ]);
146
159
 
147
160
  /**
@@ -269,8 +282,9 @@ export function isNonRequestableScope(scope: string): boolean {
269
282
  // Per-vault `vault:<name>:admin` is NO LONGER globally non-requestable
270
283
  // (single-consent change, 2026-05-29). It flows through the public OAuth
271
284
  // consent path and through `canGrant` rule 1, capped to the consenting
272
- // user's held authority at the `issueAuthCodeRedirect` choke-point. Only
273
- // the host-level operator scopes stay non-requestable here.
285
+ // user's held authority at the `issueAuthCodeRedirect` choke-point. The
286
+ // host-level operator scopes AND the service-admin scopes (hub:admin,
287
+ // scribe:admin) stay non-requestable here (see NON_REQUESTABLE_SCOPES).
274
288
  //
275
289
  // Item C — case-insensitive guard. The membership check is exact-string,
276
290
  // but Parachute scope tokens are canonically lowercase. A casing variant
package/src/sessions.ts CHANGED
@@ -4,8 +4,12 @@
4
4
  * with that cookie skip the login form and go straight to consent.
5
5
  *
6
6
  * Stored in `sessions` (one row per active session), so logout / forced
7
- * revocation is just a delete. Cookies are 24h; sliding extension is a
8
- * follow-up for now, a session expires absolutely at `expires_at`.
7
+ * revocation is just a delete. Sessions are SLIDING: `expires_at` starts at
8
+ * `created_at + SESSION_TTL_MS`, and {@link touchSession} pushes it forward on
9
+ * genuine activity (the admin SPA re-mints `/admin/host-admin-token` every
10
+ * ~10 min while a tab is open). An idle session — no more mints — still
11
+ * expires at the original 24h mark, and {@link SESSION_MAX_LIFETIME_MS} caps
12
+ * total life so sliding can't keep a left-open tab alive forever.
9
13
  *
10
14
  * The cookie value is the session id directly. It's a 32-byte base64url
11
15
  * random; collision is statistically impossible. No HMAC needed because the
@@ -18,6 +22,15 @@ import { randomBytes } from "node:crypto";
18
22
  export const SESSION_COOKIE_NAME = "parachute_hub_session";
19
23
  export const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
20
24
 
25
+ /**
26
+ * Absolute ceiling on a session's total lifetime, independent of sliding
27
+ * renewal. Sliding ({@link touchSession}) keeps an active console signed in,
28
+ * but a left-open-but-idle tab whose background polls keep re-minting must
29
+ * still be force-logged-out eventually — this caps life at
30
+ * `created_at + SESSION_MAX_LIFETIME_MS` so renewal can't extend forever.
31
+ */
32
+ export const SESSION_MAX_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
33
+
21
34
  export interface Session {
22
35
  id: string;
23
36
  userId: string;
@@ -77,6 +90,34 @@ export function findSession(
77
90
  return session;
78
91
  }
79
92
 
93
+ /**
94
+ * Slide a session's expiry forward to `now + SESSION_TTL_MS`, capped at
95
+ * `created_at + SESSION_MAX_LIFETIME_MS`. No-op when the session doesn't exist.
96
+ *
97
+ * This is what makes sessions sliding rather than fixed-24h: the admin SPA
98
+ * re-mints `/admin/host-admin-token` roughly every ~10 min while a tab is open,
99
+ * and each successful mint calls this — so an actively-used console isn't
100
+ * hard-logged-out at the 24h mark, while a closed tab (no more mints) still
101
+ * expires 24h after its last activity. The ceiling bounds a left-open-but-idle
102
+ * tab (background polls keep re-minting) so sliding can't run forever.
103
+ *
104
+ * Monotonic in practice: the production wall clock only moves forward, so the
105
+ * slid value never undershoots a previously-written expiry; once it reaches the
106
+ * ceiling it stays pinned there. (The write is unconditional — it does not read
107
+ * the current expiry — so an injected backward `now` in tests would shorten the
108
+ * session: a conservative failure mode, not a security issue.) `now` is
109
+ * injectable for tests, matching {@link findSession}.
110
+ */
111
+ export function touchSession(db: Database, id: string, now: () => Date = () => new Date()): void {
112
+ const row = db.query<Row, [string]>("SELECT * FROM sessions WHERE id = ?").get(id);
113
+ if (!row) return;
114
+ const nowMs = now().getTime();
115
+ const slidMs = nowMs + SESSION_TTL_MS;
116
+ const ceilingMs = new Date(row.created_at).getTime() + SESSION_MAX_LIFETIME_MS;
117
+ const newExpiresAt = new Date(Math.min(slidMs, ceilingMs)).toISOString();
118
+ db.prepare("UPDATE sessions SET expires_at = ? WHERE id = ?").run(newExpiresAt, id);
119
+ }
120
+
80
121
  export function deleteSession(db: Database, id: string): void {
81
122
  db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
82
123
  }
@@ -40,6 +40,7 @@
40
40
  import type { Database } from "bun:sqlite";
41
41
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
42
  import { join } from "node:path";
43
+ import { recordLoginUnlock } from "./admin-lock.ts";
43
44
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
44
45
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
45
46
  import {
@@ -1978,6 +1979,7 @@ export async function handleSetupAccountPost(
1978
1979
  // account creation the operator just completed.
1979
1980
  await ensureOperatorTokenForFirstAdmin(deps, user.id);
1980
1981
  const session = createSession(deps.db, { userId: user.id });
1982
+ recordLoginUnlock(deps.db, session.id);
1981
1983
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1982
1984
  secure: isHttpsRequest(req),
1983
1985
  });
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
- const pathsToEmit = isVault && s.paths.length > 0 ? s.paths : [s.paths[0] ?? "/"];
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}