+ {scanStatus === "completed" ? (
+
+
+
+ {summary.grants} grant{summary.grants === 1 ? "" : "s"} across {summary.systems}{" "}
+ system{summary.systems === 1 ? "" : "s"}.
+
+ {summary.unattributed > 0 ? (
+ <>
+ {" "}
+
+ {summary.unattributed} we cannot attribute to anyone.
+
+ >
+ ) : null}
+ {summary.overYearIdle > 0 ? (
+ <> {summary.overYearIdle} not used in over a year.>
+ ) : null}
+ {summary.irreversible > 0 ? (
+ <>
+ {" "}
+ {summary.irreversible} {summary.irreversible === 1 ? "is" : "are"} irreversible to
+ revoke.
+ >
+ ) : null}
+
+
+ ) : null}
{ordered.length === 0 ? (
) : (
diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts
index 8b08867..e6cf88a 100644
--- a/apps/web/src/lib/format.test.ts
+++ b/apps/web/src/lib/format.test.ts
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import type { ApiCard } from "../api/types.js";
-import { isUnattributed, sortCards, staleness } from "../lib/format.js";
+import {
+ countScanSummary,
+ isUnattributed,
+ scanSummaryText,
+ sortCards,
+ staleness,
+} from "../lib/format.js";
function card(partial: Partial
& Pick): ApiCard {
return {
@@ -62,4 +68,42 @@ describe("format helpers", () => {
it("flags missing lastUsedAt as unknown staleness", () => {
expect(staleness(null).level).toBe("unknown");
});
+
+ it("counts summary findings from cards and connected systems", () => {
+ const counts = countScanSummary(
+ [
+ card({ id: "unknown", attribution: { confidence: "speculative", reasoning: "unknown" } }),
+ card({
+ id: "old",
+ irreversible: true,
+ grant: {
+ ...card({ id: "old-base" }).grant,
+ lastUsedAt: "2024-01-01T00:00:00.000Z",
+ },
+ }),
+ ],
+ ["github", "slack", "aws"],
+ new Date("2026-06-01T00:00:00.000Z"),
+ );
+
+ expect(counts).toEqual({
+ grants: 2,
+ systems: 3,
+ unattributed: 1,
+ overYearIdle: 1,
+ irreversible: 1,
+ });
+ expect(scanSummaryText(counts)).toBe(
+ "2 grants across 3 systems. 1 we cannot attribute to anyone. 1 not used in over a year. 1 is irreversible to revoke.",
+ );
+ });
+
+ it("omits zero-category clauses from the headline", () => {
+ const counts = countScanSummary([card({ id: "one" })], ["github"]);
+
+ expect(counts.unattributed).toBe(0);
+ expect(counts.overYearIdle).toBe(0);
+ expect(counts.irreversible).toBe(0);
+ expect(scanSummaryText(counts)).toBe("1 grant across 1 system.");
+ });
});
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index a77d968..ded4ab7 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -2,6 +2,41 @@ import type { ApiCard } from "../api/types.js";
const MS_DAY = 86_400_000;
+export interface ScanSummaryCounts {
+ grants: number;
+ systems: number;
+ unattributed: number;
+ overYearIdle: number;
+ irreversible: number;
+}
+
+export function countScanSummary(
+ cards: ApiCard[],
+ systemIds: Iterable = cards.map((card) => card.grant.system),
+ now = new Date(),
+): ScanSummaryCounts {
+ return {
+ grants: cards.length,
+ systems: new Set(systemIds).size,
+ unattributed: cards.filter(isUnattributed).length,
+ overYearIdle: cards.filter((card) => staleness(card.grant.lastUsedAt, now).level === "critical")
+ .length,
+ irreversible: cards.filter((card) => card.irreversible).length,
+ };
+}
+
+export function scanSummaryText(counts: ScanSummaryCounts): string {
+ const clauses = [
+ `${counts.grants} grant${counts.grants === 1 ? "" : "s"} across ${counts.systems} system${counts.systems === 1 ? "" : "s"}.`,
+ counts.unattributed > 0 ? `${counts.unattributed} we cannot attribute to anyone.` : null,
+ counts.overYearIdle > 0 ? `${counts.overYearIdle} not used in over a year.` : null,
+ counts.irreversible > 0
+ ? `${counts.irreversible} ${counts.irreversible === 1 ? "is" : "are"} irreversible to revoke.`
+ : null,
+ ].filter((clause): clause is string => clause !== null);
+ return clauses.join(" ");
+}
+
export function principalLabel(card: ApiCard): string {
const ids = card.grant.principal.identifiers;
if (ids.length === 0) return "Unknown principal";
@@ -30,7 +65,10 @@ export function formatWhen(iso: string | null | undefined): string {
});
}
-export function staleness(iso: string | null | undefined, now = new Date()): {
+export function staleness(
+ iso: string | null | undefined,
+ now = new Date(),
+): {
label: string;
level: "ok" | "cool" | "stale" | "critical" | "unknown";
days: number | null;
diff --git a/scripts/demo.ts b/scripts/demo.ts
index a03002c..5df17e4 100644
--- a/scripts/demo.ts
+++ b/scripts/demo.ts
@@ -102,6 +102,8 @@ async function main(): Promise {
process.env.KEYRING_PGLITE_PATH = pglitePath;
process.env.KEYRING_SCAN_DRIVER = "replay";
process.env.KEYRING_EXECUTE_DRY_RUN = "1";
+ process.env.KEYRING_REPLAY_SPEED = process.env.KEYRING_REPLAY_SPEED ?? "20";
+ process.env.KEYRING_REPLAY_MAX_GAP_MS = process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000";
// Clear DATABASE_URL so demo never requires Docker Postgres
delete process.env.DATABASE_URL;
@@ -154,8 +156,6 @@ async function main(): Promise {
VITE_SCAN_DRIVER: "replay",
VITE_API_BASE_URL: "",
VITE_API_PORT: String(apiPort),
- KEYRING_REPLAY_SPEED: process.env.KEYRING_REPLAY_SPEED ?? "20",
- KEYRING_REPLAY_MAX_GAP_MS: process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000",
},
stdio: ["ignore", "pipe", "pipe"],
},