From 4db93bf2924820533abb084f826621dbff984e6f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 10 Oct 2026 06:30:26 +0000 Subject: [PATCH] feat: add explicit native admin CLI consent with memory-only approval and persistent redacted node evidence plus separate validated broker scopes and localhost proof source --- .../src/app/auth/DeviceApprovalPage.test.tsx | 96 ++++++++++ admin/src/app/auth/DeviceApprovalPage.tsx | 134 ++++++++++++++ admin/src/app/dashboard/WorkerDetailPage.tsx | 11 +- .../app/dashboard/fleet/NodeDiagnostics.tsx | 154 ++++++++++++++++ admin/src/components/layout/nav.ts | 2 + admin/src/hooks/useBrowseState.test.tsx | 48 +++++ admin/src/hooks/useBrowseState.ts | 36 ++++ admin/src/lib/adminDevice.ts | 19 ++ admin/src/lib/api/client.ts | 2 +- admin/src/lib/api/client/admin/device.test.ts | 16 ++ admin/src/lib/api/client/admin/device.ts | 24 +++ admin/src/lib/api/client/admin/fleetNodes.ts | 3 +- .../lib/api/client/admin/nodeDiagnostics.ts | 50 ++++++ admin/src/lib/auth/storage.ts | 5 + admin/src/lib/browseState.ts | 17 ++ admin/src/lib/nodeDiagnostics.test.ts | 78 +++++++++ admin/src/lib/nodeDiagnostics.ts | 82 +++++++++ admin/src/main.tsx | 2 + qa/flows/admin-operations.flow.ts | 164 ++++++++++++++++++ 19 files changed, 938 insertions(+), 5 deletions(-) create mode 100644 admin/src/app/auth/DeviceApprovalPage.test.tsx create mode 100644 admin/src/app/auth/DeviceApprovalPage.tsx create mode 100644 admin/src/app/dashboard/fleet/NodeDiagnostics.tsx create mode 100644 admin/src/hooks/useBrowseState.test.tsx create mode 100644 admin/src/hooks/useBrowseState.ts create mode 100644 admin/src/lib/adminDevice.ts create mode 100644 admin/src/lib/api/client/admin/device.test.ts create mode 100644 admin/src/lib/api/client/admin/device.ts create mode 100644 admin/src/lib/api/client/admin/nodeDiagnostics.ts create mode 100644 admin/src/lib/browseState.ts create mode 100644 admin/src/lib/nodeDiagnostics.test.ts create mode 100644 admin/src/lib/nodeDiagnostics.ts create mode 100644 qa/flows/admin-operations.flow.ts diff --git a/admin/src/app/auth/DeviceApprovalPage.test.tsx b/admin/src/app/auth/DeviceApprovalPage.test.tsx new file mode 100644 index 000000000..b6b064c65 --- /dev/null +++ b/admin/src/app/auth/DeviceApprovalPage.test.tsx @@ -0,0 +1,96 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { APIError } from "@/lib/api/client"; +import type * as ClientModule from "@/lib/api/client"; +import DeviceApprovalPage from "./DeviceApprovalPage"; + +const api = vi.hoisted(() => ({ describe: vi.fn(), decide: vi.fn(), reauth: vi.fn() })); +vi.mock("@/lib/api/client/admin/device", () => ({ describeAdminDevice: api.describe, decideAdminDevice: api.decide })); +vi.mock("@/lib/api/client", async (importOriginal) => ({ ...await importOriginal(), promptForReauth: api.reauth })); +vi.mock("@/lib/env", () => ({ API_URL: "http://localhost:8080" })); +vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: "fixture-admin", admin_permissions: 16 } }) })); + +let root: Root; +let host: HTMLDivElement; +const consentToken = "c".repeat(43); +const description = () => ({ request: { client_name: "Fixture laptop", instance_url: "http://localhost:8080", status: "pending", expires_at: new Date(Date.now() + 60000).toISOString(), consent_token: consentToken }, requested_access: "Your live platform administrator permissions through a separate revocable session", admin_permissions: 16 }); + +beforeEach(async () => { + vi.clearAllMocks(); + localStorage.clear(); sessionStorage.clear(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + api.describe.mockResolvedValue(description()); + api.decide.mockImplementation((_code, _token, decision) => Promise.resolve({ status: decision })); + api.reauth.mockResolvedValue(undefined); + host = document.createElement("div"); document.body.append(host); root = createRoot(host); + await act(async () => root.render()); +}); +afterEach(async () => { + await act(async () => root.unmount()); host.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); +}); +function button(name: string) { + const found = [...host.querySelectorAll("button")].find((b) => b.textContent === name); + if (!found) throw new Error(`Button missing: ${name}`); + return found; +} +async function review() { + await act(async () => { + const input = host.querySelector("input")!; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "ABCD-EFGH"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => host.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))); +} + +describe("explicit admin device consent", () => { + it("does not describe or approve URL codes on load", () => { + expect(api.describe).not.toHaveBeenCalled(); expect(api.decide).not.toHaveBeenCalled(); + }); + it("shows instance/client/capability/expiry without exposing or persisting consent", async () => { + await review(); + expect(api.describe).toHaveBeenCalledWith("ABCD-EFGH"); + expect(api.decide).not.toHaveBeenCalled(); + expect(host.textContent).toContain("Fixture laptop"); expect(host.textContent).toContain("http://localhost:8080"); + expect(host.textContent).toContain("live platform administrator permissions"); expect(host.textContent).toContain("Expires"); + expect(host.innerHTML).not.toContain(consentToken); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); + await act(async () => button("Approve CLI sign-in").click()); + expect(api.decide).toHaveBeenCalledWith("ABCD-EFGH", consentToken, "approved"); + expect(host.textContent).toContain("Request approved"); + }); + it("supports an explicit denial instead of an approval", async () => { + await review(); await act(async () => button("Deny request").click()); + expect(api.decide).toHaveBeenCalledWith("ABCD-EFGH", consentToken, "denied"); + expect(host.textContent).toContain("Request denied"); + }); + it("requires a new explicit decision after native reauthentication", async () => { + api.decide.mockRejectedValueOnce(new APIError("Confirm identity", 403, { code: "reauth_required" })); + await review(); await act(async () => button("Approve CLI sign-in").click()); + expect(api.decide).toHaveBeenCalledTimes(1); expect(api.reauth).not.toHaveBeenCalled(); + await act(async () => button("Confirm identity").click()); + expect(api.reauth).toHaveBeenCalledTimes(1); expect(api.decide).toHaveBeenCalledTimes(1); + await act(async () => button("Approve CLI sign-in").click()); + expect(api.decide).toHaveBeenCalledTimes(2); + }); + it("expires pending consent without an automatic decision", async () => { + vi.useFakeTimers(); await review(); + await act(async () => { vi.advanceTimersByTime(60001); }); + expect(host.textContent).toContain("Request expired"); expect(api.decide).not.toHaveBeenCalled(); + }); + it.each(["admin_device_expired", "admin_device_resolved"])("keeps %s fail-closed", async (code) => { + api.decide.mockRejectedValueOnce(new APIError("Cannot use code", code.endsWith("expired") ? 404 : 409, { code })); + await review(); await act(async () => button("Approve CLI sign-in").click()); + expect(host.textContent).toContain(code.endsWith("expired") ? "Request expired" : "Request already resolved"); + expect(host.textContent).not.toContain("Request approved"); + }); + it.each(["https://other.example.test", "http://user:password@localhost:8080", "http://localhost:8080?token=hidden"])("withholds consent for ambiguous/mismatched instance %s", async (instance_url) => { + api.describe.mockResolvedValue({ ...description(), request: { ...description().request, instance_url } }); + await review(); expect(host.textContent).toContain("could not be verified"); expect(host.innerHTML).not.toContain(instance_url); expect(api.decide).not.toHaveBeenCalled(); + }); + it("does not silently retry an uncertain failed decision", async () => { + api.decide.mockRejectedValueOnce(new Error("Network unavailable")); + await review(); await act(async () => button("Approve CLI sign-in").click()); + expect(api.decide).toHaveBeenCalledTimes(1); expect(host.textContent).toContain("Review request"); expect(host.textContent).not.toContain("Request approved"); + }); +}); diff --git a/admin/src/app/auth/DeviceApprovalPage.tsx b/admin/src/app/auth/DeviceApprovalPage.tsx new file mode 100644 index 000000000..3dc06b817 --- /dev/null +++ b/admin/src/app/auth/DeviceApprovalPage.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { ShieldCheck } from "lucide-react"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Callout, Panel, Property, PropertyList, StatusBadge } from "@/components/ui/kit"; +import { ErrorState } from "@/components/ErrorState"; +import { APIError, promptForReauth } from "@/lib/api/client"; +import { describeAdminDevice, decideAdminDevice, type DeviceDescription } from "@/lib/api/client/admin/device"; +import { API_URL } from "@/lib/env"; +import { AdminPerm } from "@/lib/auth/permissions"; +import { validDeviceDescription } from "@/lib/adminDevice"; + +type Phase = "entry" | "pending" | "approved" | "denied" | "expired" | "resolved"; +type Review = Omit & { permissions: number; requestedAccess: string }; + +export default function DeviceApprovalPage() { + const [code, setCode] = useState(""); + const [review, setReview] = useState(null); + const [phase, setPhase] = useState("entry"); + const [busy, setBusy] = useState(false); + const [needsReauth, setNeedsReauth] = useState(false); + const [error, setError] = useState(null); + const consent = useRef(""); + const submittedCode = useRef(""); + const inFlight = useRef(false); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; consent.current = ""; }; + }, []); + useEffect(() => { + if (phase !== "pending" || !review) return; + const expire = () => { consent.current = ""; setPhase("expired"); setNeedsReauth(false); }; + const remaining = Date.parse(review.expires_at) - Date.now(); + const timer = window.setTimeout(expire, Math.max(0, remaining)); + return () => window.clearTimeout(timer); + }, [phase, review]); + + function failed(err: unknown) { + setError(err); + if (err instanceof APIError && err.code === "reauth_required") { + setNeedsReauth(true); + } else { + consent.current = ""; + if (err instanceof APIError && err.code === "admin_device_expired") setPhase("expired"); + else if (err instanceof APIError && err.code === "admin_device_resolved") setPhase("resolved"); + else setPhase("entry"); + } + } + + async function describe(e: FormEvent) { + e.preventDefault(); + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); setError(null); setReview(null); setNeedsReauth(false); consent.current = ""; + try { + const normalized = code.trim().toUpperCase(); + const result = await describeAdminDevice(normalized); + if (!mounted.current) return; + if (!validDeviceDescription(result, API_URL)) throw new Error("The request identity or response could not be verified. Check this admin panel's configured backend before trying again."); + const { consent_token, ...details } = result.request; + consent.current = consent_token; + submittedCode.current = normalized; + setReview({ ...details, permissions: result.admin_permissions, requestedAccess: result.requested_access }); + setPhase(Date.parse(details.expires_at) <= Date.now() ? "expired" : "pending"); + if (Date.parse(details.expires_at) <= Date.now()) consent.current = ""; + } catch (err) { if (mounted.current) failed(err); } + finally { inFlight.current = false; if (mounted.current) setBusy(false); } + } + + async function decide(decision: "approved" | "denied") { + if (inFlight.current || phase !== "pending" || !review || !consent.current || needsReauth) return; + if (Date.parse(review.expires_at) <= Date.now()) { consent.current = ""; setPhase("expired"); return; } + inFlight.current = true; setBusy(true); setError(null); + try { + const result = await decideAdminDevice(submittedCode.current, consent.current, decision); + if (!mounted.current) return; + if (result.status !== decision) throw new Error("The decision was not confirmed. Review a new request before trying again."); + consent.current = ""; setPhase(result.status); + } catch (err) { if (mounted.current) failed(err); } + finally { inFlight.current = false; if (mounted.current) setBusy(false); } + } + + async function reauthenticate() { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); setError(null); + try { + await promptForReauth(); + if (mounted.current) setNeedsReauth(false); + } catch (err) { if (mounted.current) setError(err); } + finally { inFlight.current = false; if (mounted.current) setBusy(false); } + } + + return
+ + + Never enter a code someone else sent you. Check the instance and requester against your terminal. Client names are requester-supplied, not verified device identities. + + {error != null && } + {phase === "entry" && +
+ + setCode(e.target.value)} autoComplete="off" autoCapitalize="characters" maxLength={9} placeholder="ABCD-EFGH" data-ph-mask="" disabled={busy} /> +

