@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -59,6 +59,8 @@ import type { Database } from "bun:sqlite";
59
59
  import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
60
60
  import { SERVICES_MANIFEST_PATH } from "./config.ts";
61
61
  import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
62
+ import { enrichedPath } from "./spawn-path.ts";
63
+ import { VAULT_NAME_CHARSET_RE } from "./vault-name.ts";
62
64
  import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
63
65
 
64
66
  /** Scope required to call POST /vaults. */
@@ -72,7 +74,12 @@ export const HOST_ADMIN_SCOPE = "parachute:host:admin";
72
74
  * the hub rejects them at the API edge before a vault under those names
73
75
  * can register and capture the proxy path.
74
76
  */
75
- const VAULT_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
77
+ // Lowercase-only (item I) — single source of truth in vault-name.ts. Vault's
78
+ // init enforces `[a-z0-9_-]`; a hub-side `[a-zA-Z0-9_-]` superset let an
79
+ // uppercased name through that vault would then lowercase or reject, drifting
80
+ // the hub's idea of the vault from vault's. The hub-only reservations (`new`,
81
+ // `assets`) shadow SPA routes and stay on top of vault's `list`.
82
+ const VAULT_NAME_PATTERN = VAULT_NAME_CHARSET_RE;
76
83
  const RESERVED_VAULT_NAMES = new Set(["list", "new", "assets"]);
77
84
 
