diff --git a/.changeset/happy-teams-own.md b/.changeset/happy-teams-own.md new file mode 100644 index 00000000..95b56fc4 --- /dev/null +++ b/.changeset/happy-teams-own.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added experimental support for payment links diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1b164491..cc6c5325 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -10,7 +10,7 @@ jobs: ci-gate: name: CI Gate if: always() - needs: [checks, test] + needs: [checks, test, test-html] runs-on: ubuntu-latest steps: - run: | @@ -43,7 +43,7 @@ jobs: run: pnpm check:ci - name: Check types - run: pnpm dev && pnpm check:types && pnpm check:types:examples + run: pnpm dev && pnpm exec tsx scripts/build:html.ts && pnpm check:types && pnpm check:types:html && pnpm check:types:examples test: name: Test Runtime @@ -100,6 +100,9 @@ jobs: docker pull ghcr.io/tempoxyz/tempo:${VITE_TEMPO_TAG} docker save ghcr.io/tempoxyz/tempo:${VITE_TEMPO_TAG} -o /tmp/tempo-image.tar + - name: Bundle HTML files + run: pnpm build + - name: Run tests run: | eval "$(dbus-launch --sh-syntax)" @@ -108,3 +111,71 @@ jobs: pnpm run test --bail=1 env: CI: true + + test-html: + name: Test HTML + runs-on: ubuntu-latest + env: + VITE_TEMPO_TAG: sha-20aecec + steps: + - name: Clone repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Docker + uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0 + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Cache Playwright browsers + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install chromium + + - name: Install Playwright system deps + run: pnpm exec playwright install-deps chromium + + - name: Cache Tempo Docker image + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + id: docker-cache + with: + path: /tmp/tempo-image.tar + key: tempo-image-${{ env.VITE_TEMPO_TAG }} + + - name: Load cached Tempo image + if: steps.docker-cache.outputs.cache-hit == 'true' + run: docker load -i /tmp/tempo-image.tar + + - name: Pull and cache Tempo image + if: steps.docker-cache.outputs.cache-hit != 'true' + run: | + docker pull ghcr.io/tempoxyz/tempo:${VITE_TEMPO_TAG} + docker save ghcr.io/tempoxyz/tempo:${VITE_TEMPO_TAG} -o /tmp/tempo-image.tar + + - name: Bundle HTML files + run: pnpm build + + - name: Run tests + run: pnpm run test:html + env: + CI: true + MPP_SECRET_KEY: test-secret-key + VITE_STRIPE_SECRET_KEY: ${{ secrets.VITE_STRIPE_SECRET_KEY }} + VITE_STRIPE_PUBLIC_KEY: ${{ secrets.VITE_STRIPE_PUBLIC_KEY }} + + - name: Upload Playwright artifacts + if: ${{ always() }} + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: playwright-artifacts + path: | + playwright-report + test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index a0951fd5..76d9290e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ _ dist node_modules +*.gen.ts coverage *.tsbuildinfo .DS_Store .env .env.* !.env.example +test-results/ diff --git a/examples/charge/package.json b/examples/charge/package.json index ce7eb3b7..24d2fdd1 100644 --- a/examples/charge/package.json +++ b/examples/charge/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "check:types": "tsgo -b", - "dev": "vite", + "dev": "vite dev", "build": "vite build", "preview": "vite preview" }, diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index c4b05f01..f94cb111 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -10,8 +10,10 @@ const currency = '0x20c0000000000000000000000000000000000000' as const // pathUS const mppx = Mppx.create({ methods: [ tempo({ + account, currency, feePayer: true, + html: true, recipient: account.address, testnet: true, }), diff --git a/examples/stripe/package.json b/examples/stripe/package.json index b070bf69..d158e2f7 100644 --- a/examples/stripe/package.json +++ b/examples/stripe/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "vite dev", "check": "biome check --fix --unsafe", "check:types": "tsgo -b", "build": "vite build", diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 0aaac6ac..796d22d8 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -9,6 +9,10 @@ const mppx = Mppx.create({ methods: [ stripe.charge({ client: stripeClient, + html: { + publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, + createTokenUrl: '/api/create-spt', + }, // Stripe Business Network profile ID. networkId: 'internal', // Ensure only card is supported. diff --git a/package.json b/package.json index ddab41ff..86d2b3de 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { "scripts": { - "build": "zile", + "build": "node --import tsx scripts/build:html.ts && zile", "changeset:publish": "zile publish:prepare && changeset publish && zile publish:post", "changeset:version": "changeset version && vp fmt .", "check": "vp lint --fix && vp fmt --write .", "check:ci": "vp lint && vp fmt --check .", "check:types": "tsgo -b", + "check:types:html": "tsgo -p src/tempo/server/internal/html/tsconfig.json && tsgo -p src/stripe/server/internal/html/tsconfig.json", "check:types:examples": "pnpm -r --filter './examples/**' run check:types", "deps": "pnpx taze -r --no-ignore-other-workspaces --ignore-paths node_modules", "deps:ci": "pnpx actions-up", "dev": "zile dev", "dev:example": "node scripts/dev:example.ts", "mppx": "node --import tsx src/bin.ts", - "test": "vp test" + "test": "vp test", + "test:html": "playwright test --config test/html/playwright.config.ts" }, "browserslist": [ "defaults", @@ -29,6 +31,7 @@ "@changesets/cli": "^2.30.0", "@hono/node-server": "^1.19.9", "@modelcontextprotocol/sdk": "^1.25.3", + "@playwright/test": "^1.58.2", "@types/express": "^5.0.6", "@types/node": "^25.5.0", "@typescript/native-preview": "7.0.0-dev.20260323.1", @@ -41,6 +44,7 @@ "hono": "^4.11.9", "playwright": "^1.58.2", "prool": "^0.2.4", + "rolldown": "1.0.0-rc.12", "tempo.ts": "^0.14.2", "testcontainers": "^11.11.0", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4643e70c..030e9038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,6 @@ settings: overrides: mppx: workspace:* - vite: npm:@voidzero-dev/vite-plus-core@~0.1.14 vitest: npm:@voidzero-dev/vite-plus-test@~0.1.14 ox: ^0.14.1 viem: ^2.47.5 @@ -57,6 +56,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.26.0 version: 1.26.0(zod@4.3.6) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -93,6 +95,9 @@ importers: prool: specifier: ^0.2.4 version: 0.2.4(testcontainers@11.11.0) + rolldown: + specifier: 1.0.0-rc.12 + version: 1.0.0-rc.12 tempo.ts: specifier: ^0.14.2 version: 0.14.2(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) @@ -139,8 +144,8 @@ importers: specifier: ^2.47.5 version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/charge-wagmi: dependencies: @@ -183,13 +188,13 @@ importers: version: 7.0.0-dev.20260323.1 '@vitejs/plugin-react': specifier: latest - version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + version: 6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: latest version: 5.9.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/session/multi-fetch: dependencies: @@ -215,8 +220,8 @@ importers: specifier: ^2.47.5 version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/session/sse: dependencies: @@ -242,8 +247,8 @@ importers: specifier: ^2.47.5 version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/stripe: dependencies: @@ -269,8 +274,29 @@ importers: specifier: latest version: 5.9.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) + + src/stripe/server/internal/html: + dependencies: + '@stripe/stripe-js': + specifier: 8.9.0 + version: 8.9.0 + mppx: + specifier: workspace:* + version: link:../../../../.. + + src/tempo/server/internal/html: + dependencies: + accounts: + specifier: https://pkg.pr.new/tempoxyz/accounts@c339a21 + version: https://pkg.pr.new/tempoxyz/accounts@c339a21(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + mppx: + specifier: workspace:* + version: link:../../../../.. + viem: + specifier: ^2.47.5 + version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) packages: @@ -1099,6 +1125,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1299,6 +1330,10 @@ packages: resolution: {integrity: sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==} engines: {node: '>=12.16'} + '@stripe/stripe-js@8.9.0': + resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==} + engines: {node: '>=12.16'} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} @@ -1650,6 +1685,21 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + accounts@https://pkg.pr.new/tempoxyz/accounts@c339a21: + resolution: {integrity: sha512-Blp1YJSFPCXHARZ1Glu+0itLeBWoL2QhVUGQBtCjjGiQ1fUIieEpiTQ4/dCo/zyMhNigNnUA0yiLBTH3SCl2+Q==, tarball: https://pkg.pr.new/tempoxyz/accounts@c339a21} + version: 0.4.7 + peerDependencies: + '@wagmi/core': '>=2' + react: '>=18' + viem: ^2.47.5 + peerDependenciesMeta: + '@wagmi/core': + optional: true + react: + optional: true + viem: + optional: true + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2450,6 +2500,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3546,6 +3599,9 @@ packages: typescript: optional: true + webauthx@0.1.0: + resolution: {integrity: sha512-Z43YHetVeXdV5/4+YqKSz+cAflpbMmxSMz//kXEb2u3ZUqVbPG1zrM+Zp7KaME/QgUFGhGkAE2XHwqCAXnU75g==} + webextension-polyfill@0.10.0: resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} @@ -3678,6 +3734,24 @@ packages: use-sync-external-store: optional: true + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adraffy/ens-normalize@1.11.1': {} @@ -4473,6 +4547,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {} @@ -4626,6 +4704,8 @@ snapshots: '@stripe/stripe-js@8.7.0': {} + '@stripe/stripe-js@8.9.0': {} + '@tanstack/query-core@5.90.20': {} '@tanstack/react-query@5.90.21(react@19.2.4)': @@ -4778,10 +4858,10 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260323.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260323.1 - '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + vite: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: @@ -4898,6 +4978,27 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + accounts@https://pkg.pr.new/tempoxyz/accounts@c339a21(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): + dependencies: + '@remix-run/fetch-router': 0.17.0 + idb-keyval: 6.2.2 + mipd: 0.0.7(typescript@5.9.3) + mppx: 'link:' + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + tsx: 4.21.0 + webauthx: 0.1.0(typescript@5.9.3)(zod@4.3.6) + zod: 4.3.6 + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) + optionalDependencies: + '@wagmi/core': 3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + react: 19.2.4 + viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - '@types/react' + - immer + - typescript + - use-sync-external-store + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5776,6 +5877,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6932,6 +7035,13 @@ snapshots: - ox - porto + webauthx@0.1.0(typescript@5.9.3)(zod@4.3.6): + dependencies: + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + transitivePeerDependencies: + - typescript + - zod + webextension-polyfill@0.10.0: {} webidl-conversions@3.0.1: {} @@ -7031,3 +7141,9 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 use-sync-external-store: 1.4.0(react@19.2.4) + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.4.0(react@19.2.4) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dca77ed3..e9fee7ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,10 +2,10 @@ packages: - . - examples/* - examples/session/* + - src/*/server/internal/html overrides: mppx: 'workspace:*' - vite: 'npm:@voidzero-dev/vite-plus-core@~0.1.14' vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.14' ox: '^0.14.1' viem: '^2.47.5' diff --git a/scripts/build:html.ts b/scripts/build:html.ts new file mode 100644 index 00000000..0249a219 --- /dev/null +++ b/scripts/build:html.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { build } from 'rolldown' + +const root = path.resolve(import.meta.dirname, '..') +const outDir = path.resolve(root, '.tmp/html-build') + +// HTML entries — bundled into `)}\n` + + fs.writeFileSync(outFile, content) + fs.rmSync(outDir, { recursive: true }) + console.log(`wrote ${path.relative(root, outFile)}`) +} + +// Service worker — bundled as raw JS string +{ + const entry = 'src/server/internal/html/serviceWorker.ts' + const outFile = path.resolve(root, 'src/server/internal/html/serviceWorker.gen.ts') + + await build({ + input: path.resolve(root, entry), + output: { + dir: outDir, + format: 'iife', + minify: true, + }, + }) + + const jsFile = fs.readdirSync(outDir).find((f) => f.endsWith('.js')) + if (!jsFile) throw new Error(`No .js output found for ${entry}`) + + const code = fs.readFileSync(path.join(outDir, jsFile), 'utf8').trim() + const content = `// Generated — do not edit.\nexport const serviceWorker = ${JSON.stringify(code)}\n` + + fs.writeFileSync(outFile, content) + fs.rmSync(outDir, { recursive: true }) + console.log(`wrote ${path.relative(root, outFile)}`) +} diff --git a/src/Method.ts b/src/Method.ts index 1b196fad..8d2cad25 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -2,6 +2,7 @@ import type * as Challenge from './Challenge.js' import type * as Credential from './Credential.js' import type { ExactPartial, LooseOmit, MaybePromise } from './internal/types.js' import type * as Receipt from './Receipt.js' +import type * as Html from './server/internal/html/config.js' import type * as Transport from './server/Transport.js' import type * as z from './zod.js' @@ -10,6 +11,7 @@ import type * as z from './zod.js' */ export type Method = { name: string + html?: Html.Options | undefined intent: string schema: { credential: { @@ -74,6 +76,7 @@ export type Server< transportOverride = undefined, > = method & { defaults?: defaults | undefined + html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined @@ -202,10 +205,11 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, request, respond, transport, verify } = options + const { defaults, html, request, respond, transport, verify } = options return { ...method, defaults, + html, request, respond, transport, @@ -220,6 +224,7 @@ export declare namespace toServer { transportOverride extends Transport.AnyTransport | undefined = undefined, > = { defaults?: defaults | undefined + html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 59c5b4d2..c0ead8cb 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -304,6 +304,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.MalformedCredentialError({ reason: credentialError.message }), + html: method.html, }) return { challenge: response, status: 402 } } @@ -314,6 +315,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.PaymentRequiredError({ description }), + html: method.html, }) return { challenge: response, status: 402 } } @@ -328,6 +330,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: 'challenge was not issued by this server', }), + html: method.html, }) return { challenge: response, status: 402 } } @@ -356,6 +359,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), + html: method.html, }) return { challenge: response, status: 402 } } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 270fc1e3..3f4881ea 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -1,9 +1,13 @@ +import { Json } from 'ox' + import * as Challenge from '../Challenge.js' import * as Credential from '../Credential.js' import * as Errors from '../Errors.js' import type { Distribute, UnionToIntersection } from '../internal/types.js' import * as core_Mcp from '../Mcp.js' import * as Receipt from '../Receipt.js' +import * as Html from './internal/html/config.js' +import { serviceWorker } from './internal/html/serviceWorker.gen.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -30,6 +34,7 @@ export type Transport< respondChallenge: (options: { challenge: Challenge.Challenge error?: Errors.PaymentError | undefined + html?: Html.Options | undefined input: input }) => challengeOutput | Promise /** Attaches a receipt to a successful response. */ @@ -121,17 +126,65 @@ export function http(): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error }) { + respondChallenge(options) { + const { challenge, error, input } = options + + if (options.html && new URL(input.url).searchParams.has('__mppx_worker')) + return new Response(serviceWorker, { + status: 200, + headers: { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store', + }, + }) + const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', } - let body: string | null = null - if (error) { - headers['Content-Type'] = 'application/problem+json' - body = JSON.stringify(error.toProblemDetails(challenge.id)) - } + const body = (() => { + if (options.html && input.headers.get('Accept')?.includes('text/html')) { + headers['Content-Type'] = 'text/html; charset=utf-8' + const data = Json.stringify({ config: options.html.config, challenge }).replace( + / + + + + + Payment Required + + + +

