diff --git a/.changeset/rich-chefs-wear.md b/.changeset/rich-chefs-wear.md new file mode 100644 index 00000000..109cacf7 --- /dev/null +++ b/.changeset/rich-chefs-wear.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added `.compose` support to HTML payment links. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 906a1f9b..c283d3a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: mppx: workspace:* vitest: npm:@voidzero-dev/vite-plus-test@~0.1.14 + typescript: ~5.9.3 ox: 0.14.7 viem: ^2.47.5 path-to-regexp@<8.4.0: 8.4.0 @@ -139,7 +140,7 @@ importers: specifier: workspace:* version: link:../.. typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 viem: specifier: ^2.47.5 @@ -191,7 +192,7 @@ importers: specifier: latest 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 + specifier: ~5.9.3 version: 5.9.3 vite: specifier: latest @@ -215,7 +216,7 @@ importers: specifier: latest version: 4.21.0 typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 viem: specifier: ^2.47.5 @@ -242,7 +243,7 @@ importers: specifier: latest version: 4.21.0 typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 viem: specifier: ^2.47.5 @@ -272,7 +273,7 @@ importers: specifier: ^17.7.0 version: 17.7.0 typescript: - specifier: latest + specifier: ~5.9.3 version: 5.9.3 vite: specifier: latest @@ -291,7 +292,7 @@ importers: dependencies: accounts: specifier: 0.4.12 - version: 0.4.12(@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)) + version: 0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@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)))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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:../../../../.. @@ -1507,7 +1508,7 @@ packages: sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 - typescript: ^5.0.0 + typescript: ~5.9.3 unplugin-unused: ^0.5.0 yaml: '>=2.8.3' peerDependenciesMeta: @@ -1632,7 +1633,7 @@ packages: '@wagmi/core': 3.4.0 '@walletconnect/ethereum-provider': ^2.21.1 porto: ~0.2.35 - typescript: '>=5.7.3' + typescript: ~5.9.3 viem: ^2.47.5 peerDependenciesMeta: '@base-org/account': @@ -1657,7 +1658,7 @@ packages: peerDependencies: '@tanstack/query-core': '>=5.0.0' ox: 0.14.7 - typescript: '>=5.7.3' + typescript: ~5.9.3 viem: ^2.47.5 peerDependenciesMeta: '@tanstack/query-core': @@ -1670,7 +1671,7 @@ packages: abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: - typescript: '>=5.0.4' + typescript: ~5.9.3 zod: ^3.22.0 || ^4.0.0 peerDependenciesMeta: typescript: @@ -2096,7 +2097,7 @@ packages: exact-mirror: '>= 0.0.9' file-type: 21.3.2 openapi-types: '>= 12.0.0' - typescript: '>= 5.0.0' + typescript: ~5.9.3 peerDependenciesMeta: '@types/bun': optional: true @@ -2810,7 +2811,7 @@ packages: mipd@0.0.7: resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==} peerDependencies: - typescript: '>=5.0.4' + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -2823,6 +2824,25 @@ packages: engines: {node: '>=10'} hasBin: true + mppx@0.4.12: + resolution: {integrity: sha512-qN84ijeWcm+agw+ne+xxcMJhEw3+LJGyNxTnOQePyOrubXdPiI6duMdwNL796FXjsLMm8HizATOZBWw+qBGaMA==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': 1.26.0 + elysia: 1.4.26 + express: '>=5' + hono: 4.12.7 + viem: ^2.47.5 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + elysia: + optional: true + express: + optional: true + hono: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2916,7 +2936,7 @@ packages: ox@0.14.7: resolution: {integrity: sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==} peerDependencies: - typescript: '>=5.4.0' + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -3429,7 +3449,7 @@ packages: engines: {node: ^18 || >=20} hasBin: true peerDependencies: - typescript: ^5.0.0 + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -3527,7 +3547,7 @@ packages: viem@2.47.6: resolution: {integrity: sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==} peerDependencies: - typescript: '>=5.0.4' + typescript: ~5.9.3 peerDependenciesMeta: typescript: optional: true @@ -3585,7 +3605,7 @@ packages: peerDependencies: '@tanstack/react-query': '>=5.0.0' react: '>=18' - typescript: '>=5.7.3' + typescript: ~5.9.3 viem: ^2.47.5 peerDependenciesMeta: typescript: @@ -3689,7 +3709,7 @@ packages: hasBin: true peerDependencies: '@typescript/native-preview': '>=7.0.0' - typescript: '>=5' + typescript: ~5.9.3 peerDependenciesMeta: '@typescript/native-preview': optional: true @@ -4970,12 +4990,12 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@0.4.12(@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)): + accounts@0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@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)))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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:' + mppx: 0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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)) 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) @@ -4986,8 +5006,15 @@ snapshots: 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: + - '@cfworker/json-schema' + - '@modelcontextprotocol/sdk' - '@types/react' + - elysia + - express + - hono - immer + - openapi-types + - supports-color - typescript - use-sync-external-store @@ -6124,6 +6151,25 @@ snapshots: mkdirp@1.0.4: {} + mppx@0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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)): + dependencies: + '@remix-run/fetch-proxy': 0.7.1 + '@remix-run/node-fetch-server': 0.13.0 + incur: 0.3.1(openapi-types@12.1.3) + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + 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 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) + elysia: 1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3) + express: 5.2.1 + hono: 4.12.7 + transitivePeerDependencies: + - '@cfworker/json-schema' + - openapi-types + - supports-color + - typescript + mri@1.2.0: {} mrmime@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 64a5ef06..1aee9200 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: overrides: mppx: 'workspace:*' vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.14' + typescript: $typescript ox: '0.14.7' viem: '^2.47.5' path-to-regexp@<8.4.0: '8.4.0' diff --git a/scripts/build:html.ts b/scripts/build:html.ts index a11393ad..21b866bd 100644 --- a/scripts/build:html.ts +++ b/scripts/build:html.ts @@ -10,6 +10,33 @@ const stripeMode = process.env.STRIPE_HTML_MODE ?? defaultMode const formatBundleSize = (bytes: number) => bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)} kB` : `${bytes} B` +// Tab script (bundled as raw JS string for compose HTML) +// Must be built before HTML entries since they import config.ts which re-exports tabScript +{ + const entry = 'src/server/internal/html/compose.main.ts' + const outFile = path.resolve(root, 'src/server/internal/html/compose.main.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 bundleBytes = Buffer.byteLength(code) + const content = `// Generated — do not edit.\nexport const tabScript = ${JSON.stringify(``)}\n` + + fs.writeFileSync(outFile, content) + fs.rmSync(outDir, { recursive: true }) + console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`) +} + // HTML entries — bundled into `)}\n` + // Confirm test-only dead code was eliminated for non-test builds + if (mode !== 'test') { + const markers = testOnlyMarkers[entry] ?? [] + const leaked = markers.filter((m) => code.includes(m)) + if (leaked.length > 0) + throw new Error( + `Dead code elimination failed for ${entry} (mode=${mode}). ` + + `Test-only markers found in bundle: ${leaked.join(', ')}`, + ) + } + fs.writeFileSync(outFile, content) fs.rmSync(outDir, { recursive: true }) console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index ef827e8c..f7cfd0d8 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1184,6 +1184,209 @@ describe('compose', () => { expect(result.status).toBe(200) }) + + describe('html', () => { + const htmlOptionsA = { + config: { providerA: true }, + content: '', + formatAmount: (request: Record) => `$${request.amount}`, + text: undefined, + theme: undefined, + } + + const htmlOptionsB = { + config: { providerB: true }, + content: '', + formatAmount: (request: Record) => `$${request.amount}`, + text: undefined, + theme: undefined, + } + + const alphaWithHtml = Method.toServer(mockChargeA, { + html: htmlOptionsA, + async verify() { + return mockReceipt('alpha') + }, + }) + + const betaWithHtml = Method.toServer(mockChargeB, { + html: htmlOptionsB, + async verify() { + return mockReceipt('beta') + }, + }) + + test('returns html with tabs when multiple methods have html', async () => { + const mppx = Mppx.create({ + methods: [alphaWithHtml, betaWithHtml], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts]) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Accept: 'text/html' }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const body = await result.challenge.text() + expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8') + + // Tab a11y markup + expect(body).toContain('role="tablist"') + expect(body).toContain('role="tab"') + expect(body).toContain('role="tabpanel"') + expect(body).toContain('aria-selected="true"') + expect(body).toContain('aria-controls="mppx-panel-0"') + expect(body).toContain('aria-controls="mppx-panel-1"') + + // Tab labels from method names (capitalized via CSS) + expect(body).toContain('alpha') + expect(body).toContain('beta') + + // Both method bundles included + expect(body).toContain('/alpha-bundle.js') + expect(body).toContain('/beta-bundle.js') + + // Data map with both entries + const dataMatch = body.match( + /]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s, + ) + expect(dataMatch).not.toBeNull() + const dataMap = JSON.parse(dataMatch![1]!.replace(/\\u003c/g, '<')) + const dataValues = Object.values(dataMap) as { label: string; config: unknown }[] + expect(dataValues).toHaveLength(2) + expect(dataValues[0]!.label).toBe('alpha') + expect(dataValues[0]!.config).toEqual({ providerA: true }) + expect(dataValues[1]!.label).toBe('beta') + expect(dataValues[1]!.config).toEqual({ providerB: true }) + }) + + test('returns html without tabs when single method has html', async () => { + const mppx = Mppx.create({ + methods: [alphaWithHtml, betaMethod], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaMethod, challengeOpts]) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Accept: 'text/html' }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const body = await result.challenge.text() + expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8') + + // No tabs when only one method has html + expect(body).not.toContain('role="tablist"') + expect(body).not.toContain('role="tab"') + + // Single panel present + expect(body).toContain('mppx-panel-0') + expect(body).toContain('/alpha-bundle.js') + + // Data map with single entry + const dataMatch = body.match( + /]*id="__MPPX_DATA__"[^>]*type="application\/json"[^>]*>\s*([\s\S]*?)\s*<\/script>/s, + ) + const dataMap = JSON.parse(dataMatch![1]!.replace(/\\u003c/g, '<')) + const dataValues = Object.values(dataMap) as { label: string }[] + expect(dataValues).toHaveLength(1) + expect(dataValues[0]!.label).toBe('alpha') + }) + + test('falls back to json when Accept does not include text/html', async () => { + const mppx = Mppx.create({ + methods: [alphaWithHtml, betaWithHtml], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts]) + + const result = await handle(new Request('https://example.com/resource')) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const contentType = result.challenge.headers.get('Content-Type') + expect(contentType).not.toContain('text/html') + }) + + test('serves service worker when __mppx_worker param is set', async () => { + const mppx = Mppx.create({ + methods: [alphaWithHtml, betaWithHtml], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts]) + + const result = await handle(new Request('https://example.com/resource?__mppx_worker')) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + expect(result.challenge.status).toBe(200) + expect(result.challenge.headers.get('Content-Type')).toBe('application/javascript') + }) + + test('returns json when no methods have html configured', async () => { + const mppx = Mppx.create({ + methods: [alphaMethod, betaMethod], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaMethod, challengeOpts], [betaMethod, challengeOpts]) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Accept: 'text/html' }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const contentType = result.challenge.headers.get('Content-Type') + expect(contentType).not.toContain('text/html') + }) + + test('both WWW-Authenticate headers present even with html', async () => { + const mppx = Mppx.create({ + methods: [alphaWithHtml, betaWithHtml], + realm, + secretKey, + }) + + const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts]) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Accept: 'text/html' }, + }), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const wwwAuth = result.challenge.headers.get('WWW-Authenticate')! + expect(wwwAuth).toContain('method="alpha"') + expect(wwwAuth).toContain('method="beta"') + }) + }) }) describe('compose: pre-dispatch narrowing edge cases', () => { diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 6b7aa1ef..526fbb1f 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -10,6 +10,8 @@ import type * as Method from '../Method.js' import * as PaymentRequest from '../PaymentRequest.js' import type * as Receipt from '../Receipt.js' import type * as z from '../zod.js' +import * as Html from './internal/html/config.js' +import { serviceWorker } from './internal/html/serviceWorker.gen.js' import * as NodeListener from './NodeListener.js' import * as Request from './Request.js' import * as Transport from './Transport.js' @@ -645,6 +647,7 @@ type ConfiguredHandler = ((input: Request) => Promise } } @@ -697,12 +700,52 @@ type ComposeEntry = * }) * ``` */ +type ComposeHtmlOptions = { theme?: Html.Theme; text?: Html.Text } + export function compose( - ...handlers: readonly ((input: Request) => Promise>)[] + ...args: readonly unknown[] ): (input: Request) => Promise> { + // Extract optional html options from last argument + const last = args[args.length - 1] + const composeOptions: Html.Options | undefined = + typeof last === 'object' && + last !== null && + typeof last !== 'function' && + !('_internal' in last) + ? (() => { + const opts = last as ComposeHtmlOptions + return { + config: {}, + content: '', + formatAmount: () => '', + text: opts.text, + theme: opts.theme, + } + })() + : undefined + const handlers = (composeOptions ? args.slice(0, -1) : args) as readonly (( + input: Request, + ) => Promise>)[] + if (handlers.length === 0) throw new Error('compose() requires at least one handler') return async (input: Request) => { + // Serve service worker for html-enabled compose + if (new URL(input.url).searchParams.has(Html.serviceWorkerParam)) { + const hasHtml = handlers.some((h) => (h as ConfiguredHandler)._internal?.html) + if (hasHtml) + return { + status: 402, + challenge: new Response(serviceWorker, { + status: 200, + headers: { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store', + }, + }), + } as MethodFn.Response + } + // Try to extract a Payment credential to decide whether to dispatch or challenge. // Only gate on the Payment scheme — other auth schemes (Bearer, Basic, etc.) // should fall through to the merged-402 path so all offers are presented. @@ -754,17 +797,89 @@ export function compose( const mergedHeaders = new Headers() mergedHeaders.set('Cache-Control', 'no-store') - let body: string | null = null for (const result of results) { if (result.status !== 402) continue const response = result.challenge as Response const wwwAuth = response.headers.get('WWW-Authenticate') if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth) - // Use the first handler's body for the problem details response. + } + + // Collect html-enabled handlers and their challenges + const htmlEntries = (() => { + const entries: { + handler: ConfiguredHandler + challenge: Challenge.Challenge + }[] = [] + for (let i = 0; i < handlers.length; i++) { + const meta = (handlers[i] as ConfiguredHandler)._internal + if (!meta?.html) continue + const result = results[i] + if (result?.status !== 402) continue + const wwwAuth = result.challenge.headers.get('WWW-Authenticate') + if (!wwwAuth) continue + entries.push({ + handler: handlers[i] as ConfiguredHandler, + challenge: Challenge.deserialize(wwwAuth), + }) + } + return entries + })() + + const wantsHtml = input.headers.get('Accept')?.includes('text/html') + if (wantsHtml && htmlEntries.length > 0) { + const { theme, text } = Html.resolveOptions( + // Use compose-level options or first html-enabled method's config for the page shell + composeOptions ?? htmlEntries[0]?.handler._internal.html ?? ({} as Html.Options), + ) + + // Build data map keyed by challenge.id + const dataMap: Record = {} + for (let i = 0; i < htmlEntries.length; i++) { + const entry = htmlEntries[i]! + dataMap[entry.challenge.id] = { + label: entry.handler._internal.name, + rootId: `${Html.rootId}-${i}`, + formattedAmount: await entry.handler._internal.html!.formatAmount( + entry.challenge.request, + ), + config: entry.handler._internal.html!.config, + challenge: entry.challenge as never, + text, + theme, + } + } + + mergedHeaders.set('Content-Type', 'text/html; charset=utf-8') + + const firstData = Object.values(dataMap)[0]! + const body = Html.render({ + entries: htmlEntries.map((entry) => ({ + challenge: entry.challenge, + content: entry.handler._internal.html!.content, + })), + dataMap, + formattedAmount: firstData.formattedAmount, + panels: true, + text, + theme, + }) + + return { + status: 402, + challenge: new Response(body, { status: 402, headers: mergedHeaders }), + } + } + + // Non-HTML fallback: use first handler's body + let body: string | null = null + for (const result of results) { + if (result.status !== 402) continue if (!body) { + const response = result.challenge as Response const contentType = response.headers.get('Content-Type') if (contentType) mergedHeaders.set('Content-Type', contentType) body = await response.text() + break } } diff --git a/src/server/Transport.test.ts b/src/server/Transport.test.ts index 68ac4980..e81bdccf 100644 --- a/src/server/Transport.test.ts +++ b/src/server/Transport.test.ts @@ -285,11 +285,14 @@ describe('http', () => { const body = await response.text() // Extract the JSON data from the script tag const dataMatch = body.match( - / - ${options.html.content} - - - ` + const dataMap = { + [challenge.id]: { + label: challenge.method, + rootId: Html.rootId, + formattedAmount: amount, + config: options.html.config, + challenge, + text, + theme, + }, + } satisfies Record + + return Html.render({ + entries: [{ challenge, content: options.html.content }], + dataMap, + formattedAmount: amount, + text, + theme, + }) } if (error) { headers['Content-Type'] = 'application/problem+json' diff --git a/src/server/internal/html/compose.main.ts b/src/server/internal/html/compose.main.ts new file mode 100644 index 00000000..bf6dcd64 --- /dev/null +++ b/src/server/internal/html/compose.main.ts @@ -0,0 +1,87 @@ +const tablist = document.querySelector('.mppx-tablist')! +const summary = document.querySelector('.mppx-summary')! +const amountEl = summary.querySelector('.mppx-summary-amount')! +const param = '__mppx_tab' +const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')) + +// Generate unique slugs: tempo, stripe, stripe-2 +const slugs: string[] = [] +const counts: Record = {} +for (const tab of tabs) { + const name = tab.textContent!.trim().toLowerCase() + counts[name] = (counts[name] || 0) + 1 + slugs.push(counts[name] === 1 ? name : `${name}-${counts[name]}`) +} + +function updateSummary(tab: HTMLElement) { + amountEl.textContent = tab.dataset.amount! + + summary.querySelector('.mppx-summary-description')?.remove() + if (tab.dataset.description) { + const p = document.createElement('p') + p.className = 'mppx-summary-description' + p.textContent = tab.dataset.description + amountEl.after(p) + } + + summary.querySelector('.mppx-summary-expires')?.remove() + if (tab.dataset.expires) { + const p = document.createElement('p') + p.className = 'mppx-summary-expires' + const date = new Date(tab.dataset.expires) + const time = document.createElement('time') + time.dateTime = date.toISOString() + time.textContent = date.toLocaleString() + p.textContent = `${tab.dataset.expiresLabel} ` + p.appendChild(time) + summary.appendChild(p) + } +} + +function activate(tab: HTMLElement, updateUrl = true) { + tabs.forEach((t) => { + t.setAttribute('aria-selected', 'false') + t.setAttribute('tabindex', '-1') + }) + tab.setAttribute('aria-selected', 'true') + tab.removeAttribute('tabindex') + tab.focus() + document.querySelectorAll('[role="tabpanel"]').forEach((p) => { + p.hidden = true + }) + document.getElementById(tab.getAttribute('aria-controls')!)!.hidden = false + + updateSummary(tab) + + if (updateUrl) { + const url = new URL(location.href) + url.searchParams.set(param, slugs[tabs.indexOf(tab)]!) + history.replaceState(null, '', url) + } +} + +// Restore tab from URL on load +const initial = new URL(location.href).searchParams.get(param) +if (initial !== null) { + const idx = slugs.indexOf(initial) + if (idx >= 0) activate(tabs[idx]!, false) +} + +tablist.addEventListener('click', (e) => { + const tab = (e.target as HTMLElement).closest('[role="tab"]') + if (tab) activate(tab) +}) + +tablist.addEventListener('keydown', (e) => { + const idx = tabs.indexOf(e.target as HTMLElement) + if (idx < 0) return + let next: HTMLElement | undefined + if (e.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length] + else if (e.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length] + else if (e.key === 'Home') next = tabs[0] + else if (e.key === 'End') next = tabs[tabs.length - 1] + if (next) { + e.preventDefault() + activate(next) + } +}) diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index fde68327..46d484f0 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -1,5 +1,8 @@ +import { Json } from 'ox' + import type * as Challenge from '../../../Challenge.js' import type * as Method from '../../../Method.js' +import { tabScript } from './compose.main.gen.js' export type Options = { config: Record @@ -13,6 +16,9 @@ export type Data< method extends Method.Method = Method.Method, config extends Record = {}, > = { + label: string + rootId: string + formattedAmount: string config: config challenge: Challenge.FromMethods<[method]> text: { [k in keyof Text]-?: NonNullable } @@ -21,15 +27,48 @@ export type Data< } } -export const dataId = '__MPPX_DATA__' - export const errorId = 'root_error' - export const rootId = 'root' +const dataId = '__MPPX_DATA__' export const serviceWorkerParam = '__mppx_worker' -export const classNames = { +const challengeIdAttr = 'data-mppx-challenge-id' +const remainingAttr = 'data-remaining' + +export function getData< + method extends Method.Method = Method.Method, + config extends Record = {}, +>(methodName: method['name']): Data { + const el = document.getElementById(dataId)! + const map = Json.parse(el.textContent) as Record> + const remaining = el.getAttribute(remainingAttr) + if (!remaining || Number(remaining) <= 1) el.remove() + else el.setAttribute(remainingAttr, String(Number(remaining) - 1)) + const script = document.currentScript + const challengeId = script?.getAttribute(challengeIdAttr) + if (challengeId) { + script!.removeAttribute(challengeIdAttr) + return map[challengeId]! + } + return Object.values(map).find((d) => d.challenge.method === methodName)! +} + +export function showError(message: string) { + const existing = document.getElementById(errorId) + if (existing) { + existing.textContent = message + return + } + const el = document.createElement('p') + el.id = errorId + el.className = classNames.error + el.role = 'alert' + el.textContent = message + document.getElementById(rootId)?.after(el) +} + +const classNames = { error: 'mppx-error', header: 'mppx-header', logo: 'mppx-logo', @@ -41,25 +80,11 @@ export const classNames = { summaryAmount: 'mppx-summary-amount', summaryDescription: 'mppx-summary-description', summaryExpires: 'mppx-summary-expires', + tab: 'mppx-tab', + tabList: 'mppx-tablist', + tabPanel: 'mppx-tabpanel', } -export function sanitize(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -export function sanitizeRecord>(record: type): type { - return Object.fromEntries( - Object.entries(record).map(([key, value]) => [key, sanitize(value)]), - ) as type -} - -export const html = String.raw - class CssVar { readonly name: string constructor(token: string) { @@ -85,17 +110,236 @@ export const vars = { spacingUnit: new CssVar('spacing-unit'), } as const -export function font(theme: Theme) { - if (!theme.fontUrl) return '' - return html` - ` +export const defaultText = { + expires: 'Expires at', + pay: 'Pay', + paymentRequired: 'Payment Required', + title: 'Payment Required', +} as const satisfies Required + +export type Text = { + /** Prefix for the expiry line. @default 'Expires at' */ + expires?: string | undefined + /** Pay button label. @default 'Pay' */ + pay?: string | undefined + /** Badge label. @default 'Payment Required' */ + paymentRequired?: string | undefined + /** Page title. @default text.paymentRequired */ + title?: string | undefined +} + +export const defaultTheme = { + colorScheme: 'light dark', + fontFamily: 'system-ui, -apple-system, sans-serif', + fontSizeBase: '16px', + radius: '6px', + spacingUnit: '2px', + + accent: ['#171717', '#ededed'], + background: ['#ffffff', '#0a0a0a'], + border: ['#e5e5e5', '#2e2e2e'], + foreground: ['#0a0a0a', '#ededed'], + muted: ['#666666', '#a1a1a1'], + negative: ['#e5484d', '#e5484d'], + positive: ['#30a46c', '#30a46c'], + surface: ['#f5f5f5', '#1a1a1a'], +} as const satisfies Required> + +export type Theme = { + /** Color scheme. @default 'light dark' */ + colorScheme?: 'light' | 'dark' | 'light dark' | undefined + /** Font family. @default 'system-ui, -apple-system, sans-serif' */ + fontFamily?: string | undefined + /** Base font size. @default '16px' */ + fontSizeBase?: string | undefined + /** Font URL to inject (e.g. Google Fonts ``). */ + fontUrl?: string | undefined + /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */ + favicon?: string | { light: string; dark: string } | undefined + /** Logo URL shown in header. Light/dark variants supported. */ + logo?: string | { light: string; dark: string } | undefined + /** Border radius. @default '6px' */ + radius?: string | undefined + /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */ + spacingUnit?: string | undefined + + /** Accent color (buttons, links). @default ['#171717', '#ededed'] */ + accent?: LightDark | undefined + /** Page background. @default ['#ffffff', '#0a0a0a'] */ + background?: LightDark | undefined + /** Border color. @default ['#e5e5e5', '#2e2e2e'] */ + border?: LightDark | undefined + /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */ + foreground?: LightDark | undefined + /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */ + muted?: LightDark | undefined + /** Error/danger color. @default ['#e5484d', '#e5484d'] */ + negative?: LightDark | undefined + /** Success color. @default ['#30a46c', '#30a46c'] */ + positive?: LightDark | undefined + /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */ + surface?: LightDark | undefined +} + +type LightDark = string | readonly [light: string, dark: string] + +function resolveColor( + value: Theme[(typeof colorTokens)[number]] | undefined, + fallback: readonly [string, string], +): readonly [light: string, dark: string] { + if (!value) return fallback + if (typeof value === 'string') return [value, value] + return value +} + +const colorTokens = [ + 'accent', + 'negative', + 'positive', + 'background', + 'foreground', + 'muted', + 'surface', + 'border', +] as const satisfies readonly (keyof typeof defaultTheme)[] + +export function resolveOptions(options: Options): { + theme: ResolvedTheme + text: ResolvedText +} { + const theme = mergeDefined( + { + favicon: undefined as Theme['favicon'], + fontUrl: undefined as Theme['fontUrl'], + logo: undefined as Theme['logo'], + ...defaultTheme, + }, + (options.theme as never) ?? {}, + ) + const text = sanitizeRecord(mergeDefined(defaultText, (options.text as never) ?? {})) + return { theme, text } +} + +type ResolvedTheme = { + [k in keyof Omit]-?: NonNullable +} & Pick +type ResolvedText = { [k in keyof Text]-?: NonNullable } + +const html = String.raw + +export function render(options: { + entries: readonly { + challenge: Challenge.Challenge + content: string + }[] + dataMap: Record + formattedAmount: string + /** Whether to render panel wrappers around each entry. @default entries.length > 1 */ + panels?: boolean | undefined + text: ResolvedText + theme: ResolvedTheme +}): string { + const { entries, dataMap, formattedAmount, text, theme } = options + const firstChallenge = entries[0]!.challenge + const hasTabs = entries.length > 1 + const hasPanels = options.panels ?? hasTabs + const dataValues = Object.values(dataMap) + + const tabListHtml = hasTabs + ? html`` + : '' + + const panelsHtml = hasPanels + ? entries + .map( + (_entry, i) => + html`
+
+
`, + ) + .join('') + : html`
` + + const contentScripts = hasTabs + ? entries + .map((entry) => + entry.content.replace( + ' + ${contentScripts} ${hasTabs ? tabScript : ''} + + + ` } -export function style(theme: { +function style(theme: { [k in keyof Omit]-?: NonNullable }) { const colors = Object.fromEntries( @@ -116,7 +360,6 @@ export function style(theme: { : '' return html` + ` } -export const defaultText = { - expires: 'Expires at', - pay: 'Pay', - paymentRequired: 'Payment Required', - title: 'Payment Required', -} as const satisfies Required - -export type Theme = { - /** Color scheme. @default 'light dark' */ - colorScheme?: 'light' | 'dark' | 'light dark' | undefined - /** Font family. @default 'system-ui, -apple-system, sans-serif' */ - fontFamily?: string | undefined - /** Base font size. @default '16px' */ - fontSizeBase?: string | undefined - /** Font URL to inject (e.g. Google Fonts ``). */ - fontUrl?: string | undefined - /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */ - favicon?: string | { light: string; dark: string } | undefined - /** Logo URL shown in header. Light/dark variants supported. */ - logo?: string | { light: string; dark: string } | undefined - /** Border radius. @default '6px' */ - radius?: string | undefined - /** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */ - spacingUnit?: string | undefined +// Slimmed down Tailwind preflight +// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css +const preflight = html`` - /** Accent color (buttons, links). @default ['#171717', '#ededed'] */ - accent?: LightDark | undefined - /** Page background. @default ['#ffffff', '#0a0a0a'] */ - background?: LightDark | undefined - /** Border color. @default ['#e5e5e5', '#2e2e2e'] */ - border?: LightDark | undefined - /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */ - foreground?: LightDark | undefined - /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */ - muted?: LightDark | undefined - /** Error/danger color. @default ['#e5484d', '#e5484d'] */ - negative?: LightDark | undefined - /** Success color. @default ['#30a46c', '#30a46c'] */ - positive?: LightDark | undefined - /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */ - surface?: LightDark | undefined +function sanitize(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') } -export type LightDark = string | readonly [light: string, dark: string] - -export const defaultTheme = { - colorScheme: 'light dark', - fontFamily: 'system-ui, -apple-system, sans-serif', - fontSizeBase: '16px', - radius: '6px', - spacingUnit: '2px', - - accent: ['#171717', '#ededed'], - background: ['#ffffff', '#0a0a0a'], - border: ['#e5e5e5', '#2e2e2e'], - foreground: ['#0a0a0a', '#ededed'], - muted: ['#666666', '#a1a1a1'], - negative: ['#e5484d', '#e5484d'], - positive: ['#30a46c', '#30a46c'], - surface: ['#f5f5f5', '#1a1a1a'], -} as const satisfies Required> - -export const colorTokens = [ - 'accent', - 'negative', - 'positive', - 'background', - 'foreground', - 'muted', - 'surface', - 'border', -] as const satisfies readonly (keyof typeof defaultTheme)[] - -export function resolveColor( - value: Theme[(typeof colorTokens)[number]] | undefined, - fallback: readonly [string, string], -): readonly [light: string, dark: string] { - if (!value) return fallback - if (typeof value === 'string') return [value, value] - return value +function sanitizeRecord>(record: type): type { + return Object.fromEntries( + Object.entries(record).map(([key, value]) => [key, sanitize(value)]), + ) as type } export function mergeDefined(defaults: type, value: DeepPartial | undefined): type { - if (value === undefined) return defaults + if (value === undefined || value === null) return defaults if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type const result: Record = { ...defaults } for (const [key, nextValue] of Object.entries(value)) { - if (nextValue === undefined) continue + if (nextValue === undefined || nextValue === null || nextValue === '') continue const currentValue = result[key] @@ -388,27 +706,3 @@ type DeepPartial = { function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } - -// Slimmed down Tailwind preflight -// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css -const reset = html` - *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; - padding: 0; border: 0 solid; border-color: ${vars.border}; } html, :host { line-height: 1.5; - -webkit-text-size-adjust: 100%; tab-size: 4; -webkit-tap-highlight-color: transparent; } h1, h2, - h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; - -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } - code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, - 'Liberation Mono', 'Courier New', monospace; font-size: 1em; } small { font-size: 80%; } ol, ul, - menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; - vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, - optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; - font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; - background-color: transparent; opacity: 1; } ::file-selector-button { margin-inline-end: 4px; } - ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or - (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, - transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: - none; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], - [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } - [hidden]:where(:not([hidden='until-found'])) { display: none !important; } -` diff --git a/src/server/internal/html/tsconfig.compose.json b/src/server/internal/html/tsconfig.compose.json new file mode 100644 index 00000000..356e02e6 --- /dev/null +++ b/src/server/internal/html/tsconfig.compose.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["compose.main.ts"] +} diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index bc230d48..2c5d4b0f 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,6 +1,5 @@ import type { Appearance } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js/pure' -import { Json } from 'ox' import { stripe } from '../../../../client/index.js' import * as Html from '../../../../server/internal/html/config.js' @@ -9,13 +8,9 @@ import type { charge as chargeClient } from '../../../../stripe/client/Charge.js import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' -const dataElement = document.getElementById(Html.dataId)! -const data = Json.parse(dataElement.textContent) as Html.Data< - typeof Methods.charge, - NonNullable -> +const data = Html.getData>('stripe') -const root = document.getElementById(Html.rootId)! +const root = document.getElementById(data.rootId)! const css = String.raw const style = document.createElement('style') @@ -170,5 +165,3 @@ async function createToken(opts: chargeClient.OnChallengeParameters) { const json = (await res.json()) as { spt: string } return json.spt } - -dataElement.remove() diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 3d8c9f56..7dff0810 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -1,5 +1,4 @@ import { local, Provider } from 'accounts' -import { Json } from 'ox' import { createClient, custom, http } from 'viem' import { tempoModerato, tempoLocalnet } from 'viem/chains' @@ -8,10 +7,9 @@ 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 dataElement = document.getElementById(Html.dataId)! -const data = Json.parse(dataElement.textContent) as Html.Data +const data = Html.getData('tempo') -const root = document.getElementById(Html.rootId)! +const root = document.getElementById(data.rootId)! const css = String.raw const style = document.createElement('style') @@ -85,17 +83,22 @@ button.onclick = async () => { document.getElementById(Html.errorId)?.remove() button.disabled = true - const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( - (x) => x.id === data.challenge.request.methodDetails?.chainId, - ) - const client = createClient({ chain, transport: custom(provider) }) const account = await (async () => { const accounts = await provider.request({ method: 'eth_accounts' }) if (accounts.length > 0) return accounts.at(0) const result = await provider.request({ method: 'wallet_connect' }) return result.accounts[0]?.address })() - const method = tempo({ account, getClient: () => client })[0] + const method = tempo({ + account, + getClient(opts) { + const chainId = opts.chainId ?? data.challenge.request.methodDetails?.chainId + const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( + (x) => x.id === chainId, + ) + return createClient({ chain, transport: custom(provider) }) + }, + })[0] const credential = await method.createCredential({ challenge: data.challenge, context: {} }) await submitCredential(credential) @@ -107,5 +110,3 @@ button.onclick = async () => { } } root.appendChild(button) - -dataElement.remove() diff --git a/test/html/compose.test.ts b/test/html/compose.test.ts new file mode 100644 index 00000000..03616cca --- /dev/null +++ b/test/html/compose.test.ts @@ -0,0 +1,183 @@ +import { expect, test } from '@playwright/test' + +import { getStripePaymentFrame } from './utils.js' + +test('compose renders tabs for multiple methods', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + // Verify 402 payment page rendered + await expect(page.getByText('Payment Required')).toBeVisible() + + // Both tabs visible + const tempoTab = page.getByRole('tab', { name: 'Tempo' }) + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + await expect(tempoTab).toBeVisible() + await expect(stripeTab).toBeVisible() + + // Tempo tab is active by default + await expect(tempoTab).toHaveAttribute('aria-selected', 'true') + await expect(stripeTab).toHaveAttribute('aria-selected', 'false') + + // Tempo panel visible, Stripe panel hidden + const tempoPanel = page.locator('#mppx-panel-0') + const stripePanel = page.locator('#mppx-panel-1') + await expect(tempoPanel).toBeVisible() + await expect(stripePanel).toBeHidden() + + // Tempo content rendered + await expect(page.getByRole('button', { name: /continue with tempo/i })).toBeVisible() +}) + +test('compose tab switching', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + const tempoTab = page.getByRole('tab', { name: 'Tempo' }) + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + + // Click Stripe tab + await stripeTab.click() + await expect(stripeTab).toHaveAttribute('aria-selected', 'true') + await expect(tempoTab).toHaveAttribute('aria-selected', 'false') + + // Stripe panel visible, Tempo panel hidden + await expect(page.locator('#mppx-panel-1')).toBeVisible() + await expect(page.locator('#mppx-panel-0')).toBeHidden() + + // Click back to Tempo + await tempoTab.click() + await expect(tempoTab).toHaveAttribute('aria-selected', 'true') + await expect(page.locator('#mppx-panel-0')).toBeVisible() +}) + +test('compose arrow key navigation', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + const tempoTab = page.getByRole('tab', { name: 'Tempo' }) + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + + // Focus Tempo tab and press ArrowRight + await tempoTab.focus() + await page.keyboard.press('ArrowRight') + await expect(stripeTab).toHaveAttribute('aria-selected', 'true') + await expect(stripeTab).toBeFocused() + + // ArrowRight wraps to first tab + await page.keyboard.press('ArrowRight') + await expect(tempoTab).toHaveAttribute('aria-selected', 'true') + await expect(tempoTab).toBeFocused() + + // ArrowLeft wraps to last tab + await page.keyboard.press('ArrowLeft') + await expect(stripeTab).toHaveAttribute('aria-selected', 'true') + await expect(stripeTab).toBeFocused() +}) + +test('compose service worker endpoint', async ({ page }) => { + const response = await page.goto('/compose?__mppx_worker') + expect(response?.headers()['content-type']).toContain('application/javascript') + expect(response?.status()).toBe(200) +}) + +test('compose tab switching updates URL query param', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + const stripeTab = page.getByRole('tab', { name: 'Stripe' }) + + // Click Stripe tab — URL should update + await stripeTab.click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('stripe') + + // Click Tempo tab — URL should update + await page.getByRole('tab', { name: 'Tempo' }).click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('tempo') +}) + +test('compose restores tab from URL query param', async ({ page }) => { + await page.goto('/compose?__mppx_tab=stripe', { waitUntil: 'domcontentloaded' }) + + // Stripe tab should be active + await expect(page.getByRole('tab', { name: 'Stripe' })).toHaveAttribute('aria-selected', 'true') + await expect(page.getByRole('tab', { name: 'Tempo' })).toHaveAttribute('aria-selected', 'false') + + // Stripe panel visible, Tempo panel hidden + await expect(page.locator('#mppx-panel-1')).toBeVisible() + await expect(page.locator('#mppx-panel-0')).toBeHidden() +}) + +test('compose duplicate method names get unique slugs', async ({ page }) => { + await page.goto('/compose-duplicates', { waitUntil: 'domcontentloaded' }) + + const tabs = page.getByRole('tab') + await expect(tabs).toHaveCount(3) + + // Click second Stripe tab — should get stripe-2 slug + await tabs.nth(2).click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('stripe-2') + + // Click first Stripe tab — should get stripe slug + await tabs.nth(1).click() + expect(new URL(page.url()).searchParams.get('__mppx_tab')).toBe('stripe') +}) + +test('compose restores duplicate method tab from URL', async ({ page }) => { + await page.goto('/compose-duplicates?__mppx_tab=stripe-2', { waitUntil: 'domcontentloaded' }) + + const tabs = page.getByRole('tab') + + // Third tab (second Stripe) should be active + await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true') + await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'false') + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'false') + + // Third panel visible + await expect(page.locator('#mppx-panel-2')).toBeVisible() + await expect(page.locator('#mppx-panel-0')).toBeHidden() + await expect(page.locator('#mppx-panel-1')).toBeHidden() +}) + +test('compose pay via stripe tab', async ({ page }, testInfo) => { + test.slow() + + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + // Switch to Stripe tab + await page.getByRole('tab', { name: 'Stripe' }).click() + await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 }) + + if (!testInfo.project.use.headless) { + const stripeFrame = await getStripePaymentFrame(page) + const numberInput = stripeFrame.locator('[name="number"]') + const cardButton = stripeFrame.locator('[data-value="card"]') + + await cardButton.isVisible({ timeout: 90_000 }) + await cardButton.click() + await page.waitForTimeout(1_000) + + 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') + + const postalCode = stripeFrame.locator('[name="postalCode"]') + await postalCode.isVisible({ timeout: 2_000 }) + await postalCode.fill('10001') + + await page.waitForTimeout(500) + } + + // Submit payment (force needed — Stripe Link overlay can intercept click) + await page.getByRole('button', { name: 'Pay' }).click({ force: true }) + + // Wait for service worker to submit credential and page to reload with paid response + await expect(page.locator('body')).toContainText('"ok":', { timeout: 30_000 }) +}) + +test('compose pay via tempo tab', async ({ page }) => { + await page.goto('/compose', { waitUntil: 'domcontentloaded' }) + + // Tempo tab is active by default, click pay + await page.getByRole('button', { name: /continue with tempo/i }).click() + + // Wait for service worker to submit credential and page to reload with paid response + await expect(page.locator('body')).toContainText('"ok":', { timeout: 30_000 }) +}) diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index 6217d152..c3785116 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -28,6 +28,11 @@ export default defineConfig({ testMatch: 'stripe.test.ts', use: { baseURL: `http://localhost:${port}` }, }, + { + name: 'compose', + testMatch: 'compose.test.ts', + use: { baseURL: `http://localhost:${port}` }, + }, ], }) diff --git a/test/html/server.ts b/test/html/server.ts index 8097be5d..4fe0f9f3 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -65,8 +65,6 @@ export async function startServer(port: number): Promise { 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', @@ -93,6 +91,31 @@ export async function startServer(port: number): Promise { return result.withReceipt(Response.json({ fortune })) } + if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey) + + if (url.pathname === '/compose') { + const result = await mppx.compose( + ['tempo/charge', { amount: '0.01', description: 'Composed payment' }], + ['stripe/charge', { amount: '1', currency: 'usd', decimals: 2 }], + )(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt(Response.json({ ok: true })) + } + + if (url.pathname === '/compose-duplicates') { + const result = await mppx.compose( + ['tempo/charge', { amount: '0.01', description: 'Composed payment' }], + ['stripe/charge', { amount: '1', currency: 'usd', decimals: 2 }], + ['stripe/charge', { amount: '2', currency: 'usd', decimals: 2 }], + )(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt(Response.json({ ok: true })) + } + return new Response('Not Found', { status: 404 }) }), ) diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index cf057891..55e6f4da 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -1,6 +1,7 @@ -import type { Frame, Page } from '@playwright/test' import { expect, test } from '@playwright/test' +import { getStripePaymentFrame } from './utils.js' + test('charge via stripe html payment page', async ({ page }, testInfo) => { test.slow() @@ -45,24 +46,3 @@ test('service worker endpoint returns javascript', async ({ page }) => { 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/utils.ts b/test/html/utils.ts new file mode 100644 index 00000000..b2a1ad5b --- /dev/null +++ b/test/html/utils.ts @@ -0,0 +1,22 @@ +import type { Frame, Page } from '@playwright/test' + +export 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/tsconfig.json b/tsconfig.json index aac76729..e4b36ee0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "files": [], "references": [ { "path": "./src" }, + { "path": "./src/server/internal/html/tsconfig.compose.json" }, { "path": "./src/server/internal/html/tsconfig.worker.client.json" }, { "path": "./src/server/internal/html/tsconfig.worker.json" }, { "path": "./test" }