78
85
  export interface CreateVaultRequest {
@@ -164,7 +171,7 @@ async function parseBody(req: Request): Promise<ParseResult | ParseError> {
164
171
  return {
165
172
  ok: false,
166
173
  status: 400,
167
- message: "vault name must contain only letters, numbers, hyphens, and underscores",
174
+ message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
168
175
  };
169
176
  }
170
177
  if (RESERVED_VAULT_NAMES.has(name)) {
@@ -230,9 +237,13 @@ function buildEntry(
230
237
  async function defaultRunCommand(cmd: readonly string[]): Promise<RunResult> {
231
238
  // Inherit env so the child sees PATH, HOME, BUN_INSTALL, etc. Bun.spawn
232
239
  // defaults to empty env — see api-modules-ops.ts:defaultRun for the rationale.
240
+ // PATH enrichment (hub launchd-PATH regression): a launchd-managed hub bakes
241
+ // a minimal PATH, so this shell-out (vault ops) inherits that thin PATH too;
242
+ // enrich it with operator-tool dirs (`$HOME/.local/bin`, brew bin). See
243
+ // `spawn-path.ts`.
233
244
  const proc = Bun.spawn([...cmd], {
234
245
  stdio: ["ignore", "pipe", "pipe"],
235
- env: process.env,
246
+ env: { ...process.env, PATH: enrichedPath() },
236
247
  });
237
248
  // Drain both pipes in parallel — leaving stderr unread can deadlock long
238
249
  // installs once the OS pipe buffer fills (#97). Captured stderr is folded
@@ -268,10 +279,17 @@ async function orchestrate(
268
279
  manifestPath: string,
269
280
  name: string,
270
281
  runCommand: (cmd: readonly string[]) => Promise<RunResult>,
282
+ opts: { noMirror?: boolean } = {},
271
283
  ): Promise<OrchestrateOk | OrchestrateError> {
272
284
  const vaultRegistered = findService("parachute-vault", manifestPath) !== undefined;
285
+ // `--no-mirror` opts this create out of the default internal live mirror
286
+ // (§3 default_mirror knob). Only meaningful on the `create` branch — the
287
+ // bootstrap `install` path provisions the default vault and follows the
288
+ // server-wide knob.
273
289
  const cmd = vaultRegistered
274
- ? ["parachute-vault", "create", name, "--json"]
290
+ ? opts.noMirror
291
+ ? ["parachute-vault", "create", name, "--json", "--no-mirror"]
292
+ : ["parachute-vault", "create", name, "--json"]
275
293
  : ["parachute", "install", "vault"];
276
294
  let result: RunResult;
277
295
  try {
@@ -320,6 +338,102 @@ async function orchestrate(
320
338
  return { ok: true, createJson };
321
339
  }
322
340
 
341
+ /**
342
+ * Result of {@link provisionVault} — the programmatic vault-provisioning
343
+ * core shared by the authed HTTP handler and the invite-redeem path.
344
+ *
345
+ * - `created: true` — a fresh vault was provisioned this call. `entry`
346
+ * describes it; `createJson` (when present) carries the single-emit
347
+ * bootstrap creds.
348
+ * - `created: false` — the vault already existed (idempotent). `entry`
349
+ * describes the existing vault; no `createJson`.
350
+ */
351
+ export type ProvisionVaultResult =
352
+ | {
353
+ ok: true;
354
+ created: boolean;
355
+ entry: WellKnownVaultEntry;
356
+ createJson: VaultCreateJson | null;
357
+ }
358
+ | { ok: false; status: number; message: string };
359
+
360
+ /**
361
+ * Provision (or no-op if it exists) a vault by name — the auth-free core
362
+ * lifted out of {@link handleCreateVault} so the invite-redeem flow can
363
+ * provision a vault for a freshly-created account WITHOUT a host:admin
364
+ * bearer (the redeemer holds no admin authority; the invite IS the
365
+ * authorization). The HTTP handler keeps the host:admin gate in front of
366
+ * this; the invite path calls it directly after validating the invite.
367
+ *
368
+ * Idempotent-safe: if the vault already exists this returns
369
+ * `created:false` with the existing entry rather than re-running the CLI —
370
+ * a redeem retry (or a name collision) lands the user on the existing
371
+ * vault instead of erroring.
372
+ *
373
+ * Name validation matches `parachute-vault create`'s rules. Callers that
374
+ * accept an untrusted name (the invite redeemer naming their own vault)
375
+ * MUST pass it through here so the same regex + reserved-name gate the
376
+ * `/vaults` API edge enforces applies.
377
+ */
378
+ export async function provisionVault(
379
+ name: string,
380
+ deps: {
381
+ issuer: string;
382
+ manifestPath?: string;
383
+ runCommand?: CreateVaultDeps["runCommand"];
384
+ /** `'off'` appends `--no-mirror`; anything else follows the server knob. */
385
+ defaultMirror?: string;
386
+ },
387
+ ): Promise<ProvisionVaultResult> {
388
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
389
+ const runCommand = deps.runCommand ?? defaultRunCommand;
390
+
391
+ if (!VAULT_NAME_PATTERN.test(name)) {
392
+ return {
393
+ ok: false,
394
+ status: 400,
395
+ message: "vault name must contain only lowercase letters, numbers, hyphens, and underscores",
396
+ };
397
+ }
398
+ if (RESERVED_VAULT_NAMES.has(name)) {
399
+ return { ok: false, status: 400, message: `"${name}" is a reserved vault name` };
400
+ }
401
+
402
+ // Idempotency: if the vault already exists, return the existing entry.
403
+ const existing = findExistingVault(manifestPath, name);
404
+ if (existing) {
405
+ return {
406
+ ok: true,
407
+ created: false,
408
+ entry: buildEntry(name, existing.path, existing.version, deps.issuer),
409
+ createJson: null,
410
+ };
411
+ }
412
+
413
+ const result = await orchestrate(manifestPath, name, runCommand, {
414
+ noMirror: deps.defaultMirror === "off",
415
+ });
416
+ if (!result.ok) {
417
+ return { ok: false, status: result.status, message: result.message };
418
+ }
419
+
420
+ // Re-read services.json: the CLI just wrote it.
421
+ const created = findExistingVault(manifestPath, name);
422
+ if (!created) {
423
+ return {
424
+ ok: false,
425
+ status: 500,
426
+ message: `vault "${name}" was provisioned but is not in services.json — manual recovery required`,
427
+ };
428
+ }
429
+ return {
430
+ ok: true,
431
+ created: true,
432
+ entry: buildEntry(name, created.path, created.version, deps.issuer),
433
+ createJson: result.createJson,
434
+ };
435
+ }
436
+
323
437
  export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Promise<Response> {
324
438
  if (req.method !== "POST") {
325
439
  return new Response("method not allowed", { status: 405 });
@@ -341,35 +455,29 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
341
455
  }
342
456
  const { name } = parsed.body;
343
457
 
344
- // Idempotency: if the vault already exists, return 200 + existing entry.
345
- // Skip the CLI shell-out — re-POST is usually a UI retry.
346
- const existing = findExistingVault(manifestPath, name);
347
- if (existing) {
348
- return new Response(
349
- JSON.stringify(buildEntry(name, existing.path, existing.version, deps.issuer)),
350
- {
351
- status: 200,
352
- headers: { "content-type": "application/json" },
353
- },
354
- );
355
- }
356
-
357
- const result = await orchestrate(manifestPath, name, runCommand);
358
- if (!result.ok) {
359
- return jsonError(result.status, "server_error", result.message);
458
+ const provisioned = await provisionVault(name, {
459
+ issuer: deps.issuer,
460
+ manifestPath,
461
+ runCommand,
462
+ });
463
+ if (!provisioned.ok) {
464
+ // parseBody already enforced the name regex/reserved gate, so a
465
+ // provisionVault 400 here would be a redundant re-check; map any
466
+ // non-ok to its status. invalid_request for 400, server_error otherwise.
467
+ const error = provisioned.status === 400 ? "invalid_request" : "server_error";
468
+ return jsonError(provisioned.status, error, provisioned.message);
360
469
  }
361
470
 
362
- // Re-read services.json: the CLI just wrote it.
363
- const created = findExistingVault(manifestPath, name);
364
- if (!created) {
365
- return jsonError(
366
- 500,
367
- "server_error",
368
- `vault "${name}" was provisioned but is not in services.json — manual recovery required`,
369
- );
471
+ // Idempotent re-POST: existing vault 200, no single-emit creds.
472
+ if (!provisioned.created) {
473
+ return new Response(JSON.stringify(provisioned.entry), {
474
+ status: 200,
475
+ headers: { "content-type": "application/json" },
476
+ });
370
477
  }
371
478
 
372
- const entry = buildEntry(name, created.path, created.version, deps.issuer);
479
+ const entry = provisioned.entry;
480
+ const result = { createJson: provisioned.createJson };
373
481
  // Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
374
482
  // filesystem paths are single-emit at create time. We surface them here so
375
483
  // the caller can immediately bootstrap a connection to the new vault.
@@ -49,9 +49,12 @@ import type { Database } from "bun:sqlite";
49
49
  import { hash as argonHash } from "@node-rs/argon2";
50
50
  import { type ChangePasswordMode, renderChangePassword } from "./account-change-password-ui.ts";
51
51
  import { renderAccountHome } from "./account-home-ui.ts";
52
+ import { fetchVaultMirrorStatus, formatMirrorLine } from "./account-mirror.ts";
53
+ import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
52
54
  import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
53
55
  import { renderAdminError } from "./admin-login-ui.ts";
54
56
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
57
+ import { userHasExternalAiGrant } from "./grants.ts";
55
58
  import { changePasswordRateLimiter } from "./rate-limit.ts";
56
59
  import { isHttpsRequest } from "./request-protocol.ts";
57
60
  import { findActiveSession } from "./sessions.ts";
@@ -419,6 +422,16 @@ export async function handleAccountChangePasswordPost(
419
422
  )
420
423
  .run(passwordHash, stamp, user.id);
421
424
  if (result.changes === 0) throw new UserNotFoundError(user.id);
425
+ // Revoke the user's still-active tokens on a self-service password change
426
+ // (item F / hub#469). The admin-reset path already revokes
427
+ // (`resetUserPassword`, users.ts); applying the same on self-change closes
428
+ // the "mint a token under the admin's temp password, then rotate but keep
429
+ // the token" gap — any token minted before the rotation dies with it. The
430
+ // user re-mints under their own (now-rotated) password if they need one.
431
+ // Same transaction as the hash write so the two are atomic.
432
+ deps.db
433
+ .prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
434
+ .run(stamp, user.id);
422
435
  })();
423
436
  } catch (err) {
424
437
  // The user row vanished between the session-resolve check above and
@@ -476,9 +489,31 @@ export async function handleAccountChangePasswordPost(
476
489
  export interface AccountHomeDeps extends ApiAccountDeps {
477
490
  /** Canonical hub origin for this request (e.g. `https://my-hub.example`). */
478
491
  hubOrigin: string;
492
+ /**
493
+ * Resolve a vault's loopback port from services.json, or `null` when no vault
494
+ * is mounted under that name. Threaded in by the route handler (which reads
495
+ * the manifest) so this module stays free of services.json plumbing. Absent
496
+ * in tests / older callers → usage surfacing is skipped (tiles render without
497
+ * the stat, same as a failed fetch).
498
+ */
499
+ resolveVaultPort?: (vaultName: string) => number | null;
500
+ /**
501
+ * Fetch one vault's usage stat, or `null` on any failure. Defaults to the
502
+ * real `fetchVaultUsage` (mints a read token + hits the vault's loopback
503
+ * usage endpoint). Injectable so tests assert the render without a live
504
+ * vault.
505
+ */
506
+ fetchUsage?: typeof fetchVaultUsage;
507
+ /**
508
+ * Fetch one vault's backup (mirror) status, or `null` on any failure.
509
+ * Defaults to the real `fetchVaultMirrorStatus` (mints an admin-scoped token +
510
+ * hits the vault's loopback `/.parachute/mirror` endpoint). Injectable so
511
+ * tests assert the render without a live vault.
512
+ */
513
+ fetchMirror?: typeof fetchVaultMirrorStatus;
479
514
  }
480
515
 
481
- export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Response {
516
+ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Promise<Response> {
482
517
  const session = findActiveSession(deps.db, req);
483
518
  if (!session) {
484
519
  return redirect(`/login?next=${encodeURIComponent("/account/")}`);
@@ -501,6 +536,84 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
501
536
  const verbs = vaultVerbsForUserVault(deps.db, user.id, v);
502
537
  if (verbs && verbs.length > 0) mintableVerbs[v] = verbs;
503
538
  }
539
+
540
+ // Per-vault usage stat ("X notes · Y MB"). Fetched concurrently across the
541
+ // user's assigned vaults via the read-scoped vault endpoint; each fetch is
542
+ // independently fault-tolerant (returns null → that tile renders without a
543
+ // stat). Skipped entirely when the route didn't wire a port resolver (tests /
544
+ // older callers). The READ scope means the user's own authority suffices.
545
+ const usageStats: Record<string, string> = {};
546
+ if (deps.resolveVaultPort && user.assignedVaults.length > 0) {
547
+ const fetchUsage = deps.fetchUsage ?? fetchVaultUsage;
548
+ const resolvePort = deps.resolveVaultPort;
549
+ await Promise.all(
550
+ user.assignedVaults.map(async (vaultName) => {
551
+ const port = resolvePort(vaultName);
552
+ if (port === null) return;
553
+ const stat = await fetchUsage(vaultName, {
554
+ db: deps.db,
555
+ hubOrigin: deps.hubOrigin,
556
+ vaultPort: port,
557
+ userId: user.id,
558
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
559
+ });
560
+ if (stat) usageStats[vaultName] = formatUsageStat(stat);
561
+ }),
562
+ );
563
+ }
564
+
565
+ // Per-vault backup (mirror) line ("Backed up — full version history" / "…
566
+ // + GitHub"). The vault's mirror endpoint is ADMIN-scoped, so we only fetch
567
+ // for vaults where this user holds the admin verb (the same gate the
568
+ // "Advanced vault settings ↗" deep-link uses). Each fetch is independently
569
+ // fault-tolerant (returns null → no backup line on that tile) and backup-off
570
+ // formats to null too (we never nag with a "not backed up" warning here).
571
+ // Skipped entirely when the route didn't wire a port resolver (tests / older
572
+ // callers).
573
+ const mirrorLines: Record<string, string> = {};
574
+ // Whether each vault's backup is already pushing to a remote (`auto_push`).
575
+ // Threaded to the renderer as a proper boolean so it gates the "Back up to
576
+ // GitHub ↗" action without re-deriving "are we pushing?" from the line string.
577
+ const mirrorPushing: Record<string, boolean> = {};
578
+ if (deps.resolveVaultPort && user.assignedVaults.length > 0) {
579
+ const fetchMirror = deps.fetchMirror ?? fetchVaultMirrorStatus;
580
+ const resolvePort = deps.resolveVaultPort;
581
+ await Promise.all(
582
+ user.assignedVaults.map(async (vaultName) => {
583
+ if (!(mintableVerbs[vaultName] ?? []).includes("admin")) return;
584
+ const port = resolvePort(vaultName);
585
+ if (port === null) return;
586
+ const stat = await fetchMirror(vaultName, {
587
+ db: deps.db,
588
+ hubOrigin: deps.hubOrigin,
589
+ vaultPort: port,
590
+ userId: user.id,
591
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
592
+ });
593
+ if (stat) {
594
+ const line = formatMirrorLine(stat);
595
+ if (line) {
596
+ mirrorLines[vaultName] = line;
597
+ mirrorPushing[vaultName] = stat.backedUpToRemote;
598
+ }
599
+ }
600
+ }),
601
+ );
602
+ }
603
+
604
+ // hub#583: "connected" means an EXTERNAL AI/MCP client (Claude, Cursor, …)
605
+ // was wired to a vault — NOT a first-party browser sign-in. This is the
606
+ // PRIMARY browser GET /account/ route — the exact page the field report
607
+ // describes — so it must use the same filtered check as the vault-token
608
+ // re-render (account-vault-token.ts:196): the old `userHasVaultGrant` lit
609
+ // "✓ You're connected" the moment the user opened Notes (a first-party OAuth
610
+ // client that writes a vault-scoped grant). `userHasExternalAiGrant` excludes
611
+ // first-party browser surfaces so the checklist only condenses on a real AI
612
+ // connection.
613
+ const connectedVault = user.assignedVaults.some((v) =>
614
+ userHasExternalAiGrant(deps.db, user.id, v),
615
+ );
616
+
504
617
  return htmlResponse(
505
618
  renderAccountHome({
506
619
  username: user.username,
@@ -511,6 +624,10 @@ export function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Respo
511
624
  csrfToken: csrf.token,
512
625
  twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
513
626
  mintableVerbs,
627
+ usageStats,
628
+ mirrorLines,
629
+ mirrorPushing,
630
+ connectedVault,
514
631
  }),
515
632
  200,
516
633
  extra,