Payment Required

+
+${Json.stringify(challenge, null, 2)
+                    .replace(/&/g, '&')
+                    .replace(//g, '>')}
+
+ + ${options.html.content} + + ` + } + if (error) { + headers['Content-Type'] = 'application/problem+json' + return JSON.stringify(error.toProblemDetails(challenge.id)) + } + return null + })() return new Response(body, { status: error?.status ?? 402, headers }) }, diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts new file mode 100644 index 00000000..3cc6da4c --- /dev/null +++ b/src/server/internal/html/config.ts @@ -0,0 +1,6 @@ +export type Options = { + config: Record + content: string +} + +export const dataId = '__MPPX_DATA__' diff --git a/src/server/internal/html/serviceWorker.client.ts b/src/server/internal/html/serviceWorker.client.ts new file mode 100644 index 00000000..e2f62dd0 --- /dev/null +++ b/src/server/internal/html/serviceWorker.client.ts @@ -0,0 +1,26 @@ +export async function submitCredential(credential: string): Promise { + const url = new URL(location.href) + url.searchParams.set('__mppx_worker', '') + + const registration = await navigator.serviceWorker.register(url.pathname + url.search) + + const serviceWorker = await new Promise((resolve) => { + const mppxWorker = registration.installing ?? registration.waiting ?? registration.active + if (mppxWorker?.state === 'activated') return resolve(mppxWorker) + const target = mppxWorker ?? registration + target.addEventListener('statechange', function handler() { + const active = registration.active + if (active?.state === 'activated') { + target.removeEventListener('statechange', handler) + resolve(active) + } + }) + }) + + await new Promise((resolve) => { + const channel = new MessageChannel() + channel.port1.onmessage = () => resolve() + serviceWorker.postMessage({ credential }, [channel.port2]) + }) + location.reload() +} diff --git a/src/server/internal/html/serviceWorker.ts b/src/server/internal/html/serviceWorker.ts new file mode 100644 index 00000000..5810ac50 --- /dev/null +++ b/src/server/internal/html/serviceWorker.ts @@ -0,0 +1,27 @@ +const serviceWorker = self as unknown as ServiceWorkerGlobalScope + +let credential: string | undefined + +serviceWorker.addEventListener('activate', (event) => { + event.waitUntil(serviceWorker.clients.claim()) +}) + +serviceWorker.addEventListener('message', (event) => { + if (!event.source) return + const value = event.data?.credential + if (typeof value !== 'string' || !value.startsWith('Payment ')) return + credential = value + event.ports[0]?.postMessage('ack') +}) + +serviceWorker.addEventListener('fetch', (event) => { + if (!credential || event.request.mode !== 'navigate') return + if (new URL(event.request.url).origin !== serviceWorker.location.origin) return + + const headers = new Headers(event.request.headers) + headers.set('Authorization', credential) + credential = undefined + + event.respondWith(fetch(event.request, { headers })) + serviceWorker.registration.unregister() +}) diff --git a/src/server/internal/html/tsconfig.worker.client.json b/src/server/internal/html/tsconfig.worker.client.json new file mode 100644 index 00000000..aa58380f --- /dev/null +++ b/src/server/internal/html/tsconfig.worker.client.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["serviceWorker.client.ts"] +} diff --git a/src/server/internal/html/tsconfig.worker.json b/src/server/internal/html/tsconfig.worker.json new file mode 100644 index 00000000..4a7d0075 --- /dev/null +++ b/src/server/internal/html/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "webworker"], + "types": [] + }, + "include": ["serviceWorker.ts"] +} diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 096f05da..12a8dd7c 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -5,6 +5,7 @@ import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' import type { StripeClient } from '../internal/types.js' import * as Methods from '../Methods.js' +import { html as htmlContent } from './internal/html.gen.js' /** * Creates a Stripe charge method intent for usage on the server. @@ -38,6 +39,7 @@ export function charge(parameters: p decimals, description, externalId, + html, metadata, networkId, paymentMethodTypes, @@ -59,6 +61,8 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, + html: html ? { config: html, content: htmlContent } : undefined, + async verify({ credential }) { const { challenge } = credential const { request } = challenge @@ -108,6 +112,8 @@ export declare namespace charge { type Defaults = LooseOmit, 'recipient'> type Parameters = { + /** Render payment page when Accept header is text/html (e.g. in browsers) */ + html?: { createTokenUrl: string; publishableKey: string } | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined } & Defaults & diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts new file mode 100644 index 00000000..51aae8ff --- /dev/null +++ b/src/stripe/server/internal/html/main.ts @@ -0,0 +1,90 @@ +import { loadStripe } from '@stripe/stripe-js/pure' + +import type * as Challenge from '../../../../Challenge.js' +import { stripe } from '../../../../client/index.js' +import * as Html from '../../../../server/internal/html/config.js' +import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' +import type { charge } from '../../../../stripe/server/Charge.js' +import type * as Methods from '../../../Methods.js' + +const data = JSON.parse(document.getElementById(Html.dataId)!.textContent!) as { + config: NonNullable + challenge: Challenge.FromMethods<[typeof Methods.charge]> +} + +const root = document.getElementById('root')! + +const h2 = document.createElement('h2') +h2.textContent = 'stripe' +root.appendChild(h2) + +;(async () => { + const stripeJs = await loadStripe(data.config.publishableKey) + if (!stripeJs) throw new Error('Failed to loadStripe') + + const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') + const getAppearance = () => ({ + theme: (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe', + }) + + const elements = stripeJs.elements({ + amount: Number(data.challenge.request.amount), + appearance: getAppearance(), + currency: data.challenge.request.currency as string, + mode: 'payment', + paymentMethodCreation: 'manual', + }) + + darkQuery.addEventListener('change', () => { + elements.update({ appearance: getAppearance() }) + }) + + const form = document.createElement('form') + elements.create('payment').mount(form) + root.appendChild(form) + + const button = document.createElement('button') + button.textContent = 'Pay' + button.type = 'submit' + form.appendChild(button) + + form.onsubmit = async (event) => { + event.preventDefault() + button.disabled = true + + try { + await elements.submit() + const { paymentMethod, error } = await stripeJs.createPaymentMethod({ elements }) + if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method') + + const method = stripe({ + client: stripeJs, + createToken: async (opts) => { + const createTokenUrl = new URL(data.config.createTokenUrl, location.origin) + if (createTokenUrl.origin !== location.origin) + throw new Error('createTokenUrl must be same-origin') + const res = await fetch(createTokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paymentMethod, ...opts }), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to create SPT (${res.status}): ${text}`) + } + const { spt } = (await res.json()) as { spt: string } + return spt + }, + })[0] + + const credential = await method.createCredential({ + challenge: data.challenge, + context: { paymentMethod: paymentMethod.id }, + }) + + await submitCredential(credential) + } finally { + button.disabled = false + } + } +})() diff --git a/src/stripe/server/internal/html/package.json b/src/stripe/server/internal/html/package.json new file mode 100644 index 00000000..d2c26dfc --- /dev/null +++ b/src/stripe/server/internal/html/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mppx/stripe-html", + "private": true, + "type": "module", + "dependencies": { + "@stripe/stripe-js": "8.9.0", + "mppx": "workspace:*" + } +} diff --git a/src/stripe/server/internal/html/stripe-js-pure.d.ts b/src/stripe/server/internal/html/stripe-js-pure.d.ts new file mode 100644 index 00000000..25c0e370 --- /dev/null +++ b/src/stripe/server/internal/html/stripe-js-pure.d.ts @@ -0,0 +1,7 @@ +declare module '@stripe/stripe-js/pure' { + export * from '@stripe/stripe-js' + + export const loadStripe: typeof import('@stripe/stripe-js').loadStripe & { + setLoadParameters(parameters: { advancedFraudSignals: boolean }): void + } +} diff --git a/src/stripe/server/internal/html/tsconfig.json b/src/stripe/server/internal/html/tsconfig.json new file mode 100644 index 00000000..ac01827c --- /dev/null +++ b/src/stripe/server/internal/html/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["./**/*.ts"] +} diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index bfdb88b0..a7e6fcb3 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -22,6 +22,7 @@ import * as FeePayer from '../internal/fee-payer.js' import * as Selectors from '../internal/selectors.js' import type * as types from '../internal/types.js' import * as Methods from '../Methods.js' +import { html as htmlContent } from './internal/html.gen.js' /** * Creates a Tempo charge method intent for usage on the server. @@ -45,6 +46,7 @@ export function charge( decimals = defaults.decimals, description, externalId, + html, memo, waitForConfirmation = true, } = parameters @@ -71,6 +73,8 @@ export function charge( recipient, } as unknown as Defaults, + html: html ? { config: {}, content: htmlContent } : undefined, + // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { const chainId = await (async () => { @@ -244,6 +248,8 @@ export declare namespace charge { type Defaults = LooseOmit, 'feePayer' | 'recipient'> type Parameters = { + /** Render payment page when Accept header is text/html (e.g. in browsers) */ + html?: boolean | undefined /** Testnet mode. */ testnet?: boolean | undefined /** diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts new file mode 100644 index 00000000..06daad2e --- /dev/null +++ b/src/tempo/server/internal/html/main.ts @@ -0,0 +1,70 @@ +import { Provider } from 'accounts' +import { Hex, Json } from 'ox' +import { createClient, custom, http } from 'viem' +import { tempoModerato, tempoLocalnet } from 'viem/chains' +import { Account } from 'viem/tempo' + +import type * as Challenge from '../../../../Challenge.js' +import { tempo } from '../../../../client/index.js' +import * as Html from '../../../../server/internal/html/config.js' +import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' +import type * as Methods from '../../../Methods.js' + +const data = Json.parse(document.getElementById(Html.dataId)!.textContent) as { + challenge: Challenge.FromMethods<[typeof Methods.charge]> +} + +const root = document.getElementById('root')! + +const h2 = document.createElement('h2') +h2.textContent = 'tempo' +root.appendChild(h2) + +// Used for testing. TODO: Wire up more native way +const localTempoAccount = __LOCAL_ACCOUNT__ + ? Account.fromSecp256k1(__LOCAL_ACCOUNT__ as Hex.Hex) + : undefined +declare const __LOCAL_ACCOUNT__: string | undefined + +const provider = (() => { + if (localTempoAccount) return undefined + return Provider.create({ + testnet: + data.challenge.request.methodDetails?.chainId === tempoModerato.id || + data.challenge.request.methodDetails?.chainId === tempoLocalnet.id, + }) +})() + +const button = document.createElement('button') +button.textContent = 'Continue with Tempo' +button.onclick = async () => { + try { + button.disabled = true + + const client = (() => { + const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( + (x) => x.id === data.challenge.request.methodDetails?.chainId, + ) + if (localTempoAccount || !provider) + return createClient({ + account: localTempoAccount, + chain, + transport: http(chain?.rpcUrls.default.http[0]), + }) + return createClient({ chain, transport: custom(provider) }) + })() + + const account = await (async () => { + if (localTempoAccount || !provider) return localTempoAccount + const res = await provider.request({ method: 'wallet_connect' }) + return res.accounts[0]!.address + })() + const method = tempo({ account, getClient: () => client })[0] + + const credential = await method.createCredential({ challenge: data.challenge, context: {} }) + await submitCredential(credential) + } finally { + button.disabled = false + } +} +root.appendChild(button) diff --git a/src/tempo/server/internal/html/package.json b/src/tempo/server/internal/html/package.json new file mode 100644 index 00000000..af5ea7e8 --- /dev/null +++ b/src/tempo/server/internal/html/package.json @@ -0,0 +1,10 @@ +{ + "name": "@mppx/tempo-html", + "private": true, + "type": "module", + "dependencies": { + "accounts": "https://pkg.pr.new/tempoxyz/accounts@c339a21", + "mppx": "workspace:*", + "viem": "2.47.5" + } +} diff --git a/src/tempo/server/internal/html/tsconfig.json b/src/tempo/server/internal/html/tsconfig.json new file mode 100644 index 00000000..ac01827c --- /dev/null +++ b/src/tempo/server/internal/html/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["./**/*.ts"] +} diff --git a/src/tsconfig.json b/src/tsconfig.json index 924c7ddc..528ff1a6 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["./**/*.ts"], - "exclude": ["./**/*.test.ts", "./**/*.test-d.ts"] + "exclude": ["./**/*.test.ts", "./**/*.test-d.ts", "./**/internal/html/**"] } diff --git a/test/html/globalSetup.ts b/test/html/globalSetup.ts new file mode 100644 index 00000000..466f849a --- /dev/null +++ b/test/html/globalSetup.ts @@ -0,0 +1,24 @@ +import { execSync } from 'node:child_process' + +export default async function globalSetup() { + const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' + execSync(`LOCAL_ACCOUNT=${privateKey} pnpm build`, { + cwd: new URL('../..', import.meta.url).pathname, + stdio: 'inherit', + }) + + const port = Number(process.env._MPPX_HTML_PORT) + if (!port) throw new Error('Missing _MPPX_HTML_PORT') + + const { startServer } = await import('./server.js') + const server = await startServer(port) + + return async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) + } +} diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts new file mode 100644 index 00000000..6217d152 --- /dev/null +++ b/test/html/playwright.config.ts @@ -0,0 +1,46 @@ +import net from 'node:net' + +import { defineConfig } from '@playwright/test' + +const port = await getPort('_MPPX_HTML_PORT') + +export default defineConfig({ + globalSetup: './globalSetup.ts', + testDir: '.', + testMatch: '*.test.ts', + timeout: 60_000, + retries: 1, + reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'list', + use: { + headless: !!process.env.CI || true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'tempo', + testMatch: 'tempo.test.ts', + use: { baseURL: `http://localhost:${port}` }, + }, + { + name: 'stripe', + testMatch: 'stripe.test.ts', + use: { baseURL: `http://localhost:${port}` }, + }, + ], +}) + +async function getPort(envKey: string): Promise { + if (process.env[envKey]) return Number(process.env[envKey]) + const port = await new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, () => { + const port = (server.address() as net.AddressInfo).port + server.close(() => resolve(port)) + }) + server.on('error', reject) + }) + process.env[envKey] = String(port) + return port +} diff --git a/test/html/server.ts b/test/html/server.ts new file mode 100644 index 00000000..f8672d9b --- /dev/null +++ b/test/html/server.ts @@ -0,0 +1,172 @@ +import * as http from 'node:http' + +import { Mppx, Request as ServerRequest, stripe, tempo } from 'mppx/server' +import { createClient, http as createHttpTransport } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { tempoModerato } from 'viem/chains' +import { Actions } from 'viem/tempo' + +export async function startServer(port: number): Promise { + const stripePublishableKey = process.env.VITE_STRIPE_PUBLIC_KEY + if (!stripePublishableKey) throw new Error('Missing VITE_STRIPE_PUBLIC_KEY') + const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY + if (!stripeSecretKey) throw new Error('Missing VITE_STRIPE_SECRET_KEY') + + const account = privateKeyToAccount(generatePrivateKey()) + const tempoClient = createClient({ + chain: tempoModerato, + pollingInterval: 1_000, + transport: createHttpTransport(process.env.MPPX_RPC_URL), + }) + for (let attempt = 1; ; attempt++) + try { + await Actions.faucet.fundSync(tempoClient, { account }) + break + } catch (error) { + if (attempt >= 3) throw error + } + + const createTokenUrl = '/stripe/create-spt' + const mppx = Mppx.create({ + methods: [ + stripe.charge({ + html: { + createTokenUrl, + publishableKey: stripePublishableKey, + }, + networkId: 'internal', + paymentMethodTypes: ['card'], + secretKey: stripeSecretKey, + }), + tempo.charge({ + account, + currency: '0x20c0000000000000000000000000000000000000', + feePayer: true, + html: true, + recipient: account.address, + testnet: true, + }), + ], + secretKey: 'test-html-server-secret-key', + }) + + const server = http.createServer( + ServerRequest.toNodeListener(async (request) => { + const url = new URL(request.url) + + if (url.pathname === '/tempo/charge') { + const result = await mppx.tempo.charge({ + amount: '0.01', + description: 'Random stock photo', + })(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt(Response.json({ url: 'https://example.com/photo.jpg' })) + } + + if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey) + + if (url.pathname === '/stripe/charge') { + const result = await mppx.stripe.charge({ + amount: '1', + currency: 'usd', + decimals: 2, + })(request) + + if (result.status === 402) return result.challenge + + const fortunes = [ + 'A beautiful, smart, and loving person will come into your life.', + 'A dubious friend may be an enemy in camouflage.', + 'A faithful friend is a strong defense.', + 'A fresh start will put you on your way.', + 'A golden egg of opportunity falls into your lap this month.', + 'A good time to finish up old tasks.', + 'A hunch is creativity trying to tell you something.', + 'A lifetime of happiness lies ahead of you.', + 'A light heart carries you through all the hard times.', + 'A new perspective will come with the new year.', + ] as const + + const fortune = fortunes[Math.floor(Math.random() * fortunes.length)] + return result.withReceipt(Response.json({ fortune })) + } + + return new Response('Not Found', { status: 404 }) + }), + ) + + await new Promise((resolve) => server.listen(port, resolve)) + + return Object.assign(server, { + port, + url: `http://localhost:${port}`, + }) as HtmlTestServer +} + +type HtmlTestServer = http.Server & { + port: number + url: string +} + +async function createSharedPaymentToken(request: Request, secretKey: string): Promise { + const { paymentMethod, amount, currency, expiresAt, networkId, metadata } = + (await request.json()) as { + paymentMethod: string + amount: string + currency: string + expiresAt: number + networkId?: string + metadata?: Record + } + + if (metadata?.externalId) + return Response.json( + { error: 'metadata.externalId is reserved; use credential externalId instead' }, + { status: 400 }, + ) + + const body = new URLSearchParams({ + payment_method: paymentMethod, + 'usage_limits[currency]': currency, + 'usage_limits[max_amount]': amount, + 'usage_limits[expires_at]': expiresAt.toString(), + }) + if (networkId) body.set('seller_details[network_id]', networkId) + if (metadata) + for (const [key, value] of Object.entries(metadata)) body.set(`metadata[${key}]`, value) + + // Test-only endpoint; production SPT flow uses the agent-side issued_tokens API. + const createSpt = async (bodyParams: URLSearchParams) => + fetch('https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens', { + method: 'POST', + headers: { + Authorization: `Basic ${btoa(`${secretKey}:`)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: bodyParams, + }) + + let response = await createSpt(body) + if (!response.ok) { + const error = (await response.json()) as { error: { message: string } } + if ((metadata || networkId) && error.error.message.includes('Received unknown parameter')) { + const fallbackBody = new URLSearchParams({ + payment_method: paymentMethod, + 'usage_limits[currency]': currency, + 'usage_limits[max_amount]': amount, + 'usage_limits[expires_at]': expiresAt.toString(), + }) + response = await createSpt(fallbackBody) + } else return Response.json({ error: error.error.message }, { status: 500 }) + } + + if (!response.ok) { + const error = (await response.json()) as { error: { message: string } } + return Response.json({ error: error.error.message }, { status: 500 }) + } + + const { id: spt } = (await response.json()) as { id: string } + return Response.json({ spt }) +} diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts new file mode 100644 index 00000000..77ecf887 --- /dev/null +++ b/test/html/stripe.test.ts @@ -0,0 +1,72 @@ +import type { Frame, Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +test('charge via stripe html payment page', async ({ page }) => { + test.slow() + + await page.goto('/stripe/charge', { + waitUntil: 'domcontentloaded', + }) + + // Verify 402 payment page rendered + await expect(page.locator('h1')).toHaveText('Payment Required') + await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 }) + + // Stripe renders several private frames. Find the one that actually contains + // the payment controls instead of assuming the first frame is the card UI. + const stripeFrame = await getStripePaymentFrame(page) + const numberInput = stripeFrame.locator('[name="number"]') + + // Open card form + const cardButton = stripeFrame.locator('[data-value="card"]') + await cardButton.isVisible({ timeout: 90_000 }) + await cardButton.click() + await page.waitForTimeout(1_000) + + // Wait for card inputs to appear and fill test card details + await expect(numberInput).toBeVisible({ timeout: 90_000 }) + await numberInput.fill('4242424242424242') + await stripeFrame.locator('[name="expiry"]').fill('12/34') + await stripeFrame.locator('[name="cvc"]').fill('123') + + // Fill postal code if visible + const postalCode = stripeFrame.locator('[name="postalCode"]') + await postalCode.isVisible({ timeout: 2_000 }) + await postalCode.fill('10001') + + // Wait for Stripe Elements to settle + await page.waitForTimeout(500) + + // Submit payment + await page.getByRole('button', { name: 'Pay' }).click() + + // Wait for service worker to submit credential and page to reload with paid response + await expect(page.locator('body')).toContainText('"fortune":', { timeout: 30_000 }) +}) + +test('service worker endpoint returns javascript', async ({ page }) => { + const response = await page.goto('/stripe/charge?__mppx_worker') + expect(response?.headers()['content-type']).toContain('application/javascript') + expect(response?.status()).toBe(200) +}) + +async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise { + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + for (const frame of page.frames()) { + if (!frame.name().startsWith('__privateStripeFrame')) continue + + const hasCardButton = + (await frame + .locator('[data-value="card"]') + .count() + .catch(() => 0)) > 0 + if (hasCardButton) return frame + } + + await page.waitForTimeout(250) + } + + throw new Error('Timed out waiting for Stripe payment frame') +} diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts new file mode 100644 index 00000000..78e409dd --- /dev/null +++ b/test/html/tempo.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test' +import { createClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { tempoModerato } from 'viem/chains' +import { Actions } from 'viem/tempo' + +test.beforeAll(async () => { + const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' + const account = privateKeyToAccount(privateKey) + + // Fund the test payer account via faucet + const client = createClient({ chain: tempoModerato, transport: http() }) + await Actions.faucet.fundSync(client, { account }) +}) + +test('charge via html payment page', async ({ page, context }) => { + const logs: string[] = [] + page.on('pageerror', (err) => logs.push(`[pageerror] ${err.message}`)) + page.on('console', (msg) => logs.push(`[console.${msg.type()}] ${msg.text()}`)) + page.on('requestfailed', (req) => + logs.push(`[requestfailed] ${req.url()} ${req.failure()?.errorText}`), + ) + context.on('serviceworker', (sw) => logs.push(`[serviceworker] registered: ${sw.url()}`)) + + // Navigate to the payment endpoint as a browser + await page.goto('/tempo/charge', { + waitUntil: 'domcontentloaded', + }) + + // Verify 402 payment page rendered + await expect(page.locator('h1')).toHaveText('Payment Required') + await expect(page.getByText('Continue with Tempo')).toBeVisible() + + // Click the pay button — local adapter signs without dialog + await page.getByText('Continue with Tempo').click() + + // Wait for service worker to submit credential and page to reload with paid response + await expect(page.locator('body')) + .toContainText('"url":', { timeout: 30_000 }) + .catch((e) => { + console.error('Browser logs:\n' + logs.join('\n')) + throw e + }) +}) + +test('service worker endpoint returns javascript', async ({ page }) => { + const response = await page.goto('/tempo/charge?__mppx_worker') + expect(response?.headers()['content-type']).toContain('application/javascript') + expect(response?.status()).toBe(200) +}) diff --git a/tsconfig.json b/tsconfig.json index bba8fd6d..aac76729 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,5 +6,10 @@ "strict": true }, "files": [], - "references": [{ "path": "./src" }, { "path": "./test" }] + "references": [ + { "path": "./src" }, + { "path": "./src/server/internal/html/tsconfig.worker.client.json" }, + { "path": "./src/server/internal/html/tsconfig.worker.json" }, + { "path": "./test" } + ] } diff --git a/vite.config.ts b/vite.config.ts index 2a43d41b..e48b5f0c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ name: 'node', alias, include: ['src/**/*.test.ts'], - exclude: ['src/**/*.browser.test.ts', 'src/cli/**/*.test.ts'], + exclude: ['**/node_modules/**', 'src/**/*.browser.test.ts', 'src/cli/**/*.test.ts'], typecheck: { include: ['src/**/*.test-d.ts'], }, @@ -109,7 +109,7 @@ export default defineConfig({ 'no-control-regex': 'off', }, settings: { - polyfills: ['PaymentRequest', 'URLPattern', 'crypto'], + polyfills: ['PaymentRequest', 'URLPattern', 'crypto', 'navigator'], }, overrides: [ {