diff --git a/apps/web/app/verify/page.tsx b/apps/web/app/verify/page.tsx new file mode 100644 index 00000000..3f364739 --- /dev/null +++ b/apps/web/app/verify/page.tsx @@ -0,0 +1,348 @@ +/** + * /verify — Verifier inspectability surface (Wave 9 partial). + * + * A hospital verifier or independent auditor lands here with an NPI in + * hand (typically as `?npi=1346053246`) and gets a single page that + * exposes every trust-bearing field the backend knows about, in the + * canonical institutional order: OBJECT → OWNERSHIP → CHECKED_AT → + * CHANNEL → REPLAY → RUN_ID. + * + * Composition: + * - institutional reading order + * - decision-readiness band (GREEN/YELLOW/RED) + * - per-credential T1–T4 tier + * - per-source freshness + * - for any source not in `checked` state + * - replay continuity (with current-snapshot + * entry; prior-check enumeration is Wave 6 + * scope) + * - when trustContainer manifest is present + * + * Data source: the entity-shape backend endpoint that already exists + * (`/api/passport/npi/:npi`). No new backend endpoint is introduced + * here; this page is purely an inspector layout over the existing + * canonical PassportData contract. + * + * Server component — fetches at request time, no client hydration + * needed for the read-only inspector view. An NPI input form is + * provided as a small client island for verifier navigation. + */ +import type { Metadata } from 'next'; +import { BACKEND_URL } from '@/lib/backend-url'; +import { assertPassportData } from '@/lib/trust/passport-contract'; +import type { PassportData } from '@/lib/trust/passport-contract'; +import { + CheckedAtStamp, + DegradedStateBanner, + IssuerAttribution, + type DegradedStateCode, + ReplayLineage, + RunIdentity, + TierBadge, + TrustHeader, + TrustStateBand, + type TrustStateBandValue, +} from '@/components/trust'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export const metadata: Metadata = { + title: 'Verify · VitalCV', + description: 'Independent trust inspection for a clinician passport.', +}; + +async function fetchPassport(npi: string): Promise<{ ok: true; passport: PassportData } | { ok: false; status: number; error: string }> { + if (!/^\d{10}$/.test(npi)) { + return { ok: false, status: 400, error: 'NPI must be a 10-digit string.' }; + } + + const url = `${BACKEND_URL}/api/passport/npi/${encodeURIComponent(npi)}`; + let res: Response; + try { + res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(8000) }); + } catch (err) { + return { ok: false, status: 503, error: `Upstream unreachable: ${String(err)}` }; + } + + if (!res.ok) { + return { ok: false, status: res.status, error: `Backend returned ${res.status}.` }; + } + + const payload = await res.json().catch(() => null); + let passport: PassportData; + try { + passport = assertPassportData(payload); + } catch (err) { + return { ok: false, status: 502, error: `Invalid upstream payload: ${String(err)}` }; + } + return { ok: true, passport }; +} + +function mapBandToCanonical(band: string): TrustStateBandValue { + const upper = band.toUpperCase(); + if (upper === 'GREEN' || upper === 'YELLOW' || upper === 'RED') return upper; + return 'UNKNOWN'; +} + +function mapVerificationLevelToTier(level: string | undefined): 'T1' | 'T2' | 'T3' | 'T4' { + const v = (level ?? '').toUpperCase(); + if (v === 'CRYPTOGRAPHIC' || v === 'SIGNED' || v === 'PSV_SIGNED') return 'T4'; + if (v === 'PRIMARY_SOURCE' || v === 'SOURCE_VERIFIED' || v === 'PSV') return 'T3'; + if (v === 'INFERRED' || v === 'CROSS_CHECKED') return 'T2'; + return 'T1'; +} + +function inferDegradedCode(state: string, sourceId: string): DegradedStateCode | null { + const s = state.toLowerCase(); + if (s === 'checked') return null; // no banner needed + if (s === 'unavailable') return 'A'; + if (s === 'gated' || s === 'accessrequired' || s === 'access_required') return 'B'; + if (s === 'pending' || s === 'reviewrequired' || s === 'review_required') return 'B'; + if (s === 'notdecisiongrade' || s === 'not_decision_grade') return 'B'; + // No specific upstream code → fall through. Issuer-related sources flagged as E. + if (sourceId.toLowerCase().includes('issuer')) return 'E'; + return null; +} + +interface VerifyPageProps { + searchParams: Promise<{ npi?: string }>; +} + +export default async function VerifyPage({ searchParams }: VerifyPageProps) { + const params = await searchParams; + const npi = (params.npi ?? '').trim(); + + return ( +
+
+

+ VitalCV · verify +

+

Trust inspection

+

+ Inspect the trust posture for a clinician's NPI. Renders the + canonical institutional reading order — object, ownership, + checked_at, channel, replay, run_id — using the same canonical + primitives the passport surface uses, with every field + rendered visibly (no aria-only, no tooltip-only). +

+
+ +
+ + + +
+ + {!npi && ( +

+ Enter a 10-digit NPI above to inspect its passport. +

+ )} + + {npi && } +
+ ); +} + +async function VerifyResult({ npi }: { npi: string }) { + const result = await fetchPassport(npi); + + if (!result.ok) { + return ( + = 500 ? 'C' : 'A'} + sourceId="passport" + recoveryAction={`Retry · status=${result.status}`} + /> + ); + } + + const { passport } = result; + const channel = passport.sources?.checked?.[0] ?? 'unknown'; + // Wave 10 (#343) will surface canonical `replay.runId` / `replay.lineageKey` + // on PassportData; until that lands and this branch rebases on top of it, + // the verifier sees the entity id as a stable stand-in. The structured + // ids are visible the moment the upstream contract carries them. + const replayIdentity = (passport as PassportData & { + replay?: { runId: string; lineageKey: string; schemeVersion: 'v1' }; + }).replay; + const runId = replayIdentity?.runId ?? passport.entityId; + const lineageKey = replayIdentity?.lineageKey ?? passport.entityId; + const trustBand = mapBandToCanonical(passport.trustPosture.band); + + const degradedRows = passport.sourceCoverage.checks + .map((check) => ({ check, code: inferDegradedCode(check.state, check.sourceId) })) + .filter((row): row is { check: typeof row.check; code: DegradedStateCode } => row.code !== null); + + // Replay lineage with a single current-snapshot entry. Prior-check + // enumeration from history is Wave 6 scope — the primitive renders + // correctly with `priorChecks: []` until that's available. + const currentCheckedAt = passport.lastCheckedAt; + + return ( +
+ + +
+ +
+ readiness +
+ {passport.readiness.score} + {passport.readiness.level} + /{passport.readiness.status} +
+ {passport.readiness.blockers.length > 0 && ( +
    + {passport.readiness.blockers.slice(0, 3).map((b, i) => ( +
  • {b}
  • + ))} +
+ )} +
+
+ +
+

credentials

+ {passport.authority.credentials.length === 0 && ( +

No credentials surfaced.

+ )} +
    + {passport.authority.credentials.map((cred) => ( +
  • + +
    + {cred.type} + {cred.issuerName ?? cred.sourceId ?? 'issuer unknown'} +
    + {cred.status} + +
  • + ))} +
+
+ +
+

source coverage

+
    + {passport.sourceCoverage.checks.map((check) => ( +
  • + {check.sourceId} + state · {check.state} + + + +
  • + ))} +
+
+ + {degradedRows.length > 0 && ( +
+

degraded continuity

+
+ {degradedRows.map(({ check, code }) => ( + + ))} +
+
+ )} + + {passport.trustContainer && ( +
+

issuer attribution

+ {(() => { + const tc = passport.trustContainer as unknown as Record; + const did = typeof tc.issuerDid === 'string' ? tc.issuerDid : null; + const signer = typeof tc.issuer === 'string' ? tc.issuer : 'VitalCV PSV Issuer'; + const kid = typeof tc.kid === 'string' ? tc.kid : null; + const receipt = typeof tc.manifestId === 'string' ? tc.manifestId : null; + return ( + + ); + })()} +
+ )} + +
+

replay continuity

+ +
+ + +
+ ); +}