diff --git a/.agent/context/20260907T190600Z-a05-frontend-intent-status.md b/.agent/context/20260907T190600Z-a05-frontend-intent-status.md new file mode 100644 index 0000000..15731b1 --- /dev/null +++ b/.agent/context/20260907T190600Z-a05-frontend-intent-status.md @@ -0,0 +1,73 @@ +# Session Context: A05 Frontend Intent and Authoritative Status + +## Date/time + +- Started: 2026-09-07T19:06:00Z +- Continued: 2026-09-08T12:29:20Z + +## User goal and original request + +Implement Coder A milestone A05 after Gate P4: "Делай" in response to the identified next packet, A05 Frontend Intent and Authoritative Status. + +## Assumptions and non-goals + +- Base is current `origin/develop` at `1250dec`. +- A05 consumes frozen OpenAPI v1 and does not add settlement capability. +- The browser receives a demo service token only at runtime and does not persist it. +- B05/C05 composition remains project Gate P5 work. +- No production API deployment, mainnet action, settlement detail UI, or visual polish campaign is included. + +## Plan and decisions + +- Preserve and finish the existing uncommitted A05 implementation after moving its base from A04 to current `develop`. +- Use generated `@oneshot/contracts` types and a small typed fetch client. +- Keep exact USDC parsing and formatting string/`bigint` based. +- Preserve the same Business Intent ID for replay; generate another ID only through an explicit new-obligation action. +- Stop polling at `UNKNOWN`; expose reconciliation only, never payment retry. +- Replace the Cloudflare placeholder asset with the built A05 SPA. +- Use React/Vite dependencies already present in the workspace; add no design system or state library. + +## Files and components + +- `apps/web`: application shell, API client, intent form, status view, readiness banner, exact money helpers, tests, and setup documentation. +- Root TypeScript/ESLint/workspace lock configuration includes the new app. +- Root Wrangler assets now point to `apps/web/dist`; the old placeholder is removed. +- Root README lists the operator frontend and its local command. + +## Commands and checks + +- `pnpm --filter @oneshot/web test`: 27 tests passed. +- `pnpm --filter @oneshot/web lint`: passed. +- `pnpm --filter @oneshot/web typecheck`: passed. +- `pnpm --filter @oneshot/web build`: passed; production source maps remain disabled. +- `pnpm exec wrangler deploy --dry-run`: passed with `apps/web/dist` assets. +- `pnpm format:check`, root `pnpm lint`, root `pnpm typecheck`, generated-contract check, and fixture validation: passed. +- Root `pnpm test`: 576 tests passed across 43 files. +- `TEST_POSTGRES=1 pnpm test:integration`: unavailable locally because no container runtime is running; the first Testcontainers suite reported `Could not find a working container runtime strategy`. Required CI provides the integration environment. +- Local runtime uses Node 22.23.2 and reports the repository's expected Node 24.19.0 engine warning; CI uses `.nvmrc`. + +## External documentation findings + +- Wrangler 4.127.0 local schema accepts `assets.not_found_handling = "single-page-application"`. +- Added `build.command = "pnpm --filter @oneshot/web build"` to `wrangler.jsonc` so Cloudflare Workers Builds automatically builds the Vite SPA before asset upload. + +## Unresolved questions + +- None for A05 packet scope. Production API deployment and P5 UI composition remain later work. + +## Branch, commit, PR, and review state + +- Branch: `milestone/a05-frontend-intent-status` +- Base: `origin/develop` at `1250dec79bc702939fe2a3b0fd00e66bb34128af` +- Initial commit: `d3fbfacdee365d159807256a53011140442867ff` +- PR: [#32](https://github.com/SWOFART/OneShot/pull/32) +- Gate A: IN PROGRESS (free-pi-cli glm-5.3-flash) +- Gate B: pending CI and exact-head review + +## Handoff and next steps + +- Validate wrangler build integration locally. +- Re-run Gate A, commit fix, push to PR #32. +- Verify CI passes (including Workers Builds). +- Run Gate B and mark PR ready for review. +- Project Gate P5 will compose B05/C05 UI packages after A05 closes. diff --git a/README.md b/README.md index 5f8c746..2d466a5 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ lock: OneShot's durable state is. ```text apps/api HTTP seam +apps/web operator intent and status UI apps/worker settlement and reconciliation workers packages/contracts frozen v1 contract pack, OpenAPI, fixtures packages/domain intent, attempt, and settlement state @@ -143,6 +144,7 @@ Requires Node `24.19.0`, pnpm `11.19.0`, and PostgreSQL for integration tests. pnpm install pnpm lint && pnpm typecheck && pnpm build pnpm test +pnpm dev:frontend ``` Integration tests need a database: @@ -188,7 +190,7 @@ Under active development. **Testnet only.** | Settlement adapters and error taxonomy | Implemented, exercised against simulators | | Recovery evidence and safety core | In progress | | Subgraph MCP discovery and LLM recovery agent | Planned | -| Operator frontend | Not started | +| Operator frontend | Intent creation, replay/conflict, and authoritative status implemented | **No live settlement has been executed.** No Privy application, wallet, policy, or funded testnet account has been provisioned for this build. The adapters are diff --git a/apps/placeholder-frontend/index.html b/apps/placeholder-frontend/index.html deleted file mode 100644 index e31c964..0000000 --- a/apps/placeholder-frontend/index.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - OneShot — reliable agent payments - - - -
-
Building on Arc Testnet
-

OneShot

-

- Reliable payments for autonomous agents. - One intent, at most one settlement. -

