fix(web,ci): close TS↔Go type drift across 5 entities (D-2 master)

Closes five 2026-04-24 audit findings (all P2, all category cat-f /
diff-05x06-*) by reconciling the TypeScript interfaces in
web/src/api/types.ts with the on-wire JSON shape Go's
internal/domain/*.go structs actually emit. D-1 closed the same pattern
for one entity (Certificate / ManagedCertificate); D-2 covers the
remaining five.

Per-entity verdicts (audit's "stricter side is the contract"):

  Agent       — TRIM 5 phantoms (last_heartbeat, capabilities, tags,
                created_at, updated_at). Go emits last_heartbeat_at only.
  Target      — ADD 2 (retired_at?, retired_reason?) — I-004 fields.
  DiscCert    — ADD pem_data? — real field, real Go emit, omitempty.
  Issuer      — TRIM phantom status. Go has Enabled bool only.
  Notif       — TRIM phantom subject. Go has Message string only.
  Certificate — verify-only; D-1 closure confirmed clean at recon.

Consumer fixes (same commit as the trim):
- AgentDetailPage.tsx — remove dead Capabilities + Tags sections (always
  rendered empty); replace agent.created_at/updated_at row with the
  Go-emitted registered_at; widen heartbeatStatus() to accept undefined.
- AgentsPage.tsx — same heartbeatStatus widening.
- IssuersPage.tsx + IssuerDetailPage.tsx — issuerStatus() now derives
  from `enabled` exclusively; the dead `issuer.status || 'Unknown'`
  fallback is gone.
- NotificationsPage.tsx — drop dead `|| n.subject` fallback.
- NotificationsPage.test.tsx — drop dead `subject:` from mocks.
- api/utils.ts::timeAgo widened to accept string | undefined | null.
- api/types.test.ts — Agent (I-004) fixture trimmed of the 5 phantoms.

Tests (Vitest):
- 5 new describe blocks in web/src/api/types.test.ts:
  - Agent interface (D-2 phantom-fields trim) — 2 it blocks
  - Target interface (D-2 retirement fields) — 2 it blocks
  - DiscoveredCertificate interface (D-2 pem_data ADD) — 2 it blocks
  - Issuer interface (D-2 status phantom trim) — 1 it block
  - Notification interface (D-2 subject phantom trim) — 1 it block
- Each block uses the literal-construction pattern from D-1; trimmed
  fields are pinned via excess-property comments that compile-fail when
  uncommented if a phantom is reintroduced.

CI regression guardrail:
- .github/workflows/ci.yml — existing D-1 step renamed to "Forbidden
  StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)".
  Three new awk-windowed greps over Agent / Issuer / Notification
  interfaces in types.ts. The Agent grep includes a `grep -v
  'last_heartbeat_at'` filter to avoid false positives on the
  legitimate Go-emitted heartbeat field.

Documentation:
- CHANGELOG.md — new D-2 section above B-1 under [unreleased] with full
  Added/Removed/Audit findings closed/Known follow-ups breakdown.
- docs/architecture.md — Web Dashboard section gains a new "TS ↔ Go
  type contract rule (D-1 + D-2 closure)" paragraph capturing the
  stricter-side-wins rule and the CI guardrail it's anchored by.
- coverage-gap-audit-2026-04-24-v5/unified-audit.md — Live Tracker score
  20/47 → 25/47 (P2: 6/27 → 11/27). Per-finding  RESOLVED Status
  blocks added to all 5 diff-05x06-* entries plus the verify-only
  Certificate entry. Closed-bundle index gets D-2 row.

Verification (all gates green):
- cd web && tsc --noEmit                 → clean
- cd web && vitest run --reporter=dot    → 9 files, 302 tests passing
                                            (was 294 → +8 D-2 cases)
- cd web && vite build                   → clean
- go vet ./internal/... ./cmd/...        → clean (no Go touched)
- golangci-lint v2.11.4 run ./...        → 0 issues
- D-2 Agent guardrail dry-run            → empty (good)
- D-2 Issuer guardrail dry-run           → empty (good)
- D-2 Notification guardrail dry-run     → empty (good)
- D-2 Target ADD-shape sanity            → 2 retirement fields present
- D-2 DiscCert ADD-shape sanity          → pem_data present
- D-1 Certificate guardrail still clean  → empty (good)
- OpenAPI YAML parses                    → 89 paths

Audit findings closed:
- diff-05x06-7cdf4e78ae24 (P2, Agent TS↔Go drift)
- diff-05x06-2044a46f4dd0 (P2, Target TS↔DeploymentTarget Go drift)
- diff-05x06-85ab6b98a2f7 (P2, DiscoveredCertificate TS↔Go drift)
- diff-05x06-97fab8783a5c (P2, Issuer TS↔Go drift)
- diff-05x06-caba9eb3620e (P2, Notification TS↔NotificationEvent drift)
- diff-05x06-af18a8d7ef41 (P2) — verified clean since D-1; no edit

Deferred follow-ups:
- Issuer richer status view (enabled × test_status) — UX scope, not drift.
- Real Agent metadata (capabilities, tags) — backend feature, not drift.
- DiscoveredCertificate pem_data list-response perf — separate backend change.
This commit is contained in:
shankar0123
2026-04-25 16:07:31 +00:00
parent 2edac7e78b
commit 55eb7135be
12 changed files with 506 additions and 63 deletions
+261 -11
View File
@@ -1,6 +1,14 @@
import { describe, it, expect } from 'vitest';
import { POLICY_TYPES, POLICY_SEVERITIES } from './types';
import type { Agent, Certificate, CertificateVersion } from './types';
import type {
Agent,
Certificate,
CertificateVersion,
DiscoveredCertificate,
Issuer,
Notification,
Target,
} from './types';
/**
* Regression tests for the policy enum tuples.
@@ -85,6 +93,9 @@ describe('Agent interface (I-004 retirement)', () => {
// Construct an Agent with the retirement fields set. If Phase 2b names
// them anything other than retired_at / retired_reason, this fails to
// compile — which is exactly what the Red stage wants.
// D-2 (master): the post-D-2 Agent shape no longer carries
// last_heartbeat / capabilities / tags / created_at / updated_at —
// those were TS phantoms the Go-side struct never emitted.
const retired: Agent = {
id: 'ag-1',
name: 'decom-01',
@@ -94,13 +105,8 @@ describe('Agent interface (I-004 retirement)', () => {
architecture: 'amd64',
status: 'Offline',
version: '2.1.0',
last_heartbeat: '2026-01-01T00:00:00Z',
last_heartbeat_at: '2026-01-01T00:00:00Z',
capabilities: [],
tags: {},
registered_at: '2024-01-01T00:00:00Z',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
retired_at: '2026-01-01T00:00:00Z',
retired_reason: 'old hardware',
};
@@ -111,6 +117,7 @@ describe('Agent interface (I-004 retirement)', () => {
it('accepts an Agent without retired_at / retired_reason (optional fields)', () => {
// Active agents should not carry retirement metadata. If Phase 2b makes
// the fields required, this block fails to compile.
// D-2 (master): post-D-2 Agent shape (see sibling describe block).
const active: Agent = {
id: 'ag-2',
name: 'web01',
@@ -120,13 +127,8 @@ describe('Agent interface (I-004 retirement)', () => {
architecture: 'amd64',
status: 'Online',
version: '2.1.0',
last_heartbeat: '2026-04-18T12:00:00Z',
last_heartbeat_at: '2026-04-18T12:00:00Z',
capabilities: ['deploy', 'scan'],
tags: {},
registered_at: '2024-06-01T00:00:00Z',
created_at: '2024-06-01T00:00:00Z',
updated_at: '2026-04-18T12:00:00Z',
};
expect(active.retired_at).toBeUndefined();
expect(active.retired_reason).toBeUndefined();
@@ -220,3 +222,251 @@ describe('Certificate interface (D-5 phantom-fields trim)', () => {
expect(v.key_size).toBe(256);
});
});
/**
* D-2 (diff-05x06-7cdf4e78ae24, master): Agent TS phantom-fields trim.
*
* Pre-D-2 the `Agent` interface declared five fields that the Go-side
* struct (`internal/domain/connector.go::Agent`) does NOT emit on the
* wire: `last_heartbeat`, `capabilities`, `tags`, `created_at`,
* `updated_at`. Two of them had real consumers (`AgentDetailPage.tsx`
* read `agent.capabilities` and `agent.tags`) — both always rendered the
* empty-state branch because the runtime values were always `undefined`.
*
* Post-D-2 a `agent.capabilities` access is a TS compile error, forcing
* every consumer to acknowledge the field is not part of the Agent
* contract. The Go-side struct emits exactly: id, name, hostname, status,
* last_heartbeat_at (note the `_at` suffix — this is the real heartbeat
* field and stays), registered_at, os, architecture, ip_address, version,
* retired_at?, retired_reason?.
*/
describe('Agent interface (D-2 phantom-fields trim)', () => {
it('does NOT declare last_heartbeat / capabilities / tags / created_at / updated_at', () => {
// Construct an Agent with ONLY the post-D-2 field set. If a future
// PR re-adds any of the five trimmed fields, the excess-property
// comments below become live TS errors when uncommented (and the
// CI guardrail in .github/workflows/ci.yml fires regardless).
const a: Agent = {
id: 'ag-test',
name: 'web-01',
hostname: 'web-01.prod',
status: 'Online',
last_heartbeat_at: '2026-04-25T12:00:00Z',
registered_at: '2024-06-01T00:00:00Z',
os: 'linux',
architecture: 'amd64',
ip_address: '10.0.0.1',
version: '2.1.0',
};
expect(a.id).toBe('ag-test');
expect(a.last_heartbeat_at).toBe('2026-04-25T12:00:00Z');
// Excess-property check (each MUST be a TS error if uncommented):
// const broken1: Agent = { ...a, last_heartbeat: '2026-...' }; // ❌ TS2353
// const broken2: Agent = { ...a, capabilities: ['deploy'] }; // ❌ TS2353
// const broken3: Agent = { ...a, tags: { env: 'prod' } }; // ❌ TS2353
// const broken4: Agent = { ...a, created_at: '...' }; // ❌ TS2353
// const broken5: Agent = { ...a, updated_at: '...' }; // ❌ TS2353
});
it('keeps last_heartbeat_at (the real Go-emitted heartbeat field)', () => {
// Negative-prevention guard: the awk-windowed CI grep for the trimmed
// `last_heartbeat` field must NOT trip on the legitimate
// `last_heartbeat_at`. This test pins that the legitimate field stays.
const a: Agent = {
id: 'ag-2',
name: 'web-02',
hostname: 'web-02.prod',
status: 'Offline',
registered_at: '2024-06-01T00:00:00Z',
os: 'linux',
architecture: 'amd64',
ip_address: '10.0.0.2',
version: '2.1.0',
};
expect(a.last_heartbeat_at).toBeUndefined();
});
});
/**
* D-2 (diff-05x06-2044a46f4dd0, master): Target retirement-fields ADD.
*
* Pre-D-2 the Go-side `DeploymentTarget` struct
* (`internal/domain/connector.go:24`) emitted `retired_at` and
* `retired_reason` (I-004 soft-retirement, mirroring the Agent
* treatment), but the TS `Target` interface did not declare them.
* Consumers wanting to surface the retired state in the GUI had to
* use `(target as any).retired_at` escapes that lost type-checking.
*
* Post-D-2 the TS interface declares both as optional nullable strings,
* mirroring the existing Agent retirement-fields shape.
*/
describe('Target interface (D-2 retirement fields)', () => {
it('accepts retired_at and retired_reason as optional nullable strings', () => {
const retired: Target = {
id: 't-decom-01',
name: 'old-iis-server',
type: 'iis',
agent_id: 'ag-old',
config: {},
enabled: false,
created_at: '2024-01-01T00:00:00Z',
retired_at: '2026-03-01T00:00:00Z',
retired_reason: 'replaced by new iis-server',
};
expect(retired.retired_at).toBe('2026-03-01T00:00:00Z');
expect(retired.retired_reason).toBe('replaced by new iis-server');
});
it('accepts a Target without the retirement fields (active row)', () => {
const active: Target = {
id: 't-1',
name: 'iis-server',
type: 'iis',
agent_id: 'ag-1',
config: {},
enabled: true,
created_at: '2024-01-01T00:00:00Z',
};
expect(active.retired_at).toBeUndefined();
expect(active.retired_reason).toBeUndefined();
});
});
/**
* D-2 (diff-05x06-85ab6b98a2f7, master): DiscoveredCertificate pem_data ADD.
*
* Pre-D-2 the Go-side `DiscoveredCertificate` struct
* (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`) emitted
* `pem_data` (omitempty — populated by repo SELECT, agent ingestion at
* cmd/agent/main.go:1021, and connector scans at
* internal/connector/discovery/azurekv/azurekv.go:234), but the TS
* `DiscoveredCertificate` interface did not declare it. Consumers wanting
* to inspect or download the raw PEM had to use `(d as any).pem_data`.
*
* Post-D-2 the TS interface declares it as `pem_data?: string`, optional
* because the Go side uses `omitempty` (empty string → not emitted).
*
* Performance note (deferred follow-up): the LIST endpoint also loads
* pem_data via the same repo SELECT; for large discovered-cert tables
* this can ship kilobytes per row. Optimising the list response to omit
* pem_data is a separate backend change.
*/
describe('DiscoveredCertificate interface (D-2 pem_data ADD)', () => {
it('accepts pem_data as an optional string', () => {
const d: DiscoveredCertificate = {
id: 'dc-1',
fingerprint_sha256: 'a'.repeat(64),
common_name: 'discovered.example.com',
sans: [],
serial_number: '01:02:03',
issuer_dn: 'CN=Test CA',
subject_dn: 'CN=discovered.example.com',
key_algorithm: 'ECDSA',
key_size: 256,
is_ca: false,
source_path: '/etc/ssl/certs/disc.pem',
source_format: 'pem',
agent_id: 'ag-1',
status: 'Unmanaged',
first_seen_at: '2026-04-25T12:00:00Z',
last_seen_at: '2026-04-25T12:00:00Z',
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
pem_data: '-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n',
};
expect(d.pem_data).toContain('BEGIN CERTIFICATE');
});
it('accepts a DiscoveredCertificate without pem_data (list-response shape)', () => {
const d: DiscoveredCertificate = {
id: 'dc-2',
fingerprint_sha256: 'b'.repeat(64),
common_name: 'list.example.com',
sans: [],
serial_number: '04:05:06',
issuer_dn: 'CN=Test CA',
subject_dn: 'CN=list.example.com',
key_algorithm: 'ECDSA',
key_size: 256,
is_ca: false,
source_path: '/etc/ssl/certs/list.pem',
source_format: 'pem',
agent_id: 'ag-1',
status: 'Unmanaged',
first_seen_at: '2026-04-25T12:00:00Z',
last_seen_at: '2026-04-25T12:00:00Z',
created_at: '2026-04-25T12:00:00Z',
updated_at: '2026-04-25T12:00:00Z',
};
expect(d.pem_data).toBeUndefined();
});
});
/**
* D-2 (diff-05x06-97fab8783a5c, master): Issuer status phantom trim.
*
* Pre-D-2 the TS `Issuer` interface declared a required `status: string`
* field that the Go-side struct (`internal/domain/connector.go::Issuer`)
* never emitted — the Go struct has only `Enabled bool`. The TS interface
* comment claimed "Backend returns enabled boolean; status is derived
* from this" but no derivation logic existed: `IssuersPage.tsx::~line 23`
* read `issuer.status || 'Unknown'` and always rendered 'Unknown'.
*
* Post-D-2 the `status` field is removed; the consumer now derives the
* displayed status from `enabled` at render time.
*/
describe('Issuer interface (D-2 status phantom trim)', () => {
it('does NOT declare a phantom `status` field — derive from `enabled`', () => {
// Construct a fully-populated Issuer with the post-D-2 shape.
// If `status` is re-added, this construction fails with "missing
// required" (TS2741) when status is required, or the excess-property
// comment below trips when it's added back as optional.
const i: Issuer = {
id: 'iss-test',
name: 'Test ACME',
type: 'acme',
config: {},
enabled: true,
created_at: '2026-01-01T00:00:00Z',
};
expect(i.id).toBe('iss-test');
expect(i.enabled).toBe(true);
// Excess-property check:
// const broken: Issuer = { ...i, status: 'Active' }; // ❌ TS2353
});
});
/**
* D-2 (diff-05x06-caba9eb3620e, master): Notification subject phantom trim.
*
* Pre-D-2 the TS `Notification` interface declared `subject?: string` —
* the field was acknowledged in the existing comment as "a historical
* frontend-only field the backend never emits" but kept on the interface
* "so legacy fixtures and the pendingNotif test mock still type
* correctly." Real consumer at `NotificationsPage.tsx::~line 241` had
* `{n.message || n.subject}` as a fallback that always fell through to
* `n.message` (since `n.subject` was always undefined).
*
* Post-D-2 the field is removed; the consumer drops the dead fallback
* and the test fixtures drop the dead `subject:` initializer.
*/
describe('Notification interface (D-2 subject phantom trim)', () => {
it('does NOT declare the phantom `subject` field', () => {
const n: Notification = {
id: 'no-test',
type: 'CertificateExpiring',
channel: 'email',
recipient: 'ops@example.com',
message: 'Certificate api.example.com expires in 14 days',
status: 'pending',
created_at: '2026-04-25T12:00:00Z',
};
expect(n.id).toBe('no-test');
expect(n.message).toContain('14 days');
// Excess-property check:
// const broken: Notification = { ...n, subject: 'Cert expiring' }; // ❌ TS2353
});
});
+63 -12
View File
@@ -67,6 +67,23 @@ export interface CertificateVersion {
// API contract. See docs/architecture.md ER-diagram note and
// coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s5-apikey_leak
// for the closure rationale.
//
// D-2 (diff-05x06-7cdf4e78ae24, master): pre-D-2 this interface declared
// five fields the Go-side struct (internal/domain/connector.go::Agent)
// does NOT emit on the wire: `last_heartbeat` (the real field is
// `last_heartbeat_at`; the bare-name was a sibling typo never rejected
// at compile time), `capabilities`, `tags`, `created_at`, `updated_at`.
// Two of them had real consumers (AgentDetailPage rendered
// `agent.capabilities` and `agent.tags`) — both always rendered the
// empty-state branch because the runtime values were always undefined.
// Post-D-2 the interface field set matches the Go-emitted JSON exactly:
// id, name, hostname, status, last_heartbeat_at, registered_at, os,
// architecture, ip_address, version, retired_at?, retired_reason?. A
// `agent.capabilities` access is now a TS compile error. The CI guardrail
// in .github/workflows/ci.yml (`Forbidden StatusBadge dead-key + TS
// phantom-field regression guard (D-1 + D-2)`) blocks reintroduction of
// the trimmed field names while explicitly excluding `last_heartbeat_at`
// from the `last_heartbeat` regex.
export interface Agent {
id: string;
name: string;
@@ -76,13 +93,8 @@ export interface Agent {
architecture: string;
status: string;
version: string;
last_heartbeat: string;
last_heartbeat_at: string;
capabilities: string[];
tags: Record<string, string>;
last_heartbeat_at?: string;
registered_at: string;
created_at: string;
updated_at: string;
// I-004: soft-retirement fields. When retired_at is non-null, the agent is
// tombstoned — it will never heartbeat again and cascaded targets have been
// retired alongside it. The retired tab on AgentsPage uses these to show the
@@ -159,9 +171,17 @@ export interface Job {
* without chasing server logs.
*
* `sent_at` and `error` are the pre-I-005 audit fields on the backend struct.
* `subject` is a historical frontend-only field the backend never emits; it's
* kept optional so legacy fixtures and the pendingNotif test mock still type
* correctly without forcing a rewrite of every existing consumer.
*
* D-2 (diff-05x06-caba9eb3620e, master): pre-D-2 this interface carried a
* phantom `subject?: string` field documented as "kept optional so legacy
* fixtures and the pendingNotif test mock still type correctly without
* forcing a rewrite of every existing consumer." The Go-side struct
* (`internal/domain/notification.go::NotificationEvent`) never emitted it,
* so `n.subject` was always `undefined` at runtime. The one real consumer
* (NotificationsPage rendering `{n.message || n.subject}`) always fell
* through to `n.message`. Post-D-2 the field is removed; the consumer
* drops the dead `|| n.subject` fallback and the test fixtures drop the
* dead `subject:` initializer. The CI guardrail blocks reintroduction.
*
* Status values follow the backend NotificationStatus constants:
* pending · sent · failed · dead · read
@@ -174,7 +194,6 @@ export interface Notification {
type: string;
channel: string;
recipient: string;
subject?: string;
message: string;
status: string;
certificate_id?: string;
@@ -269,13 +288,21 @@ export interface RenewalPolicy {
updated_at: string;
}
// D-2 (diff-05x06-97fab8783a5c, master): pre-D-2 this interface declared
// a required `status: string` field that the Go-side struct
// (`internal/domain/connector.go::Issuer`) never emitted — the Go struct
// has only `Enabled bool`. The TS comment claimed "status is derived from
// this" but no derivation ever existed: `IssuersPage.tsx` read
// `issuer.status || 'Unknown'` and always rendered 'Unknown'. Post-D-2
// the phantom is removed; render sites derive the displayed status from
// `enabled` (and optionally `test_status`) at the call site. The CI
// guardrail in .github/workflows/ci.yml blocks reintroduction.
export interface Issuer {
id: string;
name: string;
type: string;
config: Record<string, unknown>;
status: string;
/** Backend returns enabled boolean; status is derived from this */
/** Backend returns enabled boolean; render sites derive status labels from this */
enabled: boolean;
/** Timestamp of last connection test */
last_tested_at?: string;
@@ -287,6 +314,14 @@ export interface Issuer {
updated_at?: string;
}
// D-2 (diff-05x06-2044a46f4dd0, master): pre-D-2 this interface lacked
// `retired_at` and `retired_reason` even though the Go-side struct
// (`internal/domain/connector.go::DeploymentTarget`) emits both as part
// of the I-004 soft-retirement model. Consumers wanting to surface the
// retired state had to escape via `(target as any).retired_at`. Post-D-2
// the TS interface declares both as optional nullable strings, mirroring
// the Agent retirement-fields shape (an Agent retire cascades to all
// associated Targets per service.RetireAgent → repository.RetireTarget).
export interface Target {
id: string;
name: string;
@@ -297,6 +332,8 @@ export interface Target {
last_tested_at?: string;
test_status?: string;
source?: string;
retired_at?: string | null;
retired_reason?: string | null;
created_at: string;
updated_at?: string;
}
@@ -403,6 +440,19 @@ export interface IssuanceRateDataPoint {
}
// Discovery types
//
// D-2 (diff-05x06-85ab6b98a2f7, master): pre-D-2 this interface lacked
// `pem_data` even though the Go-side struct
// (`internal/domain/discovery.go::DiscoveredCertificate.PEMData`,
// json:"pem_data,omitempty") emits it on the wire. The field is
// populated by the agent's filesystem scanner
// (cmd/agent/main.go::buildDiscoveryReport), the cloud-secret-manager
// connectors (e.g. internal/connector/discovery/azurekv/azurekv.go), and
// the repo SELECT that materialises the row from PostgreSQL. Post-D-2
// the TS interface declares `pem_data?: string`, optional because the
// Go side uses `omitempty` (empty string → not emitted). Performance
// follow-up: the LIST endpoint loads pem_data via the same repo SELECT;
// a future change should gate emission on the per-id detail path only.
export interface DiscoveredCertificate {
id: string;
fingerprint_sha256: string;
@@ -416,6 +466,7 @@ export interface DiscoveredCertificate {
key_algorithm: string;
key_size: number;
is_ca: boolean;
pem_data?: string;
source_path: string;
source_format: string;
agent_id: string;
+6 -1
View File
@@ -8,7 +8,12 @@ export function formatDateTime(iso: string | undefined | null): string {
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function timeAgo(iso: string): string {
// D-2 (master): widened to accept undefined/null since several Go-side
// timestamp fields are emitted as `omitempty` (e.g. Agent.last_heartbeat_at
// for never-heartbeated agents). Pre-D-2 the TS interfaces declared
// these as required strings, masking the case; post-D-2 the optionality
// is propagated end-to-end and the helper handles it explicitly.
export function timeAgo(iso: string | undefined | null): string {
if (!iso) return '—';
const now = Date.now();
const then = new Date(iso).getTime();