vtc-service 0.7.0

Service for Verifiable Trust Communities
// Audit trail viewer.
//
// Wraps `GET /v1/audit`. Super-admin only — the endpoint is gated
// server-side, and a non-super-admin caller gets a 403 rendered as
// a toast.
//
// Pagination is forward-only ("Show older" button). The page shows
// timestamp + event-kind + actor/target DIDs + a collapsible JSON
// detail panel per row. Auto-refreshes when the operator clicks
// Refresh — we deliberately don't poll, because the audit log can
// grow large and a poll would refetch the whole page each tick.

import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ClipboardList, RefreshCw } from "lucide-react";

import { getJson } from "@/lib/api";
import { formatIso } from "@/lib/format";
import { useToast } from "@/lib/toast";

// Human-readable label per event kind. Falls through to the
// camelCase tag for variants we haven't catalogued yet (new event
// types just show as raw kind until this map is updated, which is a
// quieter failure mode than throwing).
const EVENT_DESCRIPTIONS: Record<string, string> = {
  AdminUiServed: "Admin UI bundle pinned at daemon boot",
  CommunityInstalled: "Community installation completed",
  EmergencyBootstrapInvoked: "Emergency bootstrap triggered",
  AdminPasskeyRegistered: "Admin registered a passkey",
  AdminPasskeyRevoked: "Admin revoked a passkey",
  ConfigChanged: "Daemon configuration changed",
  ConfigReloaded: "Daemon configuration reloaded",
  RestartRequested: "Daemon restart requested",
  CommunityProfileUpdated: "Community profile updated",
  AuditKeyRotated: "Audit key rotated",
  MemberUpdated: "Member record updated",
  RoleChanged: "Member role changed",
  AdminPromoted: "Member promoted to admin",
  JoinRequestSubmitted: "Applicant submitted a join request",
  JoinRequestApproved: "Join request approved",
  JoinRequestRejected: "Join request rejected",
  MemberAdded: "New member joined",
  MemberRemoved: "Member removed from community",
  PolicyUploaded: "Policy revision uploaded",
  PolicyActivated: "Policy activated for a purpose",
  VmcIssued: "Membership credential (VMC) issued",
  VecIssued: "Role credential (VEC) issued",
  MembershipRenewed: "Membership renewed",
  StatusListFlipped: "Status-list bit flipped",
  DidRotated: "Member rotated their DID",
  RegistryStatusChanged: "Trust-registry reachability changed",
  RegistrySyncSucceeded: "Trust-registry sync succeeded",
  RegistrySyncFailed: "Trust-registry sync failed",
  RegistryRecordPolicyOverride: "Registry record disposition overridden",
  CrossCommunitySessionMinted: "Cross-community session issued",
  VrcPublished: "Relationship credential (VRC) published",
  VrcRevoked: "Relationship credential revoked",
  PersonhoodAsserted: "Personhood asserted",
  PersonhoodRevoked: "Personhood revoked",
  CustomEndorsementIssued: "Custom endorsement issued",
  CustomEndorsementRevoked: "Custom endorsement revoked",
  EndorsementTypeRegistered: "Endorsement type registered",
  EndorsementTypeDeleted: "Endorsement type deleted",
  WebsiteFileWritten: "Public website file written",
  WebsiteFileDeleted: "Public website file deleted",
  WebsiteBundleDeployed: "Public website bundle deployed",
  WebsiteGenerationRolledBack: "Public website rolled back",
};

// Events that fire on a schedule or at daemon-internal lifecycle
// transitions, not in response to an operator/member action. The
// audit log keeps them for security pinning + completeness, but the
// default UI filters them so "who did what" reads cleanly.
const SYSTEM_EVENT_KINDS = new Set([
  "AdminUiServed",
  "RegistryStatusChanged",
  "RegistrySyncSucceeded",
  "RegistrySyncFailed",
]);

const TRUST_TASK = "https://trusttasks.org/openvtc/vtc/audit/list/1.0";

interface AuditEnvelope {
  event_id: string;
  event_version: number;
  schema_version: number;
  timestamp: string;
  audit_key_id: string;
  actor_did_hash: string;
  actor_did_plain: string | null;
  target_did_hash: string | null;
  target_did_plain: string | null;
  event: Record<string, unknown> | string;
}

