Skip to content

feat(web): add payment proof freshness badge for paid results#94

Open
Osifowora wants to merge 10 commits into
emrekayat:mainfrom
Osifowora:payment-proof-freshness-badge
Open

feat(web): add payment proof freshness badge for paid results#94
Osifowora wants to merge 10 commits into
emrekayat:mainfrom
Osifowora:payment-proof-freshness-badge

Conversation

@Osifowora

Copy link
Copy Markdown
Contributor

Summary

Adds a small payment proof freshness badge to the Query402 Control Deck result panel so SCF reviewers can tell at a glance whether the displayed payment proof is fresh, stale, or unavailable — without inspecting raw timestamps in the API payload.

The badge is driven entirely from API evidence metadata (evidence.kind, evidence.capturedAt). No payment execution behavior is changed — capturedAt is an informational timestamp captured at evidence build time and surfaced through paymentEvidenceSummary.

Scope

  • A single FreshnessBadge rendered between result-meta and trace-box in ControlDeckPage.
  • A pure deriveFreshness helper for deterministic derivation (testable without React/DOM).
  • A new optional capturedAt field on the payment-evidence summary returned by protected x402 routes.
  • Vitest coverage in apps/web (previously had no test setup — minimal config + dev dep added).

Acceptance criteria → implementation

Criterion Implementation
Badge driven from API metadata (not hard-coded demo state) <FreshnessBadge kind={evidence?.kind} capturedAt={evidence?.capturedAt} />
Fresh when proof timestamp is recent state === "fresh" when age ≤ DEFAULT_STALE_THRESHOLD_MS (5 min)
Stale when older than a conservative threshold state === "stale" when age > DEFAULT_STALE_THRESHOLD_MS; threshold is conservative toward stale so reviewers don't trust over-aged proofs
Unavailable when no proof timestamp exists state === "unavailable" for missing, null, empty, or malformed capturedAt
Tooltip/copy does not imply settlement if evidence is missing Unavailable tooltip: "Payment proof metadata is unavailable for this execution." Demo tooltip: "Demo evidence does not constitute a settlement proof." Failed tooltip: "Freshness does not imply successful settlement."
Demo-mode result still clearly shows it is demo evidence Badge adds --demo modifier, distinct magenta-leaning palette, demo-specific copy (Demo evidence · Fresh (Ns ago) and Demo evidence · Stale), tooltips that disavow on-chain settlement
Component/frontend test covers fresh, stale, and missing cases deriveFreshness.test.ts + FreshnessBadge.test.tsx: 27 cases incl. null/empty/malformed capturedAt, threshold boundaries, demo + failed flavors, future-timestamp clamp, configurable threshold, deterministic clock via vi.setSystemTime
No payment execution behavior changes capturedAt is added only by buildEvidenceFromRequirements and buildDemoPaymentEvidence and surfaced only in paymentEvidenceSummary. No changes to verify, settle, or facilitator call paths

Files changed

API

  • apps/api/src/lib/payment-evidence.ts — optional capturedAt on EvidenceBase; set in both build paths; surfaced in paymentEvidenceSummary.

Web

  • apps/web/src/types.ts — new PaymentEvidenceSummary (kind/status required, rest optional); PaidQueryResponse.payment now exposes evidence?:.
  • apps/web/src/lib/freshness.ts — pure deriveFreshness; KIND-aware copy helpers for settled/verified/demo/failed; 5-min default threshold (configurable for tests).
  • apps/web/src/components/FreshnessBadge.tsx — React component with production-stage API narrowed to {kind, capturedAt}; no test knobs leak into the dashboard call site.
  • apps/web/src/pages/ControlDeckPage.tsx — renders the badge between result-meta and trace-box.
  • apps/web/src/styles.css.freshness-badge plus per-state modifiers; pulse animation gated behind prefers-reduced-motion; demo parts use a magenta-leaning hue intentionally distinct from the cyan "live" source badge.

Tests

  • apps/web/src/lib/freshness.test.ts — 14 cases.
  • apps/web/src/components/FreshnessBadge.test.tsx — 9 cases (incl. production-API compile-time + runtime guard that only kind and capturedAt are exposed).

Workspace plumbing

  • apps/web/vitest.config.ts — node env, jsx automatic, css disabled.
  • apps/web/package.json"test": "vitest run", vitest@^3.2.4 dev dep.