Reviewing a code does not approve it. No CLI credentials are displayed or stored here.

+ +
+
} + {phase === "pending" && review && + Pending explicit approval + + {review.instance_url} + {review.client_name} + {review.requestedAccess} + {Object.entries(AdminPerm).filter(([, bit]) => (review.permissions & bit) === bit).map(([name]) => name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()).join(", ")} (mask {review.permissions}) + + +

The CLI receives a separate, normally revocable session. Approval is not confirmation that the CLI has claimed it. Its original login proof can expire while it waits.

+ {needsReauth ?
+

Confirm your identity, then explicitly choose approve or deny again. Confirmation alone never approves this request.

+ +
:
+ + +
} +
} + {["approved", "denied", "expired", "resolved"].includes(phase) && +

{phase === "approved" ? "Return to your CLI to finish sign-in. No access or refresh credentials are returned to this page. If claiming fails or the original proof expires, start a new request." : phase === "denied" ? "This request cannot sign in. Start a new request in your CLI if needed." : "This code can no longer be used. Start a new request in your CLI, then review its new code here."}

+ +
} +
; +} diff --git a/admin/src/app/dashboard/WorkerDetailPage.tsx b/admin/src/app/dashboard/WorkerDetailPage.tsx index 3d83745e1..bb7d67bab 100644 --- a/admin/src/app/dashboard/WorkerDetailPage.tsx +++ b/admin/src/app/dashboard/WorkerDetailPage.tsx @@ -1,7 +1,7 @@ // One machine in the fleet. // // Everything shown here is reported BY the node or resolved FOR it. There is -// no install, restart, log or reboot button, because nothing reaches into a +// no install, restart or reboot button, because nothing reaches into a // machine any more: a node enrols with a token, heartbeats, and pulls the // version it should run. What an operator can actually do is rename it, hold // it at a version, and forget it. @@ -56,6 +56,8 @@ import { TONE_PANEL, TONE_TEXT } from "@/lib/tones"; import { cn } from "@/lib/utils"; import { NodeStatePill } from "./fleet/tones"; import { ResourceUsage } from "./fleet/ResourceUsage"; +import { NodeDiagnostics } from "./fleet/NodeDiagnostics"; +import { ErrorState } from "@/components/ErrorState"; const CRUMBS = [{ label: "Workers", to: "/workers" }]; @@ -134,6 +136,7 @@ export default function WorkerDetailPage() { ); } + if (nodeQ.isError) return
void nodeQ.refetch()} />
; if (!node) { return (
@@ -168,7 +171,7 @@ export default function WorkerDetailPage() { } > - @@ -178,7 +181,7 @@ export default function WorkerDetailPage() {
{node.last_error && ( - {node.last_error} + The node reported an error on its last heartbeat. Inspect redacted evidence below; raw node error text is not displayed. )} @@ -277,6 +280,8 @@ export default function WorkerDetailPage() { )} + +
{new Date(value).toLocaleString()} : <>Not observed; +} +const count = (value: unknown) => safeCount(value) ? value.toLocaleString() : "Unknown"; + +function SourceBadge({ availability }: { availability?: string }) { + return {availability === "fresh" ? "Fresh observation" : availability === "stale" ? "Stale observation" : "Unavailable observation"}; +} + +function BrokerScope({ title, source, group, topic }: { title: string; source: NodeBrokerSource; group: string; topic: string }) { + const observation = validatedBroker(source, group, topic); + const coverage = ["complete", "partial", "unavailable"].includes(source.coverage) ? source.coverage : "unknown"; + return + + + {group} + {topic} + {coverage} ({count(source.measured_scopes)} / {count(source.expected_scopes)} scopes) + + + {observation ? `${observation.lag.toLocaleString()} messages` : "Unknown / unavailable"} + {observation ? count(observation.members) : "Unknown / unavailable"} + +

{observation ? "Validated retained offsets at the observation time. Broker health and delivery remain unknown." : diagnosticReason(source.metrics?.find((m) => m.reason)?.reason ?? source.reason)}

+ {observation &&
+ Validated partition offsets +
+ + + {observation.detail.partitions.map((p) => )} +
PartitionEarliestCommittedLatestLag
{p.partition}{p.earliest}{p.committed}{p.latest}{p.committed_lag}
+
+
} +
; +} + +export function NodeDiagnostics({ node, mailboxes }: { node: FleetNode; mailboxes: AdminWorkerEmail[] }) { + const { data: me } = useMe(); + const canViewBroker = !!me && hasAdminPerm(me.admin_permissions, AdminPerm.ViewAnalytics); + const canViewMailboxes = !!me && hasAdminPerm(me.admin_permissions, AdminPerm.ViewUsers); + const [filters, setFilters] = useBrowseState(`node-logs:${node.id}`, emptyLogFilters, parseLogFilters); + const filterError = logFilterError(filters); + const logs = useQuery({ + queryKey: ["admin", "node-diagnostics", node.id, "logs", filters], + queryFn: () => getNodeLogs(node.id, filters), + enabled: !filterError, + retry: false, refetchInterval: 30_000, + }); + const broker = useQuery({ + queryKey: ["admin", "node-diagnostics", node.id, "broker"], + queryFn: () => getNodeBroker(node.id), + enabled: node.role === "worker" && canViewBroker, + retry: false, refetchInterval: 30_000, + }); + const history = logs.data; + const matchedHistory = history?.node_id === node.id ? history : undefined; + const readable = !!matchedHistory && ["fresh", "stale"].includes(matchedHistory.availability) && matchedHistory.capture?.protocol === 1; + const events = readable ? (matchedHistory.events ?? []).filter(safeLogEvent) : []; + const hidden = readable && (matchedHistory.events?.length ?? 0) > events.length; + const setFilter = (key: keyof NodeLogFilters, value: string) => setFilters((previous) => ({ ...previous, [key]: value })); + + return <> +
void logs.refetch()}>{logs.isFetching ? "Refreshing logs…" : "Refresh logs"}}> + +
+
+ +
+
setFilter("after", e.target.value)} />
+
setFilter("before", e.target.value)} />
+
setFilter("mailbox", e.target.value)} />
+
+
Inclusive time bounds; up to 200 newest matching events. Filters are saved for this admin and node in this browser tab.
+ {filterError &&

{filterError}

} + {!filterError && logs.isLoading && } + {logs.isError && !filterError && void logs.refetch()} />} + {!filterError && history && !matchedHistory &&

Evidence does not match this node. No evidence is displayed.

} + {!filterError && matchedHistory && <> + {logs.isError &&

Last successful snapshot below. The latest refresh failed; it is not a current observation.

} +
{matchedHistory.coverage === "partial" ? "Partial coverage" : "Unavailable coverage"}
+

{diagnosticReason(matchedHistory.reason)}

+ + Node-agent allowlisted events via bounded Redis history. No historical journal import. + {matchedHistory.capture?.protocol === 1 ? "Protocol 1" : "Not observed or unsupported"} + + + + {isUUID(matchedHistory.capture?.run_id ?? "") ? matchedHistory.capture?.run_id : "Not observed"} + + {matchedHistory.capture ? count(matchedHistory.capture.dropped) : "Unknown"} (per process run, not total historical loss) + Up to {count(matchedHistory.retention_seconds)} seconds and {count(matchedHistory.max_events)} events. Metadata expires separately. + {readable ? count(matchedHistory.retained_count) : "Unknown"} events across all filters. to + +

Pre-installation events, abrupt crashes and lost acknowledgements may be uncounted. Empty evidence never means zero errors or healthy mailboxes.

+ {matchedHistory.truncated &&

More matching events are retained than this response contains. Narrow the filters to inspect the window.

} + {hidden &&

Unsupported or invalid event records were withheld.

} + {!readable ? : events.length === 0 ? + : +
+ + {events.map((event) => { + const mailbox = mailboxes.find((m) => m.id === event.mailbox_id); + const mailboxID = isUUID(event.mailbox_id ?? "") ? event.mailbox_id : null; + return + + + + + ; + })} +
ObservedLevelEvidenceMailbox
{event.level}
{eventLabels[event.event].title}
{event.category.replaceAll("_", " ")} + {event.event === "sync_control_plane_held" && safeCount(event.http_status) && event.http_status >= 100 && event.http_status <= 599 && ` · HTTP ${event.http_status}`} + {safeCount(event.count) && event.count <= 1000000 && ` · count ${event.count}`}
{mailboxID ? mailbox && canViewMailboxes ? {mailboxID} : {mailboxID} : "Not provided"}
} + } +
+
+
void broker.refetch()}>{broker.isFetching ? "Refreshing broker…" : "Refresh broker"} : undefined}> + {node.role !== "worker" ? Command-group broker observations require a worker node. Consumer nodes use generic log evidence above; no worker lag is inferred for this consumer. : !canViewBroker ? View analytics permission is also required to read broker observations. Log access is unchanged. : <> + {broker.isLoading && } + {broker.isError && void broker.refetch()} />} + {!broker.isError && broker.data && (broker.data.node_id === node.id ? <> +

Snapshot queried: . Shared results belong to the result-consumer group, not this node's processing or delivery count.

+
+ + +
+ :

Broker observation does not match this node. No metrics are displayed.

)} + } +
+ ; +} diff --git a/admin/src/components/layout/nav.ts b/admin/src/components/layout/nav.ts index 616f0fd0a..74bfbb7e8 100644 --- a/admin/src/components/layout/nav.ts +++ b/admin/src/components/layout/nav.ts @@ -19,6 +19,7 @@ import { Network, Send, SlidersHorizontal, + Terminal, TicketPercent, UserCog, Users, @@ -136,6 +137,7 @@ export const NAV_GROUPS: NavGroup[] = [ { label: "Instance", items: [ + { label: "CLI sign-in", icon: Terminal, pages: [{ to: "/device", label: "CLI sign-in", title: "Approve CLI sign-in" }] }, { label: "Setup and health", icon: HeartPulse, diff --git a/admin/src/hooks/useBrowseState.test.tsx b/admin/src/hooks/useBrowseState.test.tsx new file mode 100644 index 000000000..9c63d9d98 --- /dev/null +++ b/admin/src/hooks/useBrowseState.test.tsx @@ -0,0 +1,48 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import useBrowseState from "./useBrowseState"; +import { ADMIN_BROWSE_PREFIX, clearAdminBrowse, resumeBrowse } from "@/lib/browseState"; +import { emptyLogFilters, parseLogFilters } from "@/lib/nodeDiagnostics"; + +const identity = vi.hoisted(() => ({ id: "admin-a" })); +vi.mock("@/hooks/useMe", () => ({ useMe: () => ({ data: { id: identity.id } }) })); +let root: Root; +let host: HTMLDivElement; +let node = "worker-a"; +function View() { + const [value, setValue] = useBrowseState(`node-logs:${node}`, emptyLogFilters, parseLogFilters); + return <>{value.level || "all"}; +} +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); identity.id = "admin-a"; node = "worker-a"; + sessionStorage.clear(); resumeBrowse(); host = document.createElement("div"); root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); sessionStorage.clear(); vi.unstubAllGlobals(); }); +const render = () => act(async () => root.render()); +const key = (owner: string, worker: string) => `${ADMIN_BROWSE_PREFIX}${owner}:node-logs:${worker}`; + +describe("admin-scoped diagnostic browse state", () => { + it("restores validated filters on remount and keeps other users/nodes isolated", async () => { + sessionStorage.setItem(key("admin-a", "worker-a"), JSON.stringify({ value: { ...emptyLogFilters, level: "error" } })); + await render(); expect(host.textContent).toContain("error"); + identity.id = "admin-b"; await render(); expect(host.textContent).toContain("all"); + await act(async () => host.querySelector("button")!.click()); + expect(JSON.parse(sessionStorage.getItem(key("admin-b", "worker-a"))!).value.level).toBe("warn"); + node = "consumer-a"; await render(); expect(host.textContent).toContain("all"); + identity.id = "admin-a"; node = "worker-a"; await render(); expect(host.textContent).toContain("error"); + }); + it("rejects malformed stored filters without overwriting another user's state", async () => { + sessionStorage.setItem(key("admin-a", "worker-a"), "{broken"); + sessionStorage.setItem(key("admin-b", "worker-a"), "untouched"); + await render(); expect(host.textContent).toContain("all"); + expect(sessionStorage.getItem(key("admin-b", "worker-a"))).toBe("untouched"); + }); + it("clears on logout and prevents mounted old-session state from reappearing", async () => { + await render(); await act(async () => host.querySelector("button")!.click()); + expect(sessionStorage.length).toBe(1); + clearAdminBrowse(); resumeBrowse(); + await act(async () => host.querySelector("button")!.click()); + expect(sessionStorage.length).toBe(0); + }); +}); diff --git a/admin/src/hooks/useBrowseState.ts b/admin/src/hooks/useBrowseState.ts new file mode 100644 index 000000000..b6c49dfda --- /dev/null +++ b/admin/src/hooks/useBrowseState.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { useMe } from "@/hooks/useMe"; +import { ADMIN_BROWSE_PREFIX, browseGeneration, canPersistBrowse } from "@/lib/browseState"; + +export default function useBrowseState(name: string, initial: T, parse: (value: unknown) => T | null): [T, Dispatch>] { + const { data: me } = useMe(); + const owner = me?.id; + const key = owner ? `${ADMIN_BROWSE_PREFIX}${owner}:${name}` : null; + const generation = useRef(browseGeneration()); + const read = () => { + if (key) { + try { + const raw = sessionStorage.getItem(key); + if (raw) return parse(JSON.parse(raw)?.value) ?? initial; + } catch { /* Browsing works without storage. */ } + } + return initial; + }; + const [memory, setMemory] = useState(() => ({ key, value: read() })); + let current = memory; + if (memory.key !== key) { + current = { key, value: read() }; + setMemory(current); + } + const setValue = useCallback>>((next) => { + setMemory((previous) => previous.key !== key ? previous : ({ + key, value: typeof next === "function" ? (next as (value: T) => T)(previous.value) : next, + })); + }, [key]); + useEffect(() => { + if (!key || key !== memory.key || !canPersistBrowse(generation.current)) return; + try { sessionStorage.setItem(key, JSON.stringify({ value: memory.value })); } + catch { /* Browsing works without storage. */ } + }, [key, memory]); + return [current.value, setValue]; +} diff --git a/admin/src/lib/adminDevice.ts b/admin/src/lib/adminDevice.ts new file mode 100644 index 000000000..1fab97b93 --- /dev/null +++ b/admin/src/lib/adminDevice.ts @@ -0,0 +1,19 @@ +import type { DeviceDescription } from "@/lib/api/client/admin/device"; + +export function instanceIdentity(value: string): string | null { + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null; + return url.toString().replace(/\/+$/, ""); + } catch { return null; } +} + +export function validDeviceDescription(value: DeviceDescription, backend: string): boolean { + const request = value?.request; + const configured = instanceIdentity(backend); + return !!request && request.status === "pending" && !!configured && instanceIdentity(request.instance_url) === configured && + typeof request.client_name === "string" && request.client_name.length > 0 && request.client_name.length <= 64 && + /^[A-Za-z0-9_-]{43}$/.test(request.consent_token) && Number.isFinite(Date.parse(request.expires_at)) && + value.requested_access === "Your live platform administrator permissions through a separate revocable session" && + Number.isSafeInteger(value.admin_permissions) && value.admin_permissions > 0 && value.admin_permissions <= 0xffffffff; +} diff --git a/admin/src/lib/api/client.ts b/admin/src/lib/api/client.ts index ad05239b2..3f06147bd 100644 --- a/admin/src/lib/api/client.ts +++ b/admin/src/lib/api/client.ts @@ -61,7 +61,7 @@ const REAUTH_PROMPT_TIMEOUT_MS = 2 * 60 * 1000; // promptForReauth asks ReauthDialog (mounted in the app shell) to confirm the // operator, resolving on success and rejecting on cancel. -function promptForReauth(): Promise { +export function promptForReauth(): Promise { return new Promise((resolve, reject) => { let settled = false; let timer = 0; diff --git a/admin/src/lib/api/client/admin/device.test.ts b/admin/src/lib/api/client/admin/device.test.ts new file mode 100644 index 000000000..d43442088 --- /dev/null +++ b/admin/src/lib/api/client/admin/device.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from "vitest"; +import { describeAdminDevice, decideAdminDevice } from "./device"; +const request = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api/client", () => ({ Request: request })); + +describe("device API consent contract", () => { + it("uses authorized describe without any automatic grant or poll request", async () => { + request.mockResolvedValueOnce({ request: { status: "pending" } }); + await describeAdminDevice("ABCD-EFGH"); + expect(request).toHaveBeenLastCalledWith({ method: "POST", url: "/admin/auth/device/describe", authorization: true, data: { user_code: "ABCD-EFGH" } }); + }); + it("disables the usual automatic retry after fresh-auth so consent is explicit twice", async () => { + await decideAdminDevice("ABCD-EFGH", "fixture-consent", "denied"); + expect(request).toHaveBeenLastCalledWith({ method: "POST", url: "/admin/auth/device/decide", authorization: true, skipReauthPrompt: true, data: { user_code: "ABCD-EFGH", consent_token: "fixture-consent", decision: "denied" } }); + }); +}); diff --git a/admin/src/lib/api/client/admin/device.ts b/admin/src/lib/api/client/admin/device.ts new file mode 100644 index 000000000..3248107fa --- /dev/null +++ b/admin/src/lib/api/client/admin/device.ts @@ -0,0 +1,24 @@ +import { Request } from "@/lib/api/client"; + +export interface DeviceDescription { + request: { + client_name: string; + instance_url: string; + expires_at: string; + status: "pending"; + consent_token: string; + }; + requested_access: string; + admin_permissions: number; +} + +export function describeAdminDevice(userCode: string): Promise { + return Request({ method: "POST", url: "/admin/auth/device/describe", authorization: true, data: { user_code: userCode } }); +} + +export function decideAdminDevice(userCode: string, consentToken: string, decision: "approved" | "denied"): Promise<{ status: "approved" | "denied" }> { + return Request({ + method: "POST", url: "/admin/auth/device/decide", authorization: true, skipReauthPrompt: true, + data: { user_code: userCode, consent_token: consentToken, decision }, + }); +} diff --git a/admin/src/lib/api/client/admin/fleetNodes.ts b/admin/src/lib/api/client/admin/fleetNodes.ts index 1fa0cf831..f7d10ce15 100644 --- a/admin/src/lib/api/client/admin/fleetNodes.ts +++ b/admin/src/lib/api/client/admin/fleetNodes.ts @@ -2,7 +2,8 @@ // // Nodes are pull-based: they enrol with the join token, heartbeat, and ask what // version they should be running. Nothing here reaches into a machine, so there -// is no install, restart, logs or reboot call to make. +// is no install, restart or reboot call to make. Redacted submitted evidence +// is read through the generic node diagnostics API. import { Request } from "@/lib/api/client"; diff --git a/admin/src/lib/api/client/admin/nodeDiagnostics.ts b/admin/src/lib/api/client/admin/nodeDiagnostics.ts new file mode 100644 index 000000000..f9f2412e4 --- /dev/null +++ b/admin/src/lib/api/client/admin/nodeDiagnostics.ts @@ -0,0 +1,50 @@ +import { Request } from "@/lib/api/client"; +import type { MonitoringAvailability, MonitoringSource, MonitoringMetric } from "./monitoring"; + +export interface NodeLogEvent { + id: string; + observed_at: string; + event: string; + level: "info" | "warn" | "error"; + category: string; + mailbox_id?: string; + http_status?: number; + count?: number; +} + +export interface NodeLogHistory { + node_id: string; + availability: MonitoringAvailability; + coverage: "partial" | "unavailable"; + reason?: string; + observed_at: string; + capture?: { protocol: number; run_id: string; started_at: string; observed_at: string; received_at: string; dropped: number }; + events: NodeLogEvent[]; + oldest_retained_at?: string; + newest_retained_at?: string; + retained_count: number; + truncated: boolean; + retention_seconds: number; + max_events: number; +} + +export interface NodeLogFilters { level: "" | "info" | "warn" | "error"; after: string; before: string; mailbox: string } + +export interface BrokerPartition { topic: string; partition: number; earliest: number; committed: number; latest: number; committed_lag: number } +export interface BrokerDetail { consumer_group: string; topics: string[]; partitions: BrokerPartition[] } +export type NodeBrokerMetric = MonitoringMetric & { broker?: BrokerDetail }; +export type NodeBrokerSource = Omit & { metrics: NodeBrokerMetric[] }; +export interface NodeBrokerObservation { node_id: string; observed_at: string; commands: NodeBrokerSource; results: NodeBrokerSource; note: string } + +export function getNodeLogs(id: string, filters: NodeLogFilters): Promise { + const params = new URLSearchParams({ limit: "200" }); + if (filters.level) params.set("level", filters.level); + if (filters.after) params.set("after", new Date(filters.after).toISOString()); + if (filters.before) params.set("before", new Date(filters.before).toISOString()); + if (filters.mailbox.trim()) params.set("mailbox_id", filters.mailbox.trim()); + return Request({ method: "GET", url: `/admin/fleet/nodes/${encodeURIComponent(id)}/logs?${params}`, authorization: true }); +} + +export function getNodeBroker(id: string): Promise { + return Request({ method: "GET", url: `/admin/fleet/nodes/${encodeURIComponent(id)}/broker`, authorization: true, timeout: 12_000 }); +} diff --git a/admin/src/lib/auth/storage.ts b/admin/src/lib/auth/storage.ts index 25852b79e..8a1b924f6 100644 --- a/admin/src/lib/auth/storage.ts +++ b/admin/src/lib/auth/storage.ts @@ -12,6 +12,8 @@ // intentional — the admin surface should require a fresh, deliberate // auth event, not silently inherit a dashboard session. +import { clearAdminBrowse, resumeBrowse } from "@/lib/browseState"; + const TOKEN_KEY = "warmbly_admin_token"; export interface AdminToken { @@ -33,13 +35,16 @@ export function getToken(): AdminToken | null { export function setToken(t: AdminToken | null) { if (!t) { + clearAdminBrowse(); localStorage.removeItem(TOKEN_KEY); return; } localStorage.setItem(TOKEN_KEY, JSON.stringify(t)); + resumeBrowse(); } export function clearToken() { + clearAdminBrowse(); localStorage.removeItem(TOKEN_KEY); } diff --git a/admin/src/lib/browseState.ts b/admin/src/lib/browseState.ts new file mode 100644 index 000000000..5da2f1eb3 --- /dev/null +++ b/admin/src/lib/browseState.ts @@ -0,0 +1,17 @@ +export const ADMIN_BROWSE_PREFIX = "warmbly:admin:browse:v1:"; +let generation = 0; +let active = true; +export const browseGeneration = () => generation; +export const canPersistBrowse = (value: number) => active && value === generation; +export const resumeBrowse = () => { active = true; }; + +export function clearAdminBrowse() { + active = false; + generation++; + try { + for (let index = sessionStorage.length - 1; index >= 0; index--) { + const key = sessionStorage.key(index); + if (key?.startsWith(ADMIN_BROWSE_PREFIX)) sessionStorage.removeItem(key); + } + } catch { /* Storage may be disabled. */ } +} diff --git a/admin/src/lib/nodeDiagnostics.test.ts b/admin/src/lib/nodeDiagnostics.test.ts new file mode 100644 index 000000000..86f854a7d --- /dev/null +++ b/admin/src/lib/nodeDiagnostics.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { NodeBrokerSource, NodeLogEvent } from "@/lib/api/client/admin/nodeDiagnostics"; +import { emptyLogFilters, logFilterError, parseLogFilters, safeLogEvent, validatedBroker } from "./nodeDiagnostics"; + +const id = "11111111-1111-4111-8111-111111111111"; +const group = `worker-${id}`; +const topic = `w.${id}`; +const at = "2026-10-10T08:00:00Z"; +function source(): NodeBrokerSource { + return { + id, availability: "fresh", coverage: "complete", observed_at: at, checked_at: at, measured_scopes: 1, expected_scopes: 1, + metrics: [{ id: "committed_lag", title: "Broker committed_lag", unit: "messages", scope_id: id, availability: "fresh", condition: "unknown", severity: "info", note: "Observation only", count: 6, + observed_at: at, evidence_at: at, latest_evidence_at: at, affected_mailboxes: null, affected_organizations: null, unknown_organization_rows: null, evidence_age_seconds: null, next_eligible_at: null, window_start: null, window_end: null, threshold_seconds: null, + broker: { consumer_group: group, topics: [topic], partitions: [{ topic, partition: 0, earliest: 10, committed: 16, latest: 22, committed_lag: 6 }] }, + }], + }; +} + +describe("read-only node broker evidence", () => { + it("accepts exact retained committed offsets, not send rates or invented member counts", () => { + const result = validatedBroker(source(), group, topic); + expect(result?.lag).toBe(6); + expect(result?.members).toBeNull(); + }); + it("keeps validated zero lag distinct from an unsupported observation", () => { + const zero = source(); + zero.metrics[0].count = 0; + zero.metrics[0].broker!.partitions[0].committed = 22; + zero.metrics[0].broker!.partitions[0].committed_lag = 0; + expect(validatedBroker(zero, group, topic)?.lag).toBe(0); + expect(validatedBroker({ ...zero, availability: "unavailable", metrics: [], reason: "unsupported" }, group, topic)).toBeNull(); + }); + it.each(["stale", "unavailable"] as const)("does not render %s observations as fresh values", (availability) => { + expect(validatedBroker({ ...source(), availability }, group, topic)).toBeNull(); + }); + it("does not attribute the shared result group to worker commands", () => { + expect(validatedBroker(source(), "consumer-group", "jobs.worker-events")).toBeNull(); + }); + it.each(["missing", "duplicate", "negative", "out-of-retention", "future-commit", "mismatch", "wrong-topic", "wrong-group", "overflow", "unsafe-offset", "healthy-condition", "wrong-scope", "partial"])("withholds %s data", (invalid) => { + const value = source(); + const metric = value.metrics[0]; + const detail = metric.broker!; + const partition = detail.partitions[0]; + switch (invalid) { + case "missing": metric.count = null; break; + case "duplicate": detail.partitions.push({ ...partition }); break; + case "negative": partition.partition = -1; break; + case "out-of-retention": partition.committed = 9; break; + case "future-commit": partition.committed = 23; break; + case "mismatch": metric.count = 7; break; + case "wrong-topic": partition.topic = "unexpected"; break; + case "wrong-group": detail.consumer_group = "consumer-group"; break; + case "overflow": detail.partitions = Array.from({ length: 129 }, (_, i) => ({ ...partition, partition: i })); break; + case "unsafe-offset": partition.latest = Number.MAX_SAFE_INTEGER + 1; break; + case "healthy-condition": metric.condition = "healthy"; break; + case "wrong-scope": metric.scope_id = "other"; break; + case "partial": value.coverage = "partial"; break; + } + expect(validatedBroker(value, group, topic)).toBeNull(); + }); +}); + +describe("redacted node evidence and filters", () => { + it("only accepts canonical allowlisted event records", () => { + const event: NodeLogEvent = { id, event: "sync_control_plane_held", observed_at: at, level: "warn", category: "control_plane", http_status: 503 }; + expect(safeLogEvent(event)).toBe(true); + expect(safeLogEvent({ ...event, event: "raw_provider_message" })).toBe(false); + expect(safeLogEvent({ ...event, category: "provider" })).toBe(false); + expect(safeLogEvent({ ...event, observed_at: "invalid" })).toBe(false); + }); + it("strips unrelated stored fields and rejects invalid UUID/time ranges", () => { + expect(parseLogFilters({ ...emptyLogFilters, level: "warn", secret: "not-stored" })).toEqual({ ...emptyLogFilters, level: "warn" }); + expect(parseLogFilters({ ...emptyLogFilters, mailbox: "not-a-uuid" })).toBeNull(); + expect(parseLogFilters({ ...emptyLogFilters, after: "invalid" })).toBeNull(); + expect(logFilterError({ ...emptyLogFilters, after: "2026-10-10T10:00", before: "2026-10-10T09:00" })).toMatch(/Start time/); + expect(logFilterError({ ...emptyLogFilters, mailbox: id, after: "2026-10-10T08:00" })).toBeNull(); + }); +}); diff --git a/admin/src/lib/nodeDiagnostics.ts b/admin/src/lib/nodeDiagnostics.ts new file mode 100644 index 000000000..6e4179ad2 --- /dev/null +++ b/admin/src/lib/nodeDiagnostics.ts @@ -0,0 +1,82 @@ +import type { NodeBrokerSource, NodeLogFilters, NodeLogEvent, BrokerDetail } from "@/lib/api/client/admin/nodeDiagnostics"; + +export const emptyLogFilters: NodeLogFilters = { level: "", after: "", before: "", mailbox: "" }; +export const isUUID = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) && value !== "00000000-0000-0000-0000-000000000000"; +export const safeCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +export const validTime = (value: unknown): value is string => typeof value === "string" && Number.isFinite(Date.parse(value)); + +export function parseLogFilters(value: unknown): NodeLogFilters | null { + if (!value || typeof value !== "object") return null; + const v = value as NodeLogFilters; + if (!["", "info", "warn", "error"].includes(v.level) || typeof v.after !== "string" || typeof v.before !== "string" || v.after.length > 24 || v.before.length > 24 || typeof v.mailbox !== "string" || v.mailbox.length > 36) return null; + if (logFilterError(v)) return null; + return { level: v.level, after: v.after, before: v.before, mailbox: v.mailbox }; +} + +export function logFilterError(value: NodeLogFilters): string | null { + if (value.mailbox.trim() && !isUUID(value.mailbox.trim())) return "Enter a valid mailbox UUID."; + if ((value.after && !validTime(value.after)) || (value.before && !validTime(value.before))) return "Enter valid start and end times."; + if (value.after && value.before && Date.parse(value.after) > Date.parse(value.before)) return "Start time must not be after end time."; + return null; +} + +export const eventLabels: Record = { + sync_control_plane_held: { title: "Control-plane sync held", level: "warn", category: "control_plane" }, + mailbox_provider_unreachable: { title: "Mailbox provider unreachable", level: "warn", category: "provider" }, + mailbox_provider_auth_failed: { title: "Mailbox provider authentication failed", level: "warn", category: "provider" }, + mailbox_provider_throttled: { title: "Mailbox provider throttled", level: "warn", category: "provider" }, + mailbox_provider_sync_completed: { title: "Mailbox provider sync completed", level: "info", category: "provider" }, + sync_folder_skipped: { title: "Sync folder skipped", level: "info", category: "sync_policy" }, + sync_skipped_folder_search_failed: { title: "Folder search failed; sync skipped", level: "warn", category: "provider" }, + sync_message_deferred: { title: "Sync message deferred", level: "warn", category: "control_plane" }, + sync_message_deferred_by_policy: { title: "Sync message deferred by policy", level: "info", category: "sync_policy" }, + sync_legacy_arrival: { title: "Legacy sync arrival", level: "warn", category: "compatibility" }, + worker_error: { title: "Coarse worker runtime error", level: "error", category: "worker_runtime" }, +}; + +export function safeLogEvent(event: NodeLogEvent): boolean { + const canonical = eventLabels[event.event]; + return !!canonical && isUUID(event.id) && validTime(event.observed_at) && canonical.level === event.level && canonical.category === event.category; +} + +export const diagnosticReason = (reason?: string) => ({ + log_credential_unavailable: "Log credential unavailable. Existing or manually enrolled nodes need upgrade and re-enrollment.", + capture_not_observed: "No capture has been observed.", + cache_unavailable: "Evidence cache could not be read.", + no_recent_capture: "No recent capture. Older retained evidence is not current health.", + no_recent_evidence: "Recent capture contained no retained events. This does not establish health.", + allowlisted_evidence_only: "Only allowlisted, redacted events are captured, not the full journal.", + unsupported: "This broker does not support committed-offset observations.", + dependency_missing: "Broker diagnostics are not configured.", + resource_absent: "The requested group or topic could not be observed.", + permission_denied: "The broker denied read access.", + timeout: "The broker observation timed out.", + collection_failed: "The observation could not be validated or collected. Missing commits, mismatched scopes or offsets outside retention remain unknown.", +}[reason ?? ""] ?? "Observation unavailable or incomplete."); + +export function validatedBroker(source: NodeBrokerSource, group: string, topic: string): { lag: number; members: number | null; detail: BrokerDetail } | null { + if (source.availability !== "fresh" || source.coverage !== "complete" || source.measured_scopes !== 1 || source.expected_scopes !== 1) return null; + const scope = group === "consumer-group" ? "worker_events" : group.replace(/^worker-/, ""); + if (source.id !== scope || !validTime(source.observed_at) || !validTime(source.checked_at)) return null; + const metrics = source.metrics ?? []; + const lagMetrics = metrics.filter((m) => m.id === "committed_lag"); + const metric = lagMetrics[0]; + if (lagMetrics.length !== 1 || !metric || metric.scope_id !== scope || metric.availability !== "fresh" || metric.condition !== "unknown" || !safeCount(metric.count) || !validTime(metric.observed_at)) return null; + const detail = metric.broker; + if (!detail || detail.consumer_group !== group || detail.topics?.length !== 1 || detail.topics[0] !== topic || !Array.isArray(detail.partitions) || detail.partitions.length < 1 || detail.partitions.length > 128) return null; + let total = 0; + const seen = new Set(); + for (const partition of detail.partitions) { + if (partition.topic !== topic || !safeCount(partition.partition) || partition.partition > 0x7fffffff || seen.has(partition.partition) || + !safeCount(partition.earliest) || !safeCount(partition.committed) || !safeCount(partition.latest) || !safeCount(partition.committed_lag) || + partition.earliest > partition.committed || partition.committed > partition.latest || partition.latest - partition.committed !== partition.committed_lag) return null; + seen.add(partition.partition); + total += partition.committed_lag; + if (!safeCount(total)) return null; + } + if (total !== metric.count) return null; + const memberMetrics = metrics.filter((m) => m.id === "members"); + const member = memberMetrics[0]; + const members = memberMetrics.length === 1 && member?.scope_id === scope && member.availability === "fresh" && member.condition === "unknown" && validTime(member.observed_at) && safeCount(member.count) ? member.count : null; + return { lag: total, members, detail }; +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index ca0c0e9f4..3056c402e 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -24,6 +24,7 @@ import { RequireAdmin } from "@/components/layout/RequireAdmin"; import { RouteError } from "@/components/layout/RouteError"; import LoginPage from "@/app/auth/LoginPage"; +import DeviceApprovalPage from "@/app/auth/DeviceApprovalPage"; import OverviewPage from "@/app/dashboard/OverviewPage"; import WorkersPage from "@/app/dashboard/WorkersPage"; import WorkerDetailPage from "@/app/dashboard/WorkerDetailPage"; @@ -135,6 +136,7 @@ const router = createBrowserRouter([ errorElement: , children: [ { index: true, element: }, + { path: "device", element: }, // Operations { path: "workers", element: gated(AdminPerm.ViewWorkers, ) }, diff --git a/qa/flows/admin-operations.flow.ts b/qa/flows/admin-operations.flow.ts new file mode 100644 index 000000000..b14c2ff61 --- /dev/null +++ b/qa/flows/admin-operations.flow.ts @@ -0,0 +1,164 @@ +import { expect, test } from "../lib/proof.ts"; +import { env } from "../lib/env.ts"; + +test.use({ signedIn: false, timezoneId: "UTC" }); + +const worker = "11111111-1111-4111-8111-111111111111"; +const consumer = "22222222-2222-4222-8222-222222222222"; +const mailbox = "33333333-3333-4333-8333-333333333333"; +const admin = "44444444-4444-4444-8444-444444444444"; + +// Parent-run only: the worktree's localhost admin Vite origin with VITE_API_URL=QA_API_URL. +test("admin CLI consent and redacted node diagnostics with disclosed local response fixtures", async ({ page, proof }) => { + test.skip(process.env.QA_ADMIN_OPERATIONS_FIXTURES !== "1", "Set QA_ADMIN_OPERATIONS_FIXTURES=1 on this worktree's localhost admin origin."); + for (const origin of [env.webURL, env.apiURL]) { + expect(["localhost", "127.0.0.1", "[::1]"].includes(new URL(origin).hostname)).toBe(true); + } + const at = new Date().toISOString(); + const started = new Date(Date.now() - 30000).toISOString(); + const expires = new Date(Date.now() + 5 * 60000).toISOString(); + const before = new Date(Date.now() + 60000).toISOString().slice(0, 16); + let mfa = false; + let confirmed = false; + let decisions = 0; + let descriptions = 0; + let unsupportedResults = false; + let consumerBrokerReads = 0; + const node = (id: string, role: string) => ({ id, role, name: role === "worker" ? "fixture-operations-worker" : "fixture-operations-consumer", notes: "Synthetic UI fixture", region: "local", address: "", capacity_target: 100, version: "fixture", active: true, last_seen_at: at, enrolled_at: started, usage: {}, mailbox_count: role === "worker" ? 1 : 0, created_at: started, updated_at: at }); + const metric = (scope: string, id: string, value: number) => ({ id, title: `Broker ${id}`, unit: id === "members" ? "consumers" : "messages", scope_id: scope, availability: "fresh", condition: "unknown", severity: "info", note: "Synthetic committed-offset observation, not delivery evidence", count: value, affected_mailboxes: null, affected_organizations: null, unknown_organization_rows: null, observed_at: at, evidence_at: at, latest_evidence_at: at, evidence_age_seconds: null, next_eligible_at: null, window_start: null, window_end: null, threshold_seconds: null }); + const source = (scope: string, group: string, topic: string, lag: number) => ({ id: scope, availability: "fresh", coverage: "complete", checked_at: at, observed_at: at, measured_scopes: 1, expected_scopes: 1, metrics: [{ ...metric(scope, "committed_lag", lag), broker: { consumer_group: group, topics: [topic], partitions: [{ topic, partition: 0, earliest: 10, committed: 16, latest: 16 + lag, committed_lag: lag }] } }, metric(scope, "members", 1)] }); + const held = { id: "55555555-5555-4555-8555-555555555555", observed_at: at, event: "sync_control_plane_held", level: "warn", category: "control_plane", mailbox_id: mailbox, http_status: 503, count: 1 }; + const runtime = { id: "66666666-6666-4666-8666-666666666666", observed_at: at, event: "worker_error", level: "error", category: "worker_runtime", count: 1 }; + + await page.route(`${env.apiURL}/**`, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const headers = { "Cache-Control": "no-store", "Access-Control-Allow-Origin": new URL(env.webURL).origin, "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Allow-Methods": "GET, POST, OPTIONS" }; + const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", headers, body: JSON.stringify(body) }); + if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers }); + if (path === "/v1/auth/config") return json({ captcha: false }); + if (path === "/v1/auth/me") return json({ id: admin, email: "fixture-admin@example.test", is_admin: true, admin_permissions: (1 << 0) | (1 << 4) | (1 << 11), session_mfa_verified: mfa }); + if (path === "/v1/auth/reauth") { confirmed = true; return json({ valid_for_seconds: 300 }); } + if (path === "/admin/auth/device/describe") { + descriptions++; + const body = request.postDataJSON() as { user_code: string }; + if (body.user_code === "JKLM-2345") return json({ error: "expired", message: "Request expired or already consumed", code: "admin_device_expired", request_id: "fixture-expired" }, 404); + return json({ request: { client_name: "Local fixture laptop (unverified name)", instance_url: env.apiURL, expires_at: expires, status: "pending", consent_token: "c".repeat(43) }, requested_access: "Your live platform administrator permissions through a separate revocable session", admin_permissions: (1 << 0) | (1 << 4) | (1 << 11) }); + } + if (path === "/admin/auth/device/decide") { + decisions++; + const body = request.postDataJSON() as { user_code: string; consent_token: string; decision: string }; + expect(body.user_code).toBe("ABCD-EFGH"); expect(body.consent_token).toBe("c".repeat(43)); + if (!confirmed) return json({ error: "forbidden", message: "Fresh authentication required", code: "reauth_required", request_id: "fixture-reauth" }, 403); + return json({ status: body.decision }); + } + if (path === "/admin/fleet/nodes") return json({ data: [node(worker, "worker"), node(consumer, "consumer")] }); + if (path === `/admin/workers/${worker}/stats`) return json({ worker_id: worker, total_emails_sent: 0, emails_sent_today: 0, emails_sent_this_week: 0, average_delivery_time_ms: 0, success_rate: 0, queue_depth: 0 }); + if (path === `/admin/workers/${worker}/emails`) return json({ data: [{ id: mailbox, email: "fixture-mailbox@example.test", provider: "gmail", user_id: admin }], pagination: { has_more: false, total: 1 } }); + if (path === `/admin/fleet/nodes/${worker}/logs`) { + const events = [held, runtime].filter((event) => (!url.searchParams.get("level") || event.level === url.searchParams.get("level")) && (!url.searchParams.get("mailbox_id") || ("mailbox_id" in event && event.mailbox_id === url.searchParams.get("mailbox_id"))) && (!url.searchParams.get("after") || Date.parse(event.observed_at) >= Date.parse(url.searchParams.get("after")!)) && (!url.searchParams.get("before") || Date.parse(event.observed_at) <= Date.parse(url.searchParams.get("before")!))); + return json({ node_id: worker, availability: "fresh", coverage: "partial", reason: "allowlisted_evidence_only", observed_at: at, capture: { protocol: 1, run_id: "77777777-7777-4777-8777-777777777777", started_at: started, observed_at: at, received_at: at, dropped: 3 }, events, oldest_retained_at: at, newest_retained_at: at, retained_count: 2, truncated: false, retention_seconds: 3600, max_events: 1000 }); + } + if (path === `/admin/fleet/nodes/${consumer}/logs`) return json({ node_id: consumer, availability: "unavailable", coverage: "unavailable", reason: "log_credential_unavailable", observed_at: at, events: [], retained_count: 0, truncated: false, retention_seconds: 3600, max_events: 1000 }); + if (path === `/admin/fleet/nodes/${consumer}/broker`) { consumerBrokerReads++; return json({ code: "bad_request", message: "Worker required" }, 400); } + if (path === `/admin/fleet/nodes/${worker}/broker`) return json({ node_id: worker, observed_at: at, commands: source(worker, `worker-${worker}`, `w.${worker}`, 6), results: unsupportedResults ? { id: "worker_events", availability: "unavailable", coverage: "unavailable", reason: "unsupported", checked_at: at, observed_at: at, measured_scopes: null, expected_scopes: null, metrics: [] } : source("worker_events", "consumer-group", "jobs.worker-events", 3), note: "Shared results are not this worker's delivery count; lag is not outage evidence." }); + if (path === "/admin/instance/health") return json({ checks: [], checked_at: at }); + if (path === "/admin/instance/monitoring") return json({ version: "fixture", checked_at: at, refresh_after: expires, coverage: "unavailable", sources: [] }); + return json({ code: "service_unavailable", message: "Not included in the local UI fixture", request_id: "fixture-only" }, 503); + }); + + await page.goto("/device?user_code=ABCD-EFGH&approve=true"); + await expect(page).toHaveURL(/\/auth\/login$/); + await expect(page.getByRole("heading", { name: "Sign in to Warmbly", exact: true })).toBeVisible(); + expect(decisions).toBe(0); expect(descriptions).toBe(0); + await proof.chapter("Native sign-in boundary", "Local response fixtures only. This flow does not prove actual MFA, a real CLI session, Redis retention or live Kafka observations."); + await proof.shot("admin-cli-sign-in-boundary", { caption: "Unauthenticated /device redirects to the unchanged login screen. Synthetic fixtures are used throughout; no production credentials or real device secrets." }); + + await page.addInitScript(() => localStorage.setItem("warmbly_admin_token", JSON.stringify({ access_token: "fixture-not-a-credential", refresh_token: "fixture-not-a-credential", access_token_expires_at: "2099-01-01T00:00:00Z", refresh_token_expires_at: "2099-01-01T00:00:00Z" }))); + await page.goto("/device"); + await expect(page.getByRole("heading", { name: "Two-factor authentication required", exact: true })).toBeVisible(); + expect(descriptions).toBe(0); + mfa = true; + await page.reload(); + await expect(page.getByRole("button", { name: "Review request", exact: true })).toBeVisible(); + await page.getByRole("textbox", { name: "User code", exact: true }).fill("ABCD-EFGH"); + await page.getByRole("button", { name: "Review request", exact: true }).click(); + await expect(page.getByText("Pending explicit approval", { exact: true })).toBeVisible(); + await expect(page.getByText(env.apiURL, { exact: true })).toBeVisible(); + await expect(page.getByText("Local fixture laptop (unverified name)", { exact: true })).toBeVisible(); + expect(decisions).toBe(0); + await proof.chapter("Review is not approval", "Synthetic client/instance/access/expiry response. Opening the link, passing the MFA guard fixture and reviewing do not authorize the CLI."); + await proof.shot("admin-cli-pending-consent", { caption: "Disclosed local fixture: explicit pending consent shows instance, requester-supplied client name, administrator capability and expiry. No automatic decision." }); + await page.getByRole("button", { name: "Approve CLI sign-in", exact: true }).click(); + await page.getByRole("button", { name: "Confirm identity", exact: true }).click(); + const confirmation = page.getByRole("alertdialog"); + await expect(confirmation).toBeVisible(); + await confirmation.getByPlaceholder("Two-factor or recovery code", { exact: true }).fill("123456"); + await confirmation.getByRole("button", { name: "Confirm", exact: true }).click(); + await expect(confirmation).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Approve CLI sign-in", exact: true })).toBeEnabled(); + expect(decisions).toBe(1); + await page.getByRole("button", { name: "Approve CLI sign-in", exact: true }).click(); + await expect(page.getByText("Request approved", { exact: true })).toBeVisible(); + expect(decisions).toBe(2); + await proof.shot("admin-cli-explicit-approved", { caption: "Synthetic decision and reauth responses, not real MFA or session issuance. Native identity confirmation alone never retries approval; a second explicit click is required." }); + + await page.getByRole("button", { name: "Enter another code", exact: true }).click(); + await page.getByRole("textbox", { name: "User code", exact: true }).fill("ABCD-EFGH"); + await page.getByRole("button", { name: "Review request", exact: true }).click(); + await page.getByRole("button", { name: "Deny request", exact: true }).click(); + await expect(page.getByText("Request denied", { exact: true })).toBeVisible(); + await proof.shot("admin-cli-explicit-denied", { caption: "Local decision fixture: explicit denial resolves the request without returning credentials." }); + await page.getByRole("button", { name: "Enter another code", exact: true }).click(); + await page.getByRole("textbox", { name: "User code", exact: true }).fill("JKLM-2345"); + await page.getByRole("button", { name: "Review request", exact: true }).click(); + await expect(page.getByText("Request expired or unavailable", { exact: true })).toBeVisible(); + await proof.shot("admin-cli-expired-request", { caption: "Local 404 admin_device_expired fixture keeps unknown/expired/consumed codes fail-closed." }); + + await page.goto(`/workers/${worker}`); + const logs = page.locator("section").filter({ has: page.getByRole("heading", { name: "Recent redacted node logs", exact: true }) }); + await logs.getByRole("heading").scrollIntoViewIfNeeded(); + await expect(logs.getByRole("table", { name: "Redacted node events", exact: true })).toBeVisible(); + await expect(logs.getByText("Control-plane sync held", { exact: true })).toBeVisible(); + await expect(logs).toContainText("HTTP 503"); await expect(logs).toContainText("Partial coverage"); await expect(logs).toContainText("per process run"); + await expect(logs.getByRole("link", { name: mailbox, exact: true })).toHaveAttribute("href", new RegExp("/mailboxes\\?q=fixture-mailbox%40example.test")); + await proof.chapter("Redacted operational evidence", "All node capture/log/offset responses are synthetic localhost fixtures. They do not establish production sampling, provider health, delivery or the cause of an arrival 503."); + await logs.getByRole("table", { name: "Redacted node events", exact: true }).scrollIntoViewIfNeeded(); + await proof.shot("admin-node-redacted-evidence", { caption: "Synthetic capture and allowlisted events: timestamps, partial coverage, retention bounds, three known per-run drops, numeric control-plane HTTP 503 and native mailbox link." }); + await logs.getByRole("combobox", { name: "Log level", exact: true }).click(); + await page.getByRole("option", { name: "Warn", exact: true }).click(); + await logs.getByRole("textbox", { name: "Mailbox UUID", exact: true }).fill(mailbox); + await logs.getByLabel("Before (local time)", { exact: true }).fill(before); + await expect(logs.getByText("Coarse worker runtime error", { exact: true })).toHaveCount(0); + await page.reload(); + await expect(logs.getByRole("textbox", { name: "Mailbox UUID", exact: true })).toHaveValue(mailbox); + await expect(logs.getByLabel("Before (local time)", { exact: true })).toHaveValue(before); + await expect(logs.getByRole("combobox", { name: "Log level", exact: true })).toContainText("Warn"); + await logs.getByRole("heading").scrollIntoViewIfNeeded(); + await proof.shot("admin-node-persistent-filters", { caption: "Local fixture filters survive refresh under the current admin and node. Window counts cover all retained events, not just the filtered subset." }); + + const broker = page.locator("section").filter({ has: page.getByRole("heading", { name: "Broker committed-offset observations", exact: true }) }); + await broker.getByRole("heading").scrollIntoViewIfNeeded(); + await expect(broker).toContainText("Worker command scope"); await expect(broker).toContainText("Shared result-consumer scope"); + await expect(broker).toContainText("6 messages"); await expect(broker).toContainText("3 messages"); + await expect(broker).toContainText("consumer-group"); await expect(broker).toContainText("jobs.worker-events"); + await broker.getByText("Validated partition offsets", { exact: true }).first().click(); + await expect(broker.getByRole("table", { name: "Worker command scope partition offsets", exact: true })).toBeVisible(); + await proof.shot("admin-node-separate-broker-scopes", { caption: "Synthetic retained offsets earliest 10 / committed 16 / latest 22 yield command lag 6. Shared result lag 3 is separate, never added, attributed to this worker or labeled an outage/send rate." }); + unsupportedResults = true; + await broker.getByRole("button", { name: "Refresh broker", exact: true }).click(); + await expect(broker).toContainText("This broker does not support"); await expect(broker).toContainText("Unknown / unavailable"); + await proof.shot("admin-node-unavailable-broker", { caption: "Synthetic unsupported shared-result observation remains unknown/unavailable while independent worker command evidence stays visible. No fabricated zero lag or healthy state." }); + + await page.goto(`/workers/${consumer}`); + await expect(logs.getByText("Log evidence unavailable", { exact: true })).toBeVisible(); + await expect(logs).toContainText("upgrade and re-enrollment"); + await expect(broker).toContainText("Command-group broker observations require a worker node"); + expect(consumerBrokerReads).toBe(0); + await logs.getByRole("heading").scrollIntoViewIfNeeded(); + await proof.shot("admin-consumer-generic-evidence", { caption: "Local older-enrollment consumer fixture: generic log capture unavailable, retained event count unknown, no worker-command query and no inferred consumer lag." }); + await page.goto(`/workers/${worker}`); + await expect(logs.getByRole("textbox", { name: "Mailbox UUID", exact: true })).toHaveValue(mailbox); + await expect(logs.getByRole("combobox", { name: "Log level", exact: true })).toContainText("Warn"); +});