interface Paginated<T> {
  items: T[];
  next_cursor?: string | null;
  total_estimate?: number | null;
}

async function fetchAuditPage(
  cursor: string | null,
  limit: number,
): Promise<Paginated<AuditEnvelope>> {
  const q = new URLSearchParams();
  if (cursor) q.set("cursor", cursor);
  q.set("limit", String(limit));
  return getJson<Paginated<AuditEnvelope>>(`/v1/audit?${q}`, {
    trustTask: TRUST_TASK,
  });
}

export function Audit() {
  const [cursor, setCursor] = useState<string | null>(null);
  const [items, setItems] = useState<AuditEnvelope[]>([]);
  const [nextCursor, setNextCursor] = useState<string | null>(null);
  const [showSystem, setShowSystem] = useState(false);
  const [filterText, setFilterText] = useState("");
  const toast = useToast();

  const query = useQuery({
    queryKey: ["audit", cursor],
    queryFn: () => fetchAuditPage(cursor, 50),
    staleTime: 0,
  });

  // Accumulate pages: first page replaces, subsequent appends. We
  // could let TanStack manage this with `useInfiniteQuery` instead,
  // but the read-once pattern keeps it simpler.
  useEffect(() => {
    if (!query.data) return;
    if (cursor === null) {
      setItems(query.data.items);
    } else {
      setItems((prev) => [...prev, ...query.data!.items]);
    }
    setNextCursor(query.data.next_cursor ?? null);
  }, [query.data, cursor]);

  useEffect(() => {
    if (query.error) toast.pushFromError(query.error, "Failed to load audit");
  }, [query.error, toast]);

  // Visible items = the accumulated set minus system events (when
  // hidden) minus rows that don't match the free-text filter.
  // Filter matches against event kind, description, and either DID
  // — case-insensitive substring so an operator pasting half a DID
  // still finds the row.
  const visibleItems = useMemo(() => {
    const needle = filterText.trim().toLowerCase();
    return items.filter((env) => {
      const kind = eventKind(env.event);
      if (!showSystem && SYSTEM_EVENT_KINDS.has(kind)) return false;
      if (!needle) return true;
      const haystack = [
        kind,
        EVENT_DESCRIPTIONS[kind] ?? "",
        env.actor_did_plain ?? "",
        env.target_did_plain ?? "",
      ]
        .join(" ")
        .toLowerCase();
      return haystack.includes(needle);
    });
  }, [items, showSystem, filterText]);

  const hiddenCount = items.length - visibleItems.length;

  return (
    <section className="page">
      <h2>Audit trail</h2>
      <p className="lead">
        Tamper-evident operations log. Newest first. Super-admin only —
        envelopes carry plaintext actor / target DIDs until an RTBF
        redaction nulls them.
      </p>

      <section className="card">
        <div className="toolbar">
          <label className="field inline">
            <span className="field-label">Filter</span>
            <input
              type="search"
              placeholder="kind, description, actor or target DID"
              value={filterText}
              onChange={(e) => setFilterText(e.target.value)}
            />
          </label>
          <label
            className="field inline"
            style={{ flex: "0 0 auto", minWidth: 0 }}
          >
            <span className="field-label">Show system events</span>
            <input
              type="checkbox"
              checked={showSystem}
              onChange={(e) => setShowSystem(e.target.checked)}
              style={{ width: "auto", height: "auto" }}
            />
          </label>
          <span className="muted">
            {visibleItems.length} of {items.length}
            {hiddenCount > 0 ? ` (${hiddenCount} filtered)` : ""}
            {nextCursor ? ", more available" : ""}
          </span>
          <div className="spacer" />
          <button
            type="button"
            className="secondary"
            disabled={query.isFetching && cursor === null}
            aria-busy={query.isFetching && cursor === null}
            onClick={async () => {
              // Refresh resets the accumulator + re-fetches the first
              // page. We can't lean on the `useEffect` below to wire
              // the response into state: React Query's default
              // `structuralSharing` keeps `query.data` byte-stable
              // when the response is identical, so the effect's
              // dependency array never sees a new reference and the
              // post-clear `setItems([])` would stick. Pull the
              // result out of `refetch()`'s promise and assign
              // explicitly instead.
              setCursor(null);
              setItems([]);
              setNextCursor(null);
              const result = await query.refetch();
              if (result.data) {
                setItems(result.data.items);
                setNextCursor(result.data.next_cursor ?? null);
              }
            }}
          >
            <RefreshCw size={14} aria-hidden="true" />
            {query.isFetching && cursor === null ? "Refreshing…" : "Refresh"}
          </button>
        </div>
      </section>

      <section className="card">
        <table className="data-table">
          <thead>
            <tr>
              <th>Time</th>
              <th>Event</th>
              <th>Actor</th>
              <th>Target</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {visibleItems.length === 0 && !query.isPending && (
              <tr>
                <td colSpan={5}>
                  <div className="empty-state">
                    <span className="empty-icon" aria-hidden="true">
                      <ClipboardList />
                    </span>
                    <h4>
                      {items.length === 0
                        ? "No audit entries yet"
                        : "No entries match this filter"}
                    </h4>
                    <p>
                      {items.length === 0
                        ? "Audit envelopes appear here once the community starts emitting events."
                        : "Clear the search box or enable system events to widen the view."}
                    </p>
                  </div>
                </td>
              </tr>
            )}
            {query.isPending && cursor === null && (
              <tr>
                <td colSpan={5}>Loading…</td>
              </tr>
            )}
            {visibleItems.map((env) => (
              <AuditRow key={env.event_id} env={env} />
            ))}
          </tbody>
        </table>
        {nextCursor && (
          <div className="pagination">
            <button
              type="button"
              className="secondary"
              disabled={query.isFetching}
              aria-busy={query.isFetching && cursor !== null}
              onClick={() => setCursor(nextCursor)}
            >
              {query.isFetching && cursor !== null ? "Loading…" : "Show older"}
            </button>
          </div>
        )}
      </section>
    </section>
  );
}