- -
- - diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..92999af --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_ONESHOT_API_BASE_URL=http://127.0.0.1:3001 diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..c91d7d5 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,11 @@ +# OneShot web + +Minimal operator UI for creating or replaying a Business Intent and reading its authoritative status. + +```powershell +pnpm --filter @oneshot/web dev +``` + +Vite proxies `/v1` and `/health` to the local API. For a separate deployed API, set the public build variable `VITE_ONESHOT_API_BASE_URL`. Enter the demo service token at runtime; the UI keeps it in memory and never persists it. + +The client consumes generated `@oneshot/contracts` types from frozen OpenAPI v1. Tests use deterministic fetch responses matching that contract. diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d855379 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + OneShot — Intent & Authoritative Status + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..f8d4ab1 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@oneshot/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "clean": "tsc -b --clean", + "dev": "vite", + "lint": "eslint src test", + "preview": "vite preview", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@oneshot/contracts": "workspace:*", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@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/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..ce425ab --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from 'react'; + +import { OneShotApiClient } from './api/client.js'; +import { ErrorBoundary } from './components/ErrorBoundary.js'; +import { IntentForm } from './components/IntentForm.js'; +import { IntentStatusView } from './components/IntentStatusView.js'; +import { ReadinessBanner } from './components/ReadinessBanner.js'; +import './styles.css'; + +type Tab = 'create' | 'status'; + +export function App() { + const [activeTab, setActiveTab] = useState('create'); + const [selectedIntentId, setSelectedIntentId] = useState(''); + const [authToken, setAuthToken] = useState(''); + const client = useMemo( + () => + new OneShotApiClient({ + baseUrl: import.meta.env.VITE_ONESHOT_API_BASE_URL ?? '', + getAuthToken: () => authToken.trim() || null, + }), + [authToken], + ); + + function showStatus(intentId: string): void { + setSelectedIntentId(intentId); + setActiveTab('status'); + } + + return ( + +
+
+

ONESHOT / ARC TESTNET

+

One job. Many retries. One settlement.

+

Create a stable payment intent and follow its authoritative state.

