feat(P5): Wave E dashboard surfacing — agent status, health, logs, audit export

REQ-014: surface relay agent health (green/yellow/red), target hostname,
  last 100 log lines in admin dashboard. Per-tenant view under RLS.

---ci---
phase: 5
milestone: v0.1
status: execute
---/ci---
This commit is contained in:
CIAgent
2026-08-25 02:21:51 +00:00
parent d0465a4c71
commit dfc6b8ff76
17 changed files with 1713 additions and 72 deletions
@@ -0,0 +1,94 @@
/**
* GET /api/audit/export — CSV export of the tenant's audit log (REQ-038).
*
* RBAC: admin (audit export is admin-only per spec §2.2 — no query UI in M1,
* just a download button). Under withTenant + RLS — CSV is tenant-scoped; T1
* cannot download T2's audit rows.
*
* Columns: id, timestamp, event_type, user_id, target_id, payload. The payload
* is serialized as JSON (redaction of internal hash-chain fields — we don't
* surface prev_hash/curr_hash to the CSV consumer).
*
* Response Content-Type: text/csv; filename=coreci-audit-<tenantId>-<ts>.csv
*/
import type { NextRequest } from "next/server";
import { withTenant, type ScopedClient } from "@coreci/db";
import { requireAuth } from "../../../../lib/auth.js";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface AuditLogRow {
id: string;
created_at: string;
event_type: string;
user_id: string | null;
target_id: string | null;
payload: unknown;
}
const CSV_COLUMNS = ["id", "timestamp", "event_type", "user_id", "target_id", "payload"] as const;
/** RFC 4180 CSV field escaping: wrap in quotes if it contains comma/quote/newline. */
function csvField(value: string): string {
if (value === "") return "";
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
function rowToCsvLine(r: AuditLogRow): string {
const payloadJson = r.payload === null || r.payload === undefined
? ""
: JSON.stringify(r.payload);
const fields = [
String(r.id),
r.created_at,
r.event_type,
r.user_id ?? "",
r.target_id ?? "",
payloadJson,
];
return fields.map(csvField).join(",");
}
export async function GET(req: NextRequest): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
if (auth.user.role !== "admin") {
return Response.json(
{ error: "forbidden", detail: "audit export requires the Admin role" },
{ status: 403 },
);
}
const tenantId = auth.user.tenantId;
const rows = await withTenant(tenantId, async (c: ScopedClient) => {
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
const res = await c.query<AuditLogRow>(
`SELECT id, created_at, event_type, user_id, target_id, payload
FROM audit_log
WHERE tenant_id = $1
ORDER BY id ASC`,
[tenantId],
);
return res.rows;
});
const lines = [CSV_COLUMNS.join(",")];
for (const r of rows) lines.push(rowToCsvLine(r));
const csv = lines.join("\r\n");
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `coreci-audit-${tenantId.slice(0, 8)}-${ts}.csv`;
return new Response(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
},
});
}
@@ -33,9 +33,9 @@ function signingKey(): string {
const TOKEN_LIFETIME_HOURS = 24;
export async function POST(req: NextRequest): Promise<NextResponse> {
let ctx: ReturnType<typeof requireAdmin>;
let ctx;
try {
ctx = requireAdmin(req);
ctx = await requireAdmin(req);
} catch (err) {
return toAuthResponse(err);
}
@@ -50,13 +50,13 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
);
}
const token = issueRelayToken(ctx.tenantId, key, TOKEN_LIFETIME_HOURS);
const token = issueRelayToken(ctx.user.tenantId, key, TOKEN_LIFETIME_HOURS);
const expiresAt = new Date(Date.now() + TOKEN_LIFETIME_HOURS * 60 * 60 * 1000).toISOString();
return NextResponse.json({
ok: true,
token,
tenantId: ctx.tenantId,
tenantId: ctx.user.tenantId,
expiresAt,
lifetimeHours: TOKEN_LIFETIME_HOURS,
// The install command the dashboard embeds (Wave E renders this verbatim).
@@ -0,0 +1,182 @@
/**
* GET /api/relay/status — Server-Sent Events fan-out of target status (REQ-014).
*
* Streams target status updates to connected dashboard clients.
* - On connect: send the current target list with health status (`snapshot`).
* - Every 5s: re-query targets, compute health, send an `update` event.
* - On client disconnect: stop the interval + clean up.
*
* RBAC: read (any authenticated role). The session is verified once on connect
* (SSE doesn't allow re-sending cookies mid-stream, so we auth the long-lived
* stream at handshake time — acceptable for M1 admin dashboards).
*
* Uses the in-memory `getConnectedAgents()` from the WS server for real-time
* status: if a target is currently WS-connected, it's green even if last_seen
* is slightly stale (heartbeat batching). Content-Type: text/event-stream.
*/
import type { NextRequest } from "next/server";
import { withTenant, type ScopedClient } from "@coreci/db";
import { requireAuth } from "../../../../lib/auth.js";
import { computeHealth, type HealthStatus } from "../../../../lib/health.js";
import { getDb } from "../../../../lib/db.js";
export const runtime = "nodejs";
/** Force dynamic — SSE must never be statically rendered. */
export const dynamic = "force-dynamic";
const TICK_INTERVAL_MS = 5000;
interface TargetRow {
id: string;
hostname: string;
os_name: string;
os_version: string;
ip_address: string | null;
agent_version: string;
last_seen_at: string | null;
registered_at: string;
}
export interface TargetStatusDto {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
/**
* Snapshot of currently-connected target ids (from the in-memory WS registry).
* The WS server is a separate process in prod; for M1 dev / this route we read
* the in-memory registry exported from ws-server.ts. If the WS server isn't
* loaded in this process (e.g. Next-only deploy), the registry is empty and
* health falls back to last_seen_at alone — still correct, just not real-time.
*/
async function connectedTargetIds(): Promise<ReadonlySet<string>> {
try {
const { getConnectedAgents } = await import("../../../../ws-server.js");
return new Set(getConnectedAgents().map((a) => a.targetId));
} catch {
// WS server module not loaded in this process → empty set (graceful).
return new Set();
}
}
async function queryTargets(tenantId: string): Promise<TargetRow[]> {
return withTenant(tenantId, async (c: ScopedClient) => {
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
const res = await c.query<TargetRow>(
`SELECT id, hostname, os_name, os_version, ip_address, agent_version,
last_seen_at, registered_at
FROM targets
WHERE tenant_id = $1
ORDER BY registered_at DESC`,
[tenantId],
);
return res.rows;
});
}
async function buildSnapshot(tenantId: string): Promise<TargetStatusDto[]> {
const rows = await queryTargets(tenantId);
const connected = await connectedTargetIds();
const now = Date.now();
return rows.map((r) => ({
id: r.id,
hostname: r.hostname,
osName: r.os_name,
osVersion: r.os_version,
ipAddress: r.ip_address,
agentVersion: r.agent_version,
lastSeenAt: r.last_seen_at,
registeredAt: r.registered_at,
health: computeHealth(
{ lastSeenAt: r.last_seen_at, targetId: r.id, connectedTargetIds: connected },
now,
),
}));
}
function sseMessage(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
export async function GET(req: NextRequest): Promise<Response> {
// SSE handshake: verify session + RBAC before opening the stream. We re-use
// requireAuth but need the user context even though SSE returns a stream.
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const tenantId = auth.user.tenantId;
// Ensure the DB is bootstrapped (the snapshot query needs it).
await getDb();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const enc = new TextEncoder();
let closed = false;
const send = (event: string, data: unknown): void => {
if (closed) return;
try {
controller.enqueue(enc.encode(sseMessage(event, data)));
} catch {
closed = true;
}
};
// Initial snapshot.
try {
const snapshot = await buildSnapshot(tenantId);
send("snapshot", snapshot);
} catch (err) {
send("error", { error: "snapshot_failed", detail: err instanceof Error ? err.message : String(err) });
}
// Periodic updates.
const interval = setInterval(async () => {
if (closed) return;
try {
const snapshot = await buildSnapshot(tenantId);
send("update", snapshot);
} catch (err) {
send("error", { error: "update_failed", detail: err instanceof Error ? err.message : String(err) });
}
}, TICK_INTERVAL_MS);
// SSE keepalive comment every 15s (so proxies don't idle-close).
const keepalive = setInterval(() => {
if (closed) return;
try {
controller.enqueue(enc.encode(`: keepalive\n\n`));
} catch {
closed = true;
}
}, 15000);
// Clean up on cancel (client closes the tab).
req.signal.addEventListener("abort", () => {
closed = true;
clearInterval(interval);
clearInterval(keepalive);
try {
controller.close();
} catch {
/* already closed */
}
});
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
}
@@ -0,0 +1,113 @@
/**
* GET /api/targets/[id]/logs — last 100 log lines for a target (REQ-014).
*
* RBAC: read. Under withTenant + RLS.
*
* For M1, "logs" are the audit_log entries for this target (event_type in
* ['provision', 'config', 'ssh_command', 'tool_call'] that reference the
* target). In M2+ this will include SSH command logs from the Relay Agent.
*
* Returns the last 100 audit_log entries for this target, newest first. Each
* entry: { id, eventType, createdAt, payload (redacted summary) }. We never
* surface the raw hash-chain bytes; the payload is a curated summary so we
* don't leak internal audit metadata to the dashboard viewer.
*/
import type { NextRequest } from "next/server";
import { withTenant, type ScopedClient } from "@coreci/db";
import { requireAuth } from "../../../../../lib/auth.js";
export const runtime = "nodejs";
interface AuditLogRow {
id: string;
event_type: string;
created_at: string;
payload: unknown;
}
export interface TargetLogEntry {
id: string;
eventType: string;
createdAt: string;
/** Redacted summary of the audit payload (no hash bytes, no internal fields). */
payload: Record<string, unknown>;
}
/** Allowed event types surfaced as "logs" for a target in M1. */
const TARGET_LOG_EVENT_TYPES = ["provision", "config", "ssh_command", "tool_call"];
/** Redact/curate the audit payload for dashboard display. */
function redactPayload(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return { summary: String(payload) };
}
const p = payload as Record<string, unknown>;
// Surface the human-actionable fields; drop anything that could leak
// internal state. Keep it lossy on purpose — this is a summary view.
const out: Record<string, unknown> = {};
if (typeof p.action === "string") out.action = p.action;
if (typeof p.hostname === "string") out.hostname = p.hostname;
if (typeof p.os === "string") out.os = p.os;
if (typeof p.osVersion === "string") out.osVersion = p.osVersion;
if (typeof p.ip === "string") out.ip = p.ip;
if (typeof p.agentVersion === "string") out.agentVersion = p.agentVersion;
if (p.targetId !== undefined) out.targetId = p.targetId;
if (typeof p.error === "string") out.error = p.error;
if (typeof p.detail === "string") out.detail = p.detail;
if (Object.keys(out).length === 0) {
// Unknown shape — fall back to a generic summary so the row still renders.
return { summary: "event recorded" };
}
return out;
}
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const { id } = await ctx.params;
if (!id) return Response.json({ error: "missing id" }, { status: 400 });
const rows = await withTenant(auth.user.tenantId, async (c: ScopedClient) => {
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
const res = await c.query<AuditLogRow>(
`SELECT id, event_type, created_at, payload
FROM audit_log
WHERE target_id = $1
AND tenant_id = $2
AND event_type = ANY($3::text[])
ORDER BY id DESC
LIMIT 100`,
[id, auth.user.tenantId, TARGET_LOG_EVENT_TYPES],
);
return res.rows;
});
// If no audit rows AND the target doesn't exist in this tenant → 404.
// (RLS: a cross-tenant target id yields zero rows here too.) We do a cheap
// existence check so a stale target id doesn't return an empty 200 that
// looks like "exists but no logs".
if (rows.length === 0) {
const exists = await withTenant(auth.user.tenantId, async (c: ScopedClient) => {
const res = await c.query<{ id: string }>(
"SELECT id FROM targets WHERE id = $1 AND tenant_id = $2",
[id, auth.user.tenantId],
);
return res.rows.length > 0;
});
if (!exists) return Response.json({ error: "not_found" }, { status: 404 });
}
const entries: TargetLogEntry[] = rows.map((r) => ({
id: String(r.id),
eventType: r.event_type,
createdAt: r.created_at,
payload: redactPayload(r.payload),
}));
return Response.json({ logs: entries });
}
@@ -0,0 +1,78 @@
/**
* GET /api/targets/[id] — single target metadata + health (REQ-014).
*
* RBAC: read. Under withTenant + RLS — T1 cannot view T2's target. If the
* target id belongs to another tenant, RLS returns zero rows → 404 (we never
* reveal cross-tenant existence).
*/
import type { NextRequest } from "next/server";
import { withTenant, type ScopedClient } from "@coreci/db";
import { requireAuth } from "../../../../lib/auth.js";
import { computeHealth, type HealthStatus } from "../../../../lib/health.js";
export const runtime = "nodejs";
interface TargetRow {
id: string;
hostname: string;
os_name: string;
os_version: string;
ip_address: string | null;
agent_version: string;
last_seen_at: string | null;
registered_at: string;
}
export interface TargetDetailDto {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const { id } = await ctx.params;
if (!id) return Response.json({ error: "missing id" }, { status: 400 });
const row = await withTenant(auth.user.tenantId, async (c: ScopedClient) => {
// Explicit tenant_id filter (defense-in-depth — see /api/targets note).
const res = await c.query<TargetRow>(
`SELECT id, hostname, os_name, os_version, ip_address, agent_version,
last_seen_at, registered_at
FROM targets
WHERE id = $1 AND tenant_id = $2`,
[id, auth.user.tenantId],
);
return res.rows[0] ?? null;
});
if (!row) {
// Cross-tenant access (RLS returned zero rows) OR genuinely missing — both 404.
return Response.json({ error: "not_found" }, { status: 404 });
}
const dto: TargetDetailDto = {
id: row.id,
hostname: row.hostname,
osName: row.os_name,
osVersion: row.os_version,
ipAddress: row.ip_address,
agentVersion: row.agent_version,
lastSeenAt: row.last_seen_at,
registeredAt: row.registered_at,
health: computeHealth({ lastSeenAt: row.last_seen_at, targetId: row.id }),
};
return Response.json(dto);
}
@@ -0,0 +1,81 @@
/**
* GET /api/targets — list Relay Agent targets for the current tenant (REQ-014).
*
* RBAC: read (any authenticated role). Under withTenant + RLS — T1 cannot see
* T2's targets (RLS enforces tenant_id = current_setting('app.tenant_id')).
*
* Each row includes: id, hostname, os_name, os_version, ip_address,
* agent_version, last_seen_at, registered_at, and the computed health status
* (green/yellow/red per lib/health).
*
* The dashboard's /dashboard/targets page subscribes to /api/relay/status
* (SSE) for live updates; this route is the initial snapshot.
*/
import type { NextRequest } from "next/server";
import { withTenant, type ScopedClient } from "@coreci/db";
import { requireAuth } from "../../../lib/auth.js";
import { computeHealth, type HealthStatus } from "../../../lib/health.js";
export const runtime = "nodejs";
interface TargetRow {
id: string;
hostname: string;
os_name: string;
os_version: string;
ip_address: string | null;
agent_version: string;
last_seen_at: string | null;
registered_at: string;
}
export interface TargetDto {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
async function listTargets(tenantId: string): Promise<TargetRow[]> {
return withTenant(tenantId, async (c: ScopedClient) => {
// Explicit tenant_id filter (defense-in-depth): RLS enforces this in prod
// Postgres, but PGlite 0.5.7 does not enforce RLS on SELECT, so the
// application-layer filter is the primary enforcement in dev/test.
const res = await c.query<TargetRow>(
`SELECT id, hostname, os_name, os_version, ip_address, agent_version,
last_seen_at, registered_at
FROM targets
WHERE tenant_id = $1
ORDER BY registered_at DESC`,
[tenantId],
);
return res.rows;
});
}
export async function GET(req: NextRequest): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const rows = await listTargets(auth.user.tenantId);
const now = Date.now();
const dto: TargetDto[] = rows.map((r) => ({
id: r.id,
hostname: r.hostname,
osName: r.os_name,
osVersion: r.os_version,
ipAddress: r.ip_address,
agentVersion: r.agent_version,
lastSeenAt: r.last_seen_at,
registeredAt: r.registered_at,
health: computeHealth({ lastSeenAt: r.last_seen_at, targetId: r.id }, now),
}));
return Response.json(dto);
}
@@ -0,0 +1,32 @@
/**
* dashboard onboarding helper — fetch /api/targets to determine onboarding
* step status (Wave E Task 4). Returns null on auth failure.
*/
import { headers, cookies } from "next/headers";
import type { HealthStatus } from "../../lib/health.js";
export interface TargetSummary {
id: string;
health: HealthStatus;
lastSeenAt: string | null;
}
export async function getTargetsSummary(): Promise<TargetSummary[] | null> {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("coreci_session")?.value;
if (!sessionCookie) return null;
const h = await headers();
const host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http";
const res = await fetch(`${proto}://${host}/api/targets`, {
headers: { cookie: `coreci_session=${sessionCookie}` },
cache: "no-store",
});
if (res.status === 401 || res.status === 403) return null;
if (res.status !== 200) return [];
const targets = (await res.json()) as { id: string; health: HealthStatus; lastSeenAt: string | null }[];
return targets.map((t) => ({ id: t.id, health: t.health, lastSeenAt: t.lastSeenAt }));
}
@@ -0,0 +1,76 @@
/**
* /dashboard/audit — audit export (admin only, REQ-038, spec §2.2).
*
* "Download audit log (CSV)" button — no query UI in M1 (spec §2.2 mandates
* append-only audit + admin-only export with no query surface). The CSV is
* tenant-scoped under RLS (the /api/audit/export route enforces withTenant).
*
* Non-admins see a "you must be an admin" notice. Admins see the download
* button which GETs /api/audit/export (the browser streams the CSV).
*/
import { getMe } from "../me.js";
export default async function AuditPage() {
const me = await getMe();
if (!me) {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>Audit log</h1>
<p>You are not signed in.</p>
<p>
<a href="/login">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
</a>
</p>
</main>
);
}
if (me.role !== "admin") {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
<h1>Audit log</h1>
<p style={{ color: "#666" }}>
Only admins can export the audit log. You are a <strong>{me.role}</strong>.
</p>
<p>
<a href="/dashboard"> Dashboard</a>
</p>
</main>
);
}
return (
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h1>Audit log</h1>
<span style={{ color: "#666", fontSize: "0.85rem" }}>
{me.role} · tenant {me.tenantId.slice(0, 8)}
</span>
</header>
<p>
The audit log is an append-only, per-tenant hash-chained record of every
business event (provisioning, config changes, validations, SSH commands,
prompts, responses). Download it as CSV for compliance review.
</p>
<p>
<a href="/api/audit/export">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>
Download audit log (CSV)
</button>
</a>
</p>
<p style={{ color: "#666", fontSize: "0.85rem", marginTop: "1rem" }}>
No query UI in M1 (spec §2.2). The CSV is scoped to your tenant under RLS.
</p>
<p style={{ marginTop: "2rem" }}>
<a href="/dashboard"> Dashboard</a>
</p>
</main>
);
}
+90 -26
View File
@@ -1,12 +1,28 @@
/**
* /dashboard — M1 admin dashboard shell (REQ-002).
* /dashboard — M1 admin dashboard (REQ-002, REQ-014).
*
* The 5-step onboarding checklist is rendered here (all grey — steps light up
* as later waves ship). Wave E wires live Relay Agent health + targets; this
* shell proves the SSO → /dashboard redirect works end-to-end.
* The 5-step onboarding checklist. Wave E wires the live "Verify Green Status"
* step: it turns green when at least one target has green health (M1 demo
* path: SSO → BYOM green → install → register → green dashboard). The "Register
* Target" step turns green when at least one target exists (any health).
*/
import { getMe } from "./me.js";
import { getTargetsSummary, type TargetSummary } from "./_onboarding.js";
type StepStatus = "grey" | "green";
function badgeColor(status: StepStatus): string {
return status === "green" ? "#22c55e" : "#ccc";
}
function anyGreen(targets: TargetSummary[] | null): boolean {
return !!targets && targets.some((t) => t.health === "green");
}
function anyTarget(targets: TargetSummary[] | null): boolean {
return !!targets && targets.length > 0;
}
export default async function DashboardPage() {
const me = await getMe();
@@ -25,12 +41,25 @@ export default async function DashboardPage() {
);
}
const steps = [
{ id: "byom", label: "Configure BYOM", wave: "C" },
{ id: "relay", label: "Install Relay Agent", wave: "D" },
{ id: "target", label: "Register Target", wave: "D" },
{ id: "verify", label: "Verify Green Status", wave: "E" },
{ id: "team", label: "Invite Team", wave: "B" },
// Fetch target status for the onboarding checklist (Wave E Task 4).
const targets = await getTargetsSummary();
const hasTarget = anyTarget(targets);
const hasGreen = anyGreen(targets);
// Steps light up as later waves ship:
// byom → green when a validated BYOM endpoint exists (Wave C marks this
// via /api/byom; here we keep it grey until Wave C's status fetch
// is wired — but leave the hook in place).
// relay → install instructions exist (always green for M1 demo guidance)
// target → green when at least one target is registered (any health)
// verify → green when at least one target has green health
// team → grey (admin invites when ready)
const steps: { id: string; label: string; wave: string; status: StepStatus; hint?: string }[] = [
{ id: "byom", label: "Configure BYOM", wave: "C", status: "grey", hint: "Validate your BYOM endpoint" },
{ id: "relay", label: "Install Relay Agent", wave: "D", status: "grey", hint: "Copy the curl|bash command" },
{ id: "target", label: "Register Target", wave: "D", status: hasTarget ? "green" : "grey", hint: "Relay Agent connects + registers" },
{ id: "verify", label: "Verify Green Status", wave: "E", status: hasGreen ? "green" : "grey", hint: "At least one green target" },
{ id: "team", label: "Invite Team", wave: "B", status: "grey", hint: "Invite members + roles" },
];
return (
@@ -45,25 +74,60 @@ export default async function DashboardPage() {
<section>
<h2>Onboarding</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{steps.map((s) => (
<li key={s.id} style={{ padding: "0.4rem 0", display: "flex", gap: "0.6rem", alignItems: "center" }}>
<span
aria-label={s.id + " status"}
style={{
width: "0.9rem",
height: "0.9rem",
borderRadius: "50%",
background: "#ccc",
display: "inline-block",
}}
/>
<span>{s.label}</span>
<span style={{ color: "#999", fontSize: "0.75rem" }}>(Wave {s.wave})</span>
</li>
))}
{steps.map((s) => {
const stepHref =
s.id === "target" || s.id === "verify"
? "/dashboard/targets"
: s.id === "team"
? "/dashboard/team"
: null;
return (
<li key={s.id} style={{ padding: "0.4rem 0", display: "flex", gap: "0.6rem", alignItems: "center" }}>
<span
aria-label={s.id + " status"}
title={s.status === "green" ? "complete" : "pending"}
style={{
width: "0.9rem",
height: "0.9rem",
borderRadius: "50%",
background: badgeColor(s.status),
display: "inline-block",
}}
/>
<span>
{stepHref ? <a href={stepHref}>{s.label}</a> : s.label}
</span>
<span style={{ color: "#999", fontSize: "0.75rem" }}>(Wave {s.wave})</span>
{s.hint && (
<span style={{ color: "#999", fontSize: "0.75rem" }}> {s.hint}</span>
)}
</li>
);
})}
</ul>
</section>
<section style={{ marginTop: "1.5rem" }}>
<h2>Targets</h2>
{hasTarget ? (
<p>
{targets!.length} target(s) registered.{" "}
<a href="/dashboard/targets">View targets </a>
</p>
) : (
<p style={{ color: "#666" }}>
No targets registered yet. Install the Relay Agent (Wave D).
</p>
)}
</section>
<section style={{ marginTop: "1.5rem" }}>
<h2>Compliance</h2>
<p>
<a href="/dashboard/audit">Audit log export (CSV)</a> admin only, tenant-scoped.
</p>
</section>
<p style={{ marginTop: "2rem" }}>
<a href="/api/auth/logout">Sign out</a> ·{" "}
<a href="/dashboard/team">Team</a>
@@ -0,0 +1,140 @@
"use client";
/**
* TargetStatusStream — subscribes to /api/relay/status (SSE) and re-renders
* the targets table with live health updates (REQ-014, Wave E Task 1).
*
* Used by /dashboard/targets. Server component provides the initial snapshot
* (via /api/targets); this client component overlays live health updates from
* the SSE fan-out. Minimal client JS — only the badge color + lastSeenAt
* fields update; the table structure is server-rendered.
*/
import { useEffect, useState } from "react";
import type { HealthStatus } from "../../../lib/health.js";
export interface TargetRowState {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
interface SseSnapshotEvent {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
function badgeColor(status: HealthStatus): string {
switch (status) {
case "green":
return "#22c55e";
case "yellow":
return "#eab308";
case "red":
return "#ef4444";
}
}
export function TargetStatusStream({ initial }: { initial: TargetRowState[] }) {
const [rows, setRows] = useState<TargetRowState[]>(initial);
useEffect(() => {
// EventSource doesn't send cookies cross-origin reliably in all setups,
// but same-origin (the dashboard → /api/relay/status) includes the
// httpOnly session cookie by default. M1 dashboard is same-origin.
const es = new EventSource("/api/relay/status");
const apply = (data: string): void => {
let snapshot: SseSnapshotEvent[];
try {
snapshot = JSON.parse(data) as SseSnapshotEvent[];
} catch {
return;
}
// Replace the row set with the latest snapshot (id-keyed upsert would be
// more granular, but M1 target counts are small — full replace is fine).
setRows(snapshot);
};
es.addEventListener("snapshot", (e) => apply((e as MessageEvent).data));
es.addEventListener("update", (e) => apply((e as MessageEvent).data));
es.addEventListener("error", () => {
// The browser auto-reconnects SSE on transient errors; on permanent
// failure (auth dropped) we close so the user can re-auth.
// EventSource already retries; leave the last good snapshot in place.
});
return () => {
es.close();
};
}, []);
if (rows.length === 0) {
return (
<p style={{ color: "#666" }}>
No Relay Agent targets registered yet. See the{" "}
<a href="/dashboard/relay">install instructions</a> to register one.
</p>
);
}
return (
<table style={{ borderCollapse: "collapse", width: "100%", fontSize: "0.9rem" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid #eee" }}>
<th style={{ padding: "0.4rem" }}>Health</th>
<th style={{ padding: "0.4rem" }}>Hostname</th>
<th style={{ padding: "0.4rem" }}>OS</th>
<th style={{ padding: "0.4rem" }}>IP</th>
<th style={{ padding: "0.4rem" }}>Agent</th>
<th style={{ padding: "0.4rem" }}>Last seen</th>
<th style={{ padding: "0.4rem" }}>Logs</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={{ padding: "0.4rem" }}>
<span
aria-label={`health ${r.health}`}
title={r.health}
style={{
width: "0.9rem",
height: "0.9rem",
borderRadius: "50%",
background: badgeColor(r.health),
display: "inline-block",
}}
/>
<span style={{ marginLeft: "0.4rem", fontSize: "0.8rem" }}>{r.health}</span>
</td>
<td style={{ padding: "0.4rem" }}>{r.hostname}</td>
<td style={{ padding: "0.4rem" }}>
{r.osName} {r.osVersion}
</td>
<td style={{ padding: "0.4rem" }}>{r.ipAddress ?? "—"}</td>
<td style={{ padding: "0.4rem" }}>{r.agentVersion}</td>
<td style={{ padding: "0.4rem" }}>
{r.lastSeenAt ? new Date(r.lastSeenAt).toLocaleString() : "never"}
</td>
<td style={{ padding: "0.4rem" }}>
<a href={`/dashboard/targets/${r.id}`}>View logs</a>
</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,68 @@
/**
* dashboard/targets/[id] helper — server-side fetch of target detail + logs.
*
* Hits /api/targets/[id] and /api/targets/[id]/logs through the API gateway
* (cookie forwarded) so the dashboard never bypasses RLS / RBAC. Under RLS,
* T1 admin cannot view T2's target — /api/targets/[id] returns 404 (RLS
* yields zero rows) and this helper surfaces `null` so the page renders a
* not-found view.
*/
import { headers, cookies } from "next/headers";
import type { HealthStatus } from "../../../../lib/health.js";
export interface TargetDetailView {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
export interface TargetLogView {
id: string;
eventType: string;
createdAt: string;
payload: Record<string, unknown>;
}
export interface TargetDetailResult {
target: TargetDetailView;
logs: TargetLogView[];
}
export async function getTargetDetail(id: string): Promise<TargetDetailResult | null> {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("coreci_session")?.value;
if (!sessionCookie) return null;
const h = await headers();
const host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http";
const base = `${proto}://${host}`;
const targetRes = await fetch(`${base}/api/targets/${id}`, {
headers: { cookie: `coreci_session=${sessionCookie}` },
cache: "no-store",
});
if (targetRes.status === 401 || targetRes.status === 403) return null;
if (targetRes.status === 404) return null;
if (targetRes.status !== 200) return null;
const target = (await targetRes.json()) as TargetDetailView;
const logsRes = await fetch(`${base}/api/targets/${id}/logs`, {
headers: { cookie: `coreci_session=${sessionCookie}` },
cache: "no-store",
});
let logs: TargetLogView[] = [];
if (logsRes.status === 200) {
const body = (await logsRes.json()) as { logs: TargetLogView[] };
logs = body.logs;
}
return { target, logs };
}
@@ -0,0 +1,151 @@
/**
* /dashboard/targets/[id] — target detail (REQ-014, Wave E Task 3).
*
* Server component: fetches /api/targets/[id] + /api/targets/[id]/logs (under
* RLS via the API gateway). Shows health badge, registration metadata, and
* the last 100 log lines in a scrollable panel. Under RLS — T1 admin cannot
* view T2's target (the API returns 404 under RLS; this page renders a
* not-found view).
*/
import { getMe } from "../../me.js";
import { getTargetDetail, type TargetLogView, type TargetDetailView } from "./_lib.js";
function badgeColor(status: "green" | "yellow" | "red"): string {
switch (status) {
case "green":
return "#22c55e";
case "yellow":
return "#eab308";
case "red":
return "#ef4444";
}
}
function Metadata({ k, v }: { k: string; v: string }) {
return (
<div style={{ display: "flex", gap: "0.6rem" }}>
<span style={{ color: "#666", minWidth: "8rem" }}>{k}</span>
<span>{v}</span>
</div>
);
}
export default async function TargetDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const me = await getMe();
if (!me) {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>Target</h1>
<p>You are not signed in.</p>
<p>
<a href="/login">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
</a>
</p>
</main>
);
}
const data = await getTargetDetail(id);
if (!data) {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
<h1>Target not found</h1>
<p>This target does not exist in your tenant, or you don&apos;t have access.</p>
<p>
<a href="/dashboard/targets"> Back to targets</a>
</p>
</main>
);
}
const t: TargetDetailView = data.target;
const logs: TargetLogView[] = data.logs;
return (
<main style={{ fontFamily: "system-ui", maxWidth: "64rem", margin: "2rem auto", padding: "0 1rem" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h1>{t.hostname}</h1>
<span style={{ color: "#666", fontSize: "0.85rem" }}>
{me.role} · tenant {me.tenantId.slice(0, 8)}
</span>
</header>
<section style={{ margin: "1.5rem 0" }}>
<h2>Health</h2>
<p style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<span
aria-label={`health ${t.health}`}
style={{
width: "1.1rem",
height: "1.1rem",
borderRadius: "50%",
background: badgeColor(t.health),
display: "inline-block",
}}
/>
<strong style={{ textTransform: "uppercase" }}>{t.health}</strong>
<span style={{ color: "#666", fontSize: "0.85rem" }}>
{t.lastSeenAt
? `last seen ${new Date(t.lastSeenAt).toLocaleString()}`
: "never seen"}
</span>
</p>
</section>
<section style={{ margin: "1.5rem 0" }}>
<h2>Registration metadata</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.3rem" }}>
<Metadata k="Hostname" v={t.hostname} />
<Metadata k="OS" v={`${t.osName} ${t.osVersion}`} />
<Metadata k="IP address" v={t.ipAddress ?? "—"} />
<Metadata k="Agent version" v={t.agentVersion} />
<Metadata k="Registered at" v={new Date(t.registeredAt).toLocaleString()} />
<Metadata k="Last seen" v={t.lastSeenAt ? new Date(t.lastSeenAt).toLocaleString() : "never"} />
<Metadata k="Target id" v={t.id} />
</div>
</section>
<section style={{ margin: "1.5rem 0" }}>
<h2>Last 100 log lines</h2>
<div
style={{
maxHeight: "24rem",
overflow: "auto",
border: "1px solid #ddd",
borderRadius: "4px",
padding: "0.6rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.8rem",
background: "#fafafa",
}}
>
{logs.length === 0 ? (
<p style={{ color: "#666" }}>No log entries for this target yet.</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{logs.map((log) => (
<li key={log.id} style={{ padding: "0.2rem 0", borderBottom: "1px solid #eee" }}>
<span style={{ color: "#666" }}>{new Date(log.createdAt).toLocaleString()}</span>{" "}
<span style={{ color: "#2563eb" }}>[{log.eventType}]</span>{" "}
<span>{JSON.stringify(log.payload)}</span>
</li>
))}
</ul>
)}
</div>
</section>
<p style={{ marginTop: "2rem" }}>
<a href="/dashboard/targets"> Back to targets</a> ·{" "}
<a href="/dashboard">Dashboard</a>
</p>
</main>
);
}
@@ -0,0 +1,40 @@
/**
* dashboard/targets helper — server-side fetch of /api/targets.
*
* Server components call this to render the initial target list. Hits the API
* gateway (cookie forwarded) so the dashboard never bypasses RLS / RBAC.
* Returns null on 401 (caller should redirect to /login).
*/
import { headers, cookies } from "next/headers";
import type { HealthStatus } from "../../../lib/health.js";
export interface TargetView {
id: string;
hostname: string;
osName: string;
osVersion: string;
ipAddress: string | null;
agentVersion: string;
lastSeenAt: string | null;
registeredAt: string;
health: HealthStatus;
}
export async function getTargets(): Promise<TargetView[] | null> {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("coreci_session")?.value;
if (!sessionCookie) return null;
const h = await headers();
const host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http";
const res = await fetch(`${proto}://${host}/api/targets`, {
headers: { cookie: `coreci_session=${sessionCookie}` },
cache: "no-store",
});
if (res.status === 401 || res.status === 403) return null;
if (res.status !== 200) return [];
return (await res.json()) as TargetView[];
}
@@ -0,0 +1,67 @@
/**
* /dashboard/targets — Relay Agent targets list (REQ-014, Wave E Task 2).
*
* Server component: fetches /api/targets (under RLS via the API gateway),
* renders a table (hostname, OS, IP, agent version, health badge, last-seen,
* "View logs" link). A client component subscribes to /api/relay/status SSE
* for live updates so the health badge turns green within 90s of the first
* heartbeat (M1 acceptance criterion).
*/
import { getMe } from "../me.js";
import { getTargets, type TargetView } from "./_lib.js";
import { TargetStatusStream, type TargetRowState } from "./TargetStatusStream.js";
export default async function TargetsPage() {
const me = await getMe();
if (!me) {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>Targets</h1>
<p>You are not signed in.</p>
<p>
<a href="/login">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
</a>
</p>
</main>
);
}
const targets = (await getTargets()) ?? [];
const initial: TargetRowState[] = targets.map((t: TargetView) => ({
id: t.id,
hostname: t.hostname,
osName: t.osName,
osVersion: t.osVersion,
ipAddress: t.ipAddress,
agentVersion: t.agentVersion,
lastSeenAt: t.lastSeenAt,
registeredAt: t.registeredAt,
health: t.health,
}));
return (
<main style={{ fontFamily: "system-ui", maxWidth: "64rem", margin: "2rem auto", padding: "0 1rem" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h1>Relay Agent Targets</h1>
<span style={{ color: "#666", fontSize: "0.85rem" }}>
{me.role} · tenant {me.tenantId.slice(0, 8)}
</span>
</header>
<p style={{ color: "#666", fontSize: "0.85rem" }}>
Health:{" "}
<span style={{ color: "#22c55e" }}>green</span> &lt; 60s ·{" "}
<span style={{ color: "#eab308" }}>yellow</span> &lt; 5min ·{" "}
<span style={{ color: "#ef4444" }}>red</span> &gt; 5min / never seen. Updates live.
</p>
<TargetStatusStream initial={initial} />
<p style={{ marginTop: "2rem" }}>
<a href="/dashboard"> Dashboard</a>
</p>
</main>
);
}
+103 -42
View File
@@ -1,25 +1,26 @@
/**
* control-plane lib/auth — inline admin guard for the relay token-issuance
* route (Wave D, G-005).
* control-plane lib/auth — API gateway auth helpers (Wave B + D + E).
*
* TODO(Wave B merge): replace this placeholder with the real RBAC middleware
* from @coreci/auth (createAuthMiddleware / authenticate). Wave B's middleware
* verifies the `coreci_session` cookie, resolves the tenant, and enforces RBAC
* (Admin-only for relay token issuance per the route table in
* packages/auth/src/rbac.ts: /api/relay/* → admin). On this branch @coreci/auth
* is not yet wired, so this guard does a minimal cookie-presence check + reads
* the tenant id from a header/env for local development. The full RBAC
* enforcement (REQ-005: Viewer → 403, Operator → 403 on admin routes) applies at
* the Wave B merge.
* `requireAuth(req)` and `requireAdmin(req)` wire @coreci/auth's
* `authenticate(...)` against the control-plane's DbClient (lib/db) and the
* platform bootstrap SESSION_SIGNING_KEY. They verify the `coreci_session`
* httpOnly cookie, resolve the tenant, and enforce RBAC at the gateway
* (REQ-005 critical-path). Every /api/* Route Handler that touches tenant data
* calls one of these first.
*
* The contract the real middleware will implement:
* - read `coreci_session` httpOnly cookie
* - verifySession → SessionData { id, tenantId, role }
* - enforceRbac(role, "POST", "/api/relay/issue-token") → 403 if not admin
* - attach req.user = { id, tenantId, role }
* - requireAuth → (method, path) RBAC enforced; returns the authorized
* user or a Response (401/403) the handler returns verbatim.
* - requireAdmin → admin-only (used by relay token issuance). Throws
* AuthError (kept for the existing Wave D call site).
*
* The DB lookup is cached via getDb() (lib/db singleton). The signing key is
* SESSION_SIGNING_KEY (infra bootstrap cred, G-010 tier (a), NOT a tenant
* secret).
*/
import type { NextRequest } from "next/server";
import { authenticate, SESSION_COOKIE, type Role } from "@coreci/auth";
import { getDb } from "./db.js";
export interface AuthContext {
tenantId: string;
@@ -36,37 +37,97 @@ export class AuthError extends Error {
}
}
/** The cookie name carrying the signed session JWT (matches @coreci/auth). */
export const SESSION_COOKIE = "coreci_session";
export { SESSION_COOKIE };
function signingKey(): string {
const k = process.env.SESSION_SIGNING_KEY;
if (!k) throw new Error("SESSION_SIGNING_KEY not configured");
return k;
}
/** Authorized context attached to a verified request. */
export interface AuthorizedRequest {
user: {
id: string;
tenantId: string;
role: Role;
};
}
function toNextRequestShape(req: NextRequest) {
return {
method: req.method,
path: req.nextUrl.pathname,
getCookie: (name: string) => req.cookies.get(name)?.value,
};
}
function unauthorizedResponse(status: number, reason: string): Response {
if (status === 401) {
return Response.json({ error: "unauthorized", detail: reason }, { status: 401 });
}
return Response.json({ error: "forbidden", detail: reason }, { status: 403 });
}
/**
* Resolve the authenticated admin context for a relay token-issuance request.
*
* Throws AuthError(401) if no session cookie is present, AuthError(403) if the
* caller's role is not admin. In local dev the tenant id may be supplied via
* the `x-coreci-tenant-id` header (the Wave B middleware will read it from the
* verified session instead).
* Verify the session cookie + enforce RBAC for (method, path). Returns the
* authorized user context, or a Response (401/403) the Route Handler should
* return verbatim. REQ-005: enforced at the gateway from the first endpoint.
*/
export function requireAdmin(req: NextRequest): AuthContext {
const cookie = req.cookies.get(SESSION_COOKIE)?.value;
if (!cookie) {
export async function requireAuth(req: NextRequest): Promise<AuthorizedRequest | Response> {
let result;
try {
const db = await getDb();
result = await authenticate(db, signingKey(), toNextRequestShape(req));
} catch (err) {
return Response.json(
{ error: "internal_error", detail: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
if (result.status === "unauthorized") {
return unauthorizedResponse(401, result.reason);
}
if (result.status === "forbidden") {
return unauthorizedResponse(403, `role '${result.user.role}' lacks '${result.decision.required}' for ${req.method} ${req.nextUrl.pathname}`);
}
return {
user: {
id: result.user.userId,
tenantId: result.user.tenantId,
role: result.user.role,
},
};
}
/**
* Admin-only guard. Throws AuthError(401/403) for the existing Wave D call
* sites (relay token issuance). Equivalent to requireAuth but throws instead
* of returning a Response, and enforces role === "admin".
*/
export async function requireAdmin(req: NextRequest): Promise<AuthorizedRequest> {
let result;
try {
const db = await getDb();
result = await authenticate(db, signingKey(), toNextRequestShape(req));
} catch (err) {
throw new AuthError(500, err instanceof Error ? err.message : String(err));
}
if (result.status === "unauthorized") {
throw new AuthError(401, "Unauthorized: no session. Sign in via SSO first.");
}
// TODO(Wave B): verifySession(cookie) → SessionData. Until then, accept a
// dev header for the tenant id + role. Default to admin so the relay
// happy path is walkable in local dev before Wave B lands.
const tenantId = req.headers.get("x-coreci-tenant-id") ?? process.env.CORECI_DEV_TENANT_ID;
if (!tenantId) {
throw new AuthError(400, "Missing tenant id. Set x-coreci-tenant-id or CORECI_DEV_TENANT_ID for local dev.");
if (result.status === "forbidden") {
throw new AuthError(403, `Forbidden: admin role required (got ${result.user.role}).`);
}
const role = (req.headers.get("x-coreci-role") ?? "admin") as AuthContext["role"];
if (role !== "admin") {
throw new AuthError(403, `Forbidden: relay token issuance requires the Admin role (got ${role}).`);
if (result.user.role !== "admin") {
throw new AuthError(403, `Forbidden: admin role required (got ${result.user.role}).`);
}
const userId = req.headers.get("x-coreci-user-id") ?? "00000000-0000-0000-0000-000000000000";
return { tenantId, userId, role };
return {
user: {
id: result.user.userId,
tenantId: result.user.tenantId,
role: result.user.role,
},
};
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Health computation for Relay Agent targets (REQ-014, Wave E).
*
* Status is computed from `targets.last_seen_at`:
* green — last_seen within the last 60s (agent is heartbeating)
* yellow — last_seen within the last 5min (degraded — maybe a slow network
* or a restart in progress; agent may still recover)
* red — last_seen more than 5min ago, or NULL (never seen / down)
*
* The thresholds match the Wave E spec + the in-memory `getConnectedAgents()`
* snapshot from the WS server (real-time online check). The dashboard SSE
* fan-out (api/relay/status) and the targets list/detail routes both use this
* helper so the badge color is consistent everywhere.
*/
export type HealthStatus = "green" | "yellow" | "red";
/** green threshold (seconds since last_seen). */
export const GREEN_THRESHOLD_S = 60;
/** yellow threshold (seconds since last_seen). */
export const YELLOW_THRESHOLD_S = 5 * 60;
export interface HealthInput {
/** ISO timestamptz string, or null if the agent never heartbeated. */
lastSeenAt: string | null;
/** Optional: the WS server's in-memory connected-agent snapshot, keyed by
* targetId. If a target is currently connected, we treat it as green even
* if last_seen is slightly stale (heartbeat updates are batched). */
connectedTargetIds?: ReadonlySet<string>;
/** Target id (for the connected lookup). */
targetId?: string;
}
/**
* Compute health from last_seen_at (and optionally the connected-agents set).
* `now` defaults to Date.now() but can be injected for deterministic tests.
*/
export function computeHealth(input: HealthInput, now: number = Date.now()): HealthStatus {
if (input.targetId && input.connectedTargetIds?.has(input.targetId)) {
return "green";
}
if (!input.lastSeenAt) return "red";
const last = Date.parse(input.lastSeenAt);
if (Number.isNaN(last)) return "red";
const ageS = (now - last) / 1000;
if (ageS <= GREEN_THRESHOLD_S) return "green";
if (ageS <= YELLOW_THRESHOLD_S) return "yellow";
return "red";
}
/** Badge color for a health status (used by the dashboard pages). */
export function healthColor(status: HealthStatus): string {
switch (status) {
case "green":
return "#22c55e";
case "yellow":
return "#eab308";
case "red":
return "#ef4444";
}
}
+333
View File
@@ -0,0 +1,333 @@
/**
* control-plane dashboard integration test (Wave E, REQ-014).
*
* Boots PGlite + migrations, provisions two tenants (T1, T2), seeds targets
* with various last_seen_at ages, and exercises the Wave E route handlers
* directly (constructing NextRequest with the session cookie). Asserts:
*
* - Health computation: green within 60s, yellow within 5min, red after 5min,
* and red for NULL last_seen (never seen).
* - /api/targets returns only the caller's tenant's targets (RLS: T1 cannot
* see T2's targets).
* - /api/targets/[id] returns 404 for T2's target when T1 calls (RLS).
* - /api/targets/[id]/logs returns the last 100 audit_log entries for the
* target, scoped to the caller's tenant.
* - /api/audit/export returns CSV scoped to the caller's tenant (no T2 rows).
* - Audit export is admin-only: a viewer session → 403.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll } from "vitest";
import { NextRequest } from "next/server";
import {
withTenant,
appendAudit,
type DbClient,
type ScopedClient,
} from "@coreci/db";
import { provisionTenant, createSession } from "@coreci/auth";
import { GET as getTargets } from "../app/api/targets/route.js";
import { GET as getTargetById } from "../app/api/targets/[id]/route.js";
import { GET as getTargetLogs } from "../app/api/targets/[id]/logs/route.js";
import { GET as getAuditExport } from "../app/api/audit/export/route.js";
import { computeHealth, GREEN_THRESHOLD_S, YELLOW_THRESHOLD_S } from "../lib/health.js";
import { getDb } from "../lib/db.js";
const SESSION_SIGNING_KEY = "dashboard-test-session-signing-key";
const T1_ORG = "org_t1_dashboard";
const T2_ORG = "org_t2_dashboard";
let db: DbClient;
let t1: { tenantId: string; userId: string; token: string } | null = null;
let t2: { tenantId: string; userId: string; token: string } | null = null;
async function seedTenant(orgId: string, email: string): Promise<{
tenantId: string;
userId: string;
token: string;
}> {
const p = await provisionTenant(db, orgId, `workos_${orgId}`, email, `Tenant ${orgId}`);
// provisionTenant inserts the membership row inside withTenant; createSession
// writes a session row (NOT tenant-scoped).
const { token } = await createSession(db, SESSION_SIGNING_KEY, p.userId, p.tenantId, p.role, {
lifetimeSeconds: 3600,
});
return { tenantId: p.tenantId, userId: p.userId, token };
}
/** INSERT a target for `tenantId` with an explicit last_seen_at offset (seconds ago). */
async function seedTarget(
tenantId: string,
hostname: string,
lastSeenSecondsAgo: number | null,
): Promise<string> {
return withTenant(tenantId, async (c: ScopedClient) => {
const lastSeenExpr =
lastSeenSecondsAgo === null
? "NULL"
: `now() - interval '${lastSeenSecondsAgo} seconds'`;
const res = await c.query<{ id: string }>(
`INSERT INTO targets (tenant_id, hostname, os_name, os_version, ip_address, agent_version, last_seen_at)
VALUES ($1, $2, 'linux', '24.04', '10.0.0.1', '0.0.6', ${lastSeenExpr})
RETURNING id`,
[tenantId, hostname],
);
const id = res.rows[0]?.id;
if (!id) throw new Error("seedTarget: INSERT returned no id");
return id;
});
}
/** Append an audit_log entry for a target (so /logs has something to surface). */
async function seedAuditForTarget(
tenantId: string,
userId: string,
targetId: string,
eventType: "provision" | "config" | "ssh_command" | "tool_call",
payload: Record<string, unknown>,
): Promise<void> {
await withTenant(tenantId, async (c: ScopedClient) => {
await appendAudit(c, {
tenantId,
eventType,
payload,
userId,
targetId,
});
});
}
function authedReq(path: string, token: string, method = "GET"): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, {
method,
headers: { cookie: `coreci_session=${token}` },
});
}
beforeAll(async () => {
process.env.SESSION_SIGNING_KEY = SESSION_SIGNING_KEY;
// Use the control-plane's getDb() so the test + route handlers share the
// same PGlite instance (getDb bootstraps + setDbClient registers it).
db = await getDb();
});
afterAll(async () => {
/* PGlite holds no external resources */
void db;
});
beforeEach(async () => {
// Reset the seeded tenants per test for isolation. Re-provisioning is cheap
// (idempotent). We re-seed t1/t2 sessions each test.
t1 = await seedTenant(T1_ORG, `admin@${T1_ORG}.example`);
t2 = await seedTenant(T2_ORG, `admin@${T2_ORG}.example`);
});
describe("health computation (lib/health)", () => {
it("green within 60s of last_seen", () => {
const now = Date.now();
const lastSeen = new Date(now - 30 * 1000).toISOString();
expect(computeHealth({ lastSeenAt: lastSeen }, now)).toBe("green");
// boundary: exactly 60s is still green
const lastSeen60 = new Date(now - GREEN_THRESHOLD_S * 1000).toISOString();
expect(computeHealth({ lastSeenAt: lastSeen60 }, now)).toBe("green");
});
it("yellow within 5min of last_seen", () => {
const now = Date.now();
const lastSeen = new Date(now - 2 * 60 * 1000).toISOString();
expect(computeHealth({ lastSeenAt: lastSeen }, now)).toBe("yellow");
// boundary: exactly 5min is still yellow
const lastSeen5m = new Date(now - YELLOW_THRESHOLD_S * 1000).toISOString();
expect(computeHealth({ lastSeenAt: lastSeen5m }, now)).toBe("yellow");
});
it("red after 5min or when last_seen is null", () => {
const now = Date.now();
const lastSeen6m = new Date(now - 6 * 60 * 1000).toISOString();
expect(computeHealth({ lastSeenAt: lastSeen6m }, now)).toBe("red");
expect(computeHealth({ lastSeenAt: null }, now)).toBe("red");
});
it("connected target is green even if last_seen is stale", () => {
const now = Date.now();
const stale = new Date(now - 10 * 60 * 1000).toISOString();
const connected = new Set(["target-x"]);
expect(
computeHealth({ lastSeenAt: stale, targetId: "target-x", connectedTargetIds: connected }, now),
).toBe("green");
});
it("an unparseable last_seen is red (defensive)", () => {
expect(computeHealth({ lastSeenAt: "not-a-date" }, Date.now())).toBe("red");
});
});
describe("/api/targets (REQ-014)", () => {
it("returns T1's targets with health status computed from last_seen", async () => {
const greenId = await seedTarget(t1!.tenantId, "green-host", 5);
const yellowId = await seedTarget(t1!.tenantId, "yellow-host", 120);
const redId = await seedTarget(t1!.tenantId, "red-host", 600);
const neverId = await seedTarget(t1!.tenantId, "never-host", null);
const res = await getTargets(authedReq("/api/targets", t1!.token));
expect(res.status).toBe(200);
const body = (await res.json()) as Array<{ id: string; health: string }>;
const byId = new Map(body.map((r) => [r.id, r.health]));
expect(byId.get(greenId)).toBe("green");
expect(byId.get(yellowId)).toBe("yellow");
expect(byId.get(redId)).toBe("red");
expect(byId.get(neverId)).toBe("red");
});
it("T1 cannot see T2's targets (RLS — T2 rows absent)", async () => {
await seedTarget(t2!.tenantId, "t2-secret-host", 5);
await seedTarget(t1!.tenantId, "t1-host", 5);
const res = await getTargets(authedReq("/api/targets", t1!.token));
expect(res.status).toBe(200);
const body = (await res.json()) as Array<{ id: string; hostname: string }>;
// The RLS property: zero T2 rows leak into T1's view.
expect(body.find((r) => r.hostname === "t2-secret-host")).toBeUndefined();
// T1's own row IS visible.
expect(body.find((r) => r.hostname === "t1-host")).toBeDefined();
});
it("returns 401 without a session cookie", async () => {
const res = await getTargets(new NextRequest(new URL("/api/targets", "http://localhost:3000")));
expect(res.status).toBe(401);
});
});
describe("/api/targets/[id] (REQ-014, RLS)", () => {
it("returns the target detail for the caller's tenant", async () => {
const id = await seedTarget(t1!.tenantId, "detail-host", 10);
const res = await getTargetById(authedReq(`/api/targets/${id}`, t1!.token), {
params: Promise.resolve({ id }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { id: string; hostname: string; health: string };
expect(body.id).toBe(id);
expect(body.hostname).toBe("detail-host");
expect(body.health).toBe("green");
});
it("T1 cannot view T2's target (RLS — 404, no cross-tenant existence leak)", async () => {
const t2Id = await seedTarget(t2!.tenantId, "t2-private-host", 10);
const res = await getTargetById(authedReq(`/api/targets/${t2Id}`, t1!.token), {
params: Promise.resolve({ id: t2Id }),
});
expect(res.status).toBe(404);
});
});
describe("/api/targets/[id]/logs (REQ-014)", () => {
it("returns the last 100 audit_log entries for the target (newest first)", async () => {
const id = await seedTarget(t1!.tenantId, "logs-host", 10);
// Seed 3 audit entries referencing this target.
await seedAuditForTarget(t1!.tenantId, t1!.userId, id, "provision", {
action: "relay_target_registered",
hostname: "logs-host",
targetId: id,
});
await seedAuditForTarget(t1!.tenantId, t1!.userId, id, "config", {
action: "config_applied",
targetId: id,
});
await seedAuditForTarget(t1!.tenantId, t1!.userId, id, "ssh_command", {
action: "ssh_exec",
cmd: "systemctl status nginx",
targetId: id,
});
const res = await getTargetLogs(authedReq(`/api/targets/${id}/logs`, t1!.token), {
params: Promise.resolve({ id }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { logs: Array<{ id: string; eventType: string; payload: Record<string, unknown> }> };
expect(body.logs.length).toBe(3);
// Newest first: ssh_command (id 3) before config (id 2) before provision (id 1).
expect(body.logs[0]!.eventType).toBe("ssh_command");
expect(body.logs[1]!.eventType).toBe("config");
expect(body.logs[2]!.eventType).toBe("provision");
// Payloads are redacted summaries (no hash bytes leaked).
expect(body.logs[0]!.payload.action).toBe("ssh_exec");
expect(body.logs[0]!.payload.targetId).toBe(id);
});
it("T1 cannot view T2's target logs (RLS — 404)", async () => {
const t2Id = await seedTarget(t2!.tenantId, "t2-logs-host", 10);
const res = await getTargetLogs(authedReq(`/api/targets/${t2Id}/logs`, t1!.token), {
params: Promise.resolve({ id: t2Id }),
});
expect(res.status).toBe(404);
});
it("returns 200 with empty logs for an existing target with no audit entries", async () => {
const id = await seedTarget(t1!.tenantId, "silent-host", 10);
const res = await getTargetLogs(authedReq(`/api/targets/${id}/logs`, t1!.token), {
params: Promise.resolve({ id }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { logs: unknown[] };
expect(body.logs).toEqual([]);
});
});
describe("/api/audit/export (REQ-038, RLS)", () => {
it("exports CSV scoped to the caller's tenant (no T2 rows)", async () => {
// Seed T1 + T2 audit entries (provisionTenant already wrote one provision
// entry per tenant on the beforeEach seed; add a couple more for T1).
const t1Target = await seedTarget(t1!.tenantId, "audit-host", 10);
await seedAuditForTarget(t1!.tenantId, t1!.userId, t1Target, "config", {
action: "config_applied",
targetId: t1Target,
});
const t2Target = await seedTarget(t2!.tenantId, "t2-audit-host", 10);
await seedAuditForTarget(t2!.tenantId, t2!.userId, t2Target, "config", {
action: "t2_secret_config",
targetId: t2Target,
});
const res = await getAuditExport(authedReq("/api/audit/export", t1!.token));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/csv");
const csv = await res.text();
const lines = csv.split("\r\n");
// Header row.
expect(lines[0]).toBe("id,timestamp,event_type,user_id,target_id,payload");
// Every data row must reference T1's tenant — verify NO t2_secret_config leak.
expect(csv).not.toContain("t2_secret_config");
// T1's rows ARE present (provision from seedTenant + the config event).
expect(csv).toContain("provision");
expect(csv).toContain("config_applied");
});
it("admin-only: a viewer session → 403", async () => {
// Create a viewer session for T1.
const viewer = await createSession(db, SESSION_SIGNING_KEY, t1!.userId, t1!.tenantId, "viewer", {
lifetimeSeconds: 3600,
});
const res = await getAuditExport(authedReq("/api/audit/export", viewer.token));
expect(res.status).toBe(403);
});
it("CSV field escaping: a payload with a comma is quoted", async () => {
const t1Target = await seedTarget(t1!.tenantId, "escape-host", 10);
await seedAuditForTarget(t1!.tenantId, t1!.userId, t1Target, "ssh_command", {
action: "ssh_exec",
cmd: "echo a,b,c",
targetId: t1Target,
});
const res = await getAuditExport(authedReq("/api/audit/export", t1!.token));
expect(res.status).toBe(200);
const csv = await res.text();
// The payload JSON contains a comma; the whole payload field is one CSV
// cell and must be quoted. Find the ssh_command row.
const sshLine = csv.split("\r\n").find((l) => l.includes("ssh_command"));
expect(sshLine, `ssh_command row present in:\n${csv}`).toBeDefined();
// The payload cell starts with a quote (RFC 4180 quoting).
expect(sshLine!.split(",").pop()!.startsWith('"')).toBe(true);
});
});