function AuditRow({ env }: { env: AuditEnvelope }) {
  const [open, setOpen] = useState(false);
  const kind = eventKind(env.event);
  const description = EVENT_DESCRIPTIONS[kind];
  return (
    <>
      <tr>
        <td title={env.timestamp}>{formatIso(env.timestamp)}</td>
        <td>
          <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
            <span>{description ?? kind}</span>
            {description && (
              <code
                className="muted"
                style={{ fontSize: "var(--text-xs)" }}
              >
                {kind}
              </code>
            )}
          </div>
        </td>
        <td>
          {env.actor_did_plain ? (
            <code className="truncate" title={env.actor_did_plain}>
              {env.actor_did_plain}
            </code>
          ) : (
            <span className="muted" title="Redacted by RTBF">
              redacted
            </span>
          )}
        </td>
        <td>
          {env.target_did_plain ? (
            <code className="truncate" title={env.target_did_plain}>
              {env.target_did_plain}
            </code>
          ) : env.target_did_hash ? (
            <span className="muted" title="Redacted by RTBF">
              redacted
            </span>
          ) : (
            <span className="muted">—</span>
          )}
        </td>
        <td>
          <button
            type="button"
            className="link"
            aria-expanded={open}
            onClick={() => setOpen((v) => !v)}
          >
            {open ? "Hide" : "Details"}
          </button>
        </td>
      </tr>
      {open && (
        <tr>
          <td colSpan={5}>
            <pre className="audit-detail">{formatEvent(env)}</pre>
          </td>
        </tr>
      )}
    </>
  );
}

function eventKind(event: AuditEnvelope["event"]): string {
  // The event field is serde-tagged: `{ kind: "MemberAdded", … }`
  // or `"MemberAdded"` for unit variants. Surface the tag for the
  // table column; the JSON detail row shows the full payload.
  if (typeof event === "string") return event;
  if (event && typeof event === "object") {
    const obj = event as Record<string, unknown>;
    for (const key of Object.keys(obj)) {
      return key;
    }
  }
  return "Unknown";
}

function formatEvent(env: AuditEnvelope): string {
  try {
    return JSON.stringify(env, null, 2);
  } catch {
    return String(env);
  }
}