+ +
+ +
+ + setAuthToken(event.target.value)} + /> + Memory only. Sent as Bearer authorization. +
+ + + +
+ {activeTab === 'create' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..45bab29 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,241 @@ +import type { + CreateIntentRequest, + ErrorResponse, + IntentResponse, + ReconcileResponse, +} from '@oneshot/contracts'; + +export interface ApiClientConfig { + readonly baseUrl?: string; + readonly getAuthToken?: () => string | null; + readonly fetchFn?: typeof fetch; +} + +export type CreateIntentResult = + | { readonly kind: 'ACCEPTED'; readonly intent: IntentResponse; readonly correlationId: string } + | { readonly kind: 'REPLAYED'; readonly intent: IntentResponse; readonly correlationId: string } + | { readonly kind: 'PAYLOAD_CONFLICT'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'UNAUTHORIZED'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'RATE_LIMITED'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'NOT_READY'; readonly message: string; readonly correlationId: string } + | { + readonly kind: 'ERROR'; + readonly code: string; + readonly message: string; + readonly correlationId: string; + }; + +export type GetIntentResult = + | { readonly kind: 'SUCCESS'; readonly intent: IntentResponse; readonly correlationId: string } + | { readonly kind: 'NOT_FOUND'; readonly correlationId: string } + | { readonly kind: 'UNAUTHORIZED'; readonly message: string; readonly correlationId: string } + | { + readonly kind: 'ERROR'; + readonly code: string; + readonly message: string; + readonly correlationId: string; + }; + +export type ReconcileResult = + | { + readonly kind: 'QUEUED'; + readonly response: ReconcileResponse; + readonly correlationId: string; + } + | { readonly kind: 'NOT_ALLOWED'; readonly message: string; readonly correlationId: string } + | { readonly kind: 'NOT_FOUND'; readonly correlationId: string } + | { readonly kind: 'ERROR'; readonly message: string; readonly correlationId: string }; + +export type ReadinessResult = + | { readonly status: 'ok'; readonly submissions_disabled?: boolean } + | { readonly status: 'not_ready'; readonly message: string }; + +export class OneShotApiClient { + private readonly baseUrl: string; + private readonly getAuthToken: () => string | null; + private readonly fetchFn: typeof fetch; + + constructor(config?: ApiClientConfig) { + this.baseUrl = config?.baseUrl ?? ''; + this.getAuthToken = config?.getAuthToken ?? (() => null); + this.fetchFn = config?.fetchFn ?? fetch.bind(globalThis); + } + + private buildHeaders(customCorrelationId?: string): HeadersInit { + const headers: Record = { + 'content-type': 'application/json', + 'x-correlation-id': customCorrelationId ?? crypto.randomUUID(), + }; + const token = this.getAuthToken(); + if (token) { + headers.authorization = `Bearer ${token}`; + } + return headers; + } + + async createOrReplayIntent( + request: CreateIntentRequest, + correlationId?: string, + ): Promise { + const headers = this.buildHeaders(correlationId); + const corrId = (headers as Record)['x-correlation-id'] ?? ''; + + try { + const res = await this.fetchFn(`${this.baseUrl}/v1/intents`, { + method: 'POST', + headers, + body: JSON.stringify(request), + }); + + if (res.status === 202) { + const intent = (await res.json()) as IntentResponse; + return { kind: 'ACCEPTED', intent, correlationId: corrId }; + } + + if (res.status === 200) { + const intent = (await res.json()) as IntentResponse; + return { kind: 'REPLAYED', intent, correlationId: corrId }; + } + + const err = (await res.json().catch(() => ({}))) as Partial; + const message = err.message ?? 'Unknown error'; + + if (res.status === 409 && err.code === 'INTENT_PAYLOAD_CONFLICT') { + return { kind: 'PAYLOAD_CONFLICT', message, correlationId: corrId }; + } + + if (res.status === 401 || res.status === 403) { + return { kind: 'UNAUTHORIZED', message, correlationId: corrId }; + } + + if (res.status === 429) { + return { kind: 'RATE_LIMITED', message, correlationId: corrId }; + } + + if (res.status === 503) { + return { kind: 'NOT_READY', message, correlationId: corrId }; + } + + return { + kind: 'ERROR', + code: err.code ?? 'UNKNOWN_ERROR', + message, + correlationId: corrId, + }; + } catch (e) { + return { + kind: 'ERROR', + code: 'NETWORK_ERROR', + message: e instanceof Error ? e.message : 'Network request failed', + correlationId: corrId, + }; + } + } + + async getIntent(id: string, correlationId?: string): Promise { + const headers = this.buildHeaders(correlationId); + const corrId = (headers as Record)['x-correlation-id'] ?? ''; + + try { + const res = await this.fetchFn(`${this.baseUrl}/v1/intents/${encodeURIComponent(id)}`, { + method: 'GET', + headers, + }); + + if (res.status === 200) { + const intent = (await res.json()) as IntentResponse; + return { kind: 'SUCCESS', intent, correlationId: corrId }; + } + + if (res.status === 404) { + return { kind: 'NOT_FOUND', correlationId: corrId }; + } + + const err = (await res.json().catch(() => ({}))) as Partial; + const message = err.message ?? 'Unknown error'; + + if (res.status === 401 || res.status === 403) { + return { kind: 'UNAUTHORIZED', message, correlationId: corrId }; + } + + return { + kind: 'ERROR', + code: err.code ?? 'UNKNOWN_ERROR', + message, + correlationId: corrId, + }; + } catch (e) { + return { + kind: 'ERROR', + code: 'NETWORK_ERROR', + message: e instanceof Error ? e.message : 'Network request failed', + correlationId: corrId, + }; + } + } + + async reconcileIntent(id: string, correlationId?: string): Promise { + const headers = this.buildHeaders(correlationId); + const corrId = (headers as Record)['x-correlation-id'] ?? ''; + + try { + const res = await this.fetchFn( + `${this.baseUrl}/v1/intents/${encodeURIComponent(id)}/reconcile`, + { + method: 'POST', + headers, + }, + ); + + if (res.status === 202) { + const response = (await res.json()) as ReconcileResponse; + return { kind: 'QUEUED', response, correlationId: corrId }; + } + + if (res.status === 404) { + return { kind: 'NOT_FOUND', correlationId: corrId }; + } + + const err = (await res.json().catch(() => ({}))) as Partial; + const message = err.message ?? 'Reconciliation not allowed'; + + if (res.status === 409) { + return { kind: 'NOT_ALLOWED', message, correlationId: corrId }; + } + + return { kind: 'ERROR', message, correlationId: corrId }; + } catch (e) { + return { + kind: 'ERROR', + message: e instanceof Error ? e.message : 'Network error', + correlationId: corrId, + }; + } + } + + async getReadiness(): Promise { + try { + const res = await this.fetchFn(`${this.baseUrl}/health/ready`, { + method: 'GET', + }); + + if (res.status === 200) { + const body = (await res.json()) as { status: 'ok'; submissions_disabled?: boolean }; + return { + status: 'ok', + ...(body.submissions_disabled !== undefined + ? { submissions_disabled: body.submissions_disabled } + : {}), + }; + } + + const err = (await res.json().catch(() => ({}))) as Partial; + return { status: 'not_ready', message: err.message ?? 'Service not ready' }; + } catch (e) { + return { + status: 'not_ready', + message: e instanceof Error ? e.message : 'Readiness check failed', + }; + } + } +} diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..255eb33 --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ReactNode } from 'react'; + +interface Props { + readonly children: ReactNode; +} + +interface State { + readonly error: string | null; +} + +export class ErrorBoundary extends Component { + override state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error: error.message || 'Unexpected application error.' }; + } + + override componentDidCatch(error: Error): void { + console.error('UI boundary caught error:', error.name); + } + + override render(): ReactNode { + if (this.state.error === null) return this.props.children; + return ( +
+
+

Something went wrong

+

{this.state.error}

+ +
+
+ ); + } +} diff --git a/apps/web/src/components/IntentForm.tsx b/apps/web/src/components/IntentForm.tsx new file mode 100644 index 0000000..eb98a25 --- /dev/null +++ b/apps/web/src/components/IntentForm.tsx @@ -0,0 +1,189 @@ +import { parseCreateIntentRequest, type CreateIntentRequest } from '@oneshot/contracts'; +import { useMemo, useState, type FormEvent } from 'react'; + +import type { CreateIntentResult, OneShotApiClient } from '../api/client.js'; +import { usdcToAtomicUnits } from '../utils/money.js'; + +interface Props { + readonly client: OneShotApiClient; + readonly onIntentCreatedOrSelected?: (intentId: string) => void; +} + +interface Outcome { + readonly kind: 'accepted' | 'replayed' | 'conflict' | 'error'; + readonly title: string; + readonly message: string; +} + +function buildRequest( + intentId: string, + recipient: string, + humanAmount: string, + purpose: string, +): CreateIntentRequest { + const amount = usdcToAtomicUnits(humanAmount); + if (amount === '0') throw new Error('Amount must be greater than zero.'); + return parseCreateIntentRequest({ + business_intent_id: intentId.trim(), + recipient: recipient.trim(), + amount_atomic: amount, + asset: 'USDC', + network: 'eip155:5042002', + purpose: purpose.trim(), + }); +} + +function outcomeFor(result: CreateIntentResult): Outcome { + switch (result.kind) { + case 'ACCEPTED': + return { + kind: 'accepted', + title: 'ACCEPTED — new intent', + message: `Authoritative state: ${result.intent.state}.`, + }; + case 'REPLAYED': + return { + kind: 'replayed', + title: 'REPLAYED — identical payload', + message: 'Existing intent returned. No duplicate settlement was created.', + }; + case 'PAYLOAD_CONFLICT': + return { + kind: 'conflict', + title: 'PAYLOAD CONFLICT', + message: + 'This ID already belongs to another immutable payload. Use a new ID only for a new obligation.', + }; + default: + return { kind: 'error', title: 'REQUEST FAILED', message: result.message }; + } +} + +export function IntentForm({ client, onIntentCreatedOrSelected }: Props) { + const [intentId, setIntentId] = useState(() => crypto.randomUUID()); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState('1.00'); + const [purpose, setPurpose] = useState('Paid API job'); + const [submitting, setSubmitting] = useState(false); + const [validationError, setValidationError] = useState(null); + const [outcome, setOutcome] = useState(null); + const atomicPreview = useMemo(() => { + try { + return usdcToAtomicUnits(amount); + } catch { + return 'Invalid amount'; + } + }, [amount]); + + function newObligation(): void { + setIntentId(crypto.randomUUID()); + setOutcome(null); + setValidationError(null); + } + + async function submit(event: FormEvent): Promise { + event.preventDefault(); + setOutcome(null); + setValidationError(null); + let request: CreateIntentRequest; + try { + request = buildRequest(intentId, recipient, amount, purpose); + } catch (error) { + setValidationError(error instanceof Error ? error.message : 'Invalid intent.'); + return; + } + + setSubmitting(true); + try { + const result = await client.createOrReplayIntent(request); + setOutcome(outcomeFor(result)); + if (result.kind === 'ACCEPTED' || result.kind === 'REPLAYED') { + onIntentCreatedOrSelected?.(result.intent.business_intent_id); + } + } finally { + setSubmitting(false); + } + } + + return ( +
void submit(event)} + > +
+

Create or replay intent

+

Same ID and payload returns the existing intent. A changed payload fails closed.

+
+ + {validationError && ( +
+ {validationError} +
+ )} + {outcome && ( +
+ {outcome.title} +

{outcome.message}

+
+ )} + +
+ + +
+ setIntentId(event.target.value)} + maxLength={128} + required + /> + Keep this ID unchanged for every retry of the same job. + + + setRecipient(event.target.value)} + placeholder="0x…" + spellCheck={false} + required + /> + +
+
+ + setAmount(event.target.value)} + required + /> + {atomicPreview} atomic units +
+
+ Settlement profile + Arc Testnet · USDC · eip155:5042002 + Fixed by OpenAPI v1. Payment amount is USDC; native value is separate. +
+
+ + + setPurpose(event.target.value)} + maxLength={256} + required + /> + + +
+ ); +} diff --git a/apps/web/src/components/IntentStatusView.tsx b/apps/web/src/components/IntentStatusView.tsx new file mode 100644 index 0000000..401c521 --- /dev/null +++ b/apps/web/src/components/IntentStatusView.tsx @@ -0,0 +1,225 @@ +import type { IntentResponse, IntentState } from '@oneshot/contracts'; +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; + +import type { OneShotApiClient } from '../api/client.js'; +import { atomicUnitsToUsdc } from '../utils/money.js'; + +interface Props { + readonly client: OneShotApiClient; + readonly initialIntentId?: string; +} + +const STOP_POLLING = new Set(['COMMITTED', 'FAILED_SAFE', 'REJECTED', 'UNKNOWN']); +const MAX_POLLS = 12; + +export function IntentStatusView({ client, initialIntentId = '' }: Props) { + const [searchId, setSearchId] = useState(initialIntentId); + const [activeId, setActiveId] = useState(initialIntentId); + const [intent, setIntent] = useState(null); + const [loading, setLoading] = useState(false); + const [pollCount, setPollCount] = useState(0); + const [polling, setPolling] = useState(false); + const [message, setMessage] = useState(null); + const [reconcileMessage, setReconcileMessage] = useState(null); + const [reconciling, setReconciling] = useState(false); + const timer = useRef | null>(null); + + useEffect(() => { + if (initialIntentId && initialIntentId !== activeId) { + setSearchId(initialIntentId); + setActiveId(initialIntentId); + } + }, [activeId, initialIntentId]); + + const read = useCallback( + async (id: string, showLoading = true): Promise => { + if (showLoading) setLoading(true); + setMessage(null); + try { + const result = await client.getIntent(id); + if (result.kind === 'SUCCESS') { + setIntent(result.intent); + return result.intent; + } + setIntent(null); + setMessage(result.kind === 'NOT_FOUND' ? `Intent "${id}" was not found.` : result.message); + return null; + } finally { + if (showLoading) setLoading(false); + } + }, + [client], + ); + + useEffect(() => { + if (!activeId) return; + let cancelled = false; + setPollCount(0); + setPolling(true); + + async function poll(attempt: number): Promise { + const current = await read(activeId, attempt === 0); + if ( + cancelled || + current === null || + STOP_POLLING.has(current.state) || + attempt + 1 >= MAX_POLLS + ) { + setPolling(false); + return; + } + setPollCount(attempt + 1); + timer.current = setTimeout( + () => void poll(attempt + 1), + Math.min(1_000 + attempt * 500, 4_000), + ); + } + + void poll(0); + return () => { + cancelled = true; + if (timer.current) clearTimeout(timer.current); + }; + }, [activeId, read]); + + function lookup(event: FormEvent): void { + event.preventDefault(); + const id = searchId.trim(); + if (!id) return; + if (id === activeId) void read(id); + else setActiveId(id); + } + + async function reconcile(): Promise { + if (intent?.state !== 'UNKNOWN') return; + setReconciling(true); + setReconcileMessage(null); + try { + const result = await client.reconcileIntent(intent.business_intent_id); + if (result.kind === 'QUEUED') { + setReconcileMessage( + 'Reconciliation job enqueued. Settlement remains blocked pending evidence.', + ); + await read(intent.business_intent_id); + } else if (result.kind === 'NOT_FOUND') { + setReconcileMessage('Reconciliation failed: intent was not found.'); + } else { + setReconcileMessage(`Reconciliation failed: ${result.message}`); + } + } finally { + setReconciling(false); + } + } + + return ( +
+
+

Authoritative status

+

Read from the OneShot ledger. UNKNOWN always blocks another payment.

+
+ +
+ + setSearchId(event.target.value)} + placeholder="Business Intent ID" + /> + + {activeId && ( + + )} +
+ + {message && ( +
+ {message} +
+ )} + {polling && ( +

+ Polling authoritative state · {pollCount + 1}/{MAX_POLLS} +

+ )} + + {intent && ( +
+
+
+ Authoritative state + {intent.state} +
+
+ Ledger version + {intent.version} +
+
+ Attempts + {intent.attempts.length} +
+
+ + {intent.state === 'UNKNOWN' && ( +
+

Settlement outcome is unknown

+

No retry is allowed until reconciliation finds authoritative evidence.

+ + {reconcileMessage &&

{reconcileMessage}

} +
+ )} + +
+
+
Business Intent ID
+
{intent.business_intent_id}
+
+
+
Recipient
+
{intent.recipient}
+
+
+
Amount
+
+ {atomicUnitsToUsdc(intent.amount_atomic)} USDC{' '} + ({intent.amount_atomic} atomic) +
+
+
+
Network
+
{intent.network}
+
+
+
Purpose
+
{intent.purpose}
+
+
+ +
+

Attempts

+ {intent.attempts.length === 0 ? ( +

No execution attempts yet.

+ ) : ( +
    + {intent.attempts.map((attempt) => ( +
  1. + {attempt.stage} · {attempt.created_at} + {attempt.sanitized_error &&

    {attempt.sanitized_error}

    } +
  2. + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/ReadinessBanner.tsx b/apps/web/src/components/ReadinessBanner.tsx new file mode 100644 index 0000000..261d309 --- /dev/null +++ b/apps/web/src/components/ReadinessBanner.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +import type { OneShotApiClient, ReadinessResult } from '../api/client.js'; + +export function ReadinessBanner({ client }: { readonly client: OneShotApiClient }) { + const [readiness, setReadiness] = useState(null); + + useEffect(() => { + let active = true; + void client.getReadiness().then((result) => { + if (active) setReadiness(result); + }); + return () => { + active = false; + }; + }, [client]); + + if (readiness === null) return
Checking backend readiness…
; + if (readiness.status !== 'ok') { + return
Backend unavailable: {readiness.message}
; + } + if (readiness.submissions_disabled) { + return ( +
+ Safe mode: submissions paused; reads and recovery remain available. +
+ ); + } + return
Backend ready · Arc Testnet · USDC
; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..28d3d29 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Root container #root not found in document'); +} + +const root = createRoot(container); +root.render( + + + , +); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..6e75ae2 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,350 @@ +:root { + color: #172033; + background: #f3f6fb; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +button { + min-height: 42px; + padding: 0.65rem 1rem; + border: 0; + border-radius: 0.65rem; + color: white; + background: #2056c9; + font-weight: 700; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +button.secondary { + color: #172033; + background: #e8edf7; +} + +button.compact { + min-height: 32px; + padding: 0.35rem 0.65rem; + font-size: 0.8rem; +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid #68a0ff; + outline-offset: 2px; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +.app-shell { + width: min(880px, calc(100% - 2rem)); + margin: 0 auto; + padding: 2.5rem 0; +} + +.app-header h1 { + margin-bottom: 0.5rem; + font-size: clamp(1.8rem, 5vw, 3rem); + line-height: 1.05; +} + +.app-header > p:not(.eyebrow) { + color: #52617a; +} + +.eyebrow { + margin-bottom: 0.7rem; + color: #2056c9; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.12em; +} + +.readiness, +.auth-bar, +.panel { + border: 1px solid #d8dfeb; + border-radius: 0.9rem; + background: white; +} + +.readiness { + padding: 0.75rem 1rem; + color: #42516a; +} + +.readiness.success { + border-color: #9ed8af; + color: #176b35; + background: #effaf2; +} + +.readiness.warning { + border-color: #e8c774; + color: #704d00; + background: #fff8df; +} + +.auth-bar { + display: grid; + grid-template-columns: auto minmax(180px, 1fr) auto; + align-items: center; + gap: 0.75rem; + margin: 1rem 0; + padding: 0.75rem 1rem; +} + +.auth-bar label, +.field-label, +.form label { + font-weight: 700; +} + +.auth-bar small, +.form small, +.muted { + color: #5d6b82; +} + +.tabs { + display: flex; + gap: 0.35rem; + margin: 1rem 0; + border-bottom: 1px solid #d8dfeb; +} + +.tabs button { + border-radius: 0.65rem 0.65rem 0 0; + color: #52617a; + background: transparent; +} + +.tabs button[aria-selected='true'] { + color: #1648ad; + background: white; +} + +.panel { + padding: clamp(1rem, 4vw, 1.75rem); + box-shadow: 0 12px 35px rgb(31 55 90 / 8%); +} + +.panel header p { + color: #52617a; +} + +.form { + display: grid; + gap: 0.75rem; +} + +.form input, +.lookup input, +.auth-bar input, +.form output { + width: 100%; + min-height: 42px; + padding: 0.65rem 0.75rem; + border: 1px solid #aeb9ca; + border-radius: 0.6rem; + color: #172033; + background: white; +} + +.form output { + display: flex; + align-items: center; + margin-top: 0.35rem; + background: #f6f8fb; +} + +.label-row, +.lookup, +.state-card { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.label-row { + justify-content: space-between; +} + +.payment-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.payment-grid > div { + display: grid; + gap: 0.35rem; +} + +.notice { + padding: 0.9rem 1rem; + border: 1px solid #b9c7da; + border-radius: 0.65rem; + background: #f5f8fd; +} + +.notice p:last-child { + margin-bottom: 0; +} + +.notice.accepted, +.notice.replayed { + border-color: #9ed8af; + color: #176b35; + background: #effaf2; +} + +.notice.warning, +.notice.conflict { + border-color: #e8c774; + color: #704d00; + background: #fff8df; +} + +.notice.error { + border-color: #e2a2a7; + color: #8d1f29; + background: #fff1f2; +} + +.lookup { + align-items: stretch; + margin-bottom: 1rem; +} + +.lookup input { + flex: 1; +} + +.polling { + color: #1648ad; + font-weight: 700; +} + +.intent-details { + display: grid; + gap: 1rem; +} + +.state-card { + justify-content: space-between; + padding: 1rem; + border: 1px solid #9bb9ec; + border-radius: 0.75rem; + background: #edf4ff; +} + +.state-card div { + display: grid; + gap: 0.2rem; +} + +.state-card small { + color: #52617a; +} + +.state-card strong { + font-size: 1.15rem; +} + +.state-committed { + border-color: #9ed8af; + background: #effaf2; +} + +.state-unknown { + border-color: #e8c774; + background: #fff8df; +} + +.state-failed_safe, +.state-rejected { + border-color: #e2a2a7; + background: #fff1f2; +} + +.facts { + margin: 0; +} + +.facts div { + display: grid; + grid-template-columns: minmax(140px, 0.4fr) 1fr; + gap: 1rem; + padding: 0.7rem 0; + border-bottom: 1px solid #e4e9f1; +} + +.facts dt { + color: #52617a; +} + +.facts dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.attempts { + display: grid; + gap: 0.5rem; + padding-left: 1.25rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 640px) { + .app-shell { + width: min(100% - 1rem, 880px); + padding: 1rem 0; + } + + .auth-bar, + .payment-grid, + .facts div { + grid-template-columns: 1fr; + } + + .lookup, + .state-card { + align-items: stretch; + flex-direction: column; + } +} diff --git a/apps/web/src/utils/money.ts b/apps/web/src/utils/money.ts new file mode 100644 index 0000000..2ddb327 --- /dev/null +++ b/apps/web/src/utils/money.ts @@ -0,0 +1,76 @@ +/** + * Strict non-floating-point monetary formatting and parsing for 6-decimal USDC. + * + * Invariant: Never use JavaScript floating-point numbers (no Number(), parseFloat(), Math.round(), or * 1e6). + * All conversions use string manipulation and BigInt. + */ + +export const USDC_DECIMALS = 6; +export const USDC_SCALE = 1_000_000n; + +/** + * Converts human decimal USDC string to 6-decimal atomic units string. + * Examples: + * "1.5" -> "1500000" + * "0.000001" -> "1" + * "100" -> "100000000" + */ +export function usdcToAtomicUnits(humanUsdc: string): string { + const trimmed = humanUsdc.trim(); + if (!trimmed) { + throw new Error('Amount cannot be empty'); + } + + if (!/^(0|[1-9]\d*)(\.\d+)?$/.test(trimmed)) { + throw new Error(`Invalid USDC amount format: "${humanUsdc}"`); + } + + const parts = trimmed.split('.'); + const intPart = parts[0] ?? '0'; + const fracPart = parts[1] ?? ''; + + if (fracPart.length > USDC_DECIMALS) { + throw new Error(`USDC precision exceeds ${USDC_DECIMALS} decimal places: "${humanUsdc}"`); + } + + const paddedFrac = fracPart.padEnd(USDC_DECIMALS, '0'); + const atomicBigInt = BigInt(intPart) * USDC_SCALE + BigInt(paddedFrac); + + return atomicBigInt.toString(); +} + +/** + * Converts 6-decimal atomic units string to human decimal USDC string. + * Examples: + * "1500000" -> "1.500000" + * "1" -> "0.000001" + * "100000000" -> "100.000000" + */ +export function atomicUnitsToUsdc(atomicStr: string): string { + const trimmed = atomicStr.trim(); + if (!/^(0|[1-9]\d*)$/.test(trimmed)) { + throw new Error(`Invalid atomic units format: "${atomicStr}"`); + } + + const atomicVal = BigInt(trimmed); + const intVal = atomicVal / USDC_SCALE; + const fracVal = atomicVal % USDC_SCALE; + + const fracPadded = fracVal.toString().padStart(USDC_DECIMALS, '0'); + return `${intVal}.${fracPadded}`; +} + +/** + * Formats atomic units for display. + */ +export function formatUsdcDisplay(atomicStr: string, trimTrailingZeros = false): string { + const full = atomicUnitsToUsdc(atomicStr); + if (!trimTrailingZeros) return full; + const parts = full.split('.'); + const intPart = parts[0] ?? '0'; + const fracPart = parts[1] ?? '00'; + const trimmedFrac = fracPart.replace(/0+$/, ''); + if (!trimmedFrac) return `${intPart}.00`; + if (trimmedFrac.length === 1) return `${intPart}.${trimmedFrac}0`; + return `${intPart}.${trimmedFrac}`; +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..92dea2d --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_ONESHOT_API_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/web/test/client.test.ts b/apps/web/test/client.test.ts new file mode 100644 index 0000000..07c59d1 --- /dev/null +++ b/apps/web/test/client.test.ts @@ -0,0 +1,72 @@ +import type { CreateIntentRequest, IntentResponse } from '@oneshot/contracts'; +import { describe, expect, it } from 'vitest'; + +import { OneShotApiClient } from '../src/api/client.js'; + +const request: CreateIntentRequest = { + business_intent_id: 'intent-web-1', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1000000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Paid API job', +}; + +const intent: IntentResponse = { + ...request, + payload_fingerprint: 'fingerprint', + state: 'READY', + version: 1, + attempts: [], + evidence: [], +}; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('OneShotApiClient', () => { + it('distinguishes accepted intents from identical replays', async () => { + let status = 202; + const calls: RequestInit[] = []; + const client = new OneShotApiClient({ + getAuthToken: () => 'demo-token', + fetchFn: async (_input, init) => { + calls.push(init ?? {}); + return json(status, intent); + }, + }); + + await expect(client.createOrReplayIntent(request)).resolves.toMatchObject({ kind: 'ACCEPTED' }); + status = 200; + await expect(client.createOrReplayIntent(request)).resolves.toMatchObject({ kind: 'REPLAYED' }); + expect(new Headers(calls[0]?.headers).get('authorization')).toBe('Bearer demo-token'); + }); + + it.each([ + [409, 'INTENT_PAYLOAD_CONFLICT', 'PAYLOAD_CONFLICT'], + [401, 'UNAUTHORIZED', 'UNAUTHORIZED'], + [429, 'RATE_LIMITED', 'RATE_LIMITED'], + [503, 'NOT_READY', 'NOT_READY'], + ] as const)('maps HTTP %s to %s', async (status, code, kind) => { + const client = new OneShotApiClient({ + fetchFn: async () => json(status, { code, message: code, correlation_id: 'corr-1' }), + }); + await expect(client.createOrReplayIntent(request, 'corr-1')).resolves.toMatchObject({ kind }); + }); + + it('fails closed on a network error', async () => { + const client = new OneShotApiClient({ + fetchFn: async () => { + throw new Error('offline'); + }, + }); + await expect(client.createOrReplayIntent(request)).resolves.toMatchObject({ + kind: 'ERROR', + code: 'NETWORK_ERROR', + }); + }); +}); diff --git a/apps/web/test/components.test.tsx b/apps/web/test/components.test.tsx new file mode 100644 index 0000000..49b10e9 --- /dev/null +++ b/apps/web/test/components.test.tsx @@ -0,0 +1,123 @@ +import type { IntentResponse, IntentState } from '@oneshot/contracts'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import axe from 'axe-core'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { OneShotApiClient } from '../src/api/client.js'; +import { IntentForm } from '../src/components/IntentForm.js'; +import { IntentStatusView } from '../src/components/IntentStatusView.js'; + +afterEach(cleanup); + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function intent(state: IntentState): IntentResponse { + return { + business_intent_id: 'intent-web-1', + recipient: '0x1111111111111111111111111111111111111111', + amount_atomic: '1000000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Paid API job', + payload_fingerprint: 'fingerprint', + state, + version: 1, + attempts: [], + evidence: [], + }; +} + +describe('IntentForm', () => { + it('preserves the stable ID and explains an identical replay', async () => { + const client = new OneShotApiClient({ + fetchFn: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { business_intent_id: string }; + return json(200, { ...intent('READY'), business_intent_id: body.business_intent_id }); + }, + }); + const user = userEvent.setup(); + render(); + + const id = screen.getByLabelText(/Business Intent ID/u) as HTMLInputElement; + const stableId = id.value; + await user.type( + screen.getByLabelText(/Recipient/u), + '0x1111111111111111111111111111111111111111', + ); + await user.click(screen.getByRole('button', { name: /Submit Intent/u })); + + expect(await screen.findByText(/REPLAYED/u)).toBeTruthy(); + expect(id.value).toBe(stableId); + }); + + it('shows payload conflict without generating another ID', async () => { + const client = new OneShotApiClient({ + fetchFn: async () => + json(409, { + code: 'INTENT_PAYLOAD_CONFLICT', + message: 'different payload', + correlation_id: 'corr-1', + }), + }); + const user = userEvent.setup(); + render(); + const id = screen.getByLabelText(/Business Intent ID/u) as HTMLInputElement; + const stableId = id.value; + await user.type( + screen.getByLabelText(/Recipient/u), + '0x1111111111111111111111111111111111111111', + ); + await user.click(screen.getByRole('button', { name: /Submit Intent/u })); + + expect(await screen.findByText(/PAYLOAD CONFLICT/u)).toBeTruthy(); + expect(id.value).toBe(stableId); + }); + + it('has no detectable structural accessibility violations', async () => { + const client = new OneShotApiClient({ fetchFn: async () => json(503, {}) }); + const { container } = render(); + expect( + (await axe.run(container, { rules: { 'color-contrast': { enabled: false } } })).violations, + ).toEqual([]); + }); +}); + +describe('IntentStatusView', () => { + it.each([ + 'AUTHORIZING', + 'READY', + 'SUBMITTING', + 'COMMITTED', + 'FAILED_SAFE', + 'UNKNOWN', + 'REJECTED', + ] as const)('renders authoritative %s state', async (state) => { + const client = new OneShotApiClient({ fetchFn: async () => json(200, intent(state)) }); + render(); + await waitFor(() => expect(screen.getByText(state)).toBeTruthy()); + }); + + it('holds UNKNOWN and only offers reconciliation', async () => { + const client = new OneShotApiClient({ + fetchFn: async (input, init) => { + if (String(input).endsWith('/reconcile') && init?.method === 'POST') { + return json(202, { business_intent_id: 'intent-web-1', queued: true, state: 'UNKNOWN' }); + } + return json(200, intent('UNKNOWN')); + }, + }); + const user = userEvent.setup(); + render(); + + const reconcile = await screen.findByRole('button', { name: /Enqueue Reconciliation/u }); + expect(screen.queryByRole('button', { name: /pay|retry settlement/iu })).toBeNull(); + await user.click(reconcile); + expect(await screen.findByText(/job enqueued/u)).toBeTruthy(); + }); +}); diff --git a/apps/web/test/money.test.ts b/apps/web/test/money.test.ts new file mode 100644 index 0000000..eb75d10 --- /dev/null +++ b/apps/web/test/money.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { atomicUnitsToUsdc, formatUsdcDisplay, usdcToAtomicUnits } from '../src/utils/money.js'; + +describe('USDC formatting', () => { + it.each([ + ['0.000001', '1'], + ['1', '1000000'], + ['1.25', '1250000'], + ['999999999999999999.999999', '999999999999999999999999'], + ])('round-trips %s without floating point', (human, atomic) => { + expect(usdcToAtomicUnits(human)).toBe(atomic); + expect(usdcToAtomicUnits(atomicUnitsToUsdc(atomic))).toBe(atomic); + }); + + it.each(['', '-1', '1e6', '1.0000001', '01'])('rejects invalid amount %s', (amount) => { + expect(() => usdcToAtomicUnits(amount)).toThrow(); + }); + + it('formats display values without losing atomic precision', () => { + expect(formatUsdcDisplay('1500000', true)).toBe('1.50'); + expect(formatUsdcDisplay('1000001', true)).toBe('1.000001'); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..fa2df75 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/.tsbuildinfo", + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "references": [{ "path": "../../packages/contracts" }] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..2523ea6 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,23 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/v1': { + target: 'http://127.0.0.1:3001', + changeOrigin: true, + }, + '/health': { + target: 'http://127.0.0.1:3001', + changeOrigin: true, + }, + }, + }, + build: { + sourcemap: false, + outDir: 'dist', + }, +}); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..203c68a --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['test/**/*.test.{ts,tsx}'], + restoreMocks: true, + }, +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 3891cda..400fa50 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ export default tseslint.config( }, }, { - files: ['**/*.ts'], + files: ['**/*.ts', '**/*.tsx'], rules: { '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', diff --git a/package.json b/package.json index 97342a5..05d2328 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "build": "tsc -b", "check:generated": "pnpm --filter @oneshot/contracts check:generated", "clean": "tsc -b --clean", - "deploy": "wrangler deploy", - "dev:frontend": "wrangler dev", + "deploy": "pnpm --filter @oneshot/web build && wrangler deploy", + "dev:frontend": "pnpm --filter @oneshot/web dev", "format": "prettier --write \"**/*.{ts,mts,mjs,json,jsonc,yml,yaml}\"", "format:check": "prettier --check \"**/*.{ts,mts,mjs,json,jsonc,yml,yaml}\"", "generate": "pnpm --filter @oneshot/contracts generate", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468543b..9e23ecc 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)(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)) + version: 5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(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 @@ -61,6 +61,52 @@ importers: specifier: 12.1.0 version: 12.1.0(supports-color@7.2.0) + apps/web: + dependencies: + '@oneshot/contracts': + specifier: workspace:* + version: link:../../packages/contracts + 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/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)(happy-dom@20.14.0)(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)) + apps/worker: dependencies: '@oneshot/arc-adapter': @@ -174,7 +220,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)(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)) + version: 5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(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: @@ -1072,6 +1118,12 @@ packages: '@types/ssh2@1.15.6': resolution: {integrity: sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.69.0': resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1339,6 +1391,10 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1480,6 +1536,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1673,6 +1733,10 @@ packages: engines: {node: '>=14.0.0', yarn: ^1.22.22} hasBin: true + happy-dom@20.14.0: + resolution: {integrity: sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2554,6 +2618,10 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -3353,6 +3421,14 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/whatwg-mimetype@3.0.2': + optional: true + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.3 + optional: true + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(supports-color@7.2.0))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.10.0(supports-color@7.2.0))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3618,6 +3694,11 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.13.3 + optional: true + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -3794,6 +3875,9 @@ snapshots: dependencies: once: 1.4.0 + entities@7.0.1: + optional: true + entities@8.0.0: {} error-ex@1.3.4: @@ -4056,6 +4140,20 @@ snapshots: - supports-color - typescript + happy-dom@20.14.0: + dependencies: + '@types/node': 24.13.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + has-flag@4.0.0: {} hashery@1.5.1: @@ -4937,7 +5035,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - 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)): + vitest@5.0.0(@types/node@24.13.3)(happy-dom@20.14.0)(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)) @@ -4955,6 +5053,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + happy-dom: 20.14.0 jsdom: 30.0.1(@noble/hashes@1.8.0) transitivePeerDependencies: - msw @@ -4965,6 +5064,9 @@ snapshots: webidl-conversions@8.0.1: {} + whatwg-mimetype@3.0.0: + optional: true + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1(@noble/hashes@1.8.0): diff --git a/tsconfig.json b/tsconfig.json index 6a69570..9f9125a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,9 @@ }, { "path": "./packages/recovery-ui" + }, + { + "path": "./apps/web" } ] } diff --git a/wrangler.jsonc b/wrangler.jsonc index 15032de..2850434 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,8 +2,12 @@ "$schema": "./node_modules/wrangler/config-schema.json", "name": "oneshot", "compatibility_date": "2026-09-07", + "build": { + "command": "pnpm --filter @oneshot/web build", + }, "assets": { - "directory": "./apps/placeholder-frontend", + "directory": "./apps/web/dist", + "not_found_handling": "single-page-application", }, "routes": [ {