diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d5195c..33a417b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: permissions: @@ -10,7 +11,7 @@ permissions: jobs: validate: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 services: postgres: image: postgres:18-alpine @@ -49,11 +50,32 @@ jobs: - name: Test run: npm test - - name: Install browser fixture - run: npx playwright install --with-deps chromium + - name: Install browser fixtures + run: npx playwright install --with-deps chromium firefox webkit - - name: Test wallet connection + - name: Test cross-browser product journeys run: npm run test:e2e --workspace @proofkey/web - - name: Build + - name: Build all workspaces run: npm run build + env: + VITE_ETHEREUM_SEPOLIA_RPC_URL: https://sepolia.rpc.proofkey.invalid + VITE_CREDITCOIN_RPC_URL: https://creditcoin.rpc.proofkey.invalid + VITE_USAGE_PAYMENT_REGISTRY_ADDRESS: '0x0000000000000000000000000000000000000001' + VITE_MACHINE_REGISTRY_ADDRESS: '0x0000000000000000000000000000000000000002' + VITE_ACCESS_PASS_ADDRESS: '0x0000000000000000000000000000000000000004' + VITE_PROOFKEY_ASC_ADDRESS: '0x0000000000000000000000000000000000000005' + VITE_DEMO_MACHINE_ID: '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc' + VITE_PROOF_WORKER_URL: https://relay.proofkey.invalid + + - name: Audit production configuration and bundle + run: npm run check:production + env: + VITE_ETHEREUM_SEPOLIA_RPC_URL: https://sepolia.rpc.proofkey.invalid + VITE_CREDITCOIN_RPC_URL: https://creditcoin.rpc.proofkey.invalid + VITE_USAGE_PAYMENT_REGISTRY_ADDRESS: '0x0000000000000000000000000000000000000001' + VITE_MACHINE_REGISTRY_ADDRESS: '0x0000000000000000000000000000000000000002' + VITE_ACCESS_PASS_ADDRESS: '0x0000000000000000000000000000000000000004' + VITE_PROOFKEY_ASC_ADDRESS: '0x0000000000000000000000000000000000000005' + VITE_DEMO_MACHINE_ID: '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc' + VITE_PROOF_WORKER_URL: https://relay.proofkey.invalid diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml new file mode 100644 index 0000000..d7993d1 --- /dev/null +++ b/.github/workflows/production-smoke.yml @@ -0,0 +1,39 @@ +name: Production smoke + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: production-smoke + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Verify public deployment without writes + run: npm run test:smoke --workspace @proofkey/web + env: + PRODUCTION_WEB_URL: https://proofkey.vercel.app + PRODUCTION_RELAY_URL: https://proofkey-relay.onrender.com diff --git a/README.md b/README.md index 60ebe47..2ea0c30 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ProofKey lets a customer pay for machine time on Ethereum Sepolia and unlocks a non-transferable access credential on Creditcoin. Attestcoin proves the source transaction to Creditcoin without bridging assets or trusting the relay worker. -**Status:** live testnet Product V1 · 94 automated tests · verified Sepolia-to-Creditcoin flow +**Status:** live testnet Product V1 · 143 automated checks · verified Sepolia-to-Creditcoin flow [Launch ProofKey](https://proofkey.vercel.app) · [View the Sepolia payment](https://sepolia.etherscan.io/tx/0xb646bed97cd5ecafec256ea121a3ab7b5d147cce9c38e9e8f5f96cccfd17b967) · [View the Creditcoin authorization](https://creditcoin-testnet.blockscout.com/tx/0x45313262557698e745662272a1da814b74bcebb65b39990b44603c78cca64510) @@ -187,19 +187,30 @@ Expected core result: npm run check ``` -The gate runs formatting, TypeScript checks, all automated tests, Solidity compilation, and production builds. +The gate runs formatting, TypeScript checks, all automated tests, Solidity compilation, and production builds. The browser journeys run deterministically in Chromium, Firefox, and WebKit in CI, including wallet recovery, a complete mocked payment-to-access journey, device authorization, keyboard navigation, and automated WCAG checks. | Suite | Tests | Coverage focus | | ---------------- | ------: | ------------------------------------------------------------------------------------------------------ | | Solidity | 52 | Receipt semantics, proof tampering, replay, authorization, pricing, ownership, expiry, reentrancy | | Relay worker | 23 | Leases, restart recovery, one-time handoffs, receipt signatures, CORS, readiness, secret-safe evidence | -| Customer web | 48 | Proof state, exact token math, operator workflow, session state machine, receipt tampering and expiry | -| Product browser | 7 | Multi-page rental, operator, proof, two-browser QR handoff, signed usage, and replay rejection | +| Customer web | 51 | Proof state, exact token math, operator workflow, diagnostics, privacy-safe telemetry, and sessions | +| Product browser | 11 | Multi-page rental, wallets, proof, QR handoff, accessibility, payment, and recovery journeys | | Device simulator | 6 | Locked/unlocking/unlocked/expired states, tampered results, RPC failure | -| **Total** | **136** | | +| **Total** | **143** | | The Solidity suite uses explicit verifier doubles at `0x0FD2` to isolate adversarial proof cases. Those tests are distinct from the committed live CC3 transaction, which executed against Creditcoin's real Native Query Verifier. +### Production readiness + +`/diagnostics` gives operators a privacy-safe, read-only view of public configuration, relay readiness, and both required chain IDs. Client faults use stable codes and never record wallet addresses, transaction hashes, RPC URLs, or arbitrary error messages. + +```bash +npm run check:production +npm run test:smoke --workspace @proofkey/web +``` + +The first command rejects incomplete or local-only configuration, secret-like client bundles, missing assets, source maps, and assets over the gzip budgets. The second performs a read-only deployment smoke test. See [`RELEASE.md`](RELEASE.md) for the release checklist and required environment values. + ## Repository structure | Path | Purpose | diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..deea27f --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,32 @@ +# Production release gate + +A ProofKey deployment is complete only after this checklist passes. Build success alone is not a release signal. + +## Automated gates + +- `npm run check` passes formatting, type checks, unit and contract tests, and all builds. +- `npm run check:production` rejects missing public configuration, credentials embedded in URLs, localhost coupling, source maps, secret-like bundle content, missing assets, and bundle-budget regressions. +- Mocked journeys pass in Chromium, Firefox, and WebKit, including wallet connection, checkout, relay state, activity recovery, proof exploration, operator onboarding, QR handoff, device refresh, signed usage, and replay rejection. +- Axe reports no serious or critical WCAG violations on the homepage and wallet dialog; keyboard navigation and a 390-pixel viewport are exercised. + +## Performance budgets + +| Metric | Enforced budget | +| --------------------------------- | ---------------: | +| Largest JavaScript chunk | 380 KiB gzip | +| Largest stylesheet | 20 KiB gzip | +| Production first contentful paint | 5 seconds | +| Target LCP | 2.5 seconds | +| Target INP | 200 milliseconds | +| Target CLS | 0.1 | + +The chunk and paint limits fail automation. LCP, INP, and CLS are release targets checked in the Vercel production analytics view because representative field data cannot be manufactured in CI. + +## Public smoke confirmation + +1. Run `npm run test:smoke --workspace @proofkey/web` with `PRODUCTION_WEB_URL` and `PRODUCTION_RELAY_URL` set to the deployed HTTPS origins. +2. Confirm the homepage, machine deep link, wallet modal, recorded proof deep link, and every same-origin static asset return successfully. +3. Confirm relay `/health` returns HTTP 200 and `/ready` reports whether database, chains, and relayer are ready. +4. Open `/diagnostics` and confirm configuration, Sepolia, Creditcoin, and relay checks are green. +5. Confirm Vercel contains only public `VITE_*` configuration and Render contains server secrets. Never copy a worker key, RPC credential, or deployment token into Vercel. +6. Confirm the scheduled read-only smoke workflow has a recent green run before announcing the deployment. diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts new file mode 100644 index 0000000..1b137c5 --- /dev/null +++ b/apps/web/e2e/accessibility.spec.ts @@ -0,0 +1,60 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +test('homepage and wallet modal have no serious accessibility violations', async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.route('https://**.rpc.proofkey.invalid/**', (route) => + route.abort(), + ); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + const homepage = await new AxeBuilder({ page }).analyze(); + expect( + homepage.violations.filter(({ impact }) => + ['serious', 'critical'].includes(impact ?? ''), + ), + ).toEqual([]); + + await page.getByRole('button', { name: 'Connect wallet' }).click(); + await expect( + page.getByRole('dialog', { name: 'Choose a wallet' }), + ).toBeVisible(); + const dialog = await new AxeBuilder({ page }) + .include('.wallet-dialog') + .analyze(); + expect( + dialog.violations.filter(({ impact }) => + ['serious', 'critical'].includes(impact ?? ''), + ), + ).toEqual([]); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); +}); + +test('primary navigation is keyboard-operable and mobile layout does not overflow', async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.route('https://**.rpc.proofkey.invalid/**', (route) => + route.abort(), + ); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Open navigation' }).focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveClass( + /open/, + ); + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('link', { name: 'System', exact: true }) + .focus(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/diagnostics$/); + const dimensions = await page.evaluate(() => ({ + width: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.width + 1); +}); diff --git a/apps/web/e2e/marketplace-checkout.spec.ts b/apps/web/e2e/marketplace-checkout.spec.ts index b3119c6..3e4cee8 100644 --- a/apps/web/e2e/marketplace-checkout.spec.ts +++ b/apps/web/e2e/marketplace-checkout.spec.ts @@ -19,10 +19,15 @@ const paymentInterface = new Interface([ 'function machineOffers(bytes32) view returns (address beneficiary,uint128 pricePerSecond,bool active)', 'function paymentToken() view returns (address)', 'function owner() view returns (address)', + 'function payForUsage(bytes32 machineId,uint64 duration,bytes32 paymentNonce) returns (bytes32 orderId)', ]); const tokenInterface = new Interface([ 'function decimals() view returns (uint8)', 'function symbol() view returns (string)', + 'function balanceOf(address) view returns (uint256)', + 'function allowance(address,address) view returns (uint256)', + 'function approve(address,uint256) returns (bool)', + 'function mint(address,uint256)', ]); const usageInterface = new Interface([ 'event UsagePaid(bytes32 indexed orderId,bytes32 indexed machineId,address indexed payer,address beneficiary,uint64 startTime,uint64 duration,uint256 amount)', @@ -231,6 +236,68 @@ test('operator metadata upload fails closed then resumes the confirmed onboardin }); }); +test('mocked payment proves access, unlocks, and resumes after refresh', async ({ + page, +}) => { + await installWallet(page); + await mockMarketplaceRpc(page); + let polls = 0; + await page.route('https://relay.invalid/**', async (route) => { + const request = route.request(); + if (request.method() === 'POST') + return route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ + sourceTransactionHash: `0x${'02'.padStart(64, '0')}`, + phase: 'queued', + }), + }); + polls += 1; + const completed = polls >= 2; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sourceTransactionHash: `0x${'02'.padStart(64, '0')}`, + phase: completed ? 'completed' : 'proof_generation', + orderId, + machineId, + payer: owner, + accessExpiresAt: '2500', + creditcoinTransactionHash: completed + ? `0x${'ab'.repeat(32)}` + : undefined, + }), + }); + }); + + await page.goto(`/rent/${machineId}`); + await page.getByRole('button', { name: 'Connect wallet' }).click(); + await page.getByRole('button', { name: /Playwright Wallet/ }).click(); + await page.getByRole('button', { name: 'Close wallet dialog' }).click(); + await page.getByRole('button', { name: 'Review exact rental' }).click(); + await page.getByRole('button', { name: 'Check balance & allowance' }).click(); + await expect( + page.getByRole('heading', { name: 'Verify wallet readiness' }), + ).toBeVisible({ timeout: 15_000 }); + await page + .getByRole('button', { name: 'Continue to secure payment' }) + .click(); + await page.getByRole('button', { name: /Approve if needed & pay/ }).click(); + await expect( + page.getByRole('heading', { name: 'Machine access ready' }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByRole('link', { name: 'Open secure device handoff' }), + ).toBeVisible(); + + await page.reload(); + await expect( + page.getByRole('heading', { name: 'Machine access ready' }), + ).toBeVisible({ timeout: 15_000 }); +}); + test('customer and device browsers complete a one-time signed machine session', async ({ browser, }) => { @@ -414,6 +481,10 @@ function rpcResult( result = isCreditcoin ? '0x18e8f' : '0xaa36a7'; else if (rpc.method === 'eth_blockNumber') result = isCreditcoin ? '0x539000' : '0xb26c00'; + else if (rpc.method === 'eth_getBalance') result = '0xde0b6b3a7640000'; + else if (rpc.method === 'eth_gasPrice') result = '0x3b9aca00'; + else if (rpc.method === 'eth_maxPriorityFeePerGas') result = '0x3b9aca00'; + else if (rpc.method === 'eth_estimateGas') result = '0x1d4c0'; else if (rpc.method === 'eth_getTransactionByHash') result = sourceTransaction(rpc.params?.[0] as string); else if (rpc.method === 'eth_getTransactionReceipt') @@ -479,7 +550,25 @@ function rpcResult( ) ? paymentInterface.encodeFunctionResult('owner', [owner]) : paymentInterface.encodeFunctionResult('paymentToken', [tokenAddress]); - else if ( + else if (target.endsWith('03')) { + if ( + call.data.startsWith(tokenInterface.getFunction('balanceOf')!.selector) + ) + result = tokenInterface.encodeFunctionResult('balanceOf', [10n ** 18n]); + else if ( + call.data.startsWith(tokenInterface.getFunction('allowance')!.selector) + ) + result = tokenInterface.encodeFunctionResult('allowance', [0n]); + else if ( + call.data.startsWith(tokenInterface.getFunction('decimals')!.selector) + ) + result = tokenInterface.encodeFunctionResult('decimals', [6]); + else if ( + call.data.startsWith(tokenInterface.getFunction('symbol')!.selector) + ) + result = tokenInterface.encodeFunctionResult('symbol', ['pkUSDC']); + else result = '0x'; + } else if ( call.data.startsWith(tokenInterface.getFunction('decimals')!.selector) ) result = tokenInterface.encodeFunctionResult('decimals', [6]); @@ -659,7 +748,7 @@ async function installWallet( signedMessages: Record = {}, ) { await page.addInitScript( - ({ approvedAccount, signatures }) => { + ({ approvedAccount, signatures, paidUsageLog, paySelector }) => { type Listener = (...arguments_: unknown[]) => void; const listeners = new Map>(); let chainId = '0xaa36a7'; @@ -720,6 +809,13 @@ async function installWallet( transactions.set(hash, request); return hash; } + if (method === 'eth_call') { + const call = (params?.[0] ?? {}) as { data?: string }; + if (call.data?.startsWith('0xdd62ed3e')) + return `0x${'0'.repeat(64)}`; + return '0x'; + } + if (method === 'eth_getBalance') return '0xde0b6b3a7640000'; if (method === 'eth_getTransactionByHash') { const hash = params?.[0] as string; const transaction = transactions.get(hash); @@ -757,7 +853,11 @@ async function installWallet( cumulativeGasUsed: '0x5208', gasUsed: '0x5208', contractAddress: null, - logs: [], + logs: + String(transaction.to).toLowerCase().endsWith('01') && + String(transaction.data).startsWith(paySelector) + ? [paidUsageLog] + : [], logsBloom: `0x${'00'.repeat(256)}`, status: '0x1', effectiveGasPrice: '0x1', @@ -799,7 +899,12 @@ async function installWallet( window.addEventListener('eip6963:requestProvider', announce); queueMicrotask(announce); }, - { approvedAccount: owner, signatures: signedMessages }, + { + approvedAccount: owner, + signatures: signedMessages, + paidUsageLog: usagePaid(), + paySelector: paymentInterface.getFunction('payForUsage')!.selector, + }, ); } diff --git a/apps/web/e2e/production-smoke.spec.ts b/apps/web/e2e/production-smoke.spec.ts new file mode 100644 index 0000000..f21292c --- /dev/null +++ b/apps/web/e2e/production-smoke.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; + +const machineId = + process.env.PRODUCTION_MACHINE_ID ?? + '0xc04beae61beb9471c4f24c8788a4624988d2948a5c3d3dd0b6ba1b7602875bcc'; +const proofTransaction = + process.env.PRODUCTION_PROOF_TRANSACTION ?? + '0xb646bed97cd5ecafec256ea121a3ab7b5d147cce9c38e9e8f5f96cccfd17b967'; +const relayUrl = + process.env.PRODUCTION_RELAY_URL ?? 'https://proofkey-relay.onrender.com'; + +test('deployed product routes, assets, wallet modal, and relay are live', async ({ + page, + request, +}) => { + const failedAssets: string[] = []; + page.on('response', (response) => { + const url = new URL(response.url()); + if ( + url.origin === new URL(process.env.PRODUCTION_WEB_URL!).origin && + ['document', 'script', 'stylesheet', 'font', 'image'].includes( + response.request().resourceType(), + ) && + response.status() >= 400 + ) + failedAssets.push(`${response.status()} ${url.pathname}`); + }); + + const home = await page.goto('/', { waitUntil: 'domcontentloaded' }); + expect(home?.status()).toBe(200); + await expect(page.getByRole('link', { name: 'ProofKey home' })).toBeVisible(); + await page.getByRole('button', { name: 'Connect wallet' }).click(); + await expect( + page.getByRole('dialog', { name: 'Choose a wallet' }), + ).toBeVisible(); + await page.keyboard.press('Escape'); + + const machine = await page.goto(`/machines/${machineId}`, { + waitUntil: 'domcontentloaded', + }); + expect(machine?.status()).toBe(200); + await expect( + page.locator('main h1, main [role="alert"] h2').first(), + ).toBeVisible(); + + const proof = await page.goto(`/proofs/${proofTransaction}`, { + waitUntil: 'domcontentloaded', + }); + expect(proof?.status()).toBe(200); + await expect( + page.locator('main h1, main [role="alert"] h2').first(), + ).toBeVisible(); + + const health = await request.get(`${relayUrl}/health`, { timeout: 60_000 }); + expect(health.status()).toBe(200); + expect((await health.json()).status).toBe('alive'); + expect(failedAssets).toEqual([]); + + const firstContentfulPaint = await page.evaluate( + () => + performance + .getEntriesByType('paint') + .find((entry) => entry.name === 'first-contentful-paint')?.startTime, + ); + if (firstContentfulPaint !== undefined) + expect(firstContentfulPaint).toBeLessThan(5_000); +}); diff --git a/apps/web/e2e/wallet.spec.ts b/apps/web/e2e/wallet.spec.ts index 78a727e..744b13b 100644 --- a/apps/web/e2e/wallet.spec.ts +++ b/apps/web/e2e/wallet.spec.ts @@ -103,3 +103,29 @@ test('connects an EIP-6963 wallet and restores it without another prompt', async ) .toBe('1'); }); + +test('recovers an already connected wallet from the wrong network', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Connect wallet' }).click(); + await page.getByRole('button', { name: /Playwright Wallet/ }).click(); + await page.getByRole('button', { name: 'Close wallet dialog' }).click(); + await page.evaluate(async () => { + await ( + window as Window & { + ethereum: { request: (request: unknown) => Promise }; + } + ).ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x1' }], + }); + }); + await expect( + page.getByRole('button', { name: 'Switch chain 1' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Switch chain 1' }).click(); + await expect( + page.getByRole('button', { name: 'Sepolia connected' }), + ).toBeVisible(); +}); diff --git a/apps/web/package.json b/apps/web/package.json index d97deb2..fda3191 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,8 +6,9 @@ "scripts": { "build": "tsc -p tsconfig.json --noEmit && vite build", "dev": "vite --host 0.0.0.0", - "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js dist-test/proof.test.js dist-test/operator.test.js dist-test/device-session.test.js", + "test": "tsc -p tsconfig.test.json && node --test dist-test/flow.test.js dist-test/product.test.js dist-test/wallet/state.test.js dist-test/worker.test.js dist-test/marketplace.test.js dist-test/rental.test.js dist-test/activity.test.js dist-test/proof.test.js dist-test/operator.test.js dist-test/device-session.test.js dist-test/diagnostics.test.js dist-test/telemetry.test.js", "test:e2e": "playwright test", + "test:smoke": "playwright test --config production-smoke.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { @@ -25,6 +26,7 @@ "wagmi": "^3.7.7" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@playwright/test": "^1.63.0", "@types/node": "22.7.5", "@types/react": "^19.3.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index c28abd2..c89aadd 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -2,10 +2,12 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', - fullyParallel: true, + testIgnore: '**/production-smoke.spec.ts', + fullyParallel: false, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, timeout: 90_000, + workers: 1, reporter: 'line', use: { baseURL: 'http://127.0.0.1:4175', @@ -16,9 +18,17 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, ], webServer: { - command: 'npm run build && vite preview --host 127.0.0.1 --port 4175', + command: 'vite --host 127.0.0.1 --port 4175', url: 'http://127.0.0.1:4175', reuseExistingServer: !process.env.CI, timeout: 180_000, diff --git a/apps/web/production-smoke.config.ts b/apps/web/production-smoke.config.ts new file mode 100644 index 0000000..42c78c3 --- /dev/null +++ b/apps/web/production-smoke.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.PRODUCTION_WEB_URL; +if (!baseURL) + throw new Error('Set PRODUCTION_WEB_URL for read-only smoke tests.'); + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/production-smoke.spec.ts', + timeout: 120_000, + expect: { timeout: 15_000 }, + retries: 1, + reporter: 'line', + use: { + baseURL, + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'production-chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/web/src/app/AppErrorBoundary.tsx b/apps/web/src/app/AppErrorBoundary.tsx new file mode 100644 index 0000000..b9735eb --- /dev/null +++ b/apps/web/src/app/AppErrorBoundary.tsx @@ -0,0 +1,37 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { clientFault, reportClientFault } from '../telemetry.js'; + +export class AppErrorBoundary extends Component< + { children: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo) { + reportClientFault(clientFault('REACT_RENDER_FAILURE', 'render', false)); + } + + render() { + if (!this.state.failed) return this.props.children; + return ( +
+ PROOFKEY / SAFE FAILURE +

