@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.
- package/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +182 -109
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/api-account.test.ts +51 -1
- package/src/account-home-ui.ts +176 -254
- package/src/account-mirror.ts +126 -0
- package/src/api-account.ts +49 -0
|
@@ -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
|
+
}
|
package/src/api-account.ts
CHANGED
|
@@ -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,
|