From cf0b79b6ab8ca178bcd52a187f937205e10cbf27 Mon Sep 17 00:00:00 2001 From: SuPuHe Date: Tue, 8 Sep 2026 09:22:56 +0200 Subject: [PATCH] feat(recovery-ui): add frontend recovery timeline --- .../20260908T061949Z-c05-frontend-recovery.md | 111 +++ packages/recovery-ui/README.md | 84 +++ packages/recovery-ui/index.html | 13 + packages/recovery-ui/package.json | 51 ++ .../schemas/recovery-timeline-v1.schema.json | 262 +++++++ packages/recovery-ui/src/RecoveryRoute.tsx | 72 ++ packages/recovery-ui/src/RecoveryTimeline.tsx | 395 +++++++++++ packages/recovery-ui/src/contract.ts | 414 +++++++++++ packages/recovery-ui/src/fixtures.ts | 347 +++++++++ packages/recovery-ui/src/index.ts | 8 + packages/recovery-ui/src/main.tsx | 23 + packages/recovery-ui/src/mock-server.ts | 143 ++++ packages/recovery-ui/src/styles.css | 663 ++++++++++++++++++ packages/recovery-ui/src/timeline.ts | 81 +++ packages/recovery-ui/src/vite-env.d.ts | 1 + packages/recovery-ui/test/component.test.ts | 175 +++++ packages/recovery-ui/test/contract.test.ts | 36 + packages/recovery-ui/test/mock-server.test.ts | 44 ++ packages/recovery-ui/test/route.test.ts | 44 ++ packages/recovery-ui/test/timeline.test.ts | 31 + packages/recovery-ui/tsconfig.json | 12 + packages/recovery-ui/vite.config.ts | 39 ++ packages/recovery-ui/vitest.config.ts | 8 + pnpm-lock.yaml | 532 +++++++++++++- tsconfig.json | 3 + 25 files changed, 3590 insertions(+), 2 deletions(-) create mode 100644 .agent/context/20260908T061949Z-c05-frontend-recovery.md create mode 100644 packages/recovery-ui/README.md create mode 100644 packages/recovery-ui/index.html create mode 100644 packages/recovery-ui/package.json create mode 100644 packages/recovery-ui/schemas/recovery-timeline-v1.schema.json create mode 100644 packages/recovery-ui/src/RecoveryRoute.tsx create mode 100644 packages/recovery-ui/src/RecoveryTimeline.tsx create mode 100644 packages/recovery-ui/src/contract.ts create mode 100644 packages/recovery-ui/src/fixtures.ts create mode 100644 packages/recovery-ui/src/index.ts create mode 100644 packages/recovery-ui/src/main.tsx create mode 100644 packages/recovery-ui/src/mock-server.ts create mode 100644 packages/recovery-ui/src/styles.css create mode 100644 packages/recovery-ui/src/timeline.ts create mode 100644 packages/recovery-ui/src/vite-env.d.ts create mode 100644 packages/recovery-ui/test/component.test.ts create mode 100644 packages/recovery-ui/test/contract.test.ts create mode 100644 packages/recovery-ui/test/mock-server.test.ts create mode 100644 packages/recovery-ui/test/route.test.ts create mode 100644 packages/recovery-ui/test/timeline.test.ts create mode 100644 packages/recovery-ui/tsconfig.json create mode 100644 packages/recovery-ui/vite.config.ts create mode 100644 packages/recovery-ui/vitest.config.ts diff --git a/.agent/context/20260908T061949Z-c05-frontend-recovery.md b/.agent/context/20260908T061949Z-c05-frontend-recovery.md new file mode 100644 index 0000000..401f7ad --- /dev/null +++ b/.agent/context/20260908T061949Z-c05-frontend-recovery.md @@ -0,0 +1,111 @@ +# Session Context: C05 frontend recovery + +## Date/time + +- UTC: 2026-09-08T06:19:49Z + +## User goal + +Implement Coder C milestone C05, pass FreePi Gate B with GLM 5.3 Flash, then +start C06. + +## Original prompt/request + +"we have done all milestones of coders A,B,C to 04. Lets start build milestone +for coder C 05-frontend-recovery. Before we start check if you can write +something in npx free-pi-cli or if failed npx.cmd free-pi-cli. Check also +previos task to understand what have built already. If milestone c05 will be +finished and gate B will be passed that start with next milestone C 06" + +The user later confirmed the repository path on `C:` and instructed PowerShell +use. The user also required `/model free-pi/glm-5.3-flash` before each FreePi +review. + +## Assumptions + +- Gate P4 backend convergence is merged at `25a17d8` and freezes the + `recovery-view-v1` semantics implemented by `@oneshot/reconciliation`. +- Gate P4 did not publish its planned frontend mock artifact. C05 will first + publish a C-owned, versioned, sanitized mock boundary without changing the + shared OpenAPI or A/B-owned frontend slices. +- C05 remains an independently composable React/Vite slice. Gate P5 owns final + application-shell composition. + +## Plan + +1. Freeze a versioned, sanitized recovery UI contract and mock fetch server. +2. Build the recovery route, timeline, provenance, MCP, agent/core, and UNKNOWN + experiences. +3. Add fixture scenarios and component/accessibility/keyboard/responsive tests. +4. Run package and root checks, FreePi Gate A, draft PR CI, and FreePi Gate B. +5. Start C06 only after C05 Gate B passes. + +## Key decisions + +- Keep authoritative OneShot/Arc evidence visually separate from provider, + Graph, and LLM observations. +- Expose refresh and escalation only. Never expose payment or generic retry + actions. +- Discard unknown fields and reject forbidden raw-provider or secret-shaped data + at the mock/client boundary. + +## Files/components touched + +- `packages/recovery-ui/`: independent React/Vite recovery slice, frozen JSON + schema, sanitized mock server, fixture stories, styles, and 50 tests. +- `tsconfig.json`: recovery UI project reference. +- `pnpm-lock.yaml`: pinned recovery UI dependencies. +- This context record. + +## Commands/checks + +- `git fetch origin develop` - PASS. +- Base: `25a17d86b56822a7e7440d34c331b740cb6d7f04`. +- `npx.cmd free-pi-cli` interactive write test - PASS; reviewer replied + `FREEPI_WRITE_OK`. +- `pnpm.cmd install --frozen-lockfile --config.confirmModulesPurge=false` - PASS. +- `pnpm.cmd --filter @oneshot/recovery-ui run verify` - PASS: format, lint, + typecheck, 50 tests, and Vite library build. +- Desktop and 390-pixel viewport browser inspection - PASS; no horizontal + overflow and all recovery panels/actions remain usable. +- `pnpm.cmd lint`, `pnpm.cmd typecheck`, `pnpm.cmd check:generated`, and + `pnpm.cmd validate:fixtures` - PASS. +- `pnpm.cmd test` - first run exposed the existing millisecond-sensitive C03 + replay test; immediate full rerun passed all 560 tests. +- `pnpm.cmd format:check` - baseline checkout limitation: Prettier reports 122 + untouched CRLF files. The focused recovery UI Prettier check passes. +- `npx.cmd --yes markdownlint-cli2@0.18.1` for the two new Markdown files - + PASS. +- Docker integration checks unavailable because Docker is not installed on this + Windows host; C05 adds no database/runtime integration path. + +## External-doc findings + +- npm registry metadata confirms React 19.2.8 and Vite 8-compatible + `@vitejs/plugin-react` 6.1.1. + +## Residual risks + +- Final app-shell composition remains owned by project Gate P5. +- Windows root formatting remains red on untouched CRLF files; changed-package + formatting is green. + +## Git and PR state + +- Branch: `milestone/c05-frontend-recovery`. +- Base: `origin/develop` at `25a17d86b56822a7e7440d34c331b740cb6d7f04`. +- Commit: uncommitted. +- PR: not created. +- CI: not applicable. + +## Review gates + +- Gate A: NOT RUN. +- Gate B: NOT RUN. + +## Handoff/next steps + +1. Implement and validate C05. +2. Run fresh FreePi Gate A with `free-pi/glm-5.3-flash`. +3. Push a draft PR, verify CI, and run fresh Gate B. +4. Begin C06 after Gate B passes. diff --git a/packages/recovery-ui/README.md b/packages/recovery-ui/README.md new file mode 100644 index 0000000..3359421 --- /dev/null +++ b/packages/recovery-ui/README.md @@ -0,0 +1,84 @@ +# OneShot Recovery UI + +`@oneshot/recovery-ui` is the independently composable C05 recovery timeline +and evidence-history slice. It renders only sanitized recovery data and has no +settlement submission capability. + +## Entry points + +- `RecoveryRoute`: fetches and paginates a recovery view through an injected + `RecoveryClient`. +- `RecoveryTimeline`: renders already-validated pages for later Gate P5 shell + composition. +- `createRecoveryClient`: browser client for the frozen mock/API boundary. +- `createInMemoryRecoveryClient`: deterministic component-test client. +- `handleRecoveryMockRequest`: request handler used by the Vite development + server. + +Run the standalone fixture viewer: + +```bash +pnpm --filter @oneshot/recovery-ui dev +``` + +Open `/?scenario=aged-unknown`. Any scenario exported by +`RECOVERY_SCENARIOS` may be selected. + +## Frozen mock boundary + +- Mock server version: `c05-mock-v1`. +- Response schema version: `recovery-timeline-v1`. +- Read endpoint: + `GET /mock/v1/intents/{businessIntentId}/recovery?scenario={scenario}&cursor={cursor}`. +- Safe action endpoints: `POST .../refresh` and `POST .../escalations`. +- No retry, payment, force-pay, signing, submission, or ownership endpoint + exists. + +The runtime parser rejects secret-shaped strings and forbidden raw-provider +fields before data reaches a component. Fixture values are synthetic and +contain no credentials or raw request/response bodies. + +## Fixture stories + +Fixtures cover all four advisor recommendations plus invalid output, fresh, +empty, lagging, unhealthy, unavailable, Graph-disabled fallback, +contradictory, pending, committed, failed-safe, and aged-`UNKNOWN` states. +Every fixture has two pages with one repeated observation to prove pagination +and duplicate collapse. Equal-clock events without a durable sequence are +visibly marked as order-ambiguous. + +## Copy glossary + +- **Authoritative OneShot state**: durable OneShot state. It controls whether a + terminal transition is allowed. +- **Authoritative Arc chain evidence**: independently verified receipt and + transfer-log proof bound to the intent. +- **Provider observation**: sanitized Privy status. It is evidence, not final + authority. +- **Candidate discovery**: The Graph result retrieved through Subgraph MCP. It + can find candidates but cannot authorize settlement. +- **LLM recommendation**: one of `WAIT`, `RECONCILE`, `ESCALATE`, or + `RETURN_EXISTING_RESULT`. It remains advisory. +- **Deterministic core disposition**: bounded command enforced by OneShot. +- **Not observed through block N**: no candidate appeared within the indexed + horizon. It never means a settlement did not occur. + +## Gate P5 composition + +Gate P5 should import `RecoveryRoute` or `RecoveryTimeline`, provide the real +frozen recovery API client, and import `@oneshot/recovery-ui/styles.css` inside +the A05-owned shell. +The shell must retain authority labels, the separate advisor/core panels, and +the two safe actions. Composition must not introduce a generic retry or payment +button. + +## Verification + +```bash +pnpm --filter @oneshot/recovery-ui verify +``` + +Tests cover contract redaction, every fixture story, Graph degradation, +pagination, duplicate observations, clock ambiguity, keyboard activation, +responsive breakpoints, React escaping of malicious evidence strings, and +automated accessibility checks. diff --git a/packages/recovery-ui/index.html b/packages/recovery-ui/index.html new file mode 100644 index 0000000..710cdb4 --- /dev/null +++ b/packages/recovery-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + OneShot recovery evidence + + +
+ + + diff --git a/packages/recovery-ui/package.json b/packages/recovery-ui/package.json new file mode 100644 index 0000000..be585ec --- /dev/null +++ b/packages/recovery-ui/package.json @@ -0,0 +1,51 @@ +{ + "name": "@oneshot/recovery-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Composable recovery timeline and evidence history UI for OneShot.", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/recovery-ui.js" + }, + "./styles.css": "./dist/recovery-ui.css" + }, + "sideEffects": [ + "**/*.css" + ], + "files": [ + "dist", + "schemas", + "README.md" + ], + "scripts": { + "build": "tsc -b && vite build", + "clean": "tsc -b --clean", + "dev": "vite", + "format": "prettier --check --ignore-path ../../.prettierignore \"**/*.{ts,tsx,json,css,html,md}\"", + "format:write": "prettier --write --ignore-path ../../.prettierignore \"**/*.{ts,tsx,json,css,html,md}\"", + "lint": "eslint src test vite.config.ts", + "test": "vitest run", + "typecheck": "tsc -b --pretty false", + "verify": "pnpm run format && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", + "@types/node": "24.13.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7", + "@vitejs/plugin-react": "6.1.1", + "axe-core": "4.13.0", + "jsdom": "30.0.1", + "typescript": "6.0.3", + "vite": "8.0.0", + "vitest": "5.0.0" + } +} diff --git a/packages/recovery-ui/schemas/recovery-timeline-v1.schema.json b/packages/recovery-ui/schemas/recovery-timeline-v1.schema.json new file mode 100644 index 0000000..4ff6808 --- /dev/null +++ b/packages/recovery-ui/schemas/recovery-timeline-v1.schema.json @@ -0,0 +1,262 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oneshot.invalid/schemas/recovery-timeline-v1.schema.json", + "title": "OneShot recovery timeline UI contract v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "mockServerVersion", + "businessIntentId", + "authoritativeState", + "stateVersion", + "settlementPermission", + "evaluatedAt", + "summary", + "attempts", + "timeline", + "evidence", + "graph", + "recommendation", + "coreDisposition", + "contradiction", + "contradictionCodes", + "diagnostics", + "page" + ], + "properties": { + "schemaVersion": { "const": "recovery-timeline-v1" }, + "mockServerVersion": { "const": "c05-mock-v1" }, + "businessIntentId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "authoritativeState": { + "enum": ["SUBMITTING", "UNKNOWN", "COMMITTED", "FAILED_SAFE"] + }, + "stateVersion": { "$ref": "#/$defs/atomicString" }, + "settlementPermission": { "const": "NEVER" }, + "evaluatedAt": { "$ref": "#/$defs/dateTime" }, + "summary": { "$ref": "#/$defs/safeText" }, + "attempts": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/attempt" } + }, + "timeline": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/timelineEntry" } + }, + "evidence": { + "type": "array", + "maxItems": 100, + "items": { "$ref": "#/$defs/evidence" } + }, + "graph": { + "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/graph" }] + }, + "recommendation": { "$ref": "#/$defs/recommendation" }, + "coreDisposition": { "$ref": "#/$defs/coreDisposition" }, + "contradiction": { "type": "boolean" }, + "contradictionCodes": { "$ref": "#/$defs/stringList" }, + "diagnostics": { "$ref": "#/$defs/stringList" }, + "page": { + "type": "object", + "additionalProperties": false, + "required": ["cursor", "nextCursor", "totalEntries"], + "properties": { + "cursor": { "type": ["string", "null"], "maxLength": 128 }, + "nextCursor": { "type": ["string", "null"], "maxLength": 128 }, + "totalEntries": { "type": "integer", "minimum": 0 } + } + } + }, + "$defs": { + "safeText": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "dateTime": { "type": "string", "format": "date-time", "maxLength": 35 }, + "atomicString": { "type": "string", "pattern": "^(0|[1-9][0-9]*)$" }, + "stringList": { + "type": "array", + "maxItems": 100, + "items": { "type": "string", "minLength": 1, "maxLength": 128 } + }, + "source": { "enum": ["ONESHOT", "PRIVY", "ARC", "THE_GRAPH", "LLM"] }, + "authority": { + "enum": [ + "AUTHORITATIVE_ONESHOT", + "AUTHORITATIVE_CHAIN_EVIDENCE", + "PROVIDER_OBSERVATION", + "NON_AUTHORITATIVE_CANDIDATE_DISCOVERY", + "ADVISORY_AGENT_OBSERVATION" + ] + }, + "attempt": { + "type": "object", + "additionalProperties": false, + "required": ["attemptId", "stage", "createdAt", "completedAt", "sanitizedError"], + "properties": { + "attemptId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "stage": { "type": "string", "minLength": 1, "maxLength": 64 }, + "createdAt": { "$ref": "#/$defs/dateTime" }, + "completedAt": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dateTime" }] }, + "sanitizedError": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/safeText" }] } + } + }, + "timelineEntry": { + "type": "object", + "additionalProperties": false, + "required": [ + "eventId", + "kind", + "timestamp", + "sequence", + "title", + "summary", + "source", + "authorityClass", + "evidenceReferences" + ], + "properties": { + "eventId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "kind": { "enum": ["ATTEMPT", "TRANSITION", "EVIDENCE", "RECONCILIATION", "DECISION"] }, + "timestamp": { "$ref": "#/$defs/dateTime" }, + "sequence": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] }, + "title": { "$ref": "#/$defs/safeText" }, + "summary": { "$ref": "#/$defs/safeText" }, + "source": { "$ref": "#/$defs/source" }, + "authorityClass": { "$ref": "#/$defs/authority" }, + "evidenceReferences": { "$ref": "#/$defs/stringList" } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "evidenceId", + "source", + "authorityClass", + "retrievedAt", + "finality", + "blockNumber", + "digest", + "summary", + "verifiedBinding", + "contradictionCodes" + ], + "properties": { + "evidenceId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "source": { "$ref": "#/$defs/source" }, + "authorityClass": { "$ref": "#/$defs/authority" }, + "retrievedAt": { "$ref": "#/$defs/dateTime" }, + "finality": { "type": ["string", "null"], "enum": ["FINAL", "PENDING", "UNKNOWN", null] }, + "blockNumber": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] }, + "digest": { "type": "string", "minLength": 1, "maxLength": 128 }, + "summary": { "$ref": "#/$defs/safeText" }, + "verifiedBinding": { "type": "boolean" }, + "contradictionCodes": { "$ref": "#/$defs/stringList" } + } + }, + "candidate": { + "type": "object", + "additionalProperties": false, + "required": [ + "candidateId", + "transactionHash", + "blockNumber", + "bindingStatus", + "contradictionCodes" + ], + "properties": { + "candidateId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "transactionHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" }, + "blockNumber": { "$ref": "#/$defs/atomicString" }, + "bindingStatus": { "enum": ["MATCH", "CONTRADICTORY"] }, + "contradictionCodes": { "$ref": "#/$defs/stringList" } + } + }, + "graph": { + "type": "object", + "additionalProperties": false, + "required": [ + "retrievalPath", + "serverName", + "serverVersion", + "toolName", + "deploymentId", + "manifestCid", + "observedThroughBlock", + "observedThroughTime", + "chainHeadBlock", + "lagBlocks", + "health", + "available", + "candidateCount", + "diagnostics", + "candidates" + ], + "properties": { + "retrievalPath": { "const": "SUBGRAPH_MCP" }, + "serverName": { "type": "string", "minLength": 1, "maxLength": 128 }, + "serverVersion": { "type": "string", "minLength": 1, "maxLength": 64 }, + "toolName": { "type": "string", "minLength": 1, "maxLength": 128 }, + "deploymentId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "manifestCid": { "type": "string", "minLength": 1, "maxLength": 128 }, + "observedThroughBlock": { + "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] + }, + "observedThroughTime": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dateTime" }] }, + "chainHeadBlock": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] }, + "lagBlocks": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/atomicString" }] }, + "health": { "enum": ["FRESH", "LAGGING", "UNHEALTHY", "UNAVAILABLE", "UNKNOWN_FRESHNESS"] }, + "available": { "type": "boolean" }, + "candidateCount": { "type": "integer", "minimum": 0, "maximum": 25 }, + "diagnostics": { "$ref": "#/$defs/stringList" }, + "candidates": { "type": "array", "maxItems": 25, "items": { "$ref": "#/$defs/candidate" } } + } + }, + "recommendation": { + "type": "object", + "additionalProperties": false, + "required": [ + "accepted", + "action", + "reason", + "modelName", + "promptVersion", + "evidenceReferences" + ], + "properties": { + "accepted": { "type": "boolean" }, + "action": { "enum": ["WAIT", "RECONCILE", "ESCALATE", "RETURN_EXISTING_RESULT"] }, + "reason": { "$ref": "#/$defs/safeText" }, + "modelName": { "type": "string", "minLength": 1, "maxLength": 128 }, + "promptVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, + "evidenceReferences": { "$ref": "#/$defs/stringList" } + } + }, + "coreDisposition": { + "type": "object", + "additionalProperties": false, + "required": [ + "commandType", + "targetState", + "reason", + "authoritativeProofPresent", + "evidenceReferences" + ], + "properties": { + "commandType": { + "enum": [ + "HOLD_UNKNOWN", + "READ_ONLY_LOOKUP", + "ESCALATE_UNKNOWN", + "MARK_COMMITTED", + "MARK_FAILED_SAFE" + ] + }, + "targetState": { "enum": ["UNKNOWN", "COMMITTED", "FAILED_SAFE"] }, + "reason": { "$ref": "#/$defs/safeText" }, + "authoritativeProofPresent": { "type": "boolean" }, + "evidenceReferences": { "$ref": "#/$defs/stringList" } + } + } + } +} diff --git a/packages/recovery-ui/src/RecoveryRoute.tsx b/packages/recovery-ui/src/RecoveryRoute.tsx new file mode 100644 index 0000000..5ac4dc1 --- /dev/null +++ b/packages/recovery-ui/src/RecoveryRoute.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; + +import type { RecoveryActionReceipt, RecoveryTimelinePage } from './contract.js'; +import type { RecoveryClient } from './mock-server.js'; +import { RecoveryTimeline } from './RecoveryTimeline.js'; + +export interface RecoveryRouteProps { + readonly businessIntentId: string; + readonly client: RecoveryClient; +} + +export function RecoveryRoute({ businessIntentId, client }: RecoveryRouteProps) { + const [pages, setPages] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + void client + .readPage(businessIntentId, null) + .then((page) => { + if (active) setPages([page]); + }) + .catch(() => { + if (active) setError('Recovery evidence is unavailable. Authoritative state is unchanged.'); + }); + return () => { + active = false; + }; + }, [businessIntentId, client]); + + if (error !== null) { + return ( +
+

ONESHOT / RECOVERY CONTROL

+

Evidence unavailable

+

{error}

+
+ ); + } + if (pages.length === 0) { + return ( +
+

ONESHOT / RECOVERY CONTROL

+

Loading recovery evidence…

+
+ ); + } + + const nextCursor = pages.at(-1)?.page.nextCursor ?? null; + + async function refresh(): Promise { + const receipt = await client.refresh(businessIntentId); + const page = await client.readPage(businessIntentId, null); + setPages([page]); + return receipt; + } + + async function loadMore(): Promise { + if (nextCursor === null) return; + const page = await client.readPage(businessIntentId, nextCursor); + setPages((current) => [...current, page]); + } + + return ( + client.escalate(businessIntentId)} + onLoadMore={nextCursor === null ? null : loadMore} + /> + ); +} diff --git a/packages/recovery-ui/src/RecoveryTimeline.tsx b/packages/recovery-ui/src/RecoveryTimeline.tsx new file mode 100644 index 0000000..7efa42b --- /dev/null +++ b/packages/recovery-ui/src/RecoveryTimeline.tsx @@ -0,0 +1,395 @@ +import { useMemo, useState } from 'react'; + +import type { + EvidenceSummary, + GraphObservationSummary, + RecoveryActionReceipt, + RecoveryTimelinePage, +} from './contract.js'; +import { authorityLabel, formatUtc, mergeTimelinePages } from './timeline.js'; + +export interface RecoveryTimelineProps { + readonly pages: readonly RecoveryTimelinePage[]; + readonly onRefresh: () => Promise; + readonly onEscalate: () => Promise; + readonly onLoadMore: (() => Promise) | null; +} + +const SOURCE_LABELS: Readonly> = { + ONESHOT: 'OneShot', + PRIVY: 'Privy', + ARC: 'Arc', + THE_GRAPH: 'The Graph', + LLM: 'Recovery agent', +}; + +function stateTone(state: RecoveryTimelinePage['authoritativeState']): string { + if (state === 'COMMITTED') return 'success'; + if (state === 'FAILED_SAFE') return 'neutral'; + return 'warning'; +} + +function shortHash(value: string): string { + return value.length <= 18 ? value : `${value.slice(0, 10)}…${value.slice(-8)}`; +} + +function EvidenceCard({ evidence }: { readonly evidence: EvidenceSummary }) { + return ( +
+
+ +
+

{SOURCE_LABELS[evidence.source]}

+

{authorityLabel(evidence.authorityClass)}

+
+ + {evidence.verifiedBinding ? 'Binding verified' : 'Binding unverified'} + +
+

{evidence.summary}

+
+
+
Retrieved
+
{formatUtc(evidence.retrievedAt)}
+
+ {evidence.blockNumber !== null && ( +
+
Block
+
{evidence.blockNumber}
+
+ )} + {evidence.finality !== null && ( +
+
Finality
+
{evidence.finality}
+
+ )} +
+
Digest
+
{shortHash(evidence.digest)}
+
+
+ {evidence.contradictionCodes.length > 0 && ( +

+ Contradiction: {evidence.contradictionCodes.join(', ')} +

+ )} +
+ ); +} + +function observationCopy(graph: GraphObservationSummary): string { + if (!graph.available || graph.health === 'UNAVAILABLE') return 'Subgraph MCP unavailable.'; + if (graph.observedThroughBlock === null) return 'Observation height unavailable.'; + if (graph.candidateCount === 0) { + return `Not observed through block ${graph.observedThroughBlock}. This is not settlement evidence.`; + } + return `${graph.candidateCount} candidate${graph.candidateCount === 1 ? '' : 's'} observed through block ${graph.observedThroughBlock}. Arc verification is still required.`; +} + +function GraphPanel({ graph }: { readonly graph: GraphObservationSummary }) { + return ( +
+
+
+

Non-authoritative candidate discovery

+

Subgraph MCP

+
+ {graph.health} +
+

{observationCopy(graph)}

+
+
+
Retrieval path
+
{graph.retrievalPath}
+
+
+
Tool
+
{graph.toolName}
+
+
+
Server
+
+ {graph.serverName} · {graph.serverVersion} +
+
+
+
Deployment
+
{shortHash(graph.deploymentId)}
+
+
+
Observed through
+
{graph.observedThroughBlock ?? 'Unavailable'}
+
+
+
Chain head / lag
+
+ {graph.chainHeadBlock ?? 'Unknown'} / {graph.lagBlocks ?? 'Unknown'} blocks +
+
+
+ {graph.diagnostics.length > 0 && ( +
    + {graph.diagnostics.map((diagnostic) => ( +
  • {diagnostic}
  • + ))} +
+ )} + {graph.candidates.length > 0 && ( +
+ {graph.candidates.map((candidate) => ( +
+
+

Candidate · Block {candidate.blockNumber}

+

{shortHash(candidate.transactionHash)}

+
+ + {candidate.bindingStatus} + + {candidate.contradictionCodes.length > 0 && ( +

+ {candidate.contradictionCodes.join(', ')} +

+ )} +
+ ))} +
+ )} +
+ ); +} + +export function RecoveryTimeline({ + pages, + onRefresh, + onEscalate, + onLoadMore, +}: RecoveryTimelineProps) { + const current = pages[0]; + const timeline = useMemo(() => mergeTimelinePages(pages), [pages]); + const [pendingAction, setPendingAction] = useState<'refresh' | 'escalate' | 'more' | null>(null); + const [actionMessage, setActionMessage] = useState(''); + + if (!current) return null; + + async function runAction( + action: 'refresh' | 'escalate', + callback: () => Promise, + ): Promise { + setPendingAction(action); + try { + const receipt = await callback(); + setActionMessage(receipt.message); + } catch { + setActionMessage('Safe action failed. Authoritative state was not changed.'); + } finally { + setPendingAction(null); + } + } + + async function loadMore(): Promise { + if (onLoadMore === null) return; + setPendingAction('more'); + try { + await onLoadMore(); + } finally { + setPendingAction(null); + } + } + + return ( +
+
+
+

ONESHOT / RECOVERY CONTROL

+

Evidence before action.

+

One job. Many retries. One settlement.

+
+
+
+
Business intent
+
{current.businessIntentId}
+
+
+
State version
+
{current.stateVersion}
+
+
+
+ +
+
+

Authoritative OneShot state

+

{current.authoritativeState}

+

{current.summary}

+
+
+ + New settlement blocked + Settlement permission: {current.settlementPermission} +
+
+ + {current.contradiction && ( +
+ Contradictory evidence. {current.contradictionCodes.join(', ')}. Core + remains authoritative and blocks a new settlement. +
+ )} + +
+ + +

Read and escalation only. No payment action is available.

+

+ {actionMessage} +

+
+ +
+
+
+
+

Durable history

+

Recovery timeline

+
+ {current.page.totalEntries} events +
+
    + {timeline.map((entry) => ( +
  1. +
  2. + ))} +
+ {onLoadMore !== null && ( + + )} +
+ +
+
+
+
+

Execution history

+

Attempts

+
+ {current.attempts.length} +
+
    + {current.attempts.map((attempt) => ( +
  • +
    + {attempt.attemptId} + {formatUtc(attempt.createdAt)} +
    + {attempt.stage} + {attempt.sanitizedError !== null &&

    {attempt.sanitizedError}

    } +
  • + ))} +
+
+ +
+
+
+

Advice and authority stay separate

+

Decision split

+
+
+
+
+ LLM recommendation + {current.recommendation.action} +

{current.recommendation.reason}

+ + {current.recommendation.accepted ? 'Boundary accepted' : 'Boundary rejected'} ·{' '} + {current.recommendation.modelName} + +
+
+ Deterministic core + {current.coreDisposition.commandType} +

{current.coreDisposition.reason}

+ + Target: {current.coreDisposition.targetState} · Proof:{' '} + {current.coreDisposition.authoritativeProofPresent ? 'present' : 'absent'} + +
+
+
+
+
+ + {current.graph !== null && } + +
+
+
+

Sanitized provenance

+

Evidence history

+
+ {current.evidence.length} records +
+
+ {current.evidence.map((evidence) => ( + + ))} +
+
+ + {current.diagnostics.length > 0 && ( +
+

Safe diagnostics

+
    + {current.diagnostics.map((diagnostic) => ( +
  • {diagnostic}
  • + ))} +
+
+ )} +
+ ); +} diff --git a/packages/recovery-ui/src/contract.ts b/packages/recovery-ui/src/contract.ts new file mode 100644 index 0000000..d1ea910 --- /dev/null +++ b/packages/recovery-ui/src/contract.ts @@ -0,0 +1,414 @@ +export const RECOVERY_TIMELINE_VERSION = 'recovery-timeline-v1' as const; +export const RECOVERY_MOCK_SERVER_VERSION = 'c05-mock-v1' as const; + +export type RecoveryState = 'SUBMITTING' | 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE'; +export type RecoveryAction = 'WAIT' | 'RECONCILE' | 'ESCALATE' | 'RETURN_EXISTING_RESULT'; +export type CoreDisposition = + 'HOLD_UNKNOWN' | 'READ_ONLY_LOOKUP' | 'ESCALATE_UNKNOWN' | 'MARK_COMMITTED' | 'MARK_FAILED_SAFE'; +export type EvidenceSource = 'ONESHOT' | 'PRIVY' | 'ARC' | 'THE_GRAPH' | 'LLM'; +export type AuthorityClass = + | 'AUTHORITATIVE_ONESHOT' + | 'AUTHORITATIVE_CHAIN_EVIDENCE' + | 'PROVIDER_OBSERVATION' + | 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY' + | 'ADVISORY_AGENT_OBSERVATION'; +export type IndexHealth = 'FRESH' | 'LAGGING' | 'UNHEALTHY' | 'UNAVAILABLE' | 'UNKNOWN_FRESHNESS'; +export type TimelineKind = 'ATTEMPT' | 'TRANSITION' | 'EVIDENCE' | 'RECONCILIATION' | 'DECISION'; + +export interface AttemptSummary { + readonly attemptId: string; + readonly stage: string; + readonly createdAt: string; + readonly completedAt: string | null; + readonly sanitizedError: string | null; +} + +export interface TimelineEntry { + readonly eventId: string; + readonly kind: TimelineKind; + readonly timestamp: string; + readonly sequence: string | null; + readonly title: string; + readonly summary: string; + readonly source: EvidenceSource; + readonly authorityClass: AuthorityClass; + readonly evidenceReferences: readonly string[]; +} + +export interface EvidenceSummary { + readonly evidenceId: string; + readonly source: EvidenceSource; + readonly authorityClass: AuthorityClass; + readonly retrievedAt: string; + readonly finality: 'FINAL' | 'PENDING' | 'UNKNOWN' | null; + readonly blockNumber: string | null; + readonly digest: string; + readonly summary: string; + readonly verifiedBinding: boolean; + readonly contradictionCodes: readonly string[]; +} + +export interface IndexedCandidateSummary { + readonly candidateId: string; + readonly transactionHash: string; + readonly blockNumber: string; + readonly bindingStatus: 'MATCH' | 'CONTRADICTORY'; + readonly contradictionCodes: readonly string[]; +} + +export interface GraphObservationSummary { + readonly retrievalPath: 'SUBGRAPH_MCP'; + readonly serverName: string; + readonly serverVersion: string; + readonly toolName: string; + readonly deploymentId: string; + readonly manifestCid: string; + readonly observedThroughBlock: string | null; + readonly observedThroughTime: string | null; + readonly chainHeadBlock: string | null; + readonly lagBlocks: string | null; + readonly health: IndexHealth; + readonly available: boolean; + readonly candidateCount: number; + readonly diagnostics: readonly string[]; + readonly candidates: readonly IndexedCandidateSummary[]; +} + +export interface RecommendationSummary { + readonly accepted: boolean; + readonly action: RecoveryAction; + readonly reason: string; + readonly modelName: string; + readonly promptVersion: string; + readonly evidenceReferences: readonly string[]; +} + +export interface CoreDispositionSummary { + readonly commandType: CoreDisposition; + readonly targetState: 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE'; + readonly reason: string; + readonly authoritativeProofPresent: boolean; + readonly evidenceReferences: readonly string[]; +} + +export interface RecoveryTimelinePage { + readonly schemaVersion: typeof RECOVERY_TIMELINE_VERSION; + readonly mockServerVersion: typeof RECOVERY_MOCK_SERVER_VERSION; + readonly businessIntentId: string; + readonly authoritativeState: RecoveryState; + readonly stateVersion: string; + readonly settlementPermission: 'NEVER'; + readonly evaluatedAt: string; + readonly summary: string; + readonly attempts: readonly AttemptSummary[]; + readonly timeline: readonly TimelineEntry[]; + readonly evidence: readonly EvidenceSummary[]; + readonly graph: GraphObservationSummary | null; + readonly recommendation: RecommendationSummary; + readonly coreDisposition: CoreDispositionSummary; + readonly contradiction: boolean; + readonly contradictionCodes: readonly string[]; + readonly diagnostics: readonly string[]; + readonly page: { + readonly cursor: string | null; + readonly nextCursor: string | null; + readonly totalEntries: number; + }; +} + +export interface RecoveryActionReceipt { + readonly schemaVersion: 'recovery-action-receipt-v1'; + readonly businessIntentId: string; + readonly action: 'REFRESH_STATUS' | 'ESCALATE'; + readonly accepted: true; + readonly message: string; +} + +const FORBIDDEN_KEYS = new Set([ + 'accesstoken', + 'apikey', + 'authorization', + 'credential', + 'credentials', + 'headers', + 'privatekey', + 'providerbody', + 'rawbody', + 'rawproviderbody', + 'requestbody', + 'responsebody', + 'secret', + 'seedphrase', +]); + +const SECRET_TEXT = + /(?:bearer\s+[a-z0-9._~-]+|(?:api[_-]?key|access[_-]?token|private[_-]?key|seed[_-]?phrase)\s*[:=]\s*[^\s,;]+)/iu; + +function normalizedKey(key: string): string { + return key.replace(/[^a-z0-9]/giu, '').toLowerCase(); +} + +function assertSanitized(value: unknown, path = '$', seen = new Set()): void { + if (typeof value === 'string') { + if (value.length > 1_000 || SECRET_TEXT.test(value)) { + throw new Error(`Unsafe recovery UI text at ${path}`); + } + return; + } + if (value === null || typeof value !== 'object') return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((item, index) => assertSanitized(item, `${path}[${index}]`, seen)); + return; + } + Object.entries(value as Record).forEach(([key, child]) => { + if (FORBIDDEN_KEYS.has(normalizedKey(key))) { + throw new Error(`Forbidden recovery UI field at ${path}.${key}`); + } + assertSanitized(child, `${path}.${key}`, seen); + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireRecord(value: unknown, path: string): Record { + if (!isRecord(value)) throw new Error(`Expected object at ${path}`); + return value; +} + +function requireString(value: unknown, path: string): string { + if (typeof value !== 'string' || value.length === 0) + throw new Error(`Expected string at ${path}`); + return value; +} + +function requireNullableString(value: unknown, path: string): string | null { + return value === null ? null : requireString(value, path); +} + +function requireBoolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') throw new Error(`Expected boolean at ${path}`); + return value; +} + +function requireNumber(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Expected non-negative integer at ${path}`); + } + return value; +} + +function requireDate(value: unknown, path: string): string { + const date = requireString(value, path); + if (!Number.isFinite(Date.parse(date))) throw new Error(`Expected date-time at ${path}`); + return date; +} + +function requireEnum(value: unknown, allowed: readonly T[], path: string): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new Error(`Unexpected value at ${path}`); + } + return value as T; +} + +function requireStringArray(value: unknown, path: string): readonly string[] { + if (!Array.isArray(value)) throw new Error(`Expected array at ${path}`); + return value.map((item, index) => requireString(item, `${path}[${index}]`)); +} + +const RECOVERY_STATES = ['SUBMITTING', 'UNKNOWN', 'COMMITTED', 'FAILED_SAFE'] as const; +const ACTIONS = ['WAIT', 'RECONCILE', 'ESCALATE', 'RETURN_EXISTING_RESULT'] as const; +const DISPOSITIONS = [ + 'HOLD_UNKNOWN', + 'READ_ONLY_LOOKUP', + 'ESCALATE_UNKNOWN', + 'MARK_COMMITTED', + 'MARK_FAILED_SAFE', +] as const; +const SOURCES = ['ONESHOT', 'PRIVY', 'ARC', 'THE_GRAPH', 'LLM'] as const; +const AUTHORITIES = [ + 'AUTHORITATIVE_ONESHOT', + 'AUTHORITATIVE_CHAIN_EVIDENCE', + 'PROVIDER_OBSERVATION', + 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY', + 'ADVISORY_AGENT_OBSERVATION', +] as const; +const HEALTH = ['FRESH', 'LAGGING', 'UNHEALTHY', 'UNAVAILABLE', 'UNKNOWN_FRESHNESS'] as const; +const KINDS = ['ATTEMPT', 'TRANSITION', 'EVIDENCE', 'RECONCILIATION', 'DECISION'] as const; + +function parseAttempt(value: unknown, path: string): AttemptSummary { + const item = requireRecord(value, path); + return { + attemptId: requireString(item.attemptId, `${path}.attemptId`), + stage: requireString(item.stage, `${path}.stage`), + createdAt: requireDate(item.createdAt, `${path}.createdAt`), + completedAt: requireNullableString(item.completedAt, `${path}.completedAt`), + sanitizedError: requireNullableString(item.sanitizedError, `${path}.sanitizedError`), + }; +} + +function parseTimeline(value: unknown, path: string): TimelineEntry { + const item = requireRecord(value, path); + return { + eventId: requireString(item.eventId, `${path}.eventId`), + kind: requireEnum(item.kind, KINDS, `${path}.kind`), + timestamp: requireDate(item.timestamp, `${path}.timestamp`), + sequence: requireNullableString(item.sequence, `${path}.sequence`), + title: requireString(item.title, `${path}.title`), + summary: requireString(item.summary, `${path}.summary`), + source: requireEnum(item.source, SOURCES, `${path}.source`), + authorityClass: requireEnum(item.authorityClass, AUTHORITIES, `${path}.authorityClass`), + evidenceReferences: requireStringArray(item.evidenceReferences, `${path}.evidenceReferences`), + }; +} + +function parseEvidence(value: unknown, path: string): EvidenceSummary { + const item = requireRecord(value, path); + return { + evidenceId: requireString(item.evidenceId, `${path}.evidenceId`), + source: requireEnum(item.source, SOURCES, `${path}.source`), + authorityClass: requireEnum(item.authorityClass, AUTHORITIES, `${path}.authorityClass`), + retrievedAt: requireDate(item.retrievedAt, `${path}.retrievedAt`), + finality: + item.finality === null + ? null + : requireEnum(item.finality, ['FINAL', 'PENDING', 'UNKNOWN'] as const, `${path}.finality`), + blockNumber: requireNullableString(item.blockNumber, `${path}.blockNumber`), + digest: requireString(item.digest, `${path}.digest`), + summary: requireString(item.summary, `${path}.summary`), + verifiedBinding: requireBoolean(item.verifiedBinding, `${path}.verifiedBinding`), + contradictionCodes: requireStringArray(item.contradictionCodes, `${path}.contradictionCodes`), + }; +} + +function parseCandidate(value: unknown, path: string): IndexedCandidateSummary { + const item = requireRecord(value, path); + return { + candidateId: requireString(item.candidateId, `${path}.candidateId`), + transactionHash: requireString(item.transactionHash, `${path}.transactionHash`), + blockNumber: requireString(item.blockNumber, `${path}.blockNumber`), + bindingStatus: requireEnum( + item.bindingStatus, + ['MATCH', 'CONTRADICTORY'], + `${path}.bindingStatus`, + ), + contradictionCodes: requireStringArray(item.contradictionCodes, `${path}.contradictionCodes`), + }; +} + +function parseGraph(value: unknown, path: string): GraphObservationSummary | null { + if (value === null) return null; + const item = requireRecord(value, path); + if (!Array.isArray(item.candidates) || !Array.isArray(item.diagnostics)) { + throw new Error(`Expected Graph arrays at ${path}`); + } + return { + retrievalPath: requireEnum(item.retrievalPath, ['SUBGRAPH_MCP'], `${path}.retrievalPath`), + serverName: requireString(item.serverName, `${path}.serverName`), + serverVersion: requireString(item.serverVersion, `${path}.serverVersion`), + toolName: requireString(item.toolName, `${path}.toolName`), + deploymentId: requireString(item.deploymentId, `${path}.deploymentId`), + manifestCid: requireString(item.manifestCid, `${path}.manifestCid`), + observedThroughBlock: requireNullableString( + item.observedThroughBlock, + `${path}.observedThroughBlock`, + ), + observedThroughTime: requireNullableString( + item.observedThroughTime, + `${path}.observedThroughTime`, + ), + chainHeadBlock: requireNullableString(item.chainHeadBlock, `${path}.chainHeadBlock`), + lagBlocks: requireNullableString(item.lagBlocks, `${path}.lagBlocks`), + health: requireEnum(item.health, HEALTH, `${path}.health`), + available: requireBoolean(item.available, `${path}.available`), + candidateCount: requireNumber(item.candidateCount, `${path}.candidateCount`), + diagnostics: requireStringArray(item.diagnostics, `${path}.diagnostics`), + candidates: item.candidates.map((candidate, index) => + parseCandidate(candidate, `${path}.candidates[${index}]`), + ), + }; +} + +export function parseRecoveryTimelinePage(value: unknown): RecoveryTimelinePage { + assertSanitized(value); + const input = requireRecord(value, '$'); + if ( + !Array.isArray(input.attempts) || + !Array.isArray(input.timeline) || + !Array.isArray(input.evidence) + ) { + throw new Error('Recovery timeline arrays are required'); + } + const recommendation = requireRecord(input.recommendation, '$.recommendation'); + const core = requireRecord(input.coreDisposition, '$.coreDisposition'); + const page = requireRecord(input.page, '$.page'); + const parsed: RecoveryTimelinePage = { + schemaVersion: requireEnum(input.schemaVersion, [RECOVERY_TIMELINE_VERSION], '$.schemaVersion'), + mockServerVersion: requireEnum( + input.mockServerVersion, + [RECOVERY_MOCK_SERVER_VERSION], + '$.mockServerVersion', + ), + businessIntentId: requireString(input.businessIntentId, '$.businessIntentId'), + authoritativeState: requireEnum( + input.authoritativeState, + RECOVERY_STATES, + '$.authoritativeState', + ), + stateVersion: requireString(input.stateVersion, '$.stateVersion'), + settlementPermission: requireEnum( + input.settlementPermission, + ['NEVER'], + '$.settlementPermission', + ), + evaluatedAt: requireDate(input.evaluatedAt, '$.evaluatedAt'), + summary: requireString(input.summary, '$.summary'), + attempts: input.attempts.map((attempt, index) => parseAttempt(attempt, `$.attempts[${index}]`)), + timeline: input.timeline.map((event, index) => parseTimeline(event, `$.timeline[${index}]`)), + evidence: input.evidence.map((item, index) => parseEvidence(item, `$.evidence[${index}]`)), + graph: parseGraph(input.graph, '$.graph'), + recommendation: { + accepted: requireBoolean(recommendation.accepted, '$.recommendation.accepted'), + action: requireEnum(recommendation.action, ACTIONS, '$.recommendation.action'), + reason: requireString(recommendation.reason, '$.recommendation.reason'), + modelName: requireString(recommendation.modelName, '$.recommendation.modelName'), + promptVersion: requireString(recommendation.promptVersion, '$.recommendation.promptVersion'), + evidenceReferences: requireStringArray( + recommendation.evidenceReferences, + '$.recommendation.evidenceReferences', + ), + }, + coreDisposition: { + commandType: requireEnum(core.commandType, DISPOSITIONS, '$.coreDisposition.commandType'), + targetState: requireEnum( + core.targetState, + ['UNKNOWN', 'COMMITTED', 'FAILED_SAFE'], + '$.coreDisposition.targetState', + ), + reason: requireString(core.reason, '$.coreDisposition.reason'), + authoritativeProofPresent: requireBoolean( + core.authoritativeProofPresent, + '$.coreDisposition.authoritativeProofPresent', + ), + evidenceReferences: requireStringArray( + core.evidenceReferences, + '$.coreDisposition.evidenceReferences', + ), + }, + contradiction: requireBoolean(input.contradiction, '$.contradiction'), + contradictionCodes: requireStringArray(input.contradictionCodes, '$.contradictionCodes'), + diagnostics: requireStringArray(input.diagnostics, '$.diagnostics'), + page: { + cursor: requireNullableString(page.cursor, '$.page.cursor'), + nextCursor: requireNullableString(page.nextCursor, '$.page.nextCursor'), + totalEntries: requireNumber(page.totalEntries, '$.page.totalEntries'), + }, + }; + if (parsed.graph !== null && parsed.graph.candidateCount !== parsed.graph.candidates.length) { + throw new Error('Graph candidate count mismatch'); + } + return parsed; +} diff --git a/packages/recovery-ui/src/fixtures.ts b/packages/recovery-ui/src/fixtures.ts new file mode 100644 index 0000000..3f7a897 --- /dev/null +++ b/packages/recovery-ui/src/fixtures.ts @@ -0,0 +1,347 @@ +import { + RECOVERY_MOCK_SERVER_VERSION, + RECOVERY_TIMELINE_VERSION, + type CoreDisposition, + type GraphObservationSummary, + type IndexHealth, + type RecoveryAction, + type RecoveryState, + type RecoveryTimelinePage, +} from './contract.js'; + +export const RECOVERY_SCENARIOS = [ + 'fresh-wait', + 'fresh-reconcile', + 'fresh-escalate', + 'committed-return-existing', + 'invalid-output', + 'empty', + 'lagging', + 'unhealthy', + 'unavailable', + 'fallback-disabled', + 'contradictory', + 'pending', + 'committed', + 'failed-safe', + 'aged-unknown', +] as const; + +export type RecoveryScenario = (typeof RECOVERY_SCENARIOS)[number]; + +export function isRecoveryScenario(value: string | null): value is RecoveryScenario { + return value !== null && RECOVERY_SCENARIOS.some((scenario) => scenario === value); +} + +const INTENT_ID = 'intent_demo_018f'; +const EVALUATED_AT = '2026-09-08T06:00:30.000Z'; +const TX_HASH = `0x${'a'.repeat(64)}`; + +interface ScenarioOptions { + readonly state?: RecoveryState; + readonly action?: RecoveryAction; + readonly accepted?: boolean; + readonly commandType?: CoreDisposition; + readonly targetState?: 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE'; + readonly proof?: boolean; + readonly graphHealth?: IndexHealth; + readonly graphAvailable?: boolean; + readonly observedThroughBlock?: string | null; + readonly lagBlocks?: string | null; + readonly candidateMode?: 'MATCH' | 'EMPTY' | 'CONTRADICTORY'; + readonly graphHidden?: boolean; + readonly contradiction?: boolean; + readonly diagnostics?: readonly string[]; + readonly aged?: boolean; +} + +function graphFixture(options: ScenarioOptions): GraphObservationSummary | null { + if (options.graphHidden) return null; + const mode = options.candidateMode ?? 'MATCH'; + const candidates = + mode === 'EMPTY' + ? [] + : [ + { + candidateId: 'candidate_arc_701', + transactionHash: TX_HASH, + blockNumber: '701', + bindingStatus: + mode === 'CONTRADICTORY' ? ('CONTRADICTORY' as const) : ('MATCH' as const), + contradictionCodes: + mode === 'CONTRADICTORY' ? (['RECIPIENT_MISMATCH'] as const) : ([] as const), + }, + ]; + return { + retrievalPath: 'SUBGRAPH_MCP', + serverName: 'graph-mcp', + serverVersion: '1.4.0', + toolName: 'execute_query_by_deployment_id', + deploymentId: 'QmOneShotArcRecoveryV1', + manifestCid: 'QmOneShotManifestV1', + observedThroughBlock: + options.observedThroughBlock === undefined ? '704' : options.observedThroughBlock, + observedThroughTime: '2026-09-08T06:00:18.000Z', + chainHeadBlock: '706', + lagBlocks: options.lagBlocks === undefined ? '2' : options.lagBlocks, + health: options.graphHealth ?? 'FRESH', + available: options.graphAvailable ?? true, + candidateCount: candidates.length, + diagnostics: options.diagnostics ?? [], + candidates, + }; +} + +function makeScenario(options: ScenarioOptions = {}): readonly RecoveryTimelinePage[] { + const state = options.state ?? 'UNKNOWN'; + const action = options.action ?? 'WAIT'; + const accepted = options.accepted ?? true; + const commandType = options.commandType ?? 'HOLD_UNKNOWN'; + const targetState = options.targetState ?? 'UNKNOWN'; + const proof = options.proof ?? false; + const contradiction = options.contradiction ?? options.candidateMode === 'CONTRADICTORY'; + const graph = graphFixture(options); + const diagnostics = options.diagnostics ?? []; + const ageMessage = options.aged + ? 'UNKNOWN for 46 minutes. Settlement remains blocked; operator escalation is recommended.' + : 'Submission outcome is unresolved. Settlement remains blocked while evidence is reconciled.'; + + const common = { + schemaVersion: RECOVERY_TIMELINE_VERSION, + mockServerVersion: RECOVERY_MOCK_SERVER_VERSION, + businessIntentId: INTENT_ID, + authoritativeState: state, + stateVersion: '12', + settlementPermission: 'NEVER' as const, + evaluatedAt: EVALUATED_AT, + summary: state === 'UNKNOWN' ? ageMessage : `Intent is ${state}.`, + attempts: [ + { + attemptId: 'attempt_01', + stage: 'UNKNOWN', + createdAt: '2026-09-08T05:58:00.000Z', + completedAt: null, + sanitizedError: 'Provider response was not received after possible submission.', + }, + { + attemptId: 'attempt_00', + stage: 'FAILED_SAFE', + createdAt: '2026-09-08T05:55:00.000Z', + completedAt: '2026-09-08T05:55:02.000Z', + sanitizedError: 'Authorization was unavailable before submission.', + }, + ], + evidence: [ + { + evidenceId: 'oneshot:state:12', + source: 'ONESHOT' as const, + authorityClass: 'AUTHORITATIVE_ONESHOT' as const, + retrievedAt: '2026-09-08T06:00:00.000Z', + finality: null, + blockNumber: null, + digest: 'sha256:local-state-12', + summary: `Durable state version 12 records ${state}.`, + verifiedBinding: true, + contradictionCodes: [] as const, + }, + { + evidenceId: 'privy:request:89', + source: 'PRIVY' as const, + authorityClass: 'PROVIDER_OBSERVATION' as const, + retrievedAt: '2026-09-08T06:00:04.000Z', + finality: 'UNKNOWN' as const, + blockNumber: null, + digest: 'sha256:privy-request-89', + summary: 'Privy request accepted; transaction identity was not returned.', + verifiedBinding: true, + contradictionCodes: [] as const, + }, + ...(proof + ? [ + { + evidenceId: 'arc:receipt:701', + source: 'ARC' as const, + authorityClass: 'AUTHORITATIVE_CHAIN_EVIDENCE' as const, + retrievedAt: '2026-09-08T06:00:24.000Z', + finality: 'FINAL' as const, + blockNumber: '701', + digest: 'sha256:arc-receipt-701', + summary: 'Arc receipt and transfer log match the durable intent binding.', + verifiedBinding: true, + contradictionCodes: [] as const, + }, + ] + : []), + ], + graph, + recommendation: { + accepted, + action, + reason: accepted + ? `Advisor recommends ${action} from referenced sanitized evidence.` + : 'Advisor output was rejected at the recovery boundary; safe fallback applied.', + modelName: 'recovery-advisor-demo', + promptVersion: 'recovery-prompt-v1', + evidenceReferences: ['oneshot:state:12'], + }, + coreDisposition: { + commandType, + targetState, + reason: proof + ? 'Deterministic core found independently verified Arc proof.' + : 'Deterministic core found no authoritative proof for a terminal transition.', + authoritativeProofPresent: proof, + evidenceReferences: proof ? ['oneshot:state:12', 'arc:receipt:701'] : ['oneshot:state:12'], + }, + contradiction, + contradictionCodes: contradiction ? (['RECIPIENT_MISMATCH'] as const) : ([] as const), + diagnostics, + }; + + const recent: RecoveryTimelinePage = { + ...common, + timeline: [ + { + eventId: 'decision-12', + kind: 'DECISION', + timestamp: '2026-09-08T06:00:30.000Z', + sequence: '12', + title: `Core disposition: ${commandType}`, + summary: common.coreDisposition.reason, + source: 'ONESHOT', + authorityClass: 'AUTHORITATIVE_ONESHOT', + evidenceReferences: common.coreDisposition.evidenceReferences, + }, + { + eventId: 'advisor-12', + kind: 'DECISION', + timestamp: '2026-09-08T06:00:28.000Z', + sequence: '11', + title: `Advisor: ${action}`, + summary: common.recommendation.reason, + source: 'LLM', + authorityClass: 'ADVISORY_AGENT_OBSERVATION', + evidenceReferences: common.recommendation.evidenceReferences, + }, + { + eventId: 'graph-observation-10', + kind: 'EVIDENCE', + timestamp: '2026-09-08T06:00:18.000Z', + sequence: null, + title: 'Subgraph MCP observation', + summary: graph === null ? 'Graph discovery disabled.' : `Graph health: ${graph.health}.`, + source: 'THE_GRAPH', + authorityClass: 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY', + evidenceReferences: graph?.candidates.map((candidate) => candidate.candidateId) ?? [], + }, + ], + page: { cursor: null, nextCursor: 'older', totalEntries: 5 }, + }; + + const older: RecoveryTimelinePage = { + ...common, + timeline: [ + { + eventId: 'graph-observation-10', + kind: 'EVIDENCE', + timestamp: '2026-09-08T06:00:18.000Z', + sequence: null, + title: 'Subgraph MCP observation', + summary: graph === null ? 'Graph discovery disabled.' : `Graph health: ${graph.health}.`, + source: 'THE_GRAPH', + authorityClass: 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY', + evidenceReferences: graph?.candidates.map((candidate) => candidate.candidateId) ?? [], + }, + { + eventId: 'privy-observation-09', + kind: 'EVIDENCE', + timestamp: '2026-09-08T06:00:04.000Z', + sequence: null, + title: 'Privy lookup', + summary: 'Provider observation has no transaction hash.', + source: 'PRIVY', + authorityClass: 'PROVIDER_OBSERVATION', + evidenceReferences: ['privy:request:89'], + }, + { + eventId: 'submission-unknown-08', + kind: 'TRANSITION', + timestamp: '2026-09-08T06:00:04.000Z', + sequence: null, + title: 'State changed to UNKNOWN', + summary: 'Response was lost after possible submission. No retry right was granted.', + source: 'ONESHOT', + authorityClass: 'AUTHORITATIVE_ONESHOT', + evidenceReferences: ['oneshot:state:12'], + }, + ], + page: { cursor: 'older', nextCursor: null, totalEntries: 5 }, + }; + return [recent, older]; +} + +export const recoveryScenarioPages: Readonly< + Record +> = { + 'fresh-wait': makeScenario(), + 'fresh-reconcile': makeScenario({ action: 'RECONCILE', commandType: 'READ_ONLY_LOOKUP' }), + 'fresh-escalate': makeScenario({ action: 'ESCALATE', commandType: 'ESCALATE_UNKNOWN' }), + 'committed-return-existing': makeScenario({ + state: 'COMMITTED', + action: 'RETURN_EXISTING_RESULT', + commandType: 'MARK_COMMITTED', + targetState: 'COMMITTED', + proof: true, + }), + 'invalid-output': makeScenario({ accepted: false, diagnostics: ['ADVISOR_BOUNDARY_REJECTED'] }), + empty: makeScenario({ + candidateMode: 'EMPTY', + observedThroughBlock: '704', + diagnostics: ['NO_CANDIDATES'], + }), + lagging: makeScenario({ graphHealth: 'LAGGING', lagBlocks: '42', diagnostics: ['INDEX_LAG'] }), + unhealthy: makeScenario({ graphHealth: 'UNHEALTHY', diagnostics: ['INDEXING_ERRORS'] }), + unavailable: makeScenario({ + graphHealth: 'UNAVAILABLE', + graphAvailable: false, + observedThroughBlock: null, + lagBlocks: null, + candidateMode: 'EMPTY', + diagnostics: ['MCP_UNAVAILABLE'], + }), + 'fallback-disabled': makeScenario({ + graphHidden: true, + diagnostics: ['GRAPH_FALLBACK_SELECTED'], + }), + contradictory: makeScenario({ + candidateMode: 'CONTRADICTORY', + contradiction: true, + diagnostics: ['MULTIPLE_CANDIDATES'], + }), + pending: makeScenario({ state: 'SUBMITTING', commandType: 'HOLD_UNKNOWN' }), + committed: makeScenario({ + state: 'COMMITTED', + action: 'RETURN_EXISTING_RESULT', + commandType: 'MARK_COMMITTED', + targetState: 'COMMITTED', + proof: true, + }), + 'failed-safe': makeScenario({ + state: 'FAILED_SAFE', + commandType: 'MARK_FAILED_SAFE', + targetState: 'FAILED_SAFE', + }), + 'aged-unknown': makeScenario({ + action: 'ESCALATE', + commandType: 'ESCALATE_UNKNOWN', + aged: true, + diagnostics: ['AGED_UNKNOWN'], + }), +}; + +export function scenarioPage( + scenario: RecoveryScenario, + cursor: string | null, +): RecoveryTimelinePage | null { + return recoveryScenarioPages[scenario].find((page) => page.page.cursor === cursor) ?? null; +} diff --git a/packages/recovery-ui/src/index.ts b/packages/recovery-ui/src/index.ts new file mode 100644 index 0000000..b336c72 --- /dev/null +++ b/packages/recovery-ui/src/index.ts @@ -0,0 +1,8 @@ +import './styles.css'; + +export * from './contract.js'; +export * from './fixtures.js'; +export * from './mock-server.js'; +export * from './RecoveryRoute.js'; +export * from './RecoveryTimeline.js'; +export * from './timeline.js'; diff --git a/packages/recovery-ui/src/main.tsx b/packages/recovery-ui/src/main.tsx new file mode 100644 index 0000000..bdcf7b9 --- /dev/null +++ b/packages/recovery-ui/src/main.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { isRecoveryScenario } from './fixtures.js'; +import { createRecoveryClient } from './mock-server.js'; +import { RecoveryRoute } from './RecoveryRoute.js'; +import './styles.css'; + +const params = new URLSearchParams(window.location.search); +const requestedScenario = params.get('scenario'); +const scenario = isRecoveryScenario(requestedScenario) ? requestedScenario : 'aged-unknown'; +const root = document.querySelector('#root'); + +if (!(root instanceof HTMLElement)) throw new Error('Missing recovery UI root'); + +createRoot(root).render( + + + , +); diff --git a/packages/recovery-ui/src/mock-server.ts b/packages/recovery-ui/src/mock-server.ts new file mode 100644 index 0000000..f06bc15 --- /dev/null +++ b/packages/recovery-ui/src/mock-server.ts @@ -0,0 +1,143 @@ +import { + RECOVERY_MOCK_SERVER_VERSION, + parseRecoveryTimelinePage, + type RecoveryActionReceipt, + type RecoveryTimelinePage, +} from './contract.js'; +import { isRecoveryScenario, scenarioPage, type RecoveryScenario } from './fixtures.js'; + +export interface MockServerResult { + readonly status: number; + readonly body: unknown; +} + +export function handleRecoveryMockRequest( + requestUrl: string | URL, + method = 'GET', +): MockServerResult | null { + const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl, 'http://localhost'); + const match = /^\/mock\/v1\/intents\/([^/]+)\/recovery(?:\/(refresh|escalations))?$/u.exec( + url.pathname, + ); + if (!match) return null; + + const businessIntentId = decodeURIComponent(match[1] ?? ''); + const action = match[2]; + const scenarioParam = url.searchParams.get('scenario'); + const scenario = isRecoveryScenario(scenarioParam) ? scenarioParam : 'aged-unknown'; + + if (action === undefined && method === 'GET') { + const cursor = url.searchParams.get('cursor'); + const page = scenarioPage(scenario, cursor); + if (page === null) { + return { + status: 404, + body: { code: 'PAGE_NOT_FOUND', message: 'Recovery page does not exist.' }, + }; + } + return { + status: 200, + body: { ...page, businessIntentId }, + }; + } + + if ((action === 'refresh' || action === 'escalations') && method === 'POST') { + const receipt: RecoveryActionReceipt = { + schemaVersion: 'recovery-action-receipt-v1', + businessIntentId, + action: action === 'refresh' ? 'REFRESH_STATUS' : 'ESCALATE', + accepted: true, + message: + action === 'refresh' + ? 'Status refresh requested. No settlement action was created.' + : 'Operator escalation recorded. Settlement remains blocked.', + }; + return { status: 202, body: receipt }; + } + + return { + status: 405, + body: { code: 'METHOD_NOT_ALLOWED', message: 'Only safe recovery reads and actions exist.' }, + }; +} + +export interface RecoveryClient { + readPage(businessIntentId: string, cursor: string | null): Promise; + refresh(businessIntentId: string): Promise; + escalate(businessIntentId: string): Promise; +} + +function parseReceipt(value: unknown): RecoveryActionReceipt { + if (value === null || typeof value !== 'object') + throw new Error('Invalid recovery action receipt'); + const receipt = value as Partial; + if ( + receipt.schemaVersion !== 'recovery-action-receipt-v1' || + typeof receipt.businessIntentId !== 'string' || + (receipt.action !== 'REFRESH_STATUS' && receipt.action !== 'ESCALATE') || + receipt.accepted !== true || + typeof receipt.message !== 'string' + ) { + throw new Error('Invalid recovery action receipt'); + } + return receipt as RecoveryActionReceipt; +} + +export function createRecoveryClient( + options: { + readonly baseUrl?: string; + readonly scenario?: RecoveryScenario; + readonly fetcher?: typeof fetch; + } = {}, +): RecoveryClient { + const baseUrl = options.baseUrl ?? '/mock/v1'; + const scenario = options.scenario ?? 'aged-unknown'; + const fetcher = options.fetcher ?? fetch; + + async function request(path: string, init?: RequestInit): Promise { + const response = await fetcher(path, init); + if (!response.ok) throw new Error(`Recovery mock request failed with ${response.status}`); + return response.json() as Promise; + } + + return { + async readPage(businessIntentId, cursor) { + const query = new URLSearchParams({ scenario }); + if (cursor !== null) query.set('cursor', cursor); + const value = await request( + `${baseUrl}/intents/${encodeURIComponent(businessIntentId)}/recovery?${query.toString()}`, + ); + return parseRecoveryTimelinePage(value); + }, + async refresh(businessIntentId) { + const value = await request( + `${baseUrl}/intents/${encodeURIComponent(businessIntentId)}/recovery/refresh?scenario=${scenario}`, + { method: 'POST' }, + ); + return parseReceipt(value); + }, + async escalate(businessIntentId) { + const value = await request( + `${baseUrl}/intents/${encodeURIComponent(businessIntentId)}/recovery/escalations?scenario=${scenario}`, + { method: 'POST' }, + ); + return parseReceipt(value); + }, + }; +} + +export function createInMemoryRecoveryClient(scenario: RecoveryScenario): RecoveryClient { + const fetcher: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input : input.url; + const result = handleRecoveryMockRequest(url, init?.method ?? 'GET'); + if (result === null) return new Response('Not found', { status: 404 }); + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + 'content-type': 'application/json', + 'x-oneshot-mock-version': RECOVERY_MOCK_SERVER_VERSION, + }, + }); + }; + return createRecoveryClient({ baseUrl: 'http://mock.local/mock/v1', scenario, fetcher }); +} diff --git a/packages/recovery-ui/src/styles.css b/packages/recovery-ui/src/styles.css new file mode 100644 index 0000000..96d3fd9 --- /dev/null +++ b/packages/recovery-ui/src/styles.css @@ -0,0 +1,663 @@ +:root { + color: #e8eef7; + background: #07101d; + font-family: Manrope, Inter, ui-sans-serif, system-ui, sans-serif; + font-synthesis: none; + color-scheme: dark; + --ink: #e8eef7; + --muted: #8fa1b8; + --line: #203047; + --panel: rgba(13, 26, 44, 0.88); + --panel-strong: #10243b; + --cyan: #64e1ed; + --green: #70e2ad; + --amber: #ffc65c; + --red: #ff7d7d; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + linear-gradient(rgba(7, 16, 29, 0.72), rgba(7, 16, 29, 0.96)), + repeating-linear-gradient( + 90deg, + transparent 0, + transparent 79px, + rgba(100, 225, 237, 0.045) 80px + ), + radial-gradient(circle at 78% 0%, #16395c 0, transparent 35%), #07101d; +} + +button { + font: inherit; +} + +button:focus-visible { + outline: 3px solid #fff; + outline-offset: 3px; +} + +.recovery-shell, +.route-state { + width: min(1180px, calc(100% - 40px)); + margin: 0 auto; + padding: 48px 0 80px; +} + +.route-state { + min-height: 100vh; + display: grid; + align-content: center; +} + +.hero { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; + margin-bottom: 36px; +} + +.brand, +.eyebrow, +dt, +.timeline-meta, +.authority-label, +.count, +.stage, +.health, +.binding-ok, +.binding-warning, +.duplicate-note, +.order-note { + font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.brand, +.eyebrow { + margin: 0 0 10px; + color: var(--cyan); + font-size: 0.72rem; + font-weight: 500; +} + +h1, +h2, +h3, +p { + overflow-wrap: anywhere; +} + +h1 { + max-width: 720px; + margin: 0; + font-size: clamp(2.6rem, 7vw, 5.8rem); + line-height: 0.92; + letter-spacing: -0.06em; +} + +h2, +h3, +p { + margin-top: 0; +} + +h2 { + margin-bottom: 0; + font-size: clamp(1.25rem, 3vw, 1.8rem); +} + +h3 { + margin-bottom: 8px; + font-size: 1rem; +} + +.lede { + margin: 18px 0 0; + color: var(--muted); + font-size: 1.05rem; +} + +.intent-identity { + min-width: min(100%, 320px); + margin: 0; + padding: 18px 20px; + border-left: 1px solid var(--cyan); + background: rgba(100, 225, 237, 0.04); +} + +.intent-identity div + div { + margin-top: 12px; +} + +dt { + color: var(--muted); + font-size: 0.64rem; +} + +dd { + margin: 4px 0 0; + font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.79rem; +} + +.state-banner { + position: relative; + display: flex; + justify-content: space-between; + gap: 28px; + padding: clamp(24px, 5vw, 42px); + border: 1px solid var(--line); + overflow: hidden; +} + +.state-banner::before { + position: absolute; + inset: 0 auto 0 0; + width: 5px; + content: ''; + background: var(--amber); +} + +.state-banner h2 { + margin-bottom: 12px; + font-size: clamp(2rem, 5vw, 3.8rem); + letter-spacing: -0.04em; +} + +.state-banner p { + max-width: 680px; + margin-bottom: 0; + color: var(--muted); + line-height: 1.6; +} + +.tone-success::before { + background: var(--green); +} + +.tone-neutral::before { + background: var(--muted); +} + +.lock-status { + display: grid; + align-content: center; + min-width: 230px; + padding: 20px; + border: 1px solid rgba(255, 198, 92, 0.42); + background: rgba(255, 198, 92, 0.06); +} + +.lock-status span, +.lock-status small { + color: var(--amber); + font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.68rem; + letter-spacing: 0.08em; +} + +.lock-status strong { + margin: 7px 0; +} + +.warning-strip { + margin-top: 16px; + padding: 17px 20px; + border: 1px solid rgba(255, 125, 125, 0.55); + background: rgba(255, 125, 125, 0.08); + color: #ffd6d6; +} + +.action-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin: 22px 0 34px; +} + +.primary-action, +.secondary-action, +.load-more { + min-height: 44px; + padding: 11px 18px; + border: 1px solid var(--cyan); + border-radius: 2px; + cursor: pointer; + font-weight: 700; +} + +.primary-action { + background: var(--cyan); + color: #07101d; +} + +.secondary-action, +.load-more { + background: transparent; + color: var(--cyan); +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +.action-note { + margin: 0 0 0 auto; + color: var(--muted); + font-size: 0.82rem; +} + +.sr-live { + flex-basis: 100%; + min-height: 1.2em; + margin: 0; + color: var(--green); + font-size: 0.82rem; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.85fr); + gap: 20px; +} + +.side-stack { + display: grid; + align-content: start; + gap: 20px; +} + +.panel, +.diagnostics { + padding: clamp(22px, 4vw, 32px); + border: 1px solid var(--line); + background: var(--panel); + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.18); +} + +.section-heading, +.card-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 18px; +} + +.count, +.health, +.binding-ok, +.binding-warning, +.duplicate-note, +.order-note, +.stage { + display: inline-block; + padding: 5px 7px; + border: 1px solid var(--line); + color: var(--muted); + font-size: 0.62rem; + white-space: nowrap; +} + +.timeline { + margin: 28px 0 0; + padding: 0; + list-style: none; +} + +.timeline li { + position: relative; + display: grid; + grid-template-columns: 21px minmax(0, 1fr); + gap: 16px; + padding-bottom: 30px; +} + +.timeline-rail::before { + position: absolute; + top: 4px; + left: 5px; + z-index: 1; + width: 11px; + height: 11px; + border: 2px solid var(--cyan); + border-radius: 50%; + background: #0b1728; + content: ''; +} + +.timeline-rail::after { + position: absolute; + top: 18px; + bottom: -4px; + left: 10px; + width: 1px; + background: var(--line); + content: ''; +} + +.timeline li:last-child .timeline-rail::after { + display: none; +} + +.timeline-meta { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 0.62rem; +} + +.timeline h3 { + margin: 8px 0; + font-size: 1.06rem; +} + +.timeline p, +.attempt-list p, +.decision-grid p, +.evidence-card > p, +.observation-copy { + color: var(--muted); + font-size: 0.86rem; + line-height: 1.55; +} + +.authority-label { + margin-bottom: 8px; + color: var(--cyan) !important; + font-size: 0.62rem !important; +} + +.duplicate-note, +.order-note { + margin: 4px 6px 0 0; + white-space: normal; +} + +.order-note { + border-color: rgba(255, 198, 92, 0.42); + color: var(--amber); +} + +.load-more { + width: 100%; +} + +.attempt-list, +.diagnostic-list, +.diagnostics ul { + margin: 24px 0 0; + padding: 0; + list-style: none; +} + +.attempt-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px 12px; + padding: 16px 0; + border-top: 1px solid var(--line); +} + +.attempt-list li > div { + display: grid; + gap: 4px; +} + +.attempt-list span:not(.stage) { + color: var(--muted); + font-size: 0.72rem; +} + +.attempt-list p { + grid-column: 1 / -1; + margin-bottom: 0; +} + +.decision-grid { + display: grid; + gap: 12px; + margin-top: 22px; +} + +.decision-grid article { + padding: 18px; + border: 1px solid rgba(100, 225, 237, 0.22); + background: rgba(100, 225, 237, 0.035); +} + +.decision-grid article > span, +.decision-grid small { + display: block; + color: var(--muted); + font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.66rem; + text-transform: uppercase; +} + +.decision-grid strong { + display: block; + margin-top: 8px; + color: var(--cyan); +} + +.decision-grid p { + margin: 10px 0; +} + +.decision-grid .core-decision { + border-color: rgba(112, 226, 173, 0.35); + background: rgba(112, 226, 173, 0.04); +} + +.decision-grid .core-decision strong { + color: var(--green); +} + +.graph-panel, +.panel + .panel, +.dashboard-grid + .panel, +.graph-panel + .panel, +.diagnostics { + margin-top: 20px; +} + +.health-fresh, +.binding-ok { + border-color: rgba(112, 226, 173, 0.4); + color: var(--green); +} + +.health-lagging, +.health-unknown_freshness { + border-color: rgba(255, 198, 92, 0.45); + color: var(--amber); +} + +.health-unhealthy, +.health-unavailable, +.binding-warning { + border-color: rgba(255, 125, 125, 0.45); + color: var(--red); +} + +.identity-grid, +.compact-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + margin: 22px 0 0; + background: var(--line); +} + +.identity-grid div, +.compact-facts div { + min-width: 0; + padding: 14px; + background: var(--panel-strong); +} + +.identity-grid dd, +.compact-facts dd { + overflow: hidden; + text-overflow: ellipsis; +} + +.diagnostic-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.diagnostic-list li, +.diagnostics li { + padding: 7px 10px; + border: 1px solid rgba(255, 198, 92, 0.35); + color: var(--amber); + font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.7rem; +} + +.candidate-list, +.evidence-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 22px; +} + +.candidate, +.evidence-card { + min-width: 0; + padding: 18px; + border: 1px solid var(--line); + background: rgba(3, 10, 19, 0.35); +} + +.candidate { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.candidate .contradiction { + grid-column: 1 / -1; +} + +.source-mark { + display: grid; + flex: 0 0 34px; + width: 34px; + height: 34px; + place-items: center; + border: 1px solid var(--cyan); + color: var(--cyan); + font-family: 'Cascadia Mono', 'SFMono-Regular', Consolas, monospace; +} + +.card-heading > div { + flex: 1; +} + +.card-heading h3 { + margin-bottom: 4px; +} + +.card-heading .eyebrow { + margin: 0; + color: var(--muted); + font-size: 0.58rem; +} + +.compact-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.compact-facts div { + padding: 10px; +} + +.contradiction { + margin: 14px 0 0 !important; + color: var(--red) !important; +} + +.diagnostics h2 { + font-size: 1rem; +} + +@media (max-width: 860px) { + .hero, + .state-banner { + align-items: stretch; + flex-direction: column; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .intent-identity, + .lock-status { + min-width: 0; + } + + .action-note { + flex-basis: 100%; + margin-left: 0; + } +} + +@media (max-width: 620px) { + .recovery-shell, + .route-state { + width: min(100% - 24px, 1180px); + padding-top: 28px; + } + + .state-banner, + .panel, + .diagnostics { + padding: 20px; + } + + .identity-grid, + .compact-facts, + .candidate-list, + .evidence-grid { + grid-template-columns: 1fr; + } + + .primary-action, + .secondary-action { + width: 100%; + } + + .timeline-meta { + align-items: start; + flex-direction: column; + } + + .card-heading { + flex-wrap: wrap; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + } +} diff --git a/packages/recovery-ui/src/timeline.ts b/packages/recovery-ui/src/timeline.ts new file mode 100644 index 0000000..effe60e --- /dev/null +++ b/packages/recovery-ui/src/timeline.ts @@ -0,0 +1,81 @@ +import type { RecoveryTimelinePage, TimelineEntry } from './contract.js'; + +export interface TimelineItem extends TimelineEntry { + readonly duplicateCount: number; + readonly orderAmbiguous: boolean; +} + +function compareSequence(left: string | null, right: string | null): number | null { + if (left === null || right === null || !/^\d+$/u.test(left) || !/^\d+$/u.test(right)) { + return null; + } + const leftValue = BigInt(left); + const rightValue = BigInt(right); + return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; +} + +export function mergeTimelinePages( + pages: readonly RecoveryTimelinePage[], +): readonly TimelineItem[] { + const byId = new Map< + string, + { entry: TimelineEntry; duplicateCount: number; firstSeen: number } + >(); + let seen = 0; + + for (const page of pages) { + for (const entry of page.timeline) { + const existing = byId.get(entry.eventId); + if (existing) { + existing.duplicateCount += 1; + } else { + byId.set(entry.eventId, { entry, duplicateCount: 0, firstSeen: seen }); + } + seen += 1; + } + } + + const ordered = [...byId.values()].sort((left, right) => { + const sequence = compareSequence(left.entry.sequence, right.entry.sequence); + if (sequence !== null && sequence !== 0) return sequence; + const time = Date.parse(left.entry.timestamp) - Date.parse(right.entry.timestamp); + return time === 0 ? left.firstSeen - right.firstSeen : time; + }); + + return ordered.map((item, index) => { + const previous = ordered[index - 1]?.entry; + const next = ordered[index + 1]?.entry; + const sameClockWithoutSequence = (other: TimelineEntry | undefined): boolean => + other !== undefined && + item.entry.timestamp === other.timestamp && + (item.entry.sequence === null || other.sequence === null); + return { + ...item.entry, + duplicateCount: item.duplicateCount, + orderAmbiguous: sameClockWithoutSequence(previous) || sameClockWithoutSequence(next), + }; + }); +} + +export function formatUtc(value: string): string { + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeStyle: 'medium', + timeZone: 'UTC', + }).format(new Date(value)); +} + +export function authorityLabel(authority: TimelineEntry['authorityClass']): string { + switch (authority) { + case 'AUTHORITATIVE_ONESHOT': + return 'Authoritative · OneShot'; + case 'AUTHORITATIVE_CHAIN_EVIDENCE': + return 'Authoritative · Arc chain'; + case 'PROVIDER_OBSERVATION': + return 'Observation · Provider'; + case 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY': + return 'Candidate discovery · Non-authoritative'; + case 'ADVISORY_AGENT_OBSERVATION': + return 'Advisory · LLM'; + } +} diff --git a/packages/recovery-ui/src/vite-env.d.ts b/packages/recovery-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/recovery-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/recovery-ui/test/component.test.ts b/packages/recovery-ui/test/component.test.ts new file mode 100644 index 0000000..9ec1d84 --- /dev/null +++ b/packages/recovery-ui/test/component.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import axe from 'axe-core'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { parseRecoveryTimelinePage, type RecoveryActionReceipt } from '../src/contract.js'; +import { RECOVERY_SCENARIOS, recoveryScenarioPages } from '../src/fixtures.js'; +import { RecoveryTimeline } from '../src/RecoveryTimeline.js'; + +const receipt: RecoveryActionReceipt = { + schemaVersion: 'recovery-action-receipt-v1', + businessIntentId: 'intent_demo_018f', + action: 'REFRESH_STATUS', + accepted: true, + message: 'Status refresh requested. No settlement action was created.', +}; + +afterEach(() => cleanup()); + +function renderScenario(scenario: keyof typeof recoveryScenarioPages, allPages = false) { + const pages = recoveryScenarioPages[scenario]; + return render( + createElement(RecoveryTimeline, { + pages: allPages ? pages : [pages[0]!], + onRefresh: vi.fn(async () => receipt), + onEscalate: vi.fn(async () => ({ ...receipt, action: 'ESCALATE' as const })), + onLoadMore: allPages ? null : vi.fn(async () => undefined), + }), + ); +} + +describe('RecoveryTimeline', () => { + it.each([ + ['fresh-wait', 'WAIT'], + ['fresh-reconcile', 'RECONCILE'], + ['fresh-escalate', 'ESCALATE'], + ['committed-return-existing', 'RETURN_EXISTING_RESULT'], + ] as const)( + 'renders %s recommendation separately from the deterministic core', + (scenario, action) => { + renderScenario(scenario); + expect(screen.getByText(action, { selector: '.decision-grid strong' })).toBeTruthy(); + expect(screen.getByText('Deterministic core', { selector: 'span' })).toBeTruthy(); + expect(screen.getByText('LLM recommendation', { selector: 'span' })).toBeTruthy(); + }, + ); + + it.each([ + ['invalid-output', 'ADVISOR_BOUNDARY_REJECTED'], + ['lagging', 'LAGGING'], + ['unhealthy', 'UNHEALTHY'], + ['unavailable', 'UNAVAILABLE'], + ['contradictory', 'Contradictory evidence'], + ['pending', 'SUBMITTING'], + ['committed', 'COMMITTED'], + ['failed-safe', 'FAILED_SAFE'], + ['aged-unknown', 'UNKNOWN for 46 minutes'], + ] as const)('renders safe %s state', (scenario, expected) => { + renderScenario(scenario); + expect(screen.getAllByText(new RegExp(expected, 'u')).length).toBeGreaterThan(0); + expect(screen.getByText(/New settlement blocked/u)).toBeTruthy(); + }); + + it('uses observed-through language for an empty Graph result', () => { + renderScenario('empty'); + expect(screen.getByText(/Not observed through block 704/u)).toBeTruthy(); + expect(document.body.textContent?.toLowerCase()).not.toContain('not paid'); + }); + + it('hides Subgraph MCP cleanly when fallback is selected', () => { + renderScenario('fallback-disabled'); + expect(screen.queryByRole('heading', { name: 'Subgraph MCP' })).toBeNull(); + }); + + it('offers no retry, force-pay, or settlement action', () => { + renderScenario('aged-unknown'); + const buttonNames = screen + .getAllByRole('button') + .map((button) => button.textContent?.toLowerCase()); + expect(buttonNames).toEqual([ + 'refresh status', + 'escalate to operator', + 'load earlier evidence', + ]); + expect(document.body.textContent).toContain('No payment action is available'); + }); + + it('supports keyboard activation for safe actions', async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn(async () => receipt); + render( + createElement(RecoveryTimeline, { + pages: [recoveryScenarioPages['fresh-wait'][0]!], + onRefresh, + onEscalate: vi.fn(async () => ({ ...receipt, action: 'ESCALATE' as const })), + onLoadMore: null, + }), + ); + await user.tab(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Refresh status' })); + await user.keyboard('{Enter}'); + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()); + expect(await screen.findByText(/Status refresh requested/u)).toBeTruthy(); + }); + + it('renders malicious evidence strings as inert text', () => { + const original = structuredClone(recoveryScenarioPages['fresh-wait'][0]!); + const fixture = { + ...original, + evidence: original.evidence.map((item, index) => + index === 0 ? { ...item, summary: '' } : item, + ), + }; + const safe = parseRecoveryTimelinePage(fixture); + render( + createElement(RecoveryTimeline, { + pages: [safe], + onRefresh: vi.fn(async () => receipt), + onEscalate: vi.fn(async () => ({ ...receipt, action: 'ESCALATE' as const })), + onLoadMore: null, + }), + ); + expect(screen.getByText('')).toBeTruthy(); + expect(document.querySelector('img')).toBeNull(); + }); + + it('collapses duplicate observations and flags clock ambiguity after pagination', () => { + renderScenario('fresh-wait', true); + expect(screen.getByText(/Duplicate observation collapsed ×2/u)).toBeTruthy(); + expect(screen.getAllByText(/relative order is uncertain/u)).toHaveLength(2); + }); + + it('has no detectable automated accessibility violations', async () => { + const { container } = renderScenario('contradictory', true); + const results = await axe.run(container, { rules: { 'color-contrast': { enabled: false } } }); + expect(results.violations).toEqual([]); + }); + + it('ships narrow and medium responsive breakpoints', async () => { + const repositoryRoot = process.cwd().endsWith('recovery-ui') + ? process.cwd() + : join(process.cwd(), 'packages', 'recovery-ui'); + const css = await readFile(join(repositoryRoot, 'src', 'styles.css'), 'utf8'); + expect(css).toContain('@media (max-width: 860px)'); + expect(css).toContain('@media (max-width: 620px)'); + expect(css).toContain('grid-template-columns: 1fr'); + }); + + it('covers every required C05 fixture story', () => { + expect(RECOVERY_SCENARIOS).toEqual( + expect.arrayContaining([ + 'fresh-wait', + 'fresh-reconcile', + 'fresh-escalate', + 'committed-return-existing', + 'invalid-output', + 'empty', + 'lagging', + 'unhealthy', + 'unavailable', + 'contradictory', + 'pending', + 'committed', + 'failed-safe', + 'aged-unknown', + ]), + ); + }); +}); diff --git a/packages/recovery-ui/test/contract.test.ts b/packages/recovery-ui/test/contract.test.ts new file mode 100644 index 0000000..0ca8d8e --- /dev/null +++ b/packages/recovery-ui/test/contract.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { parseRecoveryTimelinePage } from '../src/contract.js'; +import { RECOVERY_SCENARIOS, recoveryScenarioPages } from '../src/fixtures.js'; + +describe('recovery UI contract', () => { + it.each(RECOVERY_SCENARIOS)('accepts sanitized %s fixtures', (scenario) => { + for (const page of recoveryScenarioPages[scenario]) { + expect(parseRecoveryTimelinePage(structuredClone(page))).toEqual(page); + } + }); + + it.each(['rawProviderBody', 'authorization', 'private_key', 'responseBody'])( + 'rejects forbidden %s fields before component input', + (field) => { + const page = structuredClone(recoveryScenarioPages['fresh-wait'][0]); + expect(page).toBeDefined(); + const unsafe = { ...page, [field]: { hidden: true } }; + expect(() => parseRecoveryTimelinePage(unsafe)).toThrow(/Forbidden recovery UI field/u); + }, + ); + + it('rejects secret-shaped text before component input', () => { + const page = structuredClone(recoveryScenarioPages['fresh-wait'][0]); + expect(page).toBeDefined(); + const unsafe = { ...page, summary: 'Authorization: Bearer abc.def.secret' }; + expect(() => parseRecoveryTimelinePage(unsafe)).toThrow(/Unsafe recovery UI text/u); + }); + + it('rejects a mismatched candidate count', () => { + const page = structuredClone(recoveryScenarioPages['fresh-wait'][0]); + expect(page?.graph).not.toBeNull(); + const unsafe = { ...page, graph: { ...page?.graph, candidateCount: 99 } }; + expect(() => parseRecoveryTimelinePage(unsafe)).toThrow(/candidate count mismatch/u); + }); +}); diff --git a/packages/recovery-ui/test/mock-server.test.ts b/packages/recovery-ui/test/mock-server.test.ts new file mode 100644 index 0000000..2671ba8 --- /dev/null +++ b/packages/recovery-ui/test/mock-server.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { RECOVERY_MOCK_SERVER_VERSION } from '../src/contract.js'; +import { createInMemoryRecoveryClient, handleRecoveryMockRequest } from '../src/mock-server.js'; + +describe('C05 frozen mock server', () => { + it('serves versioned paginated recovery data', async () => { + const client = createInMemoryRecoveryClient('aged-unknown'); + const first = await client.readPage('intent_custom', null); + const second = await client.readPage('intent_custom', first.page.nextCursor); + + expect(first.mockServerVersion).toBe(RECOVERY_MOCK_SERVER_VERSION); + expect(first.businessIntentId).toBe('intent_custom'); + expect(first.page.nextCursor).toBe('older'); + expect(second.page.cursor).toBe('older'); + }); + + it('exposes refresh and escalation only', async () => { + const client = createInMemoryRecoveryClient('aged-unknown'); + await expect(client.refresh('intent_custom')).resolves.toMatchObject({ + action: 'REFRESH_STATUS', + accepted: true, + }); + await expect(client.escalate('intent_custom')).resolves.toMatchObject({ + action: 'ESCALATE', + accepted: true, + }); + expect( + handleRecoveryMockRequest( + 'http://mock.local/mock/v1/intents/intent_custom/recovery/retry', + 'POST', + ), + ).toBeNull(); + }); + + it('fails closed on mutating methods for recovery reads', () => { + expect( + handleRecoveryMockRequest( + 'http://mock.local/mock/v1/intents/intent_custom/recovery?scenario=fresh-wait', + 'DELETE', + ), + ).toMatchObject({ status: 405 }); + }); +}); diff --git a/packages/recovery-ui/test/route.test.ts b/packages/recovery-ui/test/route.test.ts new file mode 100644 index 0000000..50901ef --- /dev/null +++ b/packages/recovery-ui/test/route.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createInMemoryRecoveryClient } from '../src/mock-server.js'; +import { RecoveryRoute } from '../src/RecoveryRoute.js'; + +afterEach(() => cleanup()); + +describe('RecoveryRoute', () => { + it('loads the frozen mock and paginates earlier evidence', async () => { + const user = userEvent.setup(); + render( + createElement(RecoveryRoute, { + businessIntentId: 'intent_route_test', + client: createInMemoryRecoveryClient('fresh-wait'), + }), + ); + + expect(await screen.findByText('intent_route_test')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Load earlier evidence' })); + expect(await screen.findByText(/Duplicate observation collapsed ×2/u)).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Load earlier evidence' })).toBeNull(); + }); + + it('fails closed when the evidence service is unavailable', async () => { + render( + createElement(RecoveryRoute, { + businessIntentId: 'intent_route_test', + client: { + readPage: async () => Promise.reject(new Error('offline')), + refresh: async () => Promise.reject(new Error('offline')), + escalate: async () => Promise.reject(new Error('offline')), + }, + }), + ); + expect((await screen.findByRole('alert')).textContent).toMatch( + /Authoritative state is unchanged/u, + ); + }); +}); diff --git a/packages/recovery-ui/test/timeline.test.ts b/packages/recovery-ui/test/timeline.test.ts new file mode 100644 index 0000000..f23a3ac --- /dev/null +++ b/packages/recovery-ui/test/timeline.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { recoveryScenarioPages } from '../src/fixtures.js'; +import { mergeTimelinePages } from '../src/timeline.js'; + +describe('timeline normalization', () => { + it('merges pages, collapses duplicate observations, and preserves stable ordering', () => { + const timeline = mergeTimelinePages(recoveryScenarioPages['fresh-wait']); + expect(timeline).toHaveLength(5); + expect(timeline.map((entry) => entry.eventId)).toEqual([ + 'privy-observation-09', + 'submission-unknown-08', + 'graph-observation-10', + 'advisor-12', + 'decision-12', + ]); + expect(timeline.find((entry) => entry.eventId === 'graph-observation-10')?.duplicateCount).toBe( + 1, + ); + }); + + it('labels equal-clock events without durable sequence as order ambiguous', () => { + const timeline = mergeTimelinePages(recoveryScenarioPages['fresh-wait']); + expect(timeline.find((entry) => entry.eventId === 'privy-observation-09')?.orderAmbiguous).toBe( + true, + ); + expect( + timeline.find((entry) => entry.eventId === 'submission-unknown-08')?.orderAmbiguous, + ).toBe(true); + }); +}); diff --git a/packages/recovery-ui/tsconfig.json b/packages/recovery-ui/tsconfig.json new file mode 100644 index 0000000..271c557 --- /dev/null +++ b/packages/recovery-ui/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "dist/.tsbuildinfo", + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "vite.config.ts"] +} diff --git a/packages/recovery-ui/vite.config.ts b/packages/recovery-ui/vite.config.ts new file mode 100644 index 0000000..6108317 --- /dev/null +++ b/packages/recovery-ui/vite.config.ts @@ -0,0 +1,39 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig, type Plugin } from 'vite'; + +import { RECOVERY_MOCK_SERVER_VERSION } from './src/contract.js'; +import { handleRecoveryMockRequest } from './src/mock-server.js'; + +function recoveryMockPlugin(): Plugin { + return { + name: 'oneshot-recovery-mock-v1', + configureServer(server) { + server.middlewares.use((request, response, next) => { + const result = handleRecoveryMockRequest(request.url ?? '/', request.method ?? 'GET'); + if (result === null) { + next(); + return; + } + response.statusCode = result.status; + response.setHeader('content-type', 'application/json; charset=utf-8'); + response.setHeader('x-oneshot-mock-version', RECOVERY_MOCK_SERVER_VERSION); + response.end(JSON.stringify(result.body)); + }); + }, + }; +} + +export default defineConfig({ + plugins: [react(), recoveryMockPlugin()], + build: { + emptyOutDir: false, + lib: { + entry: 'src/index.ts', + formats: ['es'], + fileName: 'recovery-ui', + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }, +}); diff --git a/packages/recovery-ui/vitest.config.ts b/packages/recovery-ui/vitest.config.ts new file mode 100644 index 0000000..a44e73c --- /dev/null +++ b/packages/recovery-ui/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523e19e..468543b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0) vitest: specifier: 5.0.0 - version: 5.0.0(@types/node@24.13.3)(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)) + version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)) wrangler: specifier: 4.127.0 version: 4.127.0 @@ -130,6 +130,52 @@ importers: packages/reconciliation: {} + packages/recovery-ui: + dependencies: + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@testing-library/dom': + specifier: 10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: 16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 14.6.7 + version: 14.6.7(@testing-library/dom@10.4.1) + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/react': + specifier: 19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: 19.2.7 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: 6.1.1 + version: 6.1.1(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)) + axe-core: + specifier: 4.13.0 + version: 4.13.0 + jsdom: + specifier: 30.0.1 + version: 30.0.1(@noble/hashes@1.8.0) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.0.0 + version: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0) + vitest: + specifier: 5.0.0 + version: 5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)) + packages/storage-postgres: dependencies: '@oneshot/contracts': @@ -166,6 +212,14 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -174,9 +228,17 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} @@ -230,6 +292,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.11.3': resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} @@ -434,6 +532,15 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} @@ -850,6 +957,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.9': resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scure/base@1.2.6': resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} @@ -869,9 +979,37 @@ packages: '@testcontainers/postgresql@12.1.0': resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -914,6 +1052,14 @@ packages: '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/semver@7.8.0': resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} @@ -985,6 +1131,22 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -1053,6 +1215,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1068,6 +1234,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -1088,6 +1257,10 @@ packages: avvio@9.3.0: resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -1146,6 +1319,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1243,6 +1419,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1252,6 +1439,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1275,6 +1465,9 @@ packages: resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==} engines: {node: '>= 14.17'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1287,6 +1480,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1490,6 +1687,10 @@ packages: hookified@2.2.0: resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1535,6 +1736,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -1560,6 +1764,15 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -1691,9 +1904,20 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + miniflare@5.20260826.0-alpha: resolution: {integrity: sha512-ZXR3Bieg+B5MK0T/zYIWaZiCGCb9Z3en4+/TleYKhIGPLx/e0cRcG/ZvvTviUapVBBK2zODB+aiypdIojU3x6Q==} engines: {node: '>=22.0.0'} @@ -1782,6 +2006,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1884,6 +2111,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1922,6 +2153,18 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -1992,6 +2235,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -2085,6 +2335,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tar-fs@2.1.5: resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} @@ -2124,6 +2377,13 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + hasBin: true + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -2132,6 +2392,14 @@ packages: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2278,6 +2546,26 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2330,6 +2618,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -2369,6 +2664,21 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2377,8 +2687,14 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} + '@babel/runtime@7.29.7': {} + '@balena/dockerignore@1.0.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -2418,6 +2734,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.11.3': dependencies: '@emnapi/wasi-threads': 1.2.3 @@ -2546,6 +2886,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 @@ -2865,6 +3209,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.9': {} + '@rolldown/pluginutils@1.0.1': {} + '@scure/base@1.2.6': {} '@scure/bip32@1.7.0': @@ -2900,11 +3246,38 @@ snapshots: - react-native-b4a - supports-color + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2957,6 +3330,14 @@ snapshots: pg-protocol: 1.16.0 pg-types: 2.2.0 + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + '@types/semver@7.8.0': {} '@types/ssh2-streams@0.1.13': @@ -3063,6 +3444,11 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@6.1.1(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0) + '@vitest/mocker@5.0.0(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -3116,6 +3502,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} archiver-utils@5.0.2: @@ -3144,6 +3532,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -3161,6 +3553,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.3 + axe-core@4.13.0: {} + b4a@1.8.1: {} balanced-match@1.0.2: {} @@ -3202,6 +3596,10 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -3306,6 +3704,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 @@ -3318,6 +3730,8 @@ snapshots: optionalDependencies: supports-color: 7.2.0 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} dequal@2.0.3: {} @@ -3368,6 +3782,8 @@ snapshots: transitivePeerDependencies: - supports-color + dom-accessibility-api@0.5.16: {} + eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} @@ -3378,6 +3794,8 @@ snapshots: dependencies: once: 1.4.0 + entities@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -3648,6 +4066,12 @@ snapshots: hookified@2.2.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -3677,6 +4101,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} isarray@1.0.0: {} @@ -3699,6 +4125,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.0.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-parse-even-better-errors@2.3.1: {} json-schema-ref-resolver@3.0.0: @@ -3797,10 +4249,16 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + magic-string@1.2.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + mdn-data@2.27.1: {} + miniflare@5.20260826.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -3895,6 +4353,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3989,6 +4451,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-nextick-args@2.0.1: {} process-warning@4.0.1: {} @@ -4044,6 +4512,15 @@ snapshots: quick-format-unescaped@4.0.4: {} + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.8: {} + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -4126,6 +4603,12 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} semver@7.8.5: {} @@ -4246,6 +4729,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tar-fs@2.1.5: dependencies: chownr: 1.1.4 @@ -4356,10 +4841,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tldts-core@7.4.11: {} + + tldts@7.4.11: + dependencies: + tldts-core: 7.4.11 + tmp@0.2.7: {} toad-cache@3.7.4: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.11 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -4438,7 +4937,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vitest@5.0.0(@types/node@24.13.3)(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)): + vitest@5.0.0(@types/node@24.13.3)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0)) @@ -4456,9 +4955,34 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + jsdom: 30.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4510,6 +5034,10 @@ snapshots: ws@8.21.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/tsconfig.json b/tsconfig.json index 9e245df..6a69570 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,9 @@ }, { "path": "./apps/worker" + }, + { + "path": "./packages/recovery-ui" } ] }