diff --git a/.github/workflows/nextjs-starter.yml b/.github/workflows/nextjs-starter.yml new file mode 100644 index 0000000..1b85823 --- /dev/null +++ b/.github/workflows/nextjs-starter.yml @@ -0,0 +1,53 @@ +name: Next.js starter + +on: + push: + branches: [main, develop] + paths: + - "examples/nextjs-starter/**" + - "test/examples/nextjs-starter.test.ts" + - "vitest.config.ts" + - "src/server/**" + - "src/core/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig*.json" + - ".github/workflows/nextjs-starter.yml" + pull_request: + branches: [main, develop] + paths: + - "examples/nextjs-starter/**" + - "test/examples/nextjs-starter.test.ts" + - "vitest.config.ts" + - "src/server/**" + - "src/core/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "tsconfig*.json" + - ".github/workflows/nextjs-starter.yml" + +concurrency: + group: nextjs-starter-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + APP_URL: http://localhost:3001 + VANA_ENV: dev + # Public Hardhat fixture identity. No hosted requests run during build. + VANA_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test test/examples/nextjs-starter.test.ts + - run: pnpm --filter nextjs-starter build diff --git a/README.md b/README.md index 0ae6319..55037fd 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ their Personal Server, and it drives the builder side of the protocol so an app or an agent can ask for that data, read it, and pay for it from a terminal. -This repository also ships the JavaScript SDK the CLI is built on, which is -documented further down. +This repository includes a Next.js starter that consumes the canonical +`@opendatalabs/vana-sdk` Direct integration. Legacy JavaScript connection +exports remain available for compatibility. ## Install @@ -116,252 +117,91 @@ vana mcp approve [--scopes a,b] # share all or some of it vana mcp deny ``` -## The SDK +## Web app integration -The same package is also a JavaScript SDK for apps that ask their users for -data from a web page. Which one to use: +For a web app whose users approve data access in Vana, use the Direct integration from `@opendatalabs/vana-sdk`. For terminal scripts and agents, use `vana app request` and `vana app read` above. -- **A web app** whose users approve access in the browser: use the SDK below. -- **A script, a backend job or an agent** working from a terminal: use - `vana app request` and `vana app read` above. They cover the same consent - and add escrow payment, derived answers and exit codes an agent can - branch on. +The [Next.js starter](./examples/nextjs-starter/README.md) includes the complete browser and server flow with SDK 4.3.1. It replaces the starter's session-only Account link, which current Account rejects with `client_id: Required`. -### What problem this solves +### Server -Your users already have rich personal data (ChatGPT conversations, Instagram activity, Gmail, purchase history), but it's locked inside the platforms that collected it. As a builder, you can't easily use that data to personalize onboarding, tailor recommendations, or skip lengthy signup forms. - -**Data portability** means users can export their data from these platforms and grant your app scoped access to it, with their explicit consent, cryptographic verification, and full control over what's shared and when to revoke it. - -Today, getting access to user data means asking for manual file uploads (high friction), scraping on their behalf (fragile and legally risky), or negotiating enterprise API deals (slow and expensive). This SDK gives you a standardized way to request and receive personal data through Vana's [Data Portability Protocol](https://docs.vana.org/), handling session creation, grant verification, and data fetching in three function calls. - -### How it works - -``` -Your App Vana Protocol ------------------------------- ------------------------------ - -1. connect({ scopes }) - creates a session - returns a connect URL --> 2. User opens the Vana app, - reviews scopes, exports data, - approves the grant - -3. Poll resolves with grant <-- Grant signed and registered - -4. getData({ grant }) --> 5. Personal Server returns - structured JSON user data over TLS -``` - -The [Data Portability Protocol](https://docs.vana.org/) defines how users collect data from platforms, store it under their control (on-device or hosted), and grant third-party apps scoped access. This SDK handles session creation, cryptographic request signing, polling, and data fetching. You write three function calls; the protocol handles the rest. - -### Try the example app - -`examples/nextjs-starter` is a complete working app wired to the development -environment: - -```bash -git clone https://github.com/vana-com/vana-cli.git -cd vana-cli/examples/nextjs-starter -cp .env.local.example .env.local -``` - -Use the pre-registered dev key in .env.local. Note that this private key is ONLY for testing and works only with a testing Vana environment. - -``` -VANA_PRIVATE_KEY=0x3c05ac1a00546bc0b1b8d3a11fb908409005fac3f26d25f70711e4f632e720d3 -APP_URL=http://localhost:3001 -``` - -Install and run: - -```bash -pnpm install -pnpm dev -``` - -The example's own [README](./examples/nextjs-starter/README.md) walks -through approving a request as the end user. - -### Add it to your app - -#### Installation - -```bash -npm install vana-cli -``` - -#### Prerequisites - -First, register your app in the [Developer Portal](https://vana-developers.replit.app/). You will need to provide the URL where your app will be deployed, and then be given a private key after registration. - -#### 1. Create a session (server) +Install the SDK with `pnpm add @opendatalabs/vana-sdk@4.3.1`. Keep its controller in a server-only module. ```typescript -import { connect } from "vana-cli/server"; - -const session = await connect({ - privateKey: process.env.VANA_APP_PRIVATE_KEY as `0x${string}`, +import { + createDirectDataController, + PaymentRequiredError, +} from "@opendatalabs/vana-sdk/server"; + +const vana = createDirectDataController({ + appPrivateKey: process.env.VANA_APP_PRIVATE_KEY!, + app: { id: "your-app", name: "Your App", homepageUrl: "https://yourapp.com" }, + source: "chatgpt", scopes: ["chatgpt.conversations"], - webhookUrl: "https://yourapp.com/api/webhook", // optional, data can be pushed to a web hook after a grant is approved - appUserId: "yourapp-user-42", // optional: correlate your app user with the data they provided + env: "production", + personalServerFetch: async (url, init) => { + const response = await fetch(url, init); + if (response.status === 402) { + throw new PaymentRequiredError( + "Payment required. No payment authorized.", + ); + } + return response; + }, + personalServerTransportRetry: { attempts: 1 }, }); -// Return to your frontend: -// session.sessionId - used for polling -// session.connectUrl - where the user reviews and approves the request -// session.expiresAt - ISO 8601 expiration -``` - -#### 2. Poll for user approval (client) - -```tsx -import { useVanaConnect } from "vana-cli/react"; - -function ConnectData({ sessionId }: { sessionId: string }) { - const { connect, status, grant, connectUrl } = useVanaConnect(); - - useEffect(() => { - connect({ sessionId }); - }, [sessionId]); - - if (status === "waiting" && connectUrl) { - return Connect your data; - } - if (status === "approved" && grant) { - // grant.grantId, grant.userAddress, grant.scopes are available - return

Connected.

; - } - return

{status}

; -} -``` - -Or use the pre-built button: - -```tsx -import { ConnectButton } from "vana-cli/react"; - - saveGrant(grant)} - onError={(err) => console.error(err)} -/>; -``` - -#### 3. Fetch user data (server) - -```typescript -import { getData } from "vana-cli/server"; - -const data = await getData({ - privateKey: process.env.VANA_APP_PRIVATE_KEY as `0x${string}`, - grant, // GrantPayload from step 2 +// Expose these through your own authenticated server routes. +const request = await vana.createAccessRequest({ + returnUrl: "https://yourapp.com", }); - -// Record keyed by scope -const conversations = data["chatgpt.conversations"]; -``` - -#### Web App Manifest - -The Vana app verifies your identity by fetching your manifest. Use `signVanaManifest()` to generate it: - -```typescript -import { signVanaManifest } from "vana-cli/server"; - -// In your manifest route handler (e.g. Next.js /manifest.json/route.ts): -const vanaBlock = await signVanaManifest({ - privateKey: process.env.VANA_APP_PRIVATE_KEY as `0x${string}`, - appUrl: "https://yourapp.com", - privacyPolicyUrl: "https://yourapp.com/privacy", - termsUrl: "https://yourapp.com/terms", - supportUrl: "https://yourapp.com/support", - webhookUrl: "https://yourapp.com/api/webhook", +const status = await vana.getAccessRequestStatus(request.requestId); +const result = await vana.readApprovedData({ + requestId: request.requestId, + scope: "chatgpt.conversations", }); - -const manifest = { - name: "Your App", - short_name: "YourApp", - start_url: "/", - display: "standalone", - vana: vanaBlock, // signed identity block -}; ``` -Make sure your HTML includes ``. - -### API Reference - -#### Entrypoints - -| Import | Environment | Exports | -| --------------------- | ----------- | ------------------------------------------------------------------- | -| `vana-cli/server` | Node.js | `connect()`, `getData()`, `signVanaManifest()`, low-level clients | -| `vana-cli/react` | Browser | `useVanaConnect()`, `useVanaData()`, `ConnectButton` | -| `vana-cli/core` | Universal | Types, `ConnectError`, constants | -| `vana-cli/runtime` | Node.js | `ManagedPlaywrightRuntime`, the browser runtime `vana connect` uses | -| `vana-cli/connectors` | Node.js | `listAvailableSources()` and the connector catalog | -| `vana-cli/cli` | Node.js | `runCli()`, to run the CLI in-process | - -`runtime` and `connectors` are for app surfaces such as the Vana desktop -app that collect data themselves; prefer them over shelling out to the CLI. - -#### `connect(config): Promise` - -Creates a session on the Session Relay. Returns `sessionId`, `connectUrl`, and `expiresAt`. - -| Param | Type | Required | Description | -| ------------ | ------------------- | -------- | ---------------------------------------------------------------------- | -| `privateKey` | `` `0x${string}` `` | Yes | Builder private key | -| `scopes` | `string[]` | Yes | Data scopes to request | -| `webhookUrl` | `string` | No | Public HTTPS URL for grant event notifications (localhost is rejected) | -| `appUserId` | `string` | No | Your app's user ID for correlation | - -#### `getData(config): Promise>` +Register the app identity on the selected network before requesting data. Keep app keys and scope selection on the server. The payment policy above stops every 402 before the SDK can sign a payment challenge. The starter applies this policy too. -Fetches user data from their Personal Server using a signed grant. +### Browser -| Param | Type | Required | Description | -| ------------ | ------------------- | -------- | ---------------------------- | -| `privateKey` | `` `0x${string}` `` | Yes | Builder private key | -| `grant` | `GrantPayload` | Yes | Grant from the approval step | - -#### `useVanaConnect(config?): UseVanaConnectResult` +```tsx +import { useDirectVanaConnect } from "@opendatalabs/vana-sdk/react"; -React hook that polls the Session Relay and manages connection state. +// Implement these transports with checked responses from your own routes. +const { state, start, retryRead, reset } = useDirectVanaConnect({ + createRequest, + getStatus, + readResult, +}); -```typescript -const { connect, status, grant, error, connectUrl, reset } = useVanaConnect(); +// Call start directly from a user click so the SDK can open the approval tab. +; ``` -`status` transitions: `idle`, then `connecting`, then `waiting`, then one of `approved`, `denied`, `expired` or `error`. +Render `state.type` and the SDK's request/result values. Handle blocked popups with `request.approvalUrl`, mobile `ready_to_open` with `mobileContinuationUrl`, and errors with `retryRead` or `reset`. After approval, the hook automatically reads data. See the starter for the complete state rendering and HTTP handling. -#### `GrantPayload` +SDK 4.3.1's controller reads Personal Server delivery and attempts consumer acknowledgement after the read. Acknowledgement is best effort. A completed request is terminal. The starter documents these limits and never treats a browser-supplied grant or server URL as read authority. -Returned when a user approves access: +### Legacy library exports -```typescript -interface GrantPayload { - grantId: string; // on-chain permission ID - userAddress: string; // user's wallet address - builderAddress: string; // your registered address - scopes: string[]; // approved data scopes - serverAddress?: string; // user's Personal Server - appUserId?: string; // your app's user ID (if provided) -} -``` +The existing `vana-cli/server`, `vana-cli/react`, and `vana-cli/core` exports remain available for compatibility. `connect()`, `useVanaConnect()`, `useVanaData()`, and `ConnectButton` use Session Relay. Their session-only Account URLs do not satisfy current Account's OAuth client requirements. Migrate browser approval integrations to the SDK's Direct controller and React hook above. This change does not remove those public exports or alter the terminal commands. -#### Low-level clients +`getData()` retains its grant-based server contract. New Direct integrations use `readApprovedData({ requestId, scope })` so the server rechecks the approved Direct request before reading. `signVanaManifest()` retains the signed manifest contract used by the starter. The starter's webhook remains a stub. -For full control over individual protocol interactions: +| Import | Environment | Purpose | +| ------------------------------- | ----------- | ---------------------------------------------------------- | +| `@opendatalabs/vana-sdk/server` | Node.js | Direct controller, request/status/result types, and errors | +| `@opendatalabs/vana-sdk/react` | Browser | `useDirectVanaConnect()` and Direct flow states | +| `vana-cli/server` | Node.js | Legacy Session Relay, grant reads, and signed manifests | +| `vana-cli/react` | Browser | Legacy Session Relay hooks and `ConnectButton` | +| `vana-cli/core` | Universal | Legacy connection types, errors, and constants | +| `vana-cli/runtime` | Node.js | `ManagedPlaywrightRuntime`, used by `vana connect` | +| `vana-cli/connectors` | Node.js | Connector catalog | +| `vana-cli/cli` | Node.js | In-process CLI entry point | -```typescript -import { - createRequestSigner, // Web3Signed header generation - createSessionRelay, // Session Relay HTTP client - createDataClient, // Data Gateway HTTP client -} from "vana-cli/server"; -``` +`runtime` and `connectors` are for apps that collect data themselves, including Vana Desktop. ## Connectors diff --git a/examples/nextjs-starter/.env.local.example b/examples/nextjs-starter/.env.local.example index 15aa78d..8df62da 100644 --- a/examples/nextjs-starter/.env.local.example +++ b/examples/nextjs-starter/.env.local.example @@ -1,5 +1,7 @@ -VANA_PRIVATE_KEY=0x... # Builder key, must be registered on-chain -APP_URL=http://localhost:3001 # Public URL of your deployed app -VANA_SCOPES=chatgpt.conversations # Comma-separated scopes +# Server-only app key. Register the app identity on the selected network. +VANA_PRIVATE_KEY=0x... +# VANA_APP_PRIVATE_KEY is also accepted when VANA_PRIVATE_KEY is unset. +APP_URL=http://localhost:3001 +# dev uses Moksha and app-dev.vana.org. prod uses mainnet and app.vana.org. VANA_ENV=dev -NEXT_PUBLIC_VANA_ENV=dev \ No newline at end of file +# Source and scopes are configured in src/config.ts. diff --git a/examples/nextjs-starter/README.md b/examples/nextjs-starter/README.md index 2e052c5..99af25a 100644 --- a/examples/nextjs-starter/README.md +++ b/examples/nextjs-starter/README.md @@ -1,52 +1,42 @@ -# Next.js Starter +# Next.js starter -Minimal Next.js app demonstrating the Vana Connect flow. Shows the server-side SDK (`connect()` + `getData()`) and client-side polling via `useVanaData()`. +Connect ChatGPT data to a Next.js app through Vana approval. The starter uses `createDirectDataController` and `useDirectVanaConnect` from `@opendatalabs/vana-sdk` 4.3.1. App keys, scope selection, request signing, and Personal Server reads stay on the server. -## Prerequisites +## Run the app -- A builder address registered on-chain via the Vana Gateway -- A running Personal Server with `VANA_MASTER_KEY_SIGNATURE` set +1. Register your app identity on the selected Vana network and obtain its app private key. +2. Copy `.env.local.example` to `.env.local`. +3. Set `VANA_PRIVATE_KEY` and `APP_URL`. The server also accepts `VANA_APP_PRIVATE_KEY` when `VANA_PRIVATE_KEY` is unset. +4. Set `VANA_ENV=dev` for Moksha and `app-dev.vana.org`, or `VANA_ENV=prod` for mainnet and `app.vana.org`. Other values fail configuration. +5. Run `pnpm install` from the repository root. +6. Run `pnpm --filter nextjs-starter dev`. +7. Open `http://localhost:3001` and click **Connect with Vana**. +8. Approve the requested data in Vana. The starter automatically reads the approved ChatGPT conversations and displays the response in the original tab. -## Setup +The owner needs ChatGPT data available through their Personal Server. This app resolves the server from the approved request. It does not start or configure a Personal Server. -```bash -cp .env.local.example .env.local -# Edit .env.local with your private key and APP_URL -pnpm install -pnpm dev # Opens on http://localhost:3001 -``` +Set the app identity, `source`, and concrete scope in `src/config.ts` before adapting this example. The starter requests and reads `chatgpt.conversations`. The browser cannot select the scope, grant, server URL, or return URL. Keep `.env.local` private and never give the app key a `NEXT_PUBLIC_` prefix. -## Environment Variables +## Request flow -| Variable | Required | Description | -| ------------------ | -------- | --------------------------------------- | -| `VANA_PRIVATE_KEY` | Yes | Builder private key registered on-chain | -| `APP_URL` | Yes | Public URL of your deployed app | +`POST /api/connect` creates a signed Direct access request and returns the SDK's `AccessRequest`, including `requestId` and `approvalUrl`. `GET /api/status?requestId=...` fetches signed, uncached status. `POST /api/data` accepts only `{ "requestId": "dcr_..." }`, rechecks approval, and reads the configured scope through the SDK. -> Scopes are configured in `src/config.ts`. Edit the `SCOPES` array to change which user data your app requests. +The SDK opens the approval tab during the button click. If the popup is blocked, the starter shows **Open approval**. On supported mobile requests it shows the SDK's HTTPS **Open Vana** continuation link. The starter polls while approval is pending. **Try again** reuses a live approved request after a read failure when the SDK can do so. **Reset** starts a fresh flow. -## Web App Manifest +The controller currently reads Personal Server delivery. An enclave-only result has no Personal Server URL and cannot be read by this starter. Successful reads trigger a best-effort consumer acknowledgement. Displayed data does not confirm that the acknowledgement succeeded or that Vana closed the approval tab. Completed requests are terminal and require a fresh flow. -The app serves a W3C Web App Manifest at `/manifest.json` containing a signed `vana` block. The Desktop App uses this to verify the builder's identity. The manifest is generated dynamically using `signVanaManifest()` from the SDK, which signs the vana block fields with EIP-191 using your `VANA_PRIVATE_KEY`. +## Payment policy -## Webhook +This starter does not authorize payments. Its Personal Server fetch policy rejects every HTTP 402 before the SDK can sign or send `X-PAYMENT`. It uses one transport attempt per read. A 402 response becomes **Payment required** in the app. An explicit retry makes another unpaid read attempt. There is no automatic payment opt-in. -`POST /api/webhook` is a stub endpoint for receiving grant notifications from the Desktop App. Extend it with signature verification and grant processing for production use. +## Manifest and webhook -## App Icon +`GET /manifest.json` retains the existing signed W3C manifest contract through `signVanaManifest()` from `vana-cli/server`. The manifest uses the same server-only key and `APP_URL`. Replace its example privacy, terms, and support URLs before publication. The starter exposes `/icon.svg`. -Connect resolves your app icon from `APP_URL` in this order: `/icon.svg`, `/icon.png`, `/favicon.ico`. Expose at least one of those routes publicly. In this starter, `src/app/icon.svg` serves `/icon.svg`. +`POST /api/webhook` retains the existing stub for grant notifications. It logs the payload and does not verify signatures or process grants. The Direct browser flow uses status polling and does not depend on this stub. Add authentication and processing before using the webhook in production. -## E2E Testing Workflow +## Verify -1. **Terminal 1** — Start your Personal Server (`pnpm dev`) -2. **Terminal 2** — Start this app (`pnpm dev`) -3. **Browser Tab 1** — Open `http://localhost:3001`, click "Connect with Vana" -4. Click "Open in DataConnect" to launch the deep link in the DataConnect app -5. Alternatively, copy the deep link URL and paste it into the Personal Server Dev UI → Connect tab -6. Click "Auto-Approve All" (or step through manually) -7. Tab 1 updates from "Waiting..." to "Approved!" with grant details +From the repository root, run `pnpm test test/examples/nextjs-starter.test.ts` and `pnpm --filter nextjs-starter build`. Route tests use local transport fixtures with the real SDK. They cover signed request creation and status, input rejection, read outcomes, and payment refusal. They do not prove hosted approval, live owner consent, or provider delivery. -## Personal Server - -This app does not configure the Personal Server — it resolves the user's server URL at runtime via the Data Gateway. The Personal Server is a separate protocol participant (desktop-bundled, ODL Cloud, or self-hosted). See [Personal servers](https://docs.vana.org/protocol-reference/personal-servers) for details. +For a live check, run the app with your registered identity, approve a request in Vana, and confirm the configured scope appears in the original tab. A local fixture response is separate from that proof. diff --git a/examples/nextjs-starter/package.json b/examples/nextjs-starter/package.json index 1c2b96a..f435f54 100644 --- a/examples/nextjs-starter/package.json +++ b/examples/nextjs-starter/package.json @@ -11,7 +11,9 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "vana-cli": "workspace:*", - "viem": "^2.0.0" + "viem": "^2.0.0", + "@opendatalabs/vana-sdk": "4.3.1", + "server-only": "0.0.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/examples/nextjs-starter/src/app/api/connect/route.ts b/examples/nextjs-starter/src/app/api/connect/route.ts index 53a25d8..4d6b46b 100644 --- a/examples/nextjs-starter/src/app/api/connect/route.ts +++ b/examples/nextjs-starter/src/app/api/connect/route.ts @@ -1,19 +1,16 @@ -// Creates a session to connect data from DataConnect into your app. -// Returns a connect URL for the user to approve the connection. - import { NextResponse } from "next/server"; -import { connect } from "vana-cli/server"; -import { ConnectError } from "vana-cli/core"; -import { config } from "@/config"; +import { config, vana } from "@/config"; export async function POST() { try { - const result = await connect(config); - return NextResponse.json(result); - } catch (err) { - const message = - err instanceof ConnectError ? err.message : "Failed to create session"; - const status = err instanceof ConnectError ? (err.statusCode ?? 500) : 500; - return NextResponse.json({ error: message }, { status }); + return NextResponse.json( + await vana.createAccessRequest({ returnUrl: config.appUrl }), + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + return NextResponse.json( + { error: "Failed to create access request" }, + { status: 500 }, + ); } } diff --git a/examples/nextjs-starter/src/app/api/data/route.ts b/examples/nextjs-starter/src/app/api/data/route.ts index ba8b7fb..d09f9d2 100644 --- a/examples/nextjs-starter/src/app/api/data/route.ts +++ b/examples/nextjs-starter/src/app/api/data/route.ts @@ -1,32 +1,66 @@ -// Fetches user data from their Personal Server using an approved grant. - import { NextResponse } from "next/server"; -import { getData } from "vana-cli/server"; -import { ConnectError, isValidGrant } from "vana-cli/core"; -import { config } from "@/config"; +import { + AccessNotApprovedError, + PaymentRequiredError, + ScopeNotApprovedError, +} from "@opendatalabs/vana-sdk/server"; +import { config, vana } from "@/config"; export async function POST(request: Request) { - const { grant } = await request.json(); - - if (!isValidGrant(grant)) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + if ( + !body || + typeof body !== "object" || + Array.isArray(body) || + !("requestId" in body) || + Object.keys(body).length !== 1 || + typeof body.requestId !== "string" || + body.requestId.length > 256 || + !/^dcr_[a-z0-9_-]+$/i.test(body.requestId) + ) { return NextResponse.json( - { error: "Invalid grant payload" }, + { error: "Expected only a valid requestId" }, { status: 400 }, ); } try { - const data = await getData({ - privateKey: config.privateKey, - grant, - environment: config.environment, - }); - - return NextResponse.json({ data }); - } catch (err) { - const message = - err instanceof ConnectError ? err.message : "Failed to fetch data"; - const status = err instanceof ConnectError ? (err.statusCode ?? 500) : 500; - return NextResponse.json({ error: message }, { status }); + return NextResponse.json( + await vana.readApprovedData({ + requestId: body.requestId, + scope: config.scopes[0], + }), + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + if (error instanceof PaymentRequiredError) { + return NextResponse.json( + { + error: "Payment required. This starter does not authorize payments.", + }, + { status: 402 }, + ); + } + if (error instanceof AccessNotApprovedError) { + return NextResponse.json( + { error: "Request is not ready for a Personal Server read" }, + { status: 409 }, + ); + } + if (error instanceof ScopeNotApprovedError) { + return NextResponse.json( + { error: "The configured scope was not approved" }, + { status: 403 }, + ); + } + return NextResponse.json( + { error: "Failed to read approved data" }, + { status: 500 }, + ); } } diff --git a/examples/nextjs-starter/src/app/api/status/route.ts b/examples/nextjs-starter/src/app/api/status/route.ts new file mode 100644 index 0000000..46e84a7 --- /dev/null +++ b/examples/nextjs-starter/src/app/api/status/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { vana } from "@/config"; + +export async function GET(request: Request) { + const params = new URL(request.url).searchParams; + const requestId = params.get("requestId"); + if ( + !requestId || + requestId.length > 256 || + !/^dcr_[a-z0-9_-]+$/i.test(requestId) || + params.getAll("requestId").length !== 1 + ) { + return NextResponse.json({ error: "Invalid requestId" }, { status: 400 }); + } + try { + return NextResponse.json(await vana.getAccessRequestStatus(requestId), { + headers: { "Cache-Control": "no-store" }, + }); + } catch { + return NextResponse.json( + { error: "Failed to check access request" }, + { status: 500 }, + ); + } +} diff --git a/examples/nextjs-starter/src/app/page.tsx b/examples/nextjs-starter/src/app/page.tsx index cb4875f..aff8fef 100644 --- a/examples/nextjs-starter/src/app/page.tsx +++ b/examples/nextjs-starter/src/app/page.tsx @@ -4,11 +4,11 @@ export default function Home() { return (

- Vana Connect — Next.js Starter + Vana Connect | Next.js Starter

- This app demonstrates how to get data from DataConnect into a Next.js - app. + Connect your ChatGPT data through Vana and read the approved data in + this Next.js app.

diff --git a/examples/nextjs-starter/src/components/ConnectFlow.tsx b/examples/nextjs-starter/src/components/ConnectFlow.tsx index 2ec0e40..c51ed61 100644 --- a/examples/nextjs-starter/src/components/ConnectFlow.tsx +++ b/examples/nextjs-starter/src/components/ConnectFlow.tsx @@ -1,165 +1,172 @@ "use client"; -// useVanaData() manages the full connect → poll → fetch-data lifecycle. -// initConnect() starts a session, the hook polls until approved, then -// fetchData() calls /api/data with the grant to retrieve user data. +import { + useDirectVanaConnect, + type DirectConnectState, +} from "@opendatalabs/vana-sdk/react"; +import { useState } from "react"; +import type { + AccessRequest, + AccessRequestStatus, + ApprovedDataResult, +} from "@opendatalabs/vana-sdk/server"; -import type { ConnectionStatus } from "vana-cli/core"; -import { useVanaData } from "vana-cli/react"; -import { useEffect, useRef } from "react"; +async function checkedResponse(response: Response): Promise { + const body = await response.json(); + if (!response.ok) { + throw new Error( + typeof body?.error === "string" + ? body.error + : `Request failed (${response.status})`, + ); + } + return body; +} const STATUS_DISPLAY: Record< - ConnectionStatus, - { dot: string; label: string; className: string } + DirectConnectState["type"], + { label: string; className: string } > = { - idle: { dot: "\u25CB", label: "Idle", className: "status-default" }, - connecting: { - dot: "\u25CB", - label: "Connecting", - className: "status-default", - }, - waiting: { - dot: "\u25CB", + idle: { label: "Ready to connect", className: "status-default" }, + creating: { label: "Creating request", className: "status-default" }, + awaiting_approval: { label: "Waiting for approval", className: "status-waiting", }, - approved: { dot: "\u25CF", label: "Approved", className: "status-approved" }, - denied: { dot: "\u25CF", label: "Denied", className: "status-denied" }, - expired: { dot: "\u25CF", label: "Expired", className: "status-expired" }, - error: { dot: "\u25CF", label: "Error", className: "status-error" }, + ready_to_open: { + label: "Open Vana to continue", + className: "status-waiting", + }, + reading: { label: "Reading approved data", className: "status-approved" }, + done: { label: "Data received", className: "status-approved" }, + error: { label: "Action needed", className: "status-error" }, }; export default function ConnectFlow() { - const { - status, - grant, - data, - error, - connectUrl, - initConnect, - fetchData, - isLoading, - } = useVanaData({ - environment: (process.env.NEXT_PUBLIC_VANA_ENV as "dev" | "prod") ?? "dev", + const [retrying, setRetrying] = useState(false); + const { state, start, retryRead, reset } = useDirectVanaConnect({ + createRequest: async () => + checkedResponse( + await fetch("/api/connect", { method: "POST" }), + ), + getStatus: async (requestId) => + checkedResponse( + await fetch(`/api/status?requestId=${encodeURIComponent(requestId)}`, { + cache: "no-store", + }), + ), + readResult: async (requestId) => + checkedResponse( + await fetch("/api/data", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId }), + }), + ), }); - - const initRef = useRef(false); - useEffect(() => { - if (!initRef.current) { - initRef.current = true; - void initConnect(); - } - }, [initConnect]); - - const display = STATUS_DISPLAY[status]; - const sessionReady = !!connectUrl; - const hasConnectFailure = !sessionReady && !!error; + const display = STATUS_DISPLAY[state.type]; + const approvalUrl = + state.type === "ready_to_open" + ? state.mobileContinuationUrl + : state.type === "awaiting_approval" && state.popupBlocked + ? state.request.approvalUrl + : undefined; return ( -
- {/* Launch button — shown until approved */} - {status !== "approved" && ( -
-
-
- Status - - {display.dot} {display.label} - -
-
- - {sessionReady ? ( - +
+
+ Status + + {display.label} + +
+ {state.type === "idle" && ( + <> +

+ Approve access in Vana. This app automatically reads your ChatGPT + conversations after approval. +

+
+ + )} + {state.type === "creating" && ( +

+ Creating access request... +

+ )} + {state.type === "awaiting_approval" && ( +

+ {state.popupBlocked + ? "Your browser blocked the approval tab. Open it below to continue." + : "Complete approval in the Vana tab. Your data will appear here."} +

+ )} + {approvalUrl && ( + + {state.type === "ready_to_open" ? "Open Vana" : "Open approval"} + + )} + {state.type === "reading" && ( +

+ Reading your approved data... +

+ )} + {state.type === "done" && ( + <> +
{state.result.scope}
+
+              {JSON.stringify(state.result.data, null, 2)}
+            
+ + )} + {state.type === "error" && ( + <> +

+ {state.error.message} +

- )} -
- )} - - {/* Grant details + data */} - {status === "approved" && grant && ( -
-
-
- Status - - {display.dot} {display.label} - -
-
- -
Grant
-
{JSON.stringify(grant, null, 2)}
- - - - {data != null && ( -
-
Response
-
-                {JSON.stringify(data, null, 2)}
-              
-
- )} -
- )} - - {/* Errors */} - {error && ( -
-

- {error} -

-
- )} - - {/* Reset — reloads the page to start a fresh session */} - {status !== "idle" && status !== "connecting" && ( + + )} +
+ {(state.type === "done" || state.type === "error") && !retrying && (