The interface stopped safely.

+

+ No transaction was submitted by this error. Reload the application or + return home to recover. +

+
+ + Return home +
+
+ ); + } +} diff --git a/apps/web/src/app/AppProviders.tsx b/apps/web/src/app/AppProviders.tsx index e306440..39e4501 100644 --- a/apps/web/src/app/AppProviders.tsx +++ b/apps/web/src/app/AppProviders.tsx @@ -88,7 +88,31 @@ export function AppProviders({ children }: { children: ReactNode }) { function RuntimeProvider({ children }: { children: ReactNode }) { const runtime = useMemo(() => { try { - const config = loadAppConfig(import.meta.env); + const config = loadAppConfig({ + PROD: import.meta.env.PROD, + VITE_ETHEREUM_SEPOLIA_RPC_URL: import.meta.env + .VITE_ETHEREUM_SEPOLIA_RPC_URL, + VITE_CREDITCOIN_RPC_URL: import.meta.env.VITE_CREDITCOIN_RPC_URL, + VITE_USAGE_PAYMENT_REGISTRY_ADDRESS: import.meta.env + .VITE_USAGE_PAYMENT_REGISTRY_ADDRESS, + VITE_MACHINE_REGISTRY_ADDRESS: import.meta.env + .VITE_MACHINE_REGISTRY_ADDRESS, + VITE_DEMO_MACHINE_ID: import.meta.env.VITE_DEMO_MACHINE_ID, + VITE_PROOF_WORKER_URL: import.meta.env.VITE_PROOF_WORKER_URL, + VITE_SEPOLIA_EXPLORER_URL: import.meta.env.VITE_SEPOLIA_EXPLORER_URL, + VITE_CREDITCOIN_EXPLORER_URL: import.meta.env + .VITE_CREDITCOIN_EXPLORER_URL, + VITE_DEMO_MACHINE_NAME: import.meta.env.VITE_DEMO_MACHINE_NAME, + VITE_DEMO_MACHINE_LOCATION: import.meta.env.VITE_DEMO_MACHINE_LOCATION, + VITE_MACHINE_REGISTRY_DEPLOYMENT_BLOCK: import.meta.env + .VITE_MACHINE_REGISTRY_DEPLOYMENT_BLOCK, + VITE_USAGE_PAYMENT_REGISTRY_DEPLOYMENT_BLOCK: import.meta.env + .VITE_USAGE_PAYMENT_REGISTRY_DEPLOYMENT_BLOCK, + VITE_ACCESS_PASS_ADDRESS: import.meta.env.VITE_ACCESS_PASS_ADDRESS, + VITE_PROOFKEY_ASC_ADDRESS: import.meta.env.VITE_PROOFKEY_ASC_ADDRESS, + VITE_PROOFKEY_ASC_DEPLOYMENT_BLOCK: import.meta.env + .VITE_PROOFKEY_ASC_DEPLOYMENT_BLOCK, + } as unknown as ImportMetaEnv); const paymentClient = new PaymentClient(config); return { config, diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx index 386d409..cdc4191 100644 --- a/apps/web/src/app/AppShell.tsx +++ b/apps/web/src/app/AppShell.tsx @@ -3,6 +3,7 @@ import { Blocks, Compass, ExternalLink, + HeartPulse, Menu, RadioTower, ShieldCheck, @@ -21,6 +22,7 @@ const navigation = [ { to: '/activity', label: 'My rentals', icon: Activity }, { to: '/proofs/search', label: 'Proof explorer', icon: ShieldCheck }, { to: '/operator', label: 'Operator', icon: Blocks }, + { to: '/diagnostics', label: 'System', icon: HeartPulse }, ]; export function AppShell() { @@ -141,6 +143,7 @@ export function AppShell() {

Proof-backed access for machines that work in the real world.

+ System status CC3 testnet diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index c35c078..9c1dd27 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,4 +1,6 @@ -import { createBrowserRouter } from 'react-router'; +import { useEffect } from 'react'; +import { createBrowserRouter, useRouteError } from 'react-router'; +import { clientFault, reportClientFault } from '../telemetry.js'; import { AppShell } from './AppShell.js'; export const router = createBrowserRouter([ @@ -24,6 +26,10 @@ export const router = createBrowserRouter([ lazy: () => import('../pages/ProofPage.js'), }, { path: 'operator', lazy: () => import('../pages/OperatorPage.js') }, + { + path: 'diagnostics', + lazy: () => import('../pages/DiagnosticsPage.js'), + }, { path: 'device/:machineId', lazy: () => import('../pages/DevicePage.js'), @@ -34,6 +40,18 @@ export const router = createBrowserRouter([ ]); function RouteError() { + const error = useRouteError(); + useEffect(() => { + reportClientFault( + clientFault( + error instanceof Response + ? `ROUTE_HTTP_${error.status}` + : 'ROUTE_RENDER_FAILURE', + 'routing', + error instanceof Response && error.status >= 500, + ), + ); + }, [error]); return (
PROOFKEY / ROUTE ERROR diff --git a/apps/web/src/components/WalletDialog.tsx b/apps/web/src/components/WalletDialog.tsx index 68fad4c..997054d 100644 --- a/apps/web/src/components/WalletDialog.tsx +++ b/apps/web/src/components/WalletDialog.tsx @@ -12,7 +12,7 @@ import { Wallet, X, } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useBalance, useReadContract } from 'wagmi'; import { sepolia } from 'wagmi/chains'; import { erc20Abi, formatUnits, type Address } from 'viem'; @@ -24,6 +24,7 @@ export function WalletDialog() { const runtime = useRuntime(); const offer = useMachineOffer(); const [copied, setCopied] = useState(false); + const dialogRef = useRef(null); const address = runtime.account as Address | undefined; const tokenAddress = offer.data?.tokenAddress as Address | undefined; const ethBalance = useBalance({ @@ -56,14 +57,32 @@ export function WalletDialog() { useEffect(() => { if (!runtime.walletOpen) return; const previousOverflow = document.body.style.overflow; + const previouslyFocused = document.activeElement as HTMLElement | null; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') runtime.closeWallet(); + if (event.key !== 'Tab' || !dialogRef.current) return; + const focusable = [ + ...dialogRef.current.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ]; + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } }; document.body.style.overflow = 'hidden'; window.addEventListener('keydown', closeOnEscape); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener('keydown', closeOnEscape); + previouslyFocused?.focus(); }; }, [runtime.walletOpen, runtime.closeWallet]); @@ -83,6 +102,7 @@ export function WalletDialog() { onMouseDown={runtime.closeWallet} >
{ + globalThis.fetch = originalFetch; +}); + +test('reports ready only when relay readiness and both chain IDs pass', async () => { + globalThis.fetch = async (input) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ status: 'alive' }); + if (url.endsWith('/ready')) return Response.json({ status: 'ready' }); + return Response.json({ + result: url.includes('sepolia') ? '0xaa36a7' : '0x18e8f', + }); + }; + const result = await runDiagnostics(config); + assert.equal(result.status, 'ready'); + assert.equal( + result.checks.filter((check) => check.status === 'ready').length, + 4, + ); +}); + +test('wrong chain and degraded relay remain explicit without leaking URLs', async () => { + globalThis.fetch = async (input) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ status: 'alive' }); + if (url.endsWith('/ready')) + return Response.json({ status: 'degraded' }, { status: 503 }); + return Response.json({ result: '0x1' }); + }; + const result = await runDiagnostics(config); + assert.equal(result.status, 'unavailable'); + assert.equal( + result.checks.find((check) => check.id === 'relay')?.status, + 'degraded', + ); + assert.ok(result.checks.every((check) => !check.detail.includes('example'))); +}); diff --git a/apps/web/src/diagnostics.ts b/apps/web/src/diagnostics.ts new file mode 100644 index 0000000..6ffeb41 --- /dev/null +++ b/apps/web/src/diagnostics.ts @@ -0,0 +1,157 @@ +import type { AppConfig } from './contracts.js'; +import { + clientFault, + reportClientFault, + type ClientArea, +} from './telemetry.js'; + +export type DiagnosticStatus = 'ready' | 'degraded' | 'unavailable'; + +export interface DiagnosticCheck { + id: 'configuration' | 'relay' | 'sepolia' | 'creditcoin'; + label: string; + status: DiagnosticStatus; + code: string; + latencyMs: number; + detail: string; +} + +export interface DiagnosticSnapshot { + status: DiagnosticStatus; + checkedAt: string; + checks: DiagnosticCheck[]; +} + +export async function runDiagnostics( + config: AppConfig, +): Promise { + const checks = await Promise.all([ + Promise.resolve({ + id: 'configuration' as const, + label: 'Public configuration', + status: 'ready' as const, + code: 'CONFIGURATION_READY', + latencyMs: 0, + detail: 'Required public contract addresses and HTTPS services loaded.', + }), + relayCheck(config.workerUrl), + rpcCheck( + 'sepolia', + 'Ethereum Sepolia RPC', + config.sepoliaRpcUrl, + 11155111n, + ), + rpcCheck( + 'creditcoin', + 'Creditcoin CC3 RPC', + config.creditcoinRpcUrl, + 102031n, + ), + ]); + return { + status: checks.some((check) => check.status === 'unavailable') + ? 'unavailable' + : checks.some((check) => check.status === 'degraded') + ? 'degraded' + : 'ready', + checkedAt: new Date().toISOString(), + checks, + }; +} + +async function relayCheck(workerUrl: string): Promise { + return timedCheck('relay', 'Attestcoin relay', 'relay', async () => { + const [health, readiness] = await Promise.all([ + fetch(`${workerUrl}/health`, { cache: 'no-store' }), + fetch(`${workerUrl}/ready`, { cache: 'no-store' }), + ]); + if (!health.ok) throw new Error('RELAY_HEALTH_HTTP_ERROR'); + const body = (await readiness.json().catch(() => undefined)) as + { status?: string } | undefined; + if (!readiness.ok || body?.status !== 'ready') + return { + status: 'degraded' as const, + code: 'RELAY_NOT_READY', + detail: 'Relay is alive but one or more readiness checks are degraded.', + }; + return { + status: 'ready' as const, + code: 'RELAY_READY', + detail: 'Relay API, database, RPCs, and relayer report ready.', + }; + }); +} + +async function rpcCheck( + id: 'sepolia' | 'creditcoin', + label: string, + url: string, + expectedChainId: bigint, +): Promise { + return timedCheck(id, label, `${id}_rpc` as ClientArea, async () => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_chainId', + params: [], + }), + }); + const body = (await response.json()) as { result?: string }; + if (!response.ok || !body.result) + throw new Error(`${id.toUpperCase()}_RPC_HTTP_ERROR`); + if (BigInt(body.result) !== expectedChainId) + throw new Error(`${id.toUpperCase()}_CHAIN_MISMATCH`); + return { + status: 'ready' as const, + code: `${id.toUpperCase()}_RPC_READY`, + detail: `Chain ID ${expectedChainId} verified without a wallet.`, + }; + }); +} + +async function timedCheck( + id: DiagnosticCheck['id'], + label: string, + area: ClientArea, + run: () => Promise>, +): Promise { + const started = performance.now(); + try { + return { + id, + label, + ...(await withTimeout(run(), 8_000)), + latencyMs: Math.round(performance.now() - started), + }; + } catch (error) { + const code = + error instanceof Error && /^[A-Z0-9_]+$/.test(error.message) + ? error.message + : `${id.toUpperCase()}_UNAVAILABLE`; + reportClientFault(clientFault(code, area, true)); + return { + id, + label, + status: 'unavailable', + code, + latencyMs: Math.round(performance.now() - started), + detail: + 'The service could not be verified. Dependent actions fail safely.', + }; + } +} + +function withTimeout(promise: Promise, milliseconds: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + globalThis.setTimeout( + () => reject(new Error('DIAGNOSTIC_TIMEOUT')), + milliseconds, + ), + ), + ]); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index df9ef24..eaacd41 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,16 +2,22 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router/dom'; import { AppProviders } from './app/AppProviders.js'; +import { AppErrorBoundary } from './app/AppErrorBoundary.js'; import { router } from './app/router.js'; +import { installGlobalTelemetry } from './telemetry.js'; import './styles.css'; const root = document.querySelector('#root'); if (!root) throw new Error('ProofKey could not find its application root.'); +installGlobalTelemetry(); + createRoot(root).render( - - - + + + + + , ); diff --git a/apps/web/src/pages/DiagnosticsPage.tsx b/apps/web/src/pages/DiagnosticsPage.tsx new file mode 100644 index 0000000..467d368 --- /dev/null +++ b/apps/web/src/pages/DiagnosticsPage.tsx @@ -0,0 +1,137 @@ +import { + Activity, + Check, + CircleAlert, + Clock3, + RefreshCw, + Server, + ShieldCheck, + WifiOff, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useRuntime } from '../app/AppProviders.js'; +import { DataState, PageHeading } from '../components/ProductUI.js'; +import { + runDiagnostics, + type DiagnosticSnapshot, + type DiagnosticStatus, +} from '../diagnostics.js'; + +export function Component() { + const runtime = useRuntime(); + const [snapshot, setSnapshot] = useState(); + const [checking, setChecking] = useState(false); + + async function inspect() { + if (!runtime.config) return; + setChecking(true); + try { + setSnapshot(await runDiagnostics(runtime.config)); + } finally { + setChecking(false); + } + } + + useEffect(() => { + void inspect(); + }, [runtime.config]); + + if (!runtime.config) + return ( +
+ +
+ ); + + return ( +
+ void inspect()} + > + + Run checks + + } + /> + +
+
+ {snapshot?.status === 'ready' ? ( + + ) : snapshot ? ( + + ) : ( + + )} +
+
+

CURRENT READINESS

+

{readinessTitle(snapshot?.status)}

+

+ {snapshot + ? `Checked ${new Date(snapshot.checkedAt).toLocaleString()}` + : 'Contacting public infrastructure…'} +

+
+
+ +
+ {(snapshot?.checks ?? []).map((check) => ( +
+
+ + {check.status === 'ready' ? ( + + ) : ( + + )} + + {check.code} +
+ +

{check.label}

+

{check.detail}

+
+ {check.latencyMs} ms +
+
+ ))} + {!snapshot && + [0, 1, 2, 3].map((item) => ( +
+ ))} +
+ +
+ +
+ Privacy-safe client telemetry +

+ Failures emit only a stable code, service area, retryability, + timestamp, and redacted route. Wallets, RPC URLs, errors, and public + identifiers are excluded. +

+
+
+
+ ); +} + +function readinessTitle(status?: DiagnosticStatus) { + if (!status) return 'Checking every dependency'; + if (status === 'ready') return 'ProofKey infrastructure is ready'; + if (status === 'degraded') return 'Some actions may be delayed'; + return 'Dependent actions are unavailable'; +} diff --git a/apps/web/src/product.test.ts b/apps/web/src/product.test.ts index 76bc8af..e1605d5 100644 --- a/apps/web/src/product.test.ts +++ b/apps/web/src/product.test.ts @@ -20,6 +20,7 @@ test('Product V1 exposes every required route', () => { '/sessions/:sourceTransactionHash', '/proofs/:sourceTxHash', '/operator', + '/diagnostics', '/device/:machineId', ]); }); diff --git a/apps/web/src/product.ts b/apps/web/src/product.ts index dc6a9e5..319f30f 100644 --- a/apps/web/src/product.ts +++ b/apps/web/src/product.ts @@ -7,6 +7,7 @@ export const productRoutes = [ '/sessions/:sourceTransactionHash', '/proofs/:sourceTxHash', '/operator', + '/diagnostics', '/device/:machineId', ] as const; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index fc577d7..727836d 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -11,7 +11,7 @@ --lime: #ddf45b; --orange: #ff6847; --paper: #f8f7f1; - --muted: #6c766f; + --muted: #556058; --line: rgba(18, 27, 22, 0.13); --shadow: 0 24px 70px rgba(23, 34, 28, 0.1); } @@ -307,7 +307,7 @@ input:focus-visible { } .button.primary { background: var(--orange); - color: #fff; + color: var(--forest-dark); box-shadow: 0 14px 30px rgba(255, 104, 71, 0.17); } .button.secondary { @@ -346,7 +346,7 @@ input:focus-visible { } .eyebrow { margin: 0 0 12px; - color: #768078; + color: #59645d; font-family: 'DM Mono', monospace; font-size: 9px; letter-spacing: 0.13em; @@ -388,7 +388,7 @@ input:focus-visible { display: inline-flex; align-items: center; gap: 5px; - color: #8b948e; + color: #59645d; font-family: 'DM Mono', monospace; font-size: 8px; letter-spacing: 0.08em; @@ -580,7 +580,7 @@ input:focus-visible { } .protocol-stats strong { float: right; - color: #adb3af; + color: #59645d; font-family: 'DM Mono', monospace; font-size: 9px; } @@ -2353,8 +2353,8 @@ input:focus-visible { } .wallet-option-unavailable { border-style: dashed; - background: transparent; - opacity: 0.58; + background: #f6f5ee; + color: var(--muted); } .wallet-options strong, .wallet-options small { @@ -4584,3 +4584,190 @@ input:focus-visible { grid-template-columns: 1fr; } } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +/* Production diagnostics */ +.diagnostic-hero { + display: flex; + align-items: center; + gap: 24px; + margin-bottom: 22px; + padding: 30px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 24px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} +.diagnostic-hero.ready { + color: #effff5; + border-color: #214e35; + background: + radial-gradient(circle at 12% 50%, rgb(101 241 161 / 18%), transparent 25%), + #07170f; +} +.diagnostic-hero.degraded, +.diagnostic-hero.unavailable { + border-color: #7b493d; + background: #261511; + color: #fff7f4; +} +.diagnostic-orbit { + display: grid; + flex: 0 0 92px; + width: 92px; + height: 92px; + place-items: center; + border: 1px solid currentColor; + border-radius: 50%; + color: #63e99b; + box-shadow: 0 0 0 12px rgb(100 236 158 / 6%); +} +.diagnostic-hero.degraded .diagnostic-orbit, +.diagnostic-hero.unavailable .diagnostic-orbit { + color: #ff9885; +} +.diagnostic-hero h2 { + margin: 6px 0; + font-family: var(--display); + font-size: clamp(1.8rem, 4vw, 3rem); + line-height: 1; +} +.diagnostic-hero p:last-child { + margin: 0; + color: #8ea398; +} +.diagnostic-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} +.diagnostic-grid > article, +.diagnostic-skeleton { + min-height: 260px; + padding: 23px; + border: 1px solid var(--line); + border-radius: 19px; + background: var(--surface); +} +.diagnostic-grid > article.ready { + border-top: 4px solid #4bc982; +} +.diagnostic-grid > article.degraded, +.diagnostic-grid > article.unavailable { + border-top: 4px solid var(--orange); +} +.diagnostic-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 35px; +} +.diagnostic-card-head > span { + display: grid; + width: 31px; + height: 31px; + place-items: center; + border-radius: 50%; + color: #0a3b24; + background: #dff6e8; +} +.diagnostic-grid article.unavailable .diagnostic-card-head > span, +.diagnostic-grid article.degraded .diagnostic-card-head > span { + color: #893b2d; + background: #ffe5df; +} +.diagnostic-card-head small { + overflow: hidden; + max-width: 145px; + color: var(--muted); + font: 700 0.62rem var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} +.diagnostic-grid article > svg { + color: var(--green); +} +.diagnostic-grid article h2 { + margin: 12px 0 9px; + font-size: 1rem; +} +.diagnostic-grid article p { + min-height: 60px; + margin: 0; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.55; +} +.diagnostic-grid article > div:last-child { + display: flex; + align-items: center; + gap: 6px; + margin-top: 18px; + color: var(--muted); + font: 700 0.65rem var(--mono); +} +.diagnostic-skeleton { + background: linear-gradient(100deg, #f1f3ed 25%, #fafbf8 40%, #f1f3ed 60%); + background-size: 200% 100%; + animation: skeleton-wave 1.4s infinite; +} +.privacy-telemetry-note { + display: flex; + gap: 13px; + margin-top: 20px; + padding: 18px 20px; + border: 1px solid #cde2d4; + border-radius: 14px; + color: #195d3b; + background: #eff8f2; +} +.privacy-telemetry-note p { + margin: 4px 0 0; + color: #587064; + font-size: 0.77rem; + line-height: 1.5; +} +.fatal-actions { + display: flex; + justify-content: center; + gap: 10px; +} +.fatal-actions button, +.fatal-actions a { + padding: 12px 16px; + border: 1px solid currentColor; + border-radius: 10px; + color: inherit; + background: transparent; + font-weight: 800; + text-decoration: none; +} +@media (max-width: 1000px) { + .diagnostic-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 600px) { + .diagnostic-hero { + align-items: flex-start; + flex-direction: column; + } + .diagnostic-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/telemetry.test.ts b/apps/web/src/telemetry.test.ts new file mode 100644 index 0000000..d4ee060 --- /dev/null +++ b/apps/web/src/telemetry.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { clientFault, reportClientFault } from './telemetry.js'; + +test('client telemetry redacts public identifiers and excludes arbitrary errors', () => { + let record = ''; + const previous = console.warn; + console.warn = (value) => { + record = String(value); + }; + try { + reportClientFault( + clientFault('CREDITCOIN_RPC_UNAVAILABLE', 'creditcoin_rpc', true), + `/proofs/0x${'ab'.repeat(32)}`, + ); + } finally { + console.warn = previous; + } + const parsed = JSON.parse(record) as Record; + assert.equal(parsed.code, 'CREDITCOIN_RPC_UNAVAILABLE'); + assert.equal(parsed.route, '/proofs/:public-identifier'); + assert.equal('message' in parsed, false); + assert.equal(record.includes('ab'.repeat(32)), false); +}); diff --git a/apps/web/src/telemetry.ts b/apps/web/src/telemetry.ts new file mode 100644 index 0000000..9a5b91c --- /dev/null +++ b/apps/web/src/telemetry.ts @@ -0,0 +1,61 @@ +export type ClientArea = + | 'render' + | 'routing' + | 'wallet' + | 'sepolia_rpc' + | 'creditcoin_rpc' + | 'relay' + | 'configuration'; + +export interface ClientFault { + schema: 'proofkey.client-fault.v1'; + code: string; + area: ClientArea; + retryable: boolean; + timestamp: string; +} + +export function clientFault( + code: string, + area: ClientArea, + retryable: boolean, +): ClientFault { + return { + schema: 'proofkey.client-fault.v1', + code, + area, + retryable, + timestamp: new Date().toISOString(), + }; +} + +export function reportClientFault( + fault: ClientFault, + route = globalThis.location?.pathname ?? '/', +): void { + const safeRoute = route.replace( + /0x[0-9a-fA-F]{40,64}/g, + ':public-identifier', + ); + console.warn( + JSON.stringify({ + ...fault, + route: safeRoute, + }), + ); +} + +export function installGlobalTelemetry(): () => void { + const onError = () => + reportClientFault(clientFault('UNHANDLED_WINDOW_ERROR', 'render', false)); + const onRejection = () => + reportClientFault( + clientFault('UNHANDLED_PROMISE_REJECTION', 'render', false), + ); + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onRejection); + return () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onRejection); + }; +} diff --git a/apps/web/tsconfig.test.json b/apps/web/tsconfig.test.json index fea08fa..c0fd39a 100644 --- a/apps/web/tsconfig.test.json +++ b/apps/web/tsconfig.test.json @@ -29,6 +29,10 @@ "src/operator.ts", "src/operator.test.ts", "src/device-session.ts", - "src/device-session.test.ts" + "src/device-session.test.ts", + "src/diagnostics.ts", + "src/diagnostics.test.ts", + "src/telemetry.ts", + "src/telemetry.test.ts" ] } diff --git a/package-lock.json b/package-lock.json index 8e40f37..e502415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "wagmi": "^3.7.7" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@playwright/test": "^1.63.0", "@types/node": "22.7.5", "@types/react": "^19.3.0", @@ -231,6 +232,19 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz", + "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.10.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@base-org/account": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@base-org/account/-/account-2.4.0.tgz", @@ -3838,6 +3852,16 @@ "node": ">=8.0.0" } }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", diff --git a/package.json b/package.json index 32be980..123cd2a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", + "audit:web": "node scripts/audit-web-build.mjs", "check": "npm run lint && npm run typecheck && npm run test && npm run build", + "check:production": "node scripts/validate-production-config.mjs && npm run build --workspace @proofkey/web && npm run audit:web", "format": "prettier --write .", "live:mvp": "npm run live:mvp --workspace @proofkey/worker", "live:verify": "npm run live:verify --workspace @proofkey/worker", diff --git a/scripts/audit-web-build.mjs b/scripts/audit-web-build.mjs new file mode 100644 index 0000000..fac438a --- /dev/null +++ b/scripts/audit-web-build.mjs @@ -0,0 +1,100 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { extname, join, relative } from 'node:path'; + +const root = join(import.meta.dirname, '..'); +const output = join(root, 'apps', 'web', 'dist'); +const indexPath = join(output, 'index.html'); +const failures = []; +if (!existsSync(indexPath)) + failures.push('apps/web/dist/index.html is missing.'); + +const files = existsSync(output) ? walk(output) : []; +const textFiles = files.filter((file) => + ['.html', '.js', '.css', '.json', '.map'].includes(extname(file)), +); +if (files.some((file) => extname(file) === '.map')) + failures.push('Production source maps must not be published.'); + +const combined = textFiles + .filter((file) => extname(file) !== '.map') + .map((file) => readFileSync(file, 'utf8')) + .join('\n'); +const forbidden = [ + { + pattern: + /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):(?:4173|4174|4175|5173|8787)/i, + label: 'ProofKey localhost URL', + }, + { + pattern: /VITE_DEVICE_SIMULATOR_URL/i, + label: 'retired device simulator configuration', + }, + { + pattern: /(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}/, + label: 'GitHub credential', + }, + { pattern: /sk_live_[A-Za-z0-9]{16,}/, label: 'live payment secret' }, + { pattern: /VERCEL_TOKEN\s*[:=]\s*["'][^"']+/i, label: 'Vercel token' }, + { + pattern: /PRIVATE_KEY\s*[:=]\s*["']0x[0-9a-fA-F]{64}/i, + label: 'private key', + }, +]; +for (const check of forbidden) + if (check.pattern.test(combined)) + failures.push(`Client bundle contains a forbidden ${check.label}.`); + +for (const name of [ + 'VITE_ETHEREUM_SEPOLIA_RPC_URL', + 'VITE_CREDITCOIN_RPC_URL', + 'VITE_PROOF_WORKER_URL', + 'VITE_USAGE_PAYMENT_REGISTRY_ADDRESS', + 'VITE_MACHINE_REGISTRY_ADDRESS', + 'VITE_ACCESS_PASS_ADDRESS', + 'VITE_PROOFKEY_ASC_ADDRESS', + 'VITE_DEMO_MACHINE_ID', +]) { + const value = process.env[name]; + if (!value || !combined.toLowerCase().includes(value.toLowerCase())) + failures.push(`${name} was not emitted into the production bundle.`); +} + +if (existsSync(indexPath)) { + const html = readFileSync(indexPath, 'utf8'); + const assets = [...html.matchAll(/(?:src|href)=["']([^"']+)["']/g)] + .map((match) => match[1]) + .filter((value) => value?.startsWith('/assets/')); + for (const asset of assets) { + const path = join(output, asset.slice(1)); + if (!existsSync(path)) + failures.push(`Referenced asset is missing: ${asset}`); + } +} + +const budgets = { '.js': 380 * 1024, '.css': 20 * 1024 }; +for (const file of files) { + const extension = extname(file); + const budget = budgets[extension]; + if (!budget) continue; + const compressed = gzipSync(readFileSync(file)).byteLength; + if (compressed > budget) + failures.push( + `${relative(output, file)} is ${Math.ceil(compressed / 1024)} KiB gzip; budget is ${budget / 1024} KiB.`, + ); +} + +if (failures.length) { + console.error(`Production bundle audit failed:\n- ${failures.join('\n- ')}`); + process.exit(1); +} +console.log( + `Production bundle audit passed (${files.length} files, no secrets, localhost references, missing assets, or budget violations).`, +); + +function walk(directory) { + return readdirSync(directory).flatMap((name) => { + const path = join(directory, name); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} diff --git a/scripts/validate-production-config.mjs b/scripts/validate-production-config.mjs new file mode 100644 index 0000000..33bb9eb --- /dev/null +++ b/scripts/validate-production-config.mjs @@ -0,0 +1,38 @@ +const requiredUrls = [ + 'VITE_ETHEREUM_SEPOLIA_RPC_URL', + 'VITE_CREDITCOIN_RPC_URL', + 'VITE_PROOF_WORKER_URL', +]; +const requiredAddresses = [ + 'VITE_USAGE_PAYMENT_REGISTRY_ADDRESS', + 'VITE_MACHINE_REGISTRY_ADDRESS', + 'VITE_ACCESS_PASS_ADDRESS', + 'VITE_PROOFKEY_ASC_ADDRESS', +]; + +const failures = []; +for (const name of requiredUrls) { + const value = process.env[name]?.trim(); + try { + const url = new URL(value); + if (url.protocol !== 'https:') failures.push(`${name} must use HTTPS.`); + if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) + failures.push(`${name} cannot reference localhost.`); + if (url.username || url.password) + failures.push(`${name} cannot contain embedded credentials.`); + } catch { + failures.push(`${name} must be a valid public URL.`); + } +} +for (const name of requiredAddresses) { + if (!/^0x[0-9a-fA-F]{40}$/.test(process.env[name]?.trim() ?? '')) + failures.push(`${name} must be a 20-byte address.`); +} +if (!/^0x[0-9a-fA-F]{64}$/.test(process.env.VITE_DEMO_MACHINE_ID?.trim() ?? '')) + failures.push('VITE_DEMO_MACHINE_ID must be a 32-byte machine ID.'); + +if (failures.length) { + console.error(`Production configuration failed:\n- ${failures.join('\n- ')}`); + process.exit(1); +} +console.log('Production public configuration is complete and URL-safe.');