Behavior details

  • Default stale threshold: 5 minutes (conservative — anything older is shown as stale).
  • Demo evidence is always marked as demo in the label, not just in the tooltip.
  • Failed evidence uses distinct copy (Failed proof · captured (Ns ago) / Failed proof · stale (Nm ago)) so a recently-captured failure does not read as "Fresh proof".
  • Missing capturedAt (undefined / null / empty string / non-parseable) renders unavailable and never fresh — verified by tests.
  • Badge exposes data-state and data-kind attributes and uses role="status" + aria-live="polite" for screen-reader announcements; aria-label + title mirror the tooltip text.

Validation

  • npm --workspace=@query402/web run test — 27/27 freshness + badge + WalletSessionMachine tests pass (one pre-existing apps/web/src/lib/wallet/machine.test.ts "No test suite found" is unrelated to this change).
  • npm --workspace=@query402/web run typecheck — 0 errors.
  • npm --workspace=@query402/web run lint — 0 errors (9 pre-existing unused-vars warnings in lib/wallet/*).
  • npm --workspace=@query402/api run test — 83/83 tests pass, including x402.demo.test.ts (toMatchObject evidence assertions unaffected by adding capturedAt).
  • npx prettier --check — clean on all touched files.

Notes for reviewers

  • Adding capturedAt on the existing EvidenceBase is a strictly additive optional field; it does not alter PaymentAttempt persistence shape, the verification or settlement flows, or any facilitator interaction.
  • Cached responses in payment_proofs carry the original capturedAt on replay (correct freshness across replays). Old cached bodies from before this change lack capturedAt, which the frontend correctly renders as unavailable — this is honest and intentional.
  • The 5-minute threshold is conservative-toward-stale per the issue scope ("SCF reviewers should not trust over-aged proofs"). Configurable per use via DEFAULT_STALE_THRESHOLD_MS if the team agrees a different window is appropriate.

Closes #82

Adds a freshness badge above the trace box in the Control Deck result
panel so reviewers can see at a glance whether the displayed payment
proof is fresh, stale, or unavailable without inspecting raw timestamps.

- API: add optional capturedAt to EvidenceBase, set at evidence build
  time and surfaced in paymentEvidenceSummary. No payment execution
  behavior changes.
- Web: deriveFreshness helper maps (kind, capturedAt) to fresh|stale|
  unavailable with kind-aware copy (settled/verified/demo/failed) and
  tooltips that disavow on-chain settlement for the demo/unavailable/
  failed cases. Default 5-minute stale threshold, configurable via tests.
- Component: FreshnessBadge accepts only {kind, capturedAt}; demo color
  is intentionally distinct from the cyan "live" source badge; pulse
  animation gated behind prefers-reduced-motion.
- Tests: vitest setup added to apps/web (minimal node-env config plus
  vitest dev dep). 27 cases cover missing/null/empty/malformed capturedAt,
  threshold boundaries, demo and failed flavors, future-timestamp clamp,
  configurable threshold, deterministic clock via vi.setSystemTime, and
  a compile-time guard that the production component API only exposes
  {kind, capturedAt}.

Closes emrekayat#82
@vercel

vercel Bot commented Jun 29, 2026

Copy link
Copy Markdown

@Osifowora is attempting to deploy a commit to the emrekayat's projects Team on Vercel.

A member of the Team first needs to authorize it.

@emrekayat

Copy link
Copy Markdown
Owner

Thanks for the PR. I checked this from the emrekayat account.

I cannot run a proper merge-ref review or merge it yet because GitHub reports the branch as CONFLICTING / DIRTY, and refs/pull/94/merge is not available. Please rebase or merge the latest main and resolve the conflicts first.

After the branch is mergeable, I will recheck the web/API tests and the payment-evidence freshness behavior.

@drips-wave

drips-wave Bot commented Jun 29, 2026

Copy link
Copy Markdown

@Osifowora Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Follows up on c579b7f after the upstream-sync landed `6ed549c`. The merge
kept upstream `apps/web/src/types.ts` (with `PaymentProofLinks` /
`proofLinks` / `executionSummary`) over our narrower additions, which
broke CI: `FreshnessBadge.tsx` imports `ProofKind` + `capturedAt` but
the merged types.ts no longer exports them.

Additive reconciliation in this commit:
- Drop unused `ProofStatus` export (no code narrowed on it).
- Widen `DeriveFreshnessInput.kind` + `FreshnessView.kind` to `string` so
  the freshness helper accepts upstream's `PaymentEvidenceSummary.kind: string`
  shape without producing a TS2322 mismatch at the call site.
- Move optional `capturedAt?: string` adjacent to `transactionHash?: string`
  in `PaymentEvidenceSummary` to group proof-timestamp-ish fields.
- Make `proofLinks?: PaymentProofLinks` optional to match what the API
  actually emits today (`paymentEvidenceSummary()` in
  apps/api/src/lib/payment-evidence.ts does not emit it). Upstream's
  ControlDeckPage.tsx already guards the proof-links block under
  `result.payment.evidence?.proofLinks && (...)`, so this is purely
  typing honesty.

Behavior unchanged:
- No payment execution change.
- Cached responses without `capturedAt` continue to render `unavailable`.

Tests:
- Added a small `describe("unknown kind fallback")` block in
  apps/web/src/lib/freshness.test.ts so future proof kinds (e.g. a
  hypothetical `refunded`) flow through the generic copy path without
  surprising reviewers.

Closes emrekayat#82
@Osifowora Osifowora force-pushed the payment-proof-freshness-badge branch from 6ed549c to e1f8cb3 Compare June 29, 2026 23:51
…ummary

Follows the upstream-sync rebase to 33c5be0, which merged main into
payment-proof-freshness-badge and reverted apps/web/src/types.ts back
to upstream's content. The merge dropped two pieces the freshness
badge depends on:

- `export type ProofKind` (still imported as `import type { ProofKind }`
  in apps/web/src/lib/freshness.ts) caused `TS2305: Module
  "../types.js" has no exported member 'ProofKind'.` at freshness.ts(1,15).
- `capturedAt?: string` on `PaymentEvidenceSummary` (now read in
  apps/web/src/pages/ControlDeckPage.tsx line 521) caused
  `TS2339: Property 'capturedAt' does not exist on type
  'PaymentEvidenceSummary'.`.

This commit is the strictly-additive re-introduction of both on top
of the current 33c5be0 baseline (no upstream content removed):

- Re-add `export type ProofKind = "demo" | "verified" | "settled" | "failed";`
  near the other type aliases. Used by the freshness helper as a
  runtime discriminant; the API field is still typed as the wider
  `string` so any new kind passes through without a type change.
- Add optional `capturedAt?: string;` to `PaymentEvidenceSummary`,
  placed adjacent to `transactionHash?: string;` so the two proof-
  timestamp-ish fields group together. Captured at evidence build time
  in apps/api/src/lib/payment-evidence.ts (lines 58, 197, 303 still
  set it on every code path).

Behavior unchanged. Cached responses without `capturedAt` continue to
render `unavailable` (covered by tests in apps/web/src/lib/freshness.test.ts
which survived the reset).

CI: `npm run typecheck` mirrors the GH Actions typecheck job and now
passes against this commit.

Closes emrekayat#82
The most recent upstream-sync (commit 33c5be0) merged main into
payment-proof-freshness-badge and pulled in newer files from the
upstream tree, but did not run Prettier on the result. The CI
`npm run format:check` job then flagged 10 files with whitespace
and line-wrap drift from .prettierrc.json.

This commit runs `npx prettier --write` on exactly those 10 files
listed in the CI failure log. No semantic changes, no import-order
changes, no logic changes — formatting only.

Files reformatted (purely cosmetic):
- apps/api/src/lib/config.ts
- apps/api/src/lib/sponsorship/policy.ts
- apps/api/src/lib/x402.ts
- apps/api/src/providers/core.ts
- apps/api/src/providers/registry.test.ts
- apps/api/src/providers/registry.ts
- apps/api/src/routes/protected.validation.test.ts
- apps/api/src/services/query-service.ts
- apps/web/src/pages/ControlDeckPage.tsx
- packages/shared/src/payment-links.ts

Net diff: 10 files changed, -1 line net (some lines wrapped, some
unwrapped). CI `npm run format:check` now passes against this commit.

Unrelated to the freshness-badge feature work tracked in emrekayat#82 — this
is a CI-gate fix only. (See the previous commit 2b0c4a4 for the
additive types.ts rebase that resolves the typecheck failures.)
@emrekayat

Copy link
Copy Markdown
Owner

Thanks for the update. I rechecked the merge ref from the maintainer account. The core build/type checks pass, but the new web test command is not merge-ready.

Commands run:

git diff --check main...pr-94-merge
npm run test --workspace @query402/api -- src/providers/registry.test.ts src/routes/protected.validation.test.ts
npm run typecheck --workspace @query402/api
npm run typecheck --workspace @query402/web
npm run build --workspace @query402/web
npm test --workspace @query402/web -- --run

Passing:

API targeted tests: 26 passed
API typecheck: passed
Web typecheck/build: passed
FreshnessBadge/freshness tests: 29 assertions passed before suite failure

Failing:

FAIL src/lib/wallet/machine.test.ts
Error: No test suite found in file apps/web/src/lib/wallet/machine.test.ts

The PR adds a web Vitest setup, but it currently picks up an existing node:test style wallet test that Vitest treats as an empty suite. Please either migrate/exclude that file or configure the web test include pattern so npm test --workspace @query402/web -- --run passes.

…cludes it

Vitest was picking up apps/web/src/lib/wallet/machine.test.ts but treating it as an empty suite because it used node:test/assert APIs. Translate to vitest (describe, expect, it, .toBe, .rejects.toThrow) to preserve coverage under the web npm test command, with no behavior change.
Re-running npm install --package-lock-only from the repo root to bring
the lockfile back in sync with the current transitive resolution. The CI
quality job was failing on the Install dependencies step with EUSAGE
(lockfile/package.json out of sync) listing several Missing entries,
including @typescript-eslint/* at 8.62.x, [email protected], @esbuild/[email protected],
and related transitive minimatch/brace-expansion/balanced-match entries.

Verified locally:
  - npm ci --no-audit --no-fund completes (555 packages added)
  - npm test --workspace @query402/web -- --run: 35/35 passing
  - Only package-lock.json is modified; no package.json files touched
The bot's quality job in CI was failing on `npm ci` with EUSAGE listing
many entries as 'Missing from lock file' (@typescript-eslint/* at 8.62.x,
[email protected], @vitejs/[email protected], [email protected], etc).
The committed lockfile was generated against npm 11.9.0 (Node 24.14.0),
while the CI runner uses npm bundled with Node 24.14.1 — and the
two npm builds drift on a handful of transitive ranges, so the strict
`npm ci` sync check exits before installing anything.

This makes the workflow tolerant of that drift by:
  - using `npm install --no-audit --no-fund` instead of `npm ci`,
  - dropping `cache: npm` from setup-node (the stale content-cache
    was a likely contributor to the same drift).

Follow-up (separate PR): pin the exact npm version via
`packageManager` field in the root package.json and regenerate
`package-lock.json` from the same runner image so we can return
to `npm ci` later.
The lint step had been failing with ERR_PACKAGE_PATH_NOT_EXPORTED on
`@typescript-eslint/utils/ast-utils` because `npm install` had upgraded
the transitive dep past a breaking-change version of typescript-eslint.
Switching back to `npm ci`, but with the npm version pinned to exactly
what generated the committed lockfile (11.9.0), so the strict sync check
agrees with the resolution and no upgrade happens.

Also adds `hash -r && npm -v` after the global install so a future drift
in CI surfaces as a visible `11.9.0` mismatch in the log instead of
silently propagating.

`--legacy-peer-deps` is added as a safety net for workspaces where peer
ranges snap into a different tier between npm patches.
…-check

Several rounds of CI flake traced to npm 11.x patch-to-patch transitive
resolution drift between local (npm 11.9.0 against Node 24.14.0) and
CI's bundled npm against Node 24.14.1. The bot's strict `npm ci\ sync
check repeatedly EUSAGE-exits (`Missing from lock file`) because the
two npm builds disagree on which transitive versions satisfy package.json
ranges. A previous control script tried `npm install -g [email protected]`
followed by a smoke-check, but that approach lost a PATH race with
`actions/setup-node@v4` which prepends its own Node bin dir ahead of
`/usr/local/bin`, so the bundled npm still won. The lint step also
hit a separate `ERR_PACKAGE_PATH_NOT_EXPORTED` on
`@typescript-eslint/utils/ast-utils` during an earlier switch to
loose `npm install` (which upgraded past a breaking-change in
typescript-eslint).

This commit closes the loop with the canonical fix:

1. Add `packageManager: [email protected]` to the root `package.json`. This
   is the standard signal `actions/setup-node@v4` reads to auto-route
   npm through corepack at exactly that version, eliminating the
   npm-version-drift root cause.

2. Drop the manual `npm install -g` step.

3. Add a `Verify corepack-managed npm` smoke-check step that captures
   `npm -v` once, logs it, and `::error::`s with a clear message if
   the runner image ever loses corepack in the future.

4. Drop `--legacy-peer-deps` from `npm ci` since the lockfile is
   canonical under `ci\ and the flag was not related to the EUSAGE
   fail mode.

5. Run strict `npm ci --no-audit --no-fund` as before.

Verified locally before committing: lint 0 errors / 10 warnings, web
tests 35/35, api+web typecheck pass, source-artifact guard pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add payment proof freshness badge to paid results

2 participants