diff --git a/.github/actions/sdk-drift-check/README.md b/.github/actions/sdk-drift-check/README.md index 28c6914e..1713481f 100644 --- a/.github/actions/sdk-drift-check/README.md +++ b/.github/actions/sdk-drift-check/README.md @@ -10,7 +10,7 @@ A composite GitHub Action that validates vocs.config sidebar SDK references matc - name: Run SDK drift check uses: ./.github/actions/sdk-drift-check with: - sdk-package: mpay + sdk-package: mppx sdk-version: latest output-dir: ./drift-results @@ -32,9 +32,9 @@ pnpm check:sdk-drift --output results.json | Input | Description | Default | |-------|-------------|---------| -| `sdk-package` | npm package name to check | `mpay` | +| `sdk-package` | npm package name to check | `mppx` | | `sdk-version` | SDK version to check | `latest` | -| `vocs-config` | Path to vocs config file | `./vocs.config.tsx` | +| `vocs-config` | Path to vocs config file | `./vocs.config.ts` | | `sdk-path-prefix` | Sidebar path prefix for SDK references | `/sdk/typescript` | | `output-dir` | Directory to write results | `./drift-results` | diff --git a/.github/actions/sdk-drift-check/action.yml b/.github/actions/sdk-drift-check/action.yml index 827b032b..27988273 100644 --- a/.github/actions/sdk-drift-check/action.yml +++ b/.github/actions/sdk-drift-check/action.yml @@ -5,7 +5,7 @@ inputs: sdk-package: description: "npm package name to check" required: false - default: "mpay" + default: "mppx" sdk-version: description: "SDK version to check (default: latest)" required: false @@ -13,7 +13,7 @@ inputs: vocs-config: description: "Path to vocs config file" required: false - default: "./vocs.config.tsx" + default: "./vocs.config.ts" sdk-path-prefix: description: "Sidebar path prefix for SDK references" required: false diff --git a/.github/actions/sdk-drift-check/check-sdk-drift.test.ts b/.github/actions/sdk-drift-check/check-sdk-drift.test.ts index aefa8ad3..b3ae37b7 100644 --- a/.github/actions/sdk-drift-check/check-sdk-drift.test.ts +++ b/.github/actions/sdk-drift-check/check-sdk-drift.test.ts @@ -120,6 +120,54 @@ describe("parseLink", () => { member: "http", }); }); + + it("maps method docs to the actual client export", () => { + const result = parseLink( + "/sdk/typescript/client/Method.tempo.charge", + prefix, + ); + expect(result).toEqual({ + link: "/sdk/typescript/client/Method.tempo.charge", + area: "client", + namespace: "tempo", + member: "charge", + }); + }); + + it("maps session manager docs to tempo.session", () => { + const result = parseLink( + "/sdk/typescript/client/Method.tempo.session-manager", + prefix, + ); + expect(result).toEqual({ + link: "/sdk/typescript/client/Method.tempo.session-manager", + area: "client", + namespace: "tempo", + member: "session", + }); + }); + + it("maps MCP client docs to the MCP SDK entrypoint", () => { + const result = parseLink("/sdk/typescript/client/McpClient.wrap", prefix); + expect(result).toEqual({ + link: "/sdk/typescript/client/McpClient.wrap", + area: "client", + namespace: "McpClient", + member: "wrap", + entrypoint: "mcp-sdk/client", + }); + }); + + it("maps Html docs to the html entrypoint", () => { + const result = parseLink("/sdk/typescript/Html.init", prefix); + expect(result).toEqual({ + link: "/sdk/typescript/Html.init", + area: "core", + namespace: "Html", + member: "init", + entrypoint: "html", + }); + }); }); describe("server area links", () => { @@ -135,6 +183,62 @@ describe("parseLink", () => { member: "toNodeListener", }); }); + + it("maps method docs to the actual server export", () => { + const result = parseLink( + "/sdk/typescript/server/Method.stripe.charge", + prefix, + ); + expect(result).toEqual({ + link: "/sdk/typescript/server/Method.stripe.charge", + area: "server", + namespace: "stripe", + member: "charge", + }); + }); + }); + + describe("special entrypoints", () => { + it("parses middleware entrypoints", () => { + const result = parseLink("/sdk/typescript/middlewares/hono", prefix); + expect(result).toEqual({ + link: "/sdk/typescript/middlewares/hono", + area: "middlewares", + namespace: "hono", + entrypoint: "hono", + }); + }); + + it("parses proxy entrypoint", () => { + const result = parseLink("/sdk/typescript/proxy", prefix); + expect(result).toEqual({ + link: "/sdk/typescript/proxy", + area: "proxy", + namespace: "proxy", + entrypoint: "proxy", + }); + }); + + it("parses cli entrypoint", () => { + const result = parseLink("/sdk/typescript/cli", prefix); + expect(result).toEqual({ + link: "/sdk/typescript/cli", + area: "cli", + namespace: "cli", + entrypoint: "cli", + }); + }); + + it("parses html custom docs page as docs-only", () => { + const result = parseLink("/sdk/typescript/html/custom", prefix); + expect(result).toEqual({ + link: "/sdk/typescript/html/custom", + area: "html", + namespace: "custom", + entrypoint: "html", + docsOnly: true, + }); + }); }); describe("top-level links (default to core)", () => { diff --git a/.github/actions/sdk-drift-check/check-sdk-drift.ts b/.github/actions/sdk-drift-check/check-sdk-drift.ts index 66e6fbeb..5940b676 100644 --- a/.github/actions/sdk-drift-check/check-sdk-drift.ts +++ b/.github/actions/sdk-drift-check/check-sdk-drift.ts @@ -2,7 +2,7 @@ /** * SDK Manifest Drift Check * - * Validates that sidebar SDK references in vocs.config.tsx match actual exports + * Validates that sidebar SDK references in vocs.config.ts match actual exports * from the TypeScript SDK package. Runs daily to detect drift between docs and SDK. * * Usage: @@ -10,19 +10,16 @@ * pnpm check:sdk-drift --output results.json * * Configuration (via environment or defaults): - * SDK_PACKAGE: npm package name to check (default: "mpay") - * VOCS_CONFIG: path to vocs config (default: "./vocs.config.tsx") + * SDK_PACKAGE: npm package name to check (default: "mppx") + * VOCS_CONFIG: path to vocs config (default: "./vocs.config.ts") * SDK_PATH_PREFIX: sidebar path prefix for SDK refs (default: "/sdk/typescript") */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); /** - * Find the workspace root by looking for vocs.config.tsx or package.json + * Find the workspace root by looking for a Vocs config. * Starts from cwd and walks up */ export function findWorkspaceRoot(): string { @@ -53,7 +50,9 @@ export interface SidebarReference { link: string; namespace: string; member?: string; - area: "core" | "client" | "server"; + area: string; + entrypoint?: string; + docsOnly?: boolean; } export interface ExportInfo { @@ -129,8 +128,8 @@ function emitAnnotation( function getConfig(): DriftCheckConfig { const args = parseArgs(); return { - sdkPackage: process.env.SDK_PACKAGE || "mpay", - vocsConfigPath: process.env.VOCS_CONFIG || join(rootDir, "vocs.config.tsx"), + sdkPackage: process.env.SDK_PACKAGE || "mppx", + vocsConfigPath: process.env.VOCS_CONFIG || join(rootDir, "vocs.config.ts"), sdkPathPrefix: process.env.SDK_PATH_PREFIX || "/sdk/typescript", pagesDir: join(rootDir, "src", "pages"), outputPath: args.output, @@ -159,7 +158,7 @@ export function extractSidebarLinksFromContent( } /** - * Extract sidebar links from vocs.config.tsx using regex + * Extract sidebar links from vocs.config.ts using regex * (avoids needing to execute the config) */ export function extractSidebarLinks( @@ -179,6 +178,47 @@ export function parseLink( pathPrefix: string, ): SidebarReference | null { const relativePath = link.slice(pathPrefix.length + 1); // Remove prefix and leading slash + if (!relativePath) return null; + + if (relativePath === "cli") { + return { + link, + area: "cli", + namespace: "cli", + entrypoint: "cli", + }; + } + + if (relativePath === "proxy") { + return { + link, + area: "proxy", + namespace: "proxy", + entrypoint: "proxy", + }; + } + + if (relativePath.startsWith("middlewares/")) { + const namespace = relativePath.slice("middlewares/".length); + if (!namespace) return null; + return { + link, + area: "middlewares", + namespace, + entrypoint: namespace, + }; + } + + if (relativePath === "html/custom") { + return { + link, + area: "html", + namespace: "custom", + entrypoint: "html", + docsOnly: true, + }; + } + const parts = relativePath.split("/"); if (parts.length === 0) return null; @@ -198,6 +238,42 @@ export function parseLink( if (!symbolPart) return null; + if (area === "client" && symbolPart.startsWith("McpClient.")) { + return { + link, + area, + namespace: "McpClient", + member: symbolPart.slice("McpClient.".length), + entrypoint: "mcp-sdk/client", + }; + } + + if (area === "core" && symbolPart.startsWith("Html.")) { + return { + link, + area, + namespace: "Html", + member: symbolPart.slice("Html.".length), + entrypoint: "html", + }; + } + + if ( + (area === "client" || area === "server") && + symbolPart.startsWith("Method.") + ) { + const methodParts = symbolPart.slice("Method.".length).split("."); + const namespace = methodParts[0]; + const rawMember = methodParts.slice(1).join(".") || undefined; + if (!namespace) return null; + return { + link, + area, + namespace, + member: rawMember === "session-manager" ? "session" : rawMember, + }; + } + // Parse Namespace.member or just Namespace const dotIndex = symbolPart.indexOf("."); if (dotIndex > 0) { @@ -229,6 +305,8 @@ export async function getSdkExports( { area: "core", path: packageName }, { area: "client", path: `${packageName}/client` }, { area: "server", path: `${packageName}/server` }, + { area: "html", path: `${packageName}/html` }, + { area: "mcp-sdk/client", path: `${packageName}/mcp-sdk/client` }, ]; for (const { area, path } of entrypoints) { @@ -239,16 +317,15 @@ export async function getSdkExports( if (name === "default" || name === "z") continue; const members: string[] = []; - if (value && typeof value === "object") { + if ( + value && + (typeof value === "object" || typeof value === "function") + ) { for (const key of Object.keys(value as object)) { if (!key.startsWith("_")) { members.push(key); } } - } else if (typeof value === "function") { - // Named function export (e.g., `export { tempo }`) - // Treat as a namespace with no members for matching - // This handles cases like `Method.tempo` where `tempo` is the actual export } const key = `${area}:${name}`; @@ -262,6 +339,31 @@ export async function getSdkExports( return exports; } +function getSdkEntrypoints(packageName: string): Set { + const packageJsonPath = join( + rootDir, + "node_modules", + packageName, + "package.json", + ); + if (!existsSync(packageJsonPath)) return new Set(); + + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + exports?: Record; + }; + + const entrypoints = new Set(); + for (const key of Object.keys(pkg.exports ?? {})) { + if (key === ".") { + entrypoints.add("core"); + continue; + } + if (key.startsWith("./")) entrypoints.add(key.slice(2)); + } + + return entrypoints; +} + /** * Get SDK package version */ @@ -288,16 +390,8 @@ async function getSdkVersion(packageName: string): Promise { * Check if a documentation page exists for a reference */ function pageExists(ref: SidebarReference, pagesDir: string): boolean { - // Build expected page path - const pagePath = ref.member - ? join( - pagesDir, - "sdk", - "typescript", - ref.area, - `${ref.namespace}.${ref.member}.mdx`, - ) - : join(pagesDir, "sdk", "typescript", ref.area, `${ref.namespace}.mdx`); + const relativePath = ref.link.slice("/sdk/typescript/".length); + const pagePath = join(pagesDir, "sdk", "typescript", `${relativePath}.mdx`); return existsSync(pagePath); } @@ -335,19 +429,61 @@ async function runDriftCheck(config: DriftCheckConfig): Promise { // Get SDK exports const exports = await getSdkExports(config.sdkPackage); + const entrypoints = getSdkEntrypoints(config.sdkPackage); // Track documented items for undocumented export detection const documented = new Set(); // Validate each reference for (const ref of refs) { - const exportKey = `${ref.area}:${ref.namespace}`; + const exportArea = ref.entrypoint ?? ref.area; + const exportKey = `${exportArea}:${ref.namespace}`; const exportInfo = exports[exportKey]; + if (ref.docsOnly) { + if (!pageExists(ref, config.pagesDir)) { + result.errors.push({ + type: "missing_page", + link: ref.link, + details: `Documentation page not found for ${ref.link}`, + }); + result.summary.missingPages++; + } + + result.validRefs.push(ref.link); + result.summary.valid++; + continue; + } + + if (ref.entrypoint && exportArea !== "mcp-sdk/client" && !ref.member) { + if (!entrypoints.has(ref.entrypoint)) { + result.errors.push({ + type: "missing_export", + link: ref.link, + details: `Entrypoint "${config.sdkPackage}/${ref.entrypoint}" is not exported`, + }); + result.summary.invalid++; + continue; + } + + if (!pageExists(ref, config.pagesDir)) { + result.errors.push({ + type: "missing_page", + link: ref.link, + details: `Documentation page not found for ${ref.link}`, + }); + result.summary.missingPages++; + } + + result.validRefs.push(ref.link); + result.summary.valid++; + continue; + } + // Handle "Namespace.member" pattern where sidebar uses conceptual grouping // but SDK exports the member directly (e.g., "Method.tempo" -> SDK has `tempo`) if (!exportInfo && ref.member) { - const directExportKey = `${ref.area}:${ref.member}`; + const directExportKey = `${exportArea}:${ref.member}`; const directExport = exports[directExportKey]; if (directExport) { // The member is exported directly - this is valid (conceptual grouping in docs) @@ -362,7 +498,7 @@ async function runDriftCheck(config: DriftCheckConfig): Promise { result.errors.push({ type: "missing_export", link: ref.link, - details: `Namespace "${ref.namespace}" not exported from ${config.sdkPackage}/${ref.area === "core" ? "" : ref.area}`, + details: `Namespace "${ref.namespace}" not exported from ${config.sdkPackage}/${exportArea === "core" ? "" : exportArea}`, }); result.summary.invalid++; continue; diff --git a/.github/workflows/sdk-drift-check.yml b/.github/workflows/sdk-drift-check.yml index c22fae34..d95962d5 100644 --- a/.github/workflows/sdk-drift-check.yml +++ b/.github/workflows/sdk-drift-check.yml @@ -9,7 +9,7 @@ on: sdk_package: description: "SDK package to check" required: false - default: "mpay" + default: "mppx" sdk_version: description: "SDK version to check" required: false @@ -38,7 +38,7 @@ jobs: id: drift-check uses: ./.github/actions/sdk-drift-check with: - sdk-package: ${{ github.event.inputs.sdk_package || 'mpay' }} + sdk-package: ${{ github.event.inputs.sdk_package || 'mppx' }} sdk-version: ${{ github.event.inputs.sdk_version || 'latest' }} output-dir: ./drift-results diff --git a/README.md b/README.md index 1d8540ad..3a3c2c46 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This repository contains the [mpp.dev](https://mpp.dev) documentation site and s pnpm install # Install dependencies pnpm run dev # Start development server pnpm run build # Production build +pnpm run check:sdk-drift # Validate SDK reference pages against mppx exports pnpm run preview # Preview production build ``` @@ -102,7 +103,8 @@ Contributions to documentation, the service directory, and site improvements are 1. **Types pass**: `pnpm check:types` 2. **Build succeeds**: `pnpm build` 3. **Lint passes**: `pnpm check` -4. **E2E tests pass** (if touching terminal or interactive components): `pnpm test:e2e` +4. **SDK references stay in sync** (if touching SDK docs or `vocs.config.ts`): `pnpm check:sdk-drift` +5. **E2E tests pass** (if touching terminal or interactive components): `pnpm test:e2e` ### Types of contributions diff --git a/package.json b/package.json index 28e75697..d78310c8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@stripe/stripe-js": "^8.9.0", "@vercel/blob": "^2.3.1", "mermaid": "^11.12.2", - "mppx": "0.5.5", + "mppx": "0.5.7", "react": "^19", "react-dom": "^19", "stripe": "^20.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a53800f..c06ee85d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^11.12.2 version: 11.12.2 mppx: - specifier: 0.5.5 - version: 0.5.5(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.6(typescript@5.9.3)(zod@4.3.6)) + specifier: 0.5.7 + version: 0.5.7(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.6(typescript@5.9.3)(zod@4.3.6)) react: specifier: ^19 version: 19.2.4 @@ -2809,8 +2809,8 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - mppx@0.5.5: - resolution: {integrity: sha512-HKGlnnLpRu6zXZCY17rbZNUC786aook9R5Eul5aafePAigpfK8ytlZ68QeRr11J21iR6je6rIztTk46qKcV79Q==} + mppx@0.5.7: + resolution: {integrity: sha512-jVqnpGt27RYmVqtquvOU/vVQ64j2w/oPqCyM9HAyVhOYOOUXM1uloG/dc7cuiq4LnpaNDyKTxtD9U2v1kjbUfg==} hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' @@ -6840,7 +6840,7 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 - mppx@0.5.5(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.6(typescript@5.9.3)(zod@4.3.6)): + mppx@0.5.7(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.47.6(typescript@5.9.3)(zod@4.3.6)): dependencies: '@remix-run/fetch-proxy': 0.7.1 '@remix-run/node-fetch-server': 0.13.0 diff --git a/src/pages/advanced/identity.mdx b/src/pages/advanced/identity.mdx index a5393b53..60ccbd0a 100644 --- a/src/pages/advanced/identity.mdx +++ b/src/pages/advanced/identity.mdx @@ -45,7 +45,7 @@ Zero-dollar auth uses the standard Challenge → Credential flow with the amount For Tempo charge, zero-dollar auth now uses a `proof` Credential payload instead of a real transaction. The client signs a proof message over the Challenge ID, and the server verifies that signature against the `source` DID. :::warning[Replay protection] -By default, a valid zero-dollar proof remains reusable until the Challenge expires. Pass `store` to `tempo.charge()` when you want single-use proof auth. In a multi-instance deployment, use a shared store so every instance sees consumed proofs. +By default, a valid zero-dollar proof remains reusable until the Challenge expires. Pass a `store` to `tempo.charge()` when you want single-use proof auth. In a multi-instance deployment, use a shared store so every instance sees consumed proofs. :::
-Use [`tempo`](/sdk/typescript/server/Method.tempo.session) to accept payment sessions. The server needs an RPC URL for on-chain verification during channel open/close, and a storage backend for channel state. +Use [`tempo`](/sdk/typescript/server/Method.tempo.session) to accept payment sessions. The server needs an RPC URL for on-chain verification during channel open/close, and a store backend for channel state. ```ts twoslash import { Mppx, Store, tempo } from 'mppx/server' @@ -127,6 +127,8 @@ const mppx = Mppx.create({ }) ``` +`Store.memory()` works for local development. For multi-instance deployments, use `Store.redis()`, `Store.upstash()`, or `Store.cloudflare()` so channel state is shared across processes. + During a session, the server verifies each voucher with a single `ecrecover`—no RPC calls, no database writes in the hot path. On-chain interaction only happens during open, settlement, and close. Use `mppx.session` in your request handler to meter access: diff --git a/src/pages/sdk/typescript/server/Method.tempo.charge.mdx b/src/pages/sdk/typescript/server/Method.tempo.charge.mdx index f0d32be1..72f07859 100644 --- a/src/pages/sdk/typescript/server/Method.tempo.charge.mdx +++ b/src/pages/sdk/typescript/server/Method.tempo.charge.mdx @@ -150,13 +150,13 @@ On-chain memo for the transaction. ### store (optional) -- **Type:** `Store` +- **Type:** `Store.Store` Pass a store when you want replay protection for charge Credentials. For non-zero charges, `mppx` falls back to an in-memory store when you omit this parameter. For zero-dollar proof auth, replay prevention is disabled unless you pass a store. -Use `Store.memory()` for local development, tests, or a single long-lived server process. Use `Store.redis()`, `Store.upstash()`, or `Store.cloudflare()` when you need replay prevention to survive restarts or apply across multiple instances. +Use `Store.memory()` for local development, tests, or a single long-lived server process. For multi-instance deployments, use `Store.redis()`, `Store.upstash()`, or `Store.cloudflare()` so replay markers are shared across processes. ### testnet (optional) diff --git a/src/pages/sdk/typescript/server/Method.tempo.session.mdx b/src/pages/sdk/typescript/server/Method.tempo.session.mdx index be6b561b..f4ecca19 100644 --- a/src/pages/sdk/typescript/server/Method.tempo.session.mdx +++ b/src/pages/sdk/typescript/server/Method.tempo.session.mdx @@ -119,10 +119,10 @@ Enable SSE streaming. Pass `true` to enable with defaults, or an options object ### store (optional) -- **Type:** `Store` +- **Type:** `Store.Store` - **Default:** `Store.memory()` -Storage backend for persisting channel and session state. Implements atomic read-modify-write operations for concurrency safety. +Store backend for persisting channel and session state. `mppx` handles concurrency internally. ```ts twoslash import { Store, tempo } from 'mppx/server' @@ -132,6 +132,8 @@ const method = tempo.session({ }) ``` +Use `Store.memory()` for local development and tests. For multi-instance deployments, use `Store.redis()`, `Store.upstash()`, or `Store.cloudflare()` so channel state is shared across processes. + ### suggestedDeposit (optional) - **Type:** `string` @@ -187,4 +189,3 @@ const txHash = await tempo.settle(store, client, '0xchannelId...') #### Return type - **Type:** `Hex` — The settlement transaction hash. -