diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc73178..601b701 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,8 +45,8 @@ jobs:
- name: Type-check
run: npm run typecheck
- - name: Audit production dependencies
- run: npm audit --omit=dev --audit-level=high
+ - name: Audit dependency policy
+ run: npm run audit:dependencies
- name: Test web and CLI
run: npm test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5552f9..e6c066f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,10 @@ All notable changes to this project are documented here. The format follows
contents and Embertop's emitted-event redaction.
- Made SSH preview examples bind explicitly to loopback and documented direct
terminal, web-tunnel, service-status, journal, and `doctor` checks.
+- Replaced fabricated first-load readings with an explicit waiting state and
+ improved muted-text contrast.
+- Made dependency auditing cover recognized development-tool advisories
+ without weakening the runtime gate.
## [0.3.0] - 2026-07-28
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 38c388f..c9ad994 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,7 @@ Before opening a pull request:
npm run typecheck
npm run lint
npm test
-npm audit --omit=dev
+npm run audit:dependencies
```
`npm test` builds the web app first, so it takes a minute.
diff --git a/SECURITY.md b/SECURITY.md
index ae91302..5832566 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -45,3 +45,16 @@ UI can reveal traffic patterns even when individual visitors are anonymized.
Never commit `.env` files or real collector configuration. Rotate
`EMBERTOP_COLLECTOR_TOKEN`, `EMBERTOP_UPSTREAM_TOKEN`, and
`EMBERTOP_METRICS_TOKEN` after any suspected disclosure.
+
+## Dependency audits
+
+`npm run audit:dependencies` blocks high- or critical-severity findings in
+runtime dependencies and any unrecognized high- or critical-severity finding
+in development dependencies.
+
+The policy temporarily recognizes
+[GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg)
+in ESLint's development-only glob stack. Embertop does not pass untrusted glob
+patterns to this tooling, and the affected packages are not installed by
+production-only installs. The exception should be removed as soon as the
+Next.js ESLint plugin tree supports a patched dependency chain.
diff --git a/app/Embertop.tsx b/app/Embertop.tsx
index 67aaa6c..f4b65f7 100644
--- a/app/Embertop.tsx
+++ b/app/Embertop.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
+import { EMPTY_METRICS } from "@/lib/telemetry";
import { FireCanvas } from "./FireCanvas";
import {
useCampfireAudio,
@@ -18,7 +19,7 @@ function Reading({
}: {
label: string;
caption: string;
- value: number;
+ value: number | null;
progress: number | null;
tone?: "flame" | "ember";
}) {
@@ -30,15 +31,15 @@ function Reading({
{caption}
- {Math.round(value)}
- %
+ {value == null ? "—" : Math.round(value)}
+ {value == null ? null : %}
@@ -180,8 +181,10 @@ export function Embertop() {
});
const frame = paused ? pausedSnapshot.frame : liveFrame;
const recentVisits = paused ? pausedSnapshot.recentVisits : liveRecentVisits;
+ const metrics = frame?.metrics ?? EMPTY_METRICS;
+ const visits = frame?.visits ?? [];
- useCampfireAudio(soundEnabled, frame.metrics.cpu);
+ useCampfireAudio(soundEnabled, metrics.cpu);
const toggleFocus = useCallback(() => {
setFocusMode((current) => !current);
@@ -235,8 +238,8 @@ export function Embertop() {
return (
embertop
- {frame.site}
+ {frame?.site ?? "awaiting telemetry"}
@@ -284,15 +287,25 @@ export function Embertop() {
Last 60 seconds
- {frame.metrics.requestsPerMinute} requests
- ·
- load {frame.metrics.load1.toFixed(2)}
+ {frame ? (
+ <>
+ {frame.metrics.requestsPerMinute} requests
+ ·
+ load {frame.metrics.load1.toFixed(2)}
+ >
+ ) : (
+ "awaiting first reading"
+ )}
{traffic.total === 0 ? (
// An idle server is the usual case; it should say so once.
-
No requests in the last minute
+
+ {frame
+ ? "No requests in the last minute"
+ : "Waiting for telemetry."}
+
) : (
@@ -327,7 +340,9 @@ export function Embertop() {
addresses and query strings dropped
{recentVisits.length === 0 ? (
- Waiting for the next spark.
+
+ {frame ? "Waiting for the next spark." : "Waiting for telemetry."}
+
) : (
{/* The list is clipped by its flex box, so it simply fills
diff --git a/app/globals.css b/app/globals.css
index 1ba70ea..6ffe40c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -7,8 +7,8 @@
--bg: #0a0a0b;
--fg: #eae8e5;
--fg-2: #9b9894;
- --fg-3: #6e6b67;
- --fg-4: #4a4744;
+ --fg-3: #898581;
+ --fg-4: #807c77;
--rule: rgba(255, 255, 255, 0.1);
--rule-soft: rgba(255, 255, 255, 0.055);
--ember: #ff7a3c;
diff --git a/app/useTelemetry.ts b/app/useTelemetry.ts
index ee3df37..4eba959 100644
--- a/app/useTelemetry.ts
+++ b/app/useTelemetry.ts
@@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from "react";
import {
- EMPTY_METRICS,
normalizeFrame,
type TelemetryFrame,
type VisitEvent,
@@ -12,20 +11,6 @@ import type { TrafficMix } from "@/lib/telemetry";
type ConnectionState = "connecting" | "connected" | "reconnecting";
-const INITIAL_FRAME: TelemetryFrame = {
- schema: 1,
- sequence: 0,
- at: new Date(0).toISOString(),
- source: "live",
- site: "your-server",
- metrics: {
- ...EMPTY_METRICS,
- cpu: 18,
- memory: 52,
- load1: 0.42,
- },
- visits: [],
-};
const CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
@@ -34,7 +19,7 @@ const CLOCK_FORMATTER = new Intl.DateTimeFormat("en-GB", {
});
export function useTelemetry() {
- const [frame, setFrame] = useState(INITIAL_FRAME);
+ const [frame, setFrame] = useState(null);
const [connection, setConnection] =
useState("connecting");
const [recentVisits, setRecentVisits] = useState([]);
@@ -46,7 +31,6 @@ export function useTelemetry() {
const stream = new EventSource(`${pagePath}/api/stream`);
const seen = new Set();
- stream.onopen = () => setConnection("connected");
stream.onmessage = (event) => {
try {
const normalized = normalizeFrame(JSON.parse(event.data));
diff --git a/package.json b/package.json
index 1c2ce96..f6d89c7 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"test:unit": "node --test tests/collector.test.mjs tests/cli.test.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
+ "audit:dependencies": "node scripts/audit-dependencies.mjs",
"cli": "node bin/embertop.mjs",
"collector": "node bin/embertop.mjs serve",
"prepack": "npm run typecheck && npm run lint && npm test"
diff --git a/scripts/audit-dependencies.mjs b/scripts/audit-dependencies.mjs
new file mode 100644
index 0000000..3b38e78
--- /dev/null
+++ b/scripts/audit-dependencies.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+
+import { spawnSync } from "node:child_process";
+
+const SIGNIFICANT_SEVERITIES = new Set(["high", "critical"]);
+const ALLOWED_DEVELOPMENT_ADVISORIES = new Map([
+ [
+ "https://github.com/advisories/GHSA-mh99-v99m-4gvg",
+ "brace-expansion in ESLint's development-only glob stack",
+ ],
+]);
+
+function runAudit(arguments_) {
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
+ const result = spawnSync(command, ["audit", ...arguments_, "--json"], {
+ encoding: "utf8",
+ maxBuffer: 16 * 1024 * 1024,
+ });
+
+ if (result.error) throw result.error;
+
+ let report;
+ try {
+ report = JSON.parse(result.stdout);
+ } catch {
+ const detail = result.stderr.trim() || result.stdout.trim();
+ throw new Error(`npm audit did not return valid JSON${detail ? `: ${detail}` : ""}`);
+ }
+
+ if (report.error) {
+ throw new Error(
+ `npm audit failed: ${report.error.summary ?? report.error.message ?? "unknown error"}`,
+ );
+ }
+
+ return report;
+}
+
+function significantFindings(report) {
+ return Object.entries(report.vulnerabilities ?? {}).filter(([, finding]) =>
+ SIGNIFICANT_SEVERITIES.has(finding.severity),
+ );
+}
+
+function rootAdvisories(report, packageName, seen = new Set()) {
+ if (seen.has(packageName)) return [];
+ seen.add(packageName);
+
+ const finding = report.vulnerabilities?.[packageName];
+ if (!finding) return [];
+
+ return finding.via.flatMap((via) => {
+ if (typeof via === "string") {
+ return rootAdvisories(report, via, seen);
+ }
+ return [
+ {
+ name: via.name,
+ severity: via.severity,
+ url: via.url,
+ },
+ ];
+ });
+}
+
+function auditDependencies() {
+ const runtimeReport = runAudit(["--omit=dev"]);
+ const runtimeFindings = significantFindings(runtimeReport);
+ if (runtimeFindings.length > 0) {
+ throw new Error(
+ `High or critical runtime vulnerabilities: ${runtimeFindings
+ .map(([name]) => name)
+ .join(", ")}`,
+ );
+ }
+ console.log("Runtime dependency audit passed.");
+
+ const fullReport = runAudit([]);
+ const developmentFindings = significantFindings(fullReport);
+ if (developmentFindings.length === 0) {
+ console.log("Development dependency audit passed.");
+ return;
+ }
+
+ const rootFindings = new Map();
+ const unexplained = [];
+ let hasCriticalFinding = false;
+
+ for (const [packageName, finding] of developmentFindings) {
+ if (finding.severity === "critical") hasCriticalFinding = true;
+ const roots = rootAdvisories(fullReport, packageName);
+ if (roots.length === 0) unexplained.push(packageName);
+ for (const root of roots) rootFindings.set(root.url, root);
+ }
+
+ const unexpected = [...rootFindings.values()].filter(
+ (finding) =>
+ finding.severity !== "high" ||
+ !ALLOWED_DEVELOPMENT_ADVISORIES.has(finding.url),
+ );
+
+ if (hasCriticalFinding || unexplained.length > 0 || unexpected.length > 0) {
+ const details = [
+ ...unexplained.map((name) => `unresolved dependency chain: ${name}`),
+ ...unexpected.map(
+ (finding) => `${finding.name} (${finding.severity}): ${finding.url}`,
+ ),
+ ];
+ throw new Error(
+ `High or critical development vulnerabilities require review:\n${details.join("\n")}`,
+ );
+ }
+
+ for (const finding of rootFindings.values()) {
+ console.warn(
+ `Recognized development-only advisory: ${
+ ALLOWED_DEVELOPMENT_ADVISORIES.get(finding.url)
+ } (${finding.url}).`,
+ );
+ }
+ console.warn(
+ `${developmentFindings.length} affected development dependency entries; runtime dependencies remain clear.`,
+ );
+}
+
+try {
+ auditDependencies();
+} catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+}
diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs
index fbe7fff..68d1ba4 100644
--- a/tests/rendered-html.test.mjs
+++ b/tests/rendered-html.test.mjs
@@ -137,6 +137,14 @@ test("server-renders the complete Embertop experience", async () => {
assert.match(html, /load/);
assert.match(html, /Requests/);
assert.match(html, /addresses and query strings dropped/);
+ assert.match(html, /awaiting telemetry/i);
+ assert.match(html, /awaiting first reading/i);
+ assert.equal(
+ html.match(/class="reading-value">—<\/span>/g)?.length,
+ 2,
+ "initial readings should be visibly unavailable",
+ );
+ assert.doesNotMatch(html, /your-server|load 0\.42/i);
assert.match(html, /Just the fire/);
assert.match(html, /]*>M<\/kbd>/i);
assert.match(html, /]*>Space<\/kbd>/i);
@@ -171,6 +179,14 @@ test("keeps telemetry credentials server-only", async () => {
telemetryClient,
/new EventSource\(`\$\{pagePath\}\/api\/stream`\)/,
);
+ assert.match(
+ telemetryClient,
+ /useState\(null\)/,
+ );
+ assert.doesNotMatch(
+ telemetryClient,
+ /INITIAL_FRAME|stream\.onopen|cpu:\s*18|memory:\s*52/,
+ );
assert.doesNotMatch(
`${client}\n${telemetryClient}`,
/EMBERTOP_UPSTREAM_TOKEN|NEXT_PUBLIC_/,
diff --git a/tests/web-accessibility.test.mjs b/tests/web-accessibility.test.mjs
new file mode 100644
index 0000000..34553fd
--- /dev/null
+++ b/tests/web-accessibility.test.mjs
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+function channel(value) {
+ const normalized = value / 255;
+ return normalized <= 0.04045
+ ? normalized / 12.92
+ : ((normalized + 0.055) / 1.055) ** 2.4;
+}
+
+function luminance(hex) {
+ const [red, green, blue] = hex
+ .slice(1)
+ .match(/../g)
+ .map((part) => channel(Number.parseInt(part, 16)));
+ return red * 0.2126 + green * 0.7152 + blue * 0.0722;
+}
+
+function contrast(first, second) {
+ const [lighter, darker] = [luminance(first), luminance(second)].sort(
+ (left, right) => right - left,
+ );
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+function color(css, variable) {
+ const value = css.match(
+ new RegExp(`--${variable}:\\s*(#[0-9a-f]{6})`, "i"),
+ )?.[1];
+ assert.ok(value, `expected --${variable} to be a six-digit hex color`);
+ return value;
+}
+
+test("muted interface text meets normal-text contrast", async () => {
+ const css = await readFile(
+ new URL("../app/globals.css", import.meta.url),
+ "utf8",
+ );
+ const background = color(css, "bg");
+
+ for (const variable of ["fg-3", "fg-4"]) {
+ const ratio = contrast(color(css, variable), background);
+ assert.ok(
+ ratio >= 4.5,
+ `--${variable} contrast is ${ratio.toFixed(2)}:1; expected at least 4.5:1`,
+ );
+ }
+});