@openparachute/hub 0.6.4-rc.4 → 0.6.4-rc.5

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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Per-vault backup (mirror) status fetch for the friend-facing `/account/` home.
3
+ *
4
+ * Vault serves `GET /vault/<name>/.parachute/mirror` (ADMIN-scoped) returning
5
+ * the persisted mirror config + the runtime status the manager is tracking:
6
+ * { config: { enabled, location, external_path, sync_mode, auto_push, ... },
7
+ * status: { enabled, last_commit_sha, last_error, ... } }
8
+ *
9
+ * The `/account/` GET handler renders one tile per assigned vault; this module
10
+ * fetches each vault's mirror status so the tile can show a warm, plain-language
11
+ * backup line ("✓ Backed up — full version history", or "+ GitHub" when a push
12
+ * remote is configured). Backup is the local git version-history mirror vault
13
+ * stands up by default; the GitHub variant is the auto-push-to-a-remote setup.
14
+ *
15
+ * The endpoint gates on `vault:<name>:admin`, so this is only fetched for users
16
+ * who hold the admin verb on the vault (same gate as the "Advanced vault
17
+ * settings ↗" deep-link). We mint a short-lived `vault:<name>:admin` token —
18
+ * the same authority the OAuth issuer / admin path would grant them — and call
19
+ * the vault over loopback.
20
+ *
21
+ * Tolerant by design: any failure (vault down, endpoint absent on an older
22
+ * vault, mint failure, malformed JSON, insufficient scope) resolves to `null`
23
+ * so the tile simply omits the backup line rather than breaking the page —
24
+ * exactly the posture of `account-usage.ts`'s `fetchVaultUsage`.
25
+ *
26
+ * Injectable seams (`fetchImpl`, `signToken`) keep it unit-testable without a
27
+ * live vault or real signing key.
28
+ */
29
+ import type { Database } from "bun:sqlite";
30
+ import { signAccessToken } from "./jwt-sign.ts";
31
+
32
+ /** The subset of vault's mirror report the `/account/` tile renders. */
33
+ export interface VaultMirrorStat {
34
+ /** Backup is on — a version-history mirror is configured + bootstrapped. */
35
+ enabled: boolean;
36
+ /**
37
+ * Backup leaves the box — an auto-push remote (GitHub or any git remote) is
38
+ * configured (`config.auto_push`). Drives the "+ GitHub" line variant AND
39
+ * gates the "Back up to GitHub ↗" action (suppressed once already pushing).
40
+ * Threaded through as a proper boolean so the renderer never has to re-derive
41
+ * "are we pushing?" from display-string content.
42
+ */
43
+ backedUpToRemote: boolean;
44
+ }
45
+
46
+ /** Short TTL for the admin token — used immediately for one loopback call. */
47
+ const MIRROR_READ_TOKEN_TTL_SECONDS = 60;
48
+
49
+ export interface FetchVaultMirrorStatusDeps {
50
+ db: Database;
51
+ /** Hub origin — `iss` of the minted token. */
52
+ hubOrigin: string;
53
+ /** Loopback port the vault backend listens on (from services.json). */
54
+ vaultPort: number;
55
+ /** The user minting against their own admin authority — `sub` of the token. */
56
+ userId: string;
57
+ /** Test seam — `globalThis.fetch` in production. */
58
+ fetchImpl?: typeof fetch;
59
+ /** Test seam — defaults to the real `signAccessToken`. */
60
+ signToken?: typeof signAccessToken;
61
+ /** Test seam for the clock. */
62
+ now?: () => Date;
63
+ }
64
+
65
+ /**
66
+ * Fetch one vault's backup (mirror) status for the friend's tile, or `null` on
67
+ * any failure.
68
+ *
69
+ * Mints a `vault:<name>:admin` bearer for `userId` (capped to that one vault via
70
+ * `vaultScope`) and GETs the vault's loopback mirror endpoint. Never throws —
71
+ * the page renders without the backup line on any error.
72
+ *
73
+ * "Backed up" is true when the persisted config says `enabled` (a version-
74
+ * history mirror) — we read the persisted config, not just the runtime
75
+ * `status.enabled`, so a freshly-configured-but-not-yet-bootstrapped vault still
76
+ * reads as backed up. `backedUpToRemote` is true when an auto-push remote is
77
+ * configured (the GitHub variant of backup).
78
+ */
79
+ export async function fetchVaultMirrorStatus(
80
+ vaultName: string,
81
+ deps: FetchVaultMirrorStatusDeps,
82
+ ): Promise<VaultMirrorStat | null> {
83
+ const fetchImpl = deps.fetchImpl ?? fetch;
84
+ const sign = deps.signToken ?? signAccessToken;
85
+ try {
86
+ const scope = `vault:${vaultName}:admin`;
87
+ const minted = await sign(deps.db, {
88
+ sub: deps.userId,
89
+ scopes: [scope],
90
+ audience: `vault.${vaultName}`,
91
+ clientId: "parachute-account",
92
+ issuer: deps.hubOrigin,
93
+ ttlSeconds: MIRROR_READ_TOKEN_TTL_SECONDS,
94
+ vaultScope: [vaultName],
95
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
96
+ });
97
+ const url = `http://127.0.0.1:${deps.vaultPort}/vault/${vaultName}/.parachute/mirror`;
98
+ const res = await fetchImpl(url, {
99
+ headers: { authorization: `Bearer ${minted.token}`, accept: "application/json" },
100
+ });
101
+ if (!res.ok) return null;
102
+ const body = (await res.json()) as {
103
+ config?: { enabled?: unknown; auto_push?: unknown };
104
+ };
105
+ const enabled = body.config?.enabled;
106
+ if (typeof enabled !== "boolean") return null;
107
+ const backedUpToRemote = body.config?.auto_push === true;
108
+ return { enabled, backedUpToRemote };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Format a mirror stat as the warm, plain-language backup line the tile shows,
116
+ * or `null` when backup is off (the tile then omits the line entirely — we
117
+ * don't nag with a "not backed up" warning on the everyday home).
118
+ *
119
+ * Exported for direct unit testing + reuse by the renderer.
120
+ */
121
+ export function formatMirrorLine(stat: VaultMirrorStat): string | null {
122
+ if (!stat.enabled) return null;
123
+ return stat.backedUpToRemote
124
+ ? "Backed up — version history + GitHub"
125
+ : "Backed up — full version history";
126
+ }
@@ -49,6 +49,7 @@ 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";
52
53
  import { fetchVaultUsage, formatUsageStat } from "./account-usage.ts";
53
54
  import { POST_LOGIN_DEFAULT } from "./admin-handlers.ts";
54
55
  import { renderAdminError } from "./admin-login-ui.ts";
@@ -503,6 +504,13 @@ export interface AccountHomeDeps extends ApiAccountDeps {
503
504
  * vault.
504
505
  */
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;
506
514
  }
507
515
 
508
516
  export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps): Promise<Response> {
@@ -554,6 +562,45 @@ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps):
554
562
  );
555
563
  }
556
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
+
557
604
  // "Has this user connected an AI to any of their vaults yet?" — drives the
558
605
  // onboarding checklist's "Connect your AI" step (done/condensed when true).
559
606
  // A grant row only lands after the user clicks through an OAuth consent for a
@@ -571,6 +618,8 @@ export async function handleAccountHomeGet(req: Request, deps: AccountHomeDeps):
571
618
  twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
572
619
  mintableVerbs,
573
620
  usageStats,
621
+ mirrorLines,
622
+ mirrorPushing,
574
623
  connectedVault,
575
624
  }),
576
625
  200,