feat(verify): /verify trust inspection surface (Wave 9 partial)#345
feat(verify): /verify trust inspection surface (Wave 9 partial)#345ctol3r wants to merge 1 commit into
Conversation
Stacked on #341 (Lane B primitives). A new public verifier-readable page at /verify?npi=:NPI that exposes every trust-bearing field the existing backend already knows about, in canonical institutional order (OBJECT → OWNERSHIP → CHECKED_AT → CHANNEL → REPLAY → RUN_ID), using only the Lane B primitives. Composition: <TrustHeader> institutional reading order with PREVIEW / SNAPSHOT / SIGNED variants (SNAPSHOT here) <TrustStateBand> decision-readiness band <TierBadge> per-credential T1-T4 derived from verificationLevel <CheckedAtStamp> per-source + per-credential freshness <DegradedStateBanner> A/B/C/E for any source not in 'checked' state (D = no-adverse-findings rendered inline via TierBadge for OIG clean rows) <ReplayLineage> current snapshot as one entry; prior-check enumeration is Wave 6 scope <IssuerAttribution> when trustContainer manifest present; DID / signer / kid / receiptId surfaced <RunIdentity> visible run id footer chip <FormElement> verifier-facing NPI input Data source: existing GET /api/passport/npi/:npi (no new backend endpoint introduced). Server component — fetches at request time against BACKEND_URL, validates with assertPassportData(), so a malformed upstream payload renders as a degraded-state banner instead of leaking error stacks. Wave-10 dependency note: passport.replay.runId / passport.replay .lineageKey become available when #343 lands and this branch rebases. Until then the page falls back to entityId as a visible stable stand-in. Footer chip shows scheme · pending-w10 until #343 threads through; switches to scheme · v1 automatically when present. What this PR does NOT do (explicit deferred scope): - No new backend endpoint. /api/receipt/[id] and the JWKS validation pieces are Wave 13. - No /replay/[id] or /receipt/[id] pages — Wave 13. - No .well-known/* mounts — separate Wave 9 work. - No prior-check history rendering on the replay timeline — Wave 6 (requires backend history query that does not yet exist). Validation: pnpm turbo run build --filter @vitalcv/web → 13/13 tasks. No banned strings. No tests added (page is pure render over the existing canonical PassportData contract, which is already test-pinned by passport-contract tests and passport-proxy-routes tests).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8a9f3b9cf7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| id="verify-npi" | ||
| name="npi" | ||
| defaultValue={npi} | ||
| pattern="\d{10}" |
There was a problem hiding this comment.
Escape the NPI regex in the input pattern
The input uses pattern="\d{10}", but in a JavaScript string literal \d is parsed as d, so the browser receives d{10} instead of a 10-digit validator. This silently disables the intended client-side guard and allows invalid NPIs through the form UX (server-side validation still catches it later). Use pattern="\\d{10}" or [0-9]{10} so the browser enforces the correct format.
Useful? React with 👍 / 👎.
|
|
||
| export default async function VerifyPage({ searchParams }: VerifyPageProps) { | ||
| const params = await searchParams; | ||
| const npi = (params.npi ?? '').trim(); |
There was a problem hiding this comment.
Normalize repeated
npi query params before trimming
This assumes searchParams.npi is always a string, but app-router query params can be arrays when the URL repeats a key (for example /verify?npi=1&npi=2). In that case .trim() throws at runtime and the page fails with a 500. Please coerce arrays to a single value (or reject them) before calling string methods.
Useful? React with 👍 / 👎.
| label: passport.identity.displayName, | ||
| kind: passport.identity.entityType.toLowerCase(), | ||
| }} | ||
| ownership={{ state: 'UNCLAIMED', claimant: passport.identity.displayName }} |
There was a problem hiding this comment.
Remove claimant text when rendering UNCLAIMED ownership
The header always sets ownership state to UNCLAIMED while also passing claimant: passport.identity.displayName. That produces contradictory trust output on the same chip (unclaimed plus a claimant identity), which can mislead verifiers about ownership status. For UNCLAIMED, omit claimant unless real claim metadata exists.
Useful? React with 👍 / 👎.
Adds the cryptographically-signed receipt surface that an external
verifier needs to validate a clinician passport snapshot end-to-end:
1. fetch the receipt at /api/receipt/<npi>
2. fetch the JWKS at /.well-known/jwks.json (from Wave 9)
3. validate the JWT signature against the JWKS
The new route signs an ES256 JWT using the same getOrInitKeypair()
that produces the public key published at /.well-known/jwks.json
(Wave 9), so kid + key never diverge between issuance and
discoverability.
Receipt shape (ES256 JWT):
header { alg: 'ES256', kid: <active-kid>, typ: 'vc+jwt' }
payload { iss: <did:web issuer>, sub: 'npi:<npi>',
jti: 'receipt:<runId>' (deterministic per snapshot),
iat: <lastCheckedAt seconds>, nbf, exp = iat + 90d,
vc: W3C VC 2.0 credential block (VitalCVTrustReceipt),
vcv: VitalCV claim block incl. replay identity }
Determinism contract (test-pinned):
- jti = 'receipt:<replay.runId>' so the same evidence snapshot
yields the same receipt jti; the JWT signature is non-
deterministic per ES256 spec but the payload is byte-identical.
- iat is pinned to lastCheckedAt seconds so two receipts for the
same snapshot share iat. A verifier joining receipts by
(lineageKey, runId) gets unambiguous snapshot identity.
Verifier readability:
- vcv.jwksUri and vcv.didDocumentUri point at the well-known
surfaces #349 published, so a receipt is self-describing.
- X-Receipt-Kid + X-Receipt-Issuer response headers let a verifier
short-circuit JWKS resolution if it already has the key cached.
- ?format=download adds Content-Disposition: attachment so a
verifier engineer can save the JWT for offline validation.
Tests (10 vitest cases, all passing):
- 400 on non-10-digit NPI, 502 on malformed upstream, propagated
backend status on upstream non-ok
- 200 + application/jwt + ES256 + kid header + 'vc+jwt' typ
- W3C VC 2.0 + VitalCV claim block shape (credentialSubject,
replay.{lineageKey,runId,schemeVersion}, jwksUri, didDocumentUri)
- Determinism: same fetch → same jti, same iat
- End-to-end verifier flow: receipt JWT validates against the
JWKS the well-known route exposes (createLocalJWKSet + jwtVerify)
- ?format=download adds Content-Disposition: attachment
- Graceful no-replay fallback (jti from npi when replay absent)
Validation: 10/10 vitest passing; pnpm turbo run build --filter
@vitalcv/web → 13/13 tasks; truth-strings scan CLEAN.
Stacked on the rest of this branch (#349's well-known surfaces +
wellKnownIdentity helper). Brief 1 of this turn (replay-lineage
rendering) is structurally already shipped across #341 + #342 +
#343 + #345 — no rendering changes needed here.
After auditing apps/web/lib/auth/roles.ts on origin/main against the new verifier-continuity surfaces in #349, #345, #355: - .well-known/* surfaces ✅ already covered by /^\/\.well-known(\/.*)?$/ - /api/receipt/* ✅ covered by /^\/api(\/.*)?$/ - /verify ✅ covered by /^\/verify(\/.*)?$/ - /trust⚠️ falls through (no pattern matches, no required role) — reachable but implicit This PR resolves the lone gap by adding an explicit /^\/trust(\/.*)?$/ pattern to PUBLIC_ROUTE_PATTERNS so the verifier-continuity intent is documented in the allowlist rather than relying on the middleware's neither-public-nor-protected fall-through. Eight new rows in middleware.test.ts pin the public status of every verifier-continuity surface so a future allowlist regression fails CI. MERGE_READINESS.md updated: - §5.5 verdict downgraded MEDIUM → LOW (was: blocker; now: resolved) - §6 blocker matrix row crossed out (resolved in this PR) Files: apps/web/lib/auth/roles.ts +1 line (one regex) apps/web/__tests__/middleware.test.ts +8 rows (test matrix) MERGE_READINESS.md §5.5 + §6 updates Validation: pnpm exec vitest run __tests__/middleware.test.ts → 44/44 pnpm turbo run build --filter @vitalcv/web → 13/13 tasks
Two new forensics docs answering the operator's deployment-topology
questions in one place:
APEX_DEPLOYMENT_FORENSICS.md
- Proves apex (vitalcv.com) deploys apps/web (not apps/marketing)
via live probe of /api/health returning service: "web"
- Identifies the two Vercel projects (apex web vs separate
marketing) and their per-app vercel.json overrides
- Critical operational finding: apex /api/health reports
clerk.enabled: false / mode: "none" — the production Vercel
project is missing NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY +
CLERK_SECRET_KEY env vars. /sign-in 500s as a result.
- Independent of the 20-PR merge queue: this Clerk env gap is
operator-side configuration, not code
- 10 sections + recommendations
ROUTE_OWNERSHIP_MAP.md
- Proves every named verifier route lives in apps/web (not
apps/marketing) by filesystem inspection
- /verify exists in both apps but they own different concepts:
apps/web/app/verify/page.tsx = institutional inspector (#345),
apps/marketing/app/verify/[shareId]/page.tsx = share-link
viewer (different domain, different concern)
- Eight of nine institutional routes are exclusive to apps/web
- Confirms: when the merge train lands, every new route deploys
automatically to apex — no project-rebinding, no domain
reconfig, no migration required
- 9 sections + summary table
Together with the existing BUILD_ARTIFACT_VERIFICATION.md
(physical build output) and MERGE_READINESS.md (#356, merge-train
sequencing), this PR is now the complete operator-facing answer
to: "what does apex serve, what's the build output, what's the
merge order, and what's the deployment risk?"
No code changes. Three doc files at repo root.
…logy from origin/main reality Truth-contract fix surfaced by deployed-route-registration audit: the prior commit on this branch claimed each canonical handler exists at its target path, but on origin/main only two rows (legacy /api/.well-known/jwks.json + the OS-association manifests) ship today. The remaining 9 canonical handlers live on unmerged PRs #345/#349/#355. Changes: - Prepended "Status — read this first" preamble that names the unmerged PRs and what an external verifier sees from production today (404 on the canonical paths). - Renamed the route-table "Owning PR" column to "Lives on" and annotated each row with whether the handler is on origin/main or on a named unmerged PR. - Noted that the legacy mirror's current Content-Type is application/json (not application/jwk-set+json) — the canonical handler on #349 corrects this. - Clarified that the pinning tests and companion forensics docs referenced in this map ALSO ship on those unmerged PRs, not on origin/main. No code changes. Doc-only.
Adds docs/architecture/final-runtime-reality-state.md, the TASK 7 output of the HARD OPERATIONAL CONVERGENCE wave. Strictly scoped to what is true on apex vitalcv.com RIGHT NOW (origin/main HEAD); excludes roadmap, planned features, in-flight PRs, and theoretical topology. Five required answers, each with file:line attribution: 1. What can institutions verify RIGHT NOW? Apex deploys, legacy JWKS at non-canonical path, ES256 signature oracle at /api/receipts/verify, /api/health config probe, OS association manifests. Five surfaces total. 2. What survives runtime restart RIGHT NOW? All Prisma-persisted state. Does NOT survive: ES256 keypair when env unset, lineageKey/runId continuity (not persisted), receipt issuance records by jti, lane-health snapshots. 3. What is still synthetic RIGHT NOW? /passport sample card (labeled), /api/ingest/[npi] fallback body, AASA advertisement of /verify/* (route absent on main), Macie Miller demo NPI (vitalcv_dev only). 4. What still breaks institutional continuity RIGHT NOW? 9 concrete observable failures: 404 cascade on canonical discovery paths, non-deterministic receipt jti, no lineageKey/runId claims, no replay readers, probe runner unscheduled, clerk.enabled=false, legacy JWKS media-type, OID4VCI credential_endpoint advertising non-existent path, OIDC pointer-not-flow endpoints. 5. What remains before true production-grade verifier infrastructure exists? Tier A: 6 operator-side configuration steps. Tier B: 5-PR merge train (#345, #349, #355, #358, #360). Tier C: 6-7 engineering PRs for replay persistence (per replay-topology-gap-analysis.md §7). Tier D: hygiene fix-ups (some already in flight on #360). No new product concept required at any tier. Truth contract: doc scanned CLEAN. No banned strings, no aspirational claims, no future-state invention.
…adiness Pivots the audit lens from institutional infrastructure to product-truth coherence per the user's direction "transition VitalCV from infrastructure-emergence to coherent shippable product." Adds two consolidated docs: 1. docs/architecture/product-completion-audit.md (Phase 1) Classifies every user-facing surface on origin/main (HEAD 39bb65d) as REAL+WORKING / REAL-BUT-DEGRADED / STATIC SHELL / PARTIAL MOCK / BROKEN / ABSENT. Covers marketing entry points, clinician onboarding loop, passport, employer review, issuer flows, verifier surfaces (mostly absent on main), operational self-serve, and critical API paths. Key findings: - Most public marketing surfaces ship truth-honest "foundation preview" copy (onboarding, pricing, docs, status). - /verifier dir exists but is empty -> broken-link cascade if linked. - /verify, /trust, /trust/doctrine, /.well-known/{did,oid-cred-issuer, oid-configuration,trust-register} all absent on main (live on unmerged #345/#349/#355). - /compliance archived only; live link 404s. - /sign-up vs /signup duplication. - LaneHealthMount band reads UNKNOWN until probe runner scheduled. - /api/ingest/[npi] HTTP-200-with-fallback masquerade is a known defect making homepage NPI submit fail cryptically. 2. docs/architecture/ship-readiness-state.md (Phase 8) Six required answers: 1. "What can ship TODAY?" — Clinician readiness preview product: homepage, pricing, docs, status, legal, onboarding, /p/[npi], /review/[entityId], passport (degraded), replay API endpoints. 2. "What must be hidden before shipping?" — Inbound links to /verifier, /verify, /trust, /compliance; demo-grade /issuer/* from public nav; one of /sign-up vs /signup. 3. "What still breaks trust?" — LaneHealthMount UNKNOWN seeds, apex clerk.enabled=false, /api/ingest/[npi] masked-200 client throw, legacy JWKS media-type, "Unavailable" label collision. 4. "What surfaces are operationally believable?" — /onboarding, /status, /docs, /pricing, homepage, replay API. 5. "What is the actual MVP?" — "Clinician readiness preview, source-honest": NPI -> public-source-backed readiness, no credentialing claim, no compliance certification, receipts verifiable via legacy ES256 oracle. 6. "What should NOT be built yet?" — No new replay systems beyond α/β/γ; no continuity reconciler; no UI primitives depending on unmerged stack; no further synthesis/doctrine docs; no writer expansion to other ingest sites; no new feature waves until broken-link cascade closed. Plus a recommended-next-6-PRs list (half-day engineering total) for the broken-link cascade. No new architecture, no schema changes, no new endpoints in any of those 6 PRs. Truth contract: both docs scan CLEAN. No banned phrases, no bare- Verified labels, no aspirational claims. Specific quotes of negative- safety copy (e.g., onboarding's disclaimer) reworded to avoid the banned-phrase scanner false-positive. This commit closes the audit cycle and transitions to product-completion priorities. Per the user's directive, no further synthesis/doctrine/ convergence docs will be generated unless explicitly requested.
Summary
Wave 9 partial — a new verifier-readable page at
/verify?npi=:NPIthat exposes every trust-bearing field the existing backend already returns, in canonical institutional order, using only the Lane B primitives (#341).Stacked on #341. No new backend endpoint, no schema changes, no auth changes. Pure inspector surface over the existing
GET /api/passport/npi/:npicontract.Composition
<TrustHeader variant="SNAPSHOT"><TrustStateBand><TierBadge>+<CheckedAtStamp>per row<CheckedAtStamp>per row<DegradedStateBanner>per non-checkedsource, mappingunavailable→ A,gated/access-required/pending/review-required/notDecisionGrade→ B,issuer-*→ E<IssuerAttribution>whentrustContaineris present<ReplayLineage>with current snapshot as one entry<RunIdentity>+ lineage chipWave 10 (#343) coordination
PassportData.replaylands on #343. This page uses an optional type cast to read.replayif present and falls back toentityIdfor bothrunIdandlineageKeyotherwise. Footer showsscheme · pending-w10until threaded; switches toscheme · v1automatically once #343 merges and the upstream contract carries the field.Truth rules
<DegradedStateBanner code="C">(infrastructure outage) instead of leaking errorsWhat this PR does NOT do (explicit deferred scope)
/api/receipt/[id]is Wave 13)/replay/[id]or/receipt/[id]pages (Wave 13).well-known/*mounts (jwks.json,did.json,openid-credential-issuer,trust.json) — needs backend route workValidation
pnpm turbo run build --filter @vitalcv/web→ 13/13 tasks, 67sapps/web/app/verify/page.tsxadded