mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-01 20:33:50 +00:00
Teams on the left, their vaults in the middle, and the selected vault's
detail on the right across three tabs: secret names, who can decrypt,
and the audit trail.
It never handles plaintext, and that is the point rather than a
limitation. The server only ever holds ciphertext and this keeps it that
way: no decryption key is fetched, none is unwrapped, and there is no
keybinding that would. The secrets tab says so on screen and points at
`logicsrc teams pull`, because otherwise the first thing anyone does is
hunt for a reveal key. A value that can appear on screen can appear in a
screen share, a scrollback buffer or a recording; names are what you
need to navigate, values are what you rarely need to look at.
Names, fingerprints and versions are enough to answer the questions you
actually open this for: does the vault exist, has the rotation landed,
and who can still read it.
Notes on the shape:
- Vaults and detail load lazily, because each is a round trip. The
three detail calls are settled independently, so a member without
decryption access still sees the vault's shape and a missing audit
endpoint does not blank the secrets list.
- Changing team resets the vault selection. The old index means
nothing in a different team's list, and keeping it silently selects
an unrelated vault.
- The secrets table shows a date rather than a timestamp. Three fixed
columns plus a name that can run to thirty characters leaves no room,
and a truncated clock looks like data while telling you nothing.
- The audit table gives its widest floor to the action, not the actor:
an email truncates to something recognisable, where "secrets…" could
be put, get or delete.
The view is split from the loader so it renders headlessly without an
authenticated client or a terminal. 17 tests cover that, including one
asserting no ciphertext reaches the screen; 226 pass across the CLI.
Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
/**
|
|
* Loading and running the vault browser.
|
|
*
|
|
* Split from vault-tui.ts so the view can be rendered headlessly in tests
|
|
* without an authenticated client or a terminal.
|
|
*/
|
|
import type { TeamClient } from "@logicsrc/plugin-credential-sharing";
|
|
import {
|
|
createVaultState, currentTeam, currentVault, moveSelection, nextPane, nextTab,
|
|
view, type VaultDetail, type VaultSnapshot, type VaultTuiState,
|
|
} from "./vault-tui.js";
|
|
|
|
/** Teams first; vaults and detail load lazily, because both cost a round trip. */
|
|
export async function loadTeams(client: TeamClient): Promise<VaultSnapshot> {
|
|
const { teams } = await client.listTeams();
|
|
return { teams, vaults: {}, details: {} };
|
|
}
|
|
|
|
export async function loadVaults(client: TeamClient, state: VaultTuiState): Promise<void> {
|
|
const team = currentTeam(state);
|
|
if (!team || state.snapshot.vaults[team.slug]) return;
|
|
try {
|
|
const { vaults } = await client.listVaults(team.slug);
|
|
state.snapshot.vaults[team.slug] = vaults;
|
|
} catch (error) {
|
|
state.snapshot.vaults[team.slug] = [];
|
|
state.note = `could not list vaults: ${message(error)}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Secret names, grants and audit for one vault.
|
|
*
|
|
* Each is fetched independently and a failure is recorded rather than thrown:
|
|
* a member without decryption access can still read the vault's shape, and
|
|
* losing the audit endpoint should not blank the secrets list.
|
|
*/
|
|
export async function loadDetail(client: TeamClient, state: VaultTuiState): Promise<void> {
|
|
const vault = currentVault(state);
|
|
if (!vault || state.snapshot.details[vault.id]) return;
|
|
const detail: VaultDetail = { secrets: [], grants: [], audit: [] };
|
|
const [secrets, grants, audit] = await Promise.allSettled([
|
|
client.listSecrets(vault.id),
|
|
client.listGrants(vault.id),
|
|
client.listAudit(vault.id),
|
|
]);
|
|
if (secrets.status === "fulfilled") detail.secrets = secrets.value.secrets;
|
|
else detail.error = `secrets: ${message(secrets.reason)}`;
|
|
if (grants.status === "fulfilled") detail.grants = grants.value.grants;
|
|
if (audit.status === "fulfilled") detail.audit = audit.value.audit;
|
|
state.snapshot.details[vault.id] = detail;
|
|
}
|
|
|
|
function message(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
export interface VaultTuiOptions {
|
|
client: TeamClient;
|
|
identity?: string;
|
|
theme?: string;
|
|
}
|
|
|
|
export async function runVaultTui({ client, identity = "", theme }: VaultTuiOptions): Promise<void> {
|
|
const { createApp, themes } = await loadHqtui();
|
|
const state = createVaultState(await loadTeams(client), identity);
|
|
|
|
const named = theme ? (themes as Record<string, typeof themes.dark | undefined>)[theme] : undefined;
|
|
const app = await createApp({
|
|
theme: named ?? themes.dark,
|
|
title: "logicsrc vaults",
|
|
quitKeys: ["ctrl+c"],
|
|
});
|
|
|
|
const refresh = async (): Promise<void> => {
|
|
state.loading = true;
|
|
app.invalidate();
|
|
await loadVaults(client, state);
|
|
await loadDetail(client, state);
|
|
state.loading = false;
|
|
app.invalidate();
|
|
};
|
|
|
|
app.on("key", (event: { key: string }) => {
|
|
switch (event.key) {
|
|
case "q": app.quit(); return;
|
|
case "tab": nextPane(state, 1); void refresh(); return;
|
|
case "shift+tab": nextPane(state, -1); return;
|
|
case "right": nextTab(state, 1); return;
|
|
case "left": nextTab(state, -1); return;
|
|
case "up": moveSelection(state, -1); void refresh(); return;
|
|
case "down": moveSelection(state, 1); void refresh(); return;
|
|
case "pageup": moveSelection(state, -10); void refresh(); return;
|
|
case "pagedown": moveSelection(state, 10); void refresh(); return;
|
|
case "r":
|
|
// Drop the caches so a reload actually re-fetches rather than
|
|
// redrawing what is already on screen.
|
|
state.snapshot.vaults = {};
|
|
state.snapshot.details = {};
|
|
state.note = "reloaded";
|
|
void refresh();
|
|
return;
|
|
}
|
|
});
|
|
|
|
await refresh();
|
|
app.render((args) => view(args, state));
|
|
await app.start();
|
|
}
|
|
|
|
/** hqtui needs Node 22.6+; say so rather than showing a module resolution error. */
|
|
async function loadHqtui(): Promise<typeof import("@profullstack/hqtui")> {
|
|
try {
|
|
return await import("@profullstack/hqtui");
|
|
} catch (error) {
|
|
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
const tooOld = (major ?? 0) < 22 || ((major ?? 0) === 22 && (minor ?? 0) < 6);
|
|
throw new Error(
|
|
tooOld
|
|
? `The vault browser needs Node 22.6 or newer (this is ${process.versions.node}). `
|
|
+ `Use \`logicsrc teams vaults <slug>\` instead.`
|
|
: `Could not load @profullstack/hqtui: ${message(error)}. `
|
|
+ `Use \`logicsrc teams vaults <slug>\` instead.`,
|
|
);
|
|
}
|
|
}
|