From 44edc0dc8ad008b3fa603aef0d4faf847869d612 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Wed, 30 Sep 2026 18:34:44 -0400 Subject: [PATCH 1/8] Add Stripe Link Eve extension with per-user wallet authorization --- .env.example | 5 + README.md | 34 + agent/extensions/link.ts | 4 + agent/lib/link-auth.ts | 81 + .../_components/authenticated-navigation.tsx | 3 + app/(authenticated)/link/page.tsx | 63 + app/api/link/route.ts | 84 + db/migrations/0014_chief_tigra.sql | 1 + db/migrations/meta/0014_snapshot.json | 2075 +++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema/auth.ts | 5 +- db/services/auth/index.ts | 55 + db/services/auth/link.ts | 154 ++ db/tests/database-migration.test.ts | 1 + knip.config.ts | 1 + package.json | 5 +- ...@stripe__link-integrations-eve@0.2.0.patch | 126 + patches/README.md | 24 +- pnpm-lock.yaml | 163 +- pnpm-workspace.yaml | 1 + shared/environment/env.ts | 3 + tests/integration/link.test.ts | 311 +++ tests/turbo-config.test.ts | 2 + turbo.json | 8 + 24 files changed, 3152 insertions(+), 64 deletions(-) create mode 100644 agent/extensions/link.ts create mode 100644 agent/lib/link-auth.ts create mode 100644 app/(authenticated)/link/page.tsx create mode 100644 app/api/link/route.ts create mode 100644 db/migrations/0014_chief_tigra.sql create mode 100644 db/migrations/meta/0014_snapshot.json create mode 100644 db/services/auth/link.ts create mode 100644 patches/@stripe__link-integrations-eve@0.2.0.patch create mode 100644 tests/integration/link.test.ts diff --git a/.env.example b/.env.example index c0ee8587..c16dd29f 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ GOOGLE_CONNECTOR_UID= # optional click-to-message shortcut in the workspace. LINQ_CONNECTOR= LINQ_PHONE_NUMBER= +# Optional per-user Link wallet connection. Register the exact callback URL +# https:///api/auth/callback/link with Stripe. +LINK_CLIENT_ID= +LINK_CLIENT_SECRET= +STRIPE_PUBLISHABLE_KEY= # Development benchmarks only (pnpm bench:browser). BROWSER_BENCH_LABEL=self-hosted BROWSER_BENCH_REPETITIONS=1 diff --git a/README.md b/README.md index dca0550f..c01de826 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,40 @@ Gotchas: - Sending email and creating confirmed calendar events always require approval. Calendar events with attendees send Google invitations. +## Link wallet + +The root agent mounts `@stripe/link-integrations-eve` in +`agent/extensions/link.ts`. It uses Stripe's bundled wallet tools, skills, and +approval policies, with per-user authorization through the existing Better Auth +account. It does not use a shared wallet token. + +To enable it, register a Link OAuth client and configure `LINK_CLIENT_ID`, +`LINK_CLIENT_SECRET`, and `STRIPE_PUBLISHABLE_KEY` on the server. Register the +exact redirect URI `https:///api/auth/callback/link` with Stripe, +including a separate localhost URI when developing locally. Set +`BETTER_AUTH_URL` to the canonical application origin. These values are optional +for installations that do not use Link. + +Apply the database migration with `pnpm db:migrate` before enabling Link. It +enforces one Link wallet per OpenInstinct account. Disconnect the current wallet +before connecting a different one; reconnecting the same wallet refreshes its +grant. + +Users connect or disconnect their wallet from **Link wallet** in the sidebar. +An agent request that needs a wallet opens the same connection flow and resumes +through Eve's authorization callback. Connection attempts expire after ten +minutes and belong to the signed-in user. Better Auth stores encrypted grants +and refreshes tokens; disconnection revokes the Link grant before removing it. +Phone sign-in continues to work after disconnecting a wallet. + +Wallet access is available in interactive conversations, not scheduled workers +or scheduled result delivery. The extension's default Eve approval for creating +spend requests remains enabled, separately from approval in Link. Its tools can +return payment credentials into stored Eve tool results; the bundled skills +instruct the agent not to repeat them in chat. Financial-data tools also require +the corresponding Link grant scopes; the default grant requests +`payment_methods.agentic` and `userinfo:read`. + ## Local development The **Deploy with Vercel** flow above is the simplest way to run OpenInstinct. It diff --git a/agent/extensions/link.ts b/agent/extensions/link.ts new file mode 100644 index 00000000..95d3f40f --- /dev/null +++ b/agent/extensions/link.ts @@ -0,0 +1,4 @@ +import link from "@stripe/link-integrations-eve"; +import { linkAuth } from "@agent/lib/link-auth"; + +export default link({ auth: linkAuth }); diff --git a/agent/lib/link-auth.ts b/agent/lib/link-auth.ts new file mode 100644 index 00000000..20ea1945 --- /dev/null +++ b/agent/lib/link-auth.ts @@ -0,0 +1,81 @@ +import { + ConnectionAuthorizationFailedError, + ConnectionAuthorizationRequiredError, + defineInteractiveAuthorization, + type ConnectionPrincipal, +} from "eve/connections"; +import { + consumeLinkAuthorization, + getLinkToken, + LinkConnectionRequiredError, + startLinkAuthorization, +} from "@db/services/auth/link"; +import { applicationOrigin } from "@shared/environment/origin"; +import { scopeFromPrincipal } from "@agent/lib/principal-scope"; + +function accountUserId(principal: ConnectionPrincipal) { + // Scheduled workers and delivery-only reports cannot acquire wallet access. + if ( + principal.type !== "user" || + !principal.id.startsWith("better-auth:") || + principal.attributes?.scheduleId + ) { + throw new ConnectionAuthorizationFailedError("link", { + reason: "principal_required", + retryable: false, + }); + } + scopeFromPrincipal(principal); + return principal.id.slice("better-auth:".length); +} + +async function getToken(principal: ConnectionPrincipal) { + try { + return await getLinkToken(accountUserId(principal)); + } catch (error) { + if (error instanceof LinkConnectionRequiredError) + throw new ConnectionAuthorizationRequiredError("link"); + throw error; + } +} + +export const linkAuth = defineInteractiveAuthorization<{ attempt: string }>({ + displayName: "Link wallet", + getToken: ({ principal }) => getToken(principal), + async startAuthorization({ principal, callbackUrl }) { + const { attempt, expiresAt } = await startLinkAuthorization( + accountUserId(principal), + callbackUrl + ); + return { + challenge: { + url: `${applicationOrigin()}/link?attempt=${attempt}`, + instructions: "Connect your Link wallet to continue.", + expiresAt, + }, + resume: { attempt }, + }; + }, + async completeAuthorization({ principal, callback, resume }) { + if ( + !resume || + callback.params.attempt !== resume.attempt || + !(await consumeLinkAuthorization( + accountUserId(principal), + resume.attempt + )) + ) { + throw new ConnectionAuthorizationFailedError("link", { + reason: "invalid_state", + retryable: false, + }); + } + if (callback.params.error) { + throw new ConnectionAuthorizationFailedError("link", { + reason: "access_denied", + retryable: false, + }); + } + return getToken(principal); + }, +}); diff --git a/app/(authenticated)/_components/authenticated-navigation.tsx b/app/(authenticated)/_components/authenticated-navigation.tsx index 8db5877e..63d0f77f 100644 --- a/app/(authenticated)/_components/authenticated-navigation.tsx +++ b/app/(authenticated)/_components/authenticated-navigation.tsx @@ -7,6 +7,7 @@ import { MessageSquareIcon, PanelsTopLeftIcon, UserRoundIcon, + WalletIcon, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -22,6 +23,7 @@ import { const navigation = [ { href: "/", icon: PanelsTopLeftIcon, id: "workspace", label: "Workspace" }, { href: "/vault", icon: KeyRoundIcon, id: "vault", label: "Vault" }, + { href: "/link", icon: WalletIcon, id: "link", label: "Link wallet" }, { href: "/personal-info", icon: UserRoundIcon, @@ -82,6 +84,7 @@ export function AuthenticatedMobileHeader() { function activeRoute(pathname: string) { if (pathname === "/") return "workspace"; if (pathname.startsWith("/vault")) return "vault"; + if (pathname === "/link") return "link"; if (pathname.startsWith("/personal-info")) return "personal-info"; if (pathname.startsWith("/chat/history")) return "history"; if (pathname.startsWith("/chat")) return "chat"; diff --git a/app/(authenticated)/link/page.tsx b/app/(authenticated)/link/page.tsx new file mode 100644 index 00000000..20cda6e8 --- /dev/null +++ b/app/(authenticated)/link/page.tsx @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { getLinkAccount, linkConfigured } from "@db/services/auth/link"; +import { requireRequestScope } from "@web/auth/request-scope"; +import { Button } from "@web/components/ui/button"; + +export const metadata = { + title: "Link wallet", + referrer: "no-referrer" as const, +}; + +export default async function Page({ searchParams }: PageProps<"/link">) { + const scope = await requireRequestScope(); + const params = await searchParams; + const attempt = z.uuid().safeParse(params.attempt); + const configured = linkConfigured(); + const connected = + configured && + Boolean(await getLinkAccount(scope.userId.slice("better-auth:".length))); + return ( +
+
+

Link wallet

+

+ {configured + ? connected + ? "Your Link wallet is connected. Purchases still require approval in Link." + : "Connect your Link wallet to use it with OpenInstinct. Purchases require your approval in Link." + : "Link is not available on this installation yet."} +

+
+ {params.error && ( +

+ The wallet connection could not be completed. Try again, or sign in + again before disconnecting. To use a different wallet, disconnect the + current wallet first. +

+ )} + {configured && ( +
+ {(!connected || attempt.success) && ( +
+ + {attempt.success && ( + + )} + +
+ )} + {connected && ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/app/api/link/route.ts b/app/api/link/route.ts new file mode 100644 index 00000000..a65e0183 --- /dev/null +++ b/app/api/link/route.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { getAuth } from "@db/services/auth"; +import { getAuthSession } from "@db/services/auth/session"; +import { + disconnectLink, + linkAuthorizationCallback, + linkConfigured, +} from "@db/services/auth/link"; +import { applicationOrigin } from "@shared/environment/origin"; + +const inputSchema = z.object({ + operation: z.enum(["connect", "disconnect"]), + attempt: z.union([z.uuid(), z.literal("")]).optional(), +}); +const privateHeaders = { + "cache-control": "no-store", + "referrer-policy": "no-referrer", +}; + +export async function POST(request: Request) { + if (request.headers.get("origin") !== applicationOrigin()) { + return new Response("Invalid request origin.", { + status: 403, + headers: privateHeaders, + }); + } + const session = await getAuthSession(request.headers); + if (!session) + return new Response("Sign in to continue.", { + status: 401, + headers: privateHeaders, + }); + if (!linkConfigured()) + return new Response("Link is not configured.", { + status: 503, + headers: privateHeaders, + }); + const input = inputSchema.safeParse( + Object.fromEntries(await request.formData()) + ); + if (!input.success) + return new Response("Invalid wallet request.", { + status: 400, + headers: privateHeaders, + }); + + try { + if (input.data.operation === "disconnect") { + await disconnectLink(session.user.id, request.headers); + return new Response(null, { + status: 303, + headers: { ...privateHeaders, location: "/link" }, + }); + } + const callbackURL = input.data.attempt + ? await linkAuthorizationCallback(session.user.id, input.data.attempt) + : new URL("/link", applicationOrigin()).href; + const errorCallback = new URL(callbackURL); + errorCallback.searchParams.set("error", "authorization_failed"); + const auth = await getAuth(); + const result = await auth.api.connectLink({ + body: { callbackURL, errorCallbackURL: errorCallback.href }, + headers: request.headers, + returnHeaders: true, + }); + const headers = new Headers({ + ...privateHeaders, + location: result.response.url, + }); + for (const cookie of result.headers.getSetCookie()) + headers.append("set-cookie", cookie); + return new Response(null, { status: 303, headers }); + } catch { + // Provider failures may carry tokens or upstream response bodies. + const destination = new URL("/link", applicationOrigin()); + destination.searchParams.set("error", "connection_failed"); + if (input.data.attempt) + destination.searchParams.set("attempt", input.data.attempt); + return new Response(null, { + status: 303, + headers: { ...privateHeaders, location: destination.href }, + }); + } +} diff --git a/db/migrations/0014_chief_tigra.sql b/db/migrations/0014_chief_tigra.sql new file mode 100644 index 00000000..e0a04f90 --- /dev/null +++ b/db/migrations/0014_chief_tigra.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "account_link_userId_uidx" ON "account" USING btree ("userId") WHERE "account"."providerId" = 'link'; \ No newline at end of file diff --git a/db/migrations/meta/0014_snapshot.json b/db/migrations/meta/0014_snapshot.json new file mode 100644 index 00000000..bbfc25ee --- /dev/null +++ b/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,2075 @@ +{ + "id": "6917d635-ae89-4aad-a91b-5eb74cdf6c0c", + "prevId": "8d971fcd-e5d8-46d2-93a1-93d6e8ccb63a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_issuer_accountId_uidx": { + "name": "account_issuer_accountId_uidx", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_link_userId_uidx": { + "name": "account_link_userId_uidx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account\".\"providerId\" = 'link'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "phoneNumber": { + "name": "phoneNumber", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phoneNumberVerified": { + "name": "phoneNumberVerified", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_phoneNumber_unique": { + "name": "user_phoneNumber_unique", + "nullsNotDistinct": false, + "columns": ["phoneNumber"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_image_artifacts": { + "name": "browser_image_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_session_id": { + "name": "root_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_session_id": { + "name": "browser_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_pathname": { + "name": "storage_pathname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "browser_image_artifacts_workspace_idempotency_uidx": { + "name": "browser_image_artifacts_workspace_idempotency_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_image_artifacts_workspace_created_idx": { + "name": "browser_image_artifacts_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_image_artifacts_membership_fkey": { + "name": "browser_image_artifacts_membership_fkey", + "tableFrom": "browser_image_artifacts", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "browser_image_artifacts_status_check": { + "name": "browser_image_artifacts_status_check", + "value": "\"browser_image_artifacts\".\"status\" IN ('pending', 'ready')" + }, + "browser_image_artifacts_source_kind_check": { + "name": "browser_image_artifacts_source_kind_check", + "value": "\"browser_image_artifacts\".\"source_kind\" IN ('element', 'full_page', 'image_resource', 'viewport')" + }, + "browser_image_artifacts_ready_fields_check": { + "name": "browser_image_artifacts_ready_fields_check", + "value": "\"browser_image_artifacts\".\"status\" = 'pending' OR (\"browser_image_artifacts\".\"filename\" IS NOT NULL AND \"browser_image_artifacts\".\"media_type\" IS NOT NULL AND \"browser_image_artifacts\".\"byte_size\" > 0 AND \"browser_image_artifacts\".\"content_hash\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.browser_sessions": { + "name": "browser_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "browser_sessions_workspace_idx": { + "name": "browser_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "browser_sessions_worker_idx": { + "name": "browser_sessions_worker_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "worker_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_sessions_membership_fkey": { + "name": "browser_sessions_membership_fkey", + "tableFrom": "browser_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_trace_domains": { + "name": "browser_trace_domains", + "schema": "", + "columns": { + "trace_session_id": { + "name": "trace_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_trace_domains_domain_idx": { + "name": "browser_trace_domains_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_trace_domains_trace_fkey": { + "name": "browser_trace_domains_trace_fkey", + "tableFrom": "browser_trace_domains", + "tableTo": "browser_traces", + "columnsFrom": ["trace_session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "browser_trace_domains_pkey": { + "name": "browser_trace_domains_pkey", + "columns": ["trace_session_id", "domain"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_trace_events": { + "name": "browser_trace_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_session_id": { + "name": "trace_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_trace_events_trace_idx": { + "name": "browser_trace_events_trace_idx", + "columns": [ + { + "expression": "trace_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_trace_events_trace_fkey": { + "name": "browser_trace_events_trace_fkey", + "tableFrom": "browser_trace_events", + "tableTo": "browser_traces", + "columnsFrom": ["trace_session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_traces": { + "name": "browser_traces", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task": { + "name": "task", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_message": { + "name": "result_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "browser_traces_workspace_started_idx": { + "name": "browser_traces_workspace_started_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_traces_membership_fkey": { + "name": "browser_traces_membership_fkey", + "tableFrom": "browser_traces", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "browser_traces_status_check": { + "name": "browser_traces_status_check", + "value": "\"browser_traces\".\"status\" IN ('running', 'success', 'failure', 'error', 'cancelled')" + }, + "browser_traces_duration_ms_check": { + "name": "browser_traces_duration_ms_check", + "value": "\"browser_traces\".\"duration_ms\" IS NULL OR \"browser_traces\".\"duration_ms\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(16, 8)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_workspace_updated_idx": { + "name": "chats_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_workspace_id_fkey": { + "name": "chats_workspace_id_fkey", + "tableFrom": "chats", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chats_input_tokens_check": { + "name": "chats_input_tokens_check", + "value": "\"chats\".\"input_tokens\" >= 0" + }, + "chats_output_tokens_check": { + "name": "chats_output_tokens_check", + "value": "\"chats\".\"output_tokens\" >= 0" + }, + "chats_cost_usd_check": { + "name": "chats_cost_usd_check", + "value": "\"chats\".\"cost_usd\" IS NULL OR \"chats\".\"cost_usd\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_agent_jobs": { + "name": "scheduled_agent_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_channel": { + "name": "conversation_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_anchor_message_id": { + "name": "reply_anchor_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timing": { + "name": "timing", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "missed_run_policy": { + "name": "missed_run_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'run_latest'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_agent_jobs_due_idx": { + "name": "scheduled_agent_jobs_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_jobs_owner_idx": { + "name": "scheduled_agent_jobs_owner_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_agent_jobs_membership_fkey": { + "name": "scheduled_agent_jobs_membership_fkey", + "tableFrom": "scheduled_agent_jobs", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_agent_jobs_conversation_channel_check": { + "name": "scheduled_agent_jobs_conversation_channel_check", + "value": "\"scheduled_agent_jobs\".\"conversation_channel\" IN ('eve', 'linq')" + }, + "scheduled_agent_jobs_conversation_id_check": { + "name": "scheduled_agent_jobs_conversation_id_check", + "value": "\"scheduled_agent_jobs\".\"conversation_id\" <> ''" + }, + "scheduled_agent_jobs_missed_run_policy_check": { + "name": "scheduled_agent_jobs_missed_run_policy_check", + "value": "\"scheduled_agent_jobs\".\"missed_run_policy\" IN ('skip', 'run_latest', 'catch_up')" + }, + "scheduled_agent_jobs_status_check": { + "name": "scheduled_agent_jobs_status_check", + "value": "\"scheduled_agent_jobs\".\"status\" IN ('active', 'paused', 'completed', 'deleted')" + } + }, + "isRLSEnabled": false + }, + "public.scheduled_agent_runs": { + "name": "scheduled_agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deferred_completion_turn_id": { + "name": "deferred_completion_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_input_requests": { + "name": "pending_input_requests", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_ready'" + }, + "report_sequence": { + "name": "report_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "report_lease_token": { + "name": "report_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report_lease_expires_at": { + "name": "report_lease_expires_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_agent_runs_occurrence_idx": { + "name": "scheduled_agent_runs_occurrence_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_runs_ready_idx": { + "name": "scheduled_agent_runs_ready_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scheduled_agent_runs_report_idx": { + "name": "scheduled_agent_runs_report_idx", + "columns": [ + { + "expression": "report_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_agent_runs_job_id_scheduled_agent_jobs_id_fk": { + "name": "scheduled_agent_runs_job_id_scheduled_agent_jobs_id_fk", + "tableFrom": "scheduled_agent_runs", + "tableTo": "scheduled_agent_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scheduled_agent_runs_status_check": { + "name": "scheduled_agent_runs_status_check", + "value": "\"scheduled_agent_runs\".\"status\" IN ('queued', 'running', 'waiting_for_input', 'completed', 'dead_letter')" + }, + "scheduled_agent_runs_report_status_check": { + "name": "scheduled_agent_runs_report_status_check", + "value": "\"scheduled_agent_runs\".\"report_status\" IN ('not_ready', 'not_needed', 'pending', 'queued', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_sessions_workspace_idx": { + "name": "agent_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_membership_fkey": { + "name": "agent_sessions_membership_fkey", + "tableFrom": "agent_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_secrets": { + "name": "encrypted_secrets", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_secrets_workspace_id_fkey": { + "name": "encrypted_secrets_workspace_id_fkey", + "tableFrom": "encrypted_secrets", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_secrets_pkey": { + "name": "encrypted_secrets_pkey", + "columns": ["workspace_id", "namespace", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "encrypted_secrets_namespace_check": { + "name": "encrypted_secrets_namespace_check", + "value": "\"encrypted_secrets\".\"namespace\" = 'vault'" + } + }, + "isRLSEnabled": false + }, + "public.vault_items": { + "name": "vault_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vault_items_workspace_updated_idx": { + "name": "vault_items_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_items_workspace_id_fkey": { + "name": "vault_items_workspace_id_fkey", + "tableFrom": "vault_items", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "vault_items_kind_check": { + "name": "vault_items_kind_check", + "value": "\"vault_items\".\"kind\" IN ('login', 'payment', 'address', 'contact', 'phone', 'identity', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "settings_workspace_id_fkey": { + "name": "settings_workspace_id_fkey", + "tableFrom": "settings", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_pkey": { + "name": "settings_pkey", + "columns": ["workspace_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_key_check": { + "name": "settings_key_check", + "value": "\"settings\".\"key\" = 'gateway_model'" + } + }, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line_2": { + "name": "address_line_2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_workspace_id_fkey": { + "name": "user_profiles_workspace_id_fkey", + "tableFrom": "user_profiles", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_profiles_country_code_check": { + "name": "user_profiles_country_code_check", + "value": "\"user_profiles\".\"country_code\" IS NULL OR char_length(\"user_profiles\".\"country_code\") = 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_memberships": { + "name": "workspace_memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_memberships_workspace_id_fkey": { + "name": "workspace_memberships_workspace_id_fkey", + "tableFrom": "workspace_memberships", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_memberships_pkey": { + "name": "workspace_memberships_pkey", + "columns": ["workspace_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_memberships_role_check": { + "name": "workspace_memberships_role_check", + "value": "\"workspace_memberships\".\"role\" = 'owner'" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workstreams": { + "name": "workstreams", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_operation_id": { + "name": "last_operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp (3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workstreams_recent_idx": { + "name": "workstreams_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workstreams_workspace_id_workspaces_id_fk": { + "name": "workstreams_workspace_id_workspaces_id_fk", + "tableFrom": "workstreams", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workstreams_workspace_id_scope_key_id_pk": { + "name": "workstreams_workspace_id_scope_key_id_pk", + "columns": ["workspace_id", "scope_key", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workstreams_revision_check": { + "name": "workstreams_revision_check", + "value": "\"workstreams\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 32ad7dcf..61cbfdad 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1788879251908, "tag": "0013_last_christian_walker", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1790807210118, + "tag": "0014_chief_tigra", + "breakpoints": true } ] } diff --git a/db/schema/auth.ts b/db/schema/auth.ts index 0a62249f..e2328f77 100644 --- a/db/schema/auth.ts +++ b/db/schema/auth.ts @@ -1,4 +1,4 @@ -import { relations } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { boolean, index, @@ -80,6 +80,9 @@ export const account = pgTable( table.accountId ), index("account_userId_idx").on(table.userId), + uniqueIndex("account_link_userId_uidx") + .on(table.userId) + .where(sql`${table.providerId} = 'link'`), ] ); diff --git a/db/services/auth/index.ts b/db/services/auth/index.ts index 928775d8..a3180751 100644 --- a/db/services/auth/index.ts +++ b/db/services/auth/index.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; +import { link } from "@stripe/link-integrations-better-auth"; import { betterAuth } from "better-auth"; import { APIError } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { and, eq } from "drizzle-orm"; import { phoneNumber } from "better-auth/plugins/phone-number"; import { account, db, session, user, verification } from "@db"; import { betterAuthBaseURL } from "@shared/environment/origin"; @@ -28,6 +30,7 @@ async function initializeAuthWithRetry() { async function initializeAuth() { const { betterAuthSecret } = await getInstallationSecrets(); + const { LINK_CLIENT_ID, LINK_CLIENT_SECRET, STRIPE_PUBLISHABLE_KEY } = env; return betterAuth({ appName: "Local Vault Assistant", baseURL: betterAuthBaseURL(), @@ -45,8 +48,60 @@ async function initializeAuth() { "/sign-in/social", "/sign-up/email", "/verify-email", + // Wallet disconnection must revoke the Link grant through its plugin. + "/unlink-account", ], + account: { + encryptOAuthTokens: true, + additionalFields: { + issuer: { + type: "string", + required: true, + input: false, + defaultValue: "better-auth", + }, + }, + accountLinking: { + trustedProviders: ["link"], + allowDifferentEmails: true, + // Phone verification signs users in independently of OAuth accounts. + allowUnlinkingAll: true, + }, + }, + databaseHooks: { + account: { + create: { + before: async (value) => { + if (value.providerId === "link") { + const [existing] = await db + .select({ id: account.id }) + .from(account) + .where( + and( + eq(account.userId, value.userId), + eq(account.providerId, "link") + ) + ) + .limit(1); + // Reauthorization updates the same account. Replacing it requires + // disconnecting first so the previous grant is revoked. + if (existing) return false; + } + return { data: { ...value, issuer: value.providerId } }; + }, + }, + }, + }, plugins: [ + ...(LINK_CLIENT_ID && LINK_CLIENT_SECRET && STRIPE_PUBLISHABLE_KEY + ? [ + link({ + clientId: LINK_CLIENT_ID, + clientSecret: LINK_CLIENT_SECRET, + publishableKey: STRIPE_PUBLISHABLE_KEY, + }), + ] + : []), phoneNumber({ allowedAttempts: 3, expiresIn: 300, diff --git a/db/services/auth/link.ts b/db/services/auth/link.ts new file mode 100644 index 00000000..c599c490 --- /dev/null +++ b/db/services/auth/link.ts @@ -0,0 +1,154 @@ +import { runWithAdapter } from "@better-auth/core/context"; +import { APIError } from "better-auth/api"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { and, eq, gt } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { account, db, session, user, verification } from "@db"; +import { env } from "@shared/environment"; +import { applicationOrigin } from "@shared/environment/origin"; +import { getAuth } from "@db/services/auth"; + +export function linkConfigured() { + return Boolean( + env.LINK_CLIENT_ID && env.LINK_CLIENT_SECRET && env.STRIPE_PUBLISHABLE_KEY + ); +} + +export class LinkConnectionRequiredError extends Error { + constructor() { + super("Connect your Link wallet to continue."); + this.name = "LinkConnectionRequiredError"; + } +} + +export async function getLinkAccount(userId: string) { + const [result] = await db + .select({ id: account.id }) + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, "link"))) + .limit(1); + return result; +} + +// Refresh-token rotation and disconnection share the same database lock. +async function withLinkAccount( + userId: string, + operation: (accountId: string) => Promise +) { + const auth = await getAuth(); + return db.transaction(async (tx) => { + const [saved] = await tx + .select({ id: account.id }) + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, "link"))) + .for("update"); + if (!saved) throw new LinkConnectionRequiredError(); + return runWithAdapter( + drizzleAdapter(tx, { + provider: "pg", + schema: { account, session, user, verification }, + })(auth.options), + () => operation(saved.id) + ); + }); +} + +export async function getLinkToken(userId: string) { + if (!linkConfigured()) + throw new Error("Link is not configured on this installation."); + try { + return await withLinkAccount(userId, async (accountId) => { + const auth = await getAuth(); + const result = await auth.api.getAccessToken({ + body: { userId, accountId }, + }); + if (!result.accessToken) throw new LinkConnectionRequiredError(); + return { + token: result.accessToken, + expiresAt: result.accessTokenExpiresAt?.getTime(), + }; + }); + } catch (error) { + if (error instanceof LinkConnectionRequiredError) throw error; + if ( + error instanceof APIError && + (error.statusCode === 400 || error.statusCode === 401) + ) { + throw new LinkConnectionRequiredError(); + } + // oxlint-disable-next-line eslint/preserve-caught-error -- Provider errors can contain tokens and response bodies; keep them out of Eve events. + throw new Error("Link is temporarily unavailable. Try again shortly."); + } +} + +export async function disconnectLink(userId: string, headers: Headers) { + return withLinkAccount(userId, async (accountId) => { + const auth = await getAuth(); + return auth.api.disconnectLink({ body: { accountId }, headers }); + }); +} + +function authorizationIdentifier(userId: string, attempt: string) { + return `link-eve:${JSON.stringify([userId, z.uuid().parse(attempt)])}`; +} + +export async function startLinkAuthorization( + userId: string, + callbackUrl: string +) { + const target = new URL(callbackUrl); + if ( + target.origin !== applicationOrigin() || + !target.pathname.startsWith("/eve/v1/connections/") + ) { + throw new Error("Invalid Link authorization callback."); + } + const attempt = randomUUID(); + target.searchParams.set("attempt", attempt); + const expiresAt = new Date(Date.now() + 10 * 60_000); + await db.insert(verification).values({ + id: randomUUID(), + identifier: authorizationIdentifier(userId, attempt), + value: target.href, + expiresAt, + }); + return { attempt, expiresAt: expiresAt.toISOString() }; +} + +export async function linkAuthorizationCallback( + userId: string, + attempt: string +) { + const [pending] = await db + .select({ value: verification.value }) + .from(verification) + .where( + and( + eq(verification.identifier, authorizationIdentifier(userId, attempt)), + gt(verification.expiresAt, new Date()) + ) + ) + .limit(1); + if (!pending) + throw new Error( + "Link connection expired. Start again from your conversation." + ); + return pending.value; +} + +export async function consumeLinkAuthorization( + userId: string, + attempt: string +) { + const [pending] = await db + .delete(verification) + .where( + and( + eq(verification.identifier, authorizationIdentifier(userId, attempt)), + gt(verification.expiresAt, new Date()) + ) + ) + .returning({ id: verification.id }); + return Boolean(pending); +} diff --git a/db/tests/database-migration.test.ts b/db/tests/database-migration.test.ts index a14e62ed..d4b4ae08 100644 --- a/db/tests/database-migration.test.ts +++ b/db/tests/database-migration.test.ts @@ -41,6 +41,7 @@ describe("database migrations", () => { await applyMigration(database, "0011_faulty_unicorn.sql"); await applyMigration(database, "0012_harsh_domino.sql"); await applyMigration(database, "0013_last_christian_walker.sql"); + await applyMigration(database, "0014_chief_tigra.sql"); const tables = await database.query<{ count: number }>( `SELECT count(*)::int AS count diff --git a/knip.config.ts b/knip.config.ts index 3b59e623..3a85f39a 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -3,6 +3,7 @@ import type { KnipConfig } from "knip"; export default { entry: [ "agent/channels/**/*.ts", + "agent/extensions/**/*.ts", "agent/hooks/**/*.ts", "agent/instructions/**/*.ts", "agent/instrumentation/**/*.ts", diff --git a/package.json b/package.json index 02bdab40..5d3eb8f7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "license": "MIT", "dependencies": { "@base-ui/react": "^1.7.0", + "@better-auth/core": "1.7.5", "@googleapis/calendar": "^16.0.0", "@googleapis/gmail": "^18.0.0", "@googleapis/people": "^8.0.0", @@ -14,6 +15,8 @@ "@streamdown/code": "1.1.1", "@streamdown/math": "1.0.2", "@streamdown/mermaid": "1.0.2", + "@stripe/link-integrations-better-auth": "0.2.0", + "@stripe/link-integrations-eve": "0.2.0", "@t3-oss/env-nextjs": "0.13.11", "@tailwindcss/postcss": "4.3.3", "@tanstack/react-query": "^5.102.6", @@ -24,7 +27,7 @@ "@vercel/connect": "^2.0.0", "@vercel/oidc": "3.8.5", "ai": "^7.0.105", - "better-auth": "1.7.2", + "better-auth": "1.7.5", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", diff --git a/patches/@stripe__link-integrations-eve@0.2.0.patch b/patches/@stripe__link-integrations-eve@0.2.0.patch new file mode 100644 index 00000000..aad09cc4 --- /dev/null +++ b/patches/@stripe__link-integrations-eve@0.2.0.patch @@ -0,0 +1,126 @@ +diff --git a/dist/extension/_manifest.json b/dist/extension/_manifest.json +index 4e60baa523aaa33c5dcec48e92d0912acbcb4862..a3612ee339ec169338e2efe50b03d9840e42c31f 100644 +--- a/dist/extension/_manifest.json ++++ b/dist/extension/_manifest.json +@@ -1,11 +1,11 @@ + { + "kind": "eve-extension", + "formatVersion": 2, +- "builtWithEve": "0.54.4", ++ "builtWithEve": "0.67.2", + "requires": { + "extension": 1, +- "tool": 36, +- "skill": 1, ++ "tool": 58, ++ "skill": 2, + "instructions": 2, + "config": 1 + } +diff --git a/dist/extension/tools/create_report.d.ts b/dist/extension/tools/create_report.d.ts +index 7e6278969032611d5b7b96337734b168a88a26e3..ebc4aa864d2eb7ac0c57d08d1acc2caa94b8b111 100644 +--- a/dist/extension/tools/create_report.d.ts ++++ b/dist/extension/tools/create_report.d.ts +@@ -1,6 +1,6 @@ + declare const _default: import("eve/tools").ToolDefinition<{ + domain: string; +- outcome: "abandoned" | "blocked" | "success"; ++ outcome: "success" | "abandoned" | "blocked"; + spend_request_id: string; + tags?: ("3ds_challenge" | "anti_bot_script" | "captcha" | "cdn_block" | "dns_block" | "login_required" | "other" | "page_inaccessible" | "payment_declined" | "rate_limited" | "site_error" | "stripe_checkout" | "timeout" | "waf_block")[] | undefined; + step?: string | undefined; +@@ -9,7 +9,7 @@ declare const _default: import("eve/tools").ToolDefinition<{ + }, import("@stripe/link-sdk").ReportRecord> & { + execute(input: { + domain: string; +- outcome: "abandoned" | "blocked" | "success"; ++ outcome: "success" | "abandoned" | "blocked"; + spend_request_id: string; + tags?: ("3ds_challenge" | "anti_bot_script" | "captcha" | "cdn_block" | "dns_block" | "login_required" | "other" | "page_inaccessible" | "payment_declined" | "rate_limited" | "site_error" | "stripe_checkout" | "timeout" | "waf_block")[] | undefined; + step?: string | undefined; +diff --git a/dist/extension/tools/create_spend_request.d.ts b/dist/extension/tools/create_spend_request.d.ts +index 2017075850f23206b4d48a717f312e07b5428352..bffcfdd3d5cba4e7cb17576d671681f8ea2bf75b 100644 +--- a/dist/extension/tools/create_spend_request.d.ts ++++ b/dist/extension/tools/create_spend_request.d.ts +@@ -1,15 +1,17 @@ + declare const _default: import("eve/tools").ToolDefinition<{ ++ credential_type: "card" | "shared_payment_token"; ++ amount: number; ++ currency: string; ++ context: string; ++ request_approval: boolean; ++ test: boolean; + idempotency_key?: string | undefined; + payment_details?: string | undefined; +- credential_type: "card" | "shared_payment_token"; + network_id?: string | undefined; + execution_method?: "link_pay_token" | undefined; + merchant_account_id?: string | undefined; +- amount: number; +- currency: string; + merchant_name?: string | undefined; + merchant_url?: string | undefined; +- context: string; + line_items?: { + name: string; + quantity?: number | undefined; +@@ -30,22 +32,22 @@ declare const _default: import("eve/tools").ToolDefinition<{ + display_text: string; + amount: number; + }[] | undefined; +- request_approval: boolean; +- test: boolean; + metadata?: Record | undefined; + }, import("@stripe/link-sdk").SpendRequest> & { + execute(input: { ++ credential_type: "card" | "shared_payment_token"; ++ amount: number; ++ currency: string; ++ context: string; ++ request_approval: boolean; ++ test: boolean; + idempotency_key?: string | undefined; + payment_details?: string | undefined; +- credential_type: "card" | "shared_payment_token"; + network_id?: string | undefined; + execution_method?: "link_pay_token" | undefined; + merchant_account_id?: string | undefined; +- amount: number; +- currency: string; + merchant_name?: string | undefined; + merchant_url?: string | undefined; +- context: string; + line_items?: { + name: string; + quantity?: number | undefined; +@@ -66,8 +68,6 @@ declare const _default: import("eve/tools").ToolDefinition<{ + display_text: string; + amount: number; + }[] | undefined; +- request_approval: boolean; +- test: boolean; + metadata?: Record | undefined; + }, ctx: import("eve/tools").ToolContext): Promise; + }; +diff --git a/dist/extension/tools/list_transactions.d.ts b/dist/extension/tools/list_transactions.d.ts +index a286459eb7c19ec256dfc5c7d1c15a206c90aace..85c41bf2dd04ae404a0f17e04e8b9ff05f6fa97c 100644 +--- a/dist/extension/tools/list_transactions.d.ts ++++ b/dist/extension/tools/list_transactions.d.ts +@@ -5,7 +5,7 @@ declare const _default: import("eve/tools").ToolDefinition<{ + start_date?: string | undefined; + end_date?: string | undefined; + category?: string | undefined; +- origin?: "external_connection" | "link" | undefined; ++ origin?: "link" | "external_connection" | undefined; + sources?: string[] | undefined; + }, import("@stripe/link-sdk").TransactionsPage> & { + execute(input: { +@@ -15,7 +15,7 @@ declare const _default: import("eve/tools").ToolDefinition<{ + start_date?: string | undefined; + end_date?: string | undefined; + category?: string | undefined; +- origin?: "external_connection" | "link" | undefined; ++ origin?: "link" | "external_connection" | undefined; + sources?: string[] | undefined; + }, ctx: import("eve/tools").ToolContext): Promise; + }; diff --git a/patches/README.md b/patches/README.md index c98a9566..c2397490 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,4 +1,4 @@ -# Eve and Linq compatibility patches +# Dependency compatibility patches Eve is pinned to the published `0.67.2` release. @@ -22,3 +22,25 @@ The application delivery tests exercise that adapter. `defaultTools: false`, waits for child work to start, cancels it, and checks the child's cancellation event. Remove each patch hunk when an upstream release passes its corresponding regression without the hunk. + +`@stripe__link-integrations-eve@0.2.0.patch` contains the generated output of +rebuilding Stripe's official extension source with Eve `0.67.2`. The published +package was built with `0.54.4` and requires tool contract v36, which `0.67.2` +rejects. The compiler emits tool contract v58 and skill contract v2 when it +rebuilds this source. The resulting JavaScript and skills are byte-for-byte +identical to the published package; only generated compatibility metadata and +declaration ordering differ. This is a compiler rebuild, not a manual override +of Eve's compatibility check. + +Source: `stripe/link-cli` commit +`1790c0fa1cc6549f325c74b4af57b06272d5309a` (the 0.2.0 release), directory +`packages/integrations/eve/extension`. To reproduce, copy the published package +to a temporary directory, add that exact upstream `extension/` source, resolve +its dependencies against this application's installed packages, and run +`node /node_modules/eve/bin/eve.js extension build` from the temporary +directory. Replace the patch package's `dist/` with that output and run +`pnpm patch-commit `. + +Remove this patch when a published Stripe extension builds on the pinned Eve +version without it. `pnpm exec eve build --skip-sandbox-prewarm` validates +extension discovery and compilation without requiring a local sandbox daemon. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79b037d5..5b195dd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: patchedDependencies: '@linqapp/chat-sdk-adapter@0.5.1': 78b56cdd08c4250ad2680c094858b28d6d6935538d8e59e6dd578f106c77b9e7 + '@stripe/link-integrations-eve@0.2.0': 307b95051f3778ea3fa436732396f804d5cf4bbc8b96192372b6041de15d3517 eve@0.67.2: 43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb importers: @@ -15,6 +16,9 @@ importers: '@base-ui/react': specifier: ^1.7.0 version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@better-auth/core': + specifier: 1.7.5 + version: 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@googleapis/calendar': specifier: ^16.0.0 version: 16.0.0 @@ -48,6 +52,12 @@ importers: '@streamdown/mermaid': specifier: 1.0.2 version: 1.0.2(react@19.2.8) + '@stripe/link-integrations-better-auth': + specifier: 0.2.0 + version: 0.2.0(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(better-auth@1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))) + '@stripe/link-integrations-eve': + specifier: 0.2.0 + version: 0.2.0(patch_hash=307b95051f3778ea3fa436732396f804d5cf4bbc8b96192372b6041de15d3517)(eve@0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4))) '@t3-oss/env-nextjs': specifier: 0.13.11 version: 0.13.11(typescript@6.0.3)(zod@4.5.4) @@ -71,7 +81,7 @@ importers: version: 2.8.0 '@vercel/connect': specifier: ^2.0.0 - version: 2.0.0(ai@7.0.120(zod@4.5.4))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))))(eve@0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4))) + version: 2.0.0(ai@7.0.120(zod@4.5.4))(better-auth@1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))))(eve@0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4))) '@vercel/oidc': specifier: 3.8.5 version: 3.8.5 @@ -79,8 +89,8 @@ importers: specifier: ^7.0.105 version: 7.0.120(zod@4.5.4) better-auth: - specifier: 1.7.2 - version: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + specifier: 1.7.5 + version: 1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -509,63 +519,60 @@ packages: '@types/react': optional: true - '@better-auth/core@1.7.2': - resolution: {integrity: sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==} + '@better-auth/core@1.7.5': + resolution: {integrity: sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ==} peerDependencies: '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@cloudflare/workers-types': '>=4' + '@better-fetch/fetch': 1.3.2 '@opentelemetry/api': ^1.9.0 better-call: 1.4.0 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.7.2': - resolution: {integrity: sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==} + '@better-auth/drizzle-adapter@1.7.5': + resolution: {integrity: sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': ^1.7.5 '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.7.2': - resolution: {integrity: sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==} + '@better-auth/kysely-adapter@1.7.5': + resolution: {integrity: sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': ^1.7.5 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.7.2': - resolution: {integrity: sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==} + '@better-auth/memory-adapter@1.7.5': + resolution: {integrity: sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': ^1.7.5 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.2': - resolution: {integrity: sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==} + '@better-auth/mongo-adapter@1.7.5': + resolution: {integrity: sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': ^1.7.5 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.7.2': - resolution: {integrity: sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==} + '@better-auth/prisma-adapter@1.7.5': + resolution: {integrity: sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': ^1.7.5 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -575,12 +582,12 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.7.2': - resolution: {integrity: sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==} + '@better-auth/telemetry@1.7.5': + resolution: {integrity: sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg==} peerDependencies: - '@better-auth/core': ^1.7.2 + '@better-auth/core': ^1.7.5 '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-fetch/fetch': 1.3.2 '@better-auth/utils@0.4.2': resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} @@ -591,6 +598,9 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@better-fetch/fetch@1.3.2': + resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -2889,6 +2899,23 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + '@stripe/link-integrations-better-auth@0.2.0': + resolution: {integrity: sha512-bBjqA6cmO1GNV9D6m9pXHaxnm9NR+Nde9poNO9Wn1QapU87xmV93AmUxtXsGHQkrPTIEFTp5W9+X7TQJBT/NSg==} + engines: {node: '>=22'} + peerDependencies: + '@better-auth/core': ^1.7.5 + better-auth: ^1.7.5 + + '@stripe/link-integrations-eve@0.2.0': + resolution: {integrity: sha512-ODNAfSodbtg9HKAjS//BZ/qWTLAcuRJ7Dsp9CL+I+DcDn65YQiVq/MSbDN6D4fIJpLGUmtzfYA+H+KqzhFlKUA==} + engines: {node: '>=24'} + peerDependencies: + eve: '*' + + '@stripe/link-sdk@0.9.0': + resolution: {integrity: sha512-z3qvCYMkPl2JcA2rE3qSy+/uPthI+k3gSGdJIvkf8GHMcy/vG4O70OsWjC71yxyQD/BlyjkpwDvfkdU/Rq4a/g==} + engines: {node: '>=20'} + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -3644,15 +3671,14 @@ packages: resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} engines: {node: '>=6.0.0'} - better-auth@1.7.2: - resolution: {integrity: sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==} + better-auth@1.7.5: + resolution: {integrity: sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 '@sveltejs/kit': ^2.0.0 '@tanstack/react-start': ^1.0.0 '@tanstack/solid-start': ^1.0.0 - better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 mongodb: ^6.0.0 || ^7.0.0 @@ -3664,7 +3690,7 @@ packages: react-dom: ^18.0.0 || ^19.0.0 solid-js: ^1.0.0 svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 vue: ^3.0.0 peerDependenciesMeta: '@lynx-js/react': @@ -3677,8 +3703,6 @@ packages: optional: true '@tanstack/solid-start': optional: true - better-sqlite3: - optional: true drizzle-kit: optional: true drizzle-orm: @@ -7746,10 +7770,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2)': + '@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2)': dependencies: '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-fetch/fetch': 1.3.2 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 better-call: 1.4.0(zod@4.5.4) @@ -7760,40 +7784,40 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))': + '@better-auth/drizzle-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 optionalDependencies: drizzle-orm: 0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0) - '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + '@better-auth/kysely-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.5 - '@better-auth/memory-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)': dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-fetch/fetch': 1.3.2 '@better-auth/utils@0.4.2': dependencies: @@ -7805,6 +7829,8 @@ snapshots: '@better-fetch/fetch@1.3.1': {} + '@better-fetch/fetch@1.3.2': {} + '@braintree/sanitize-url@7.1.2': {} '@bytecodealliance/preview2-shim@0.17.6': {} @@ -9398,6 +9424,22 @@ snapshots: mermaid: 11.17.2 react: 19.2.8 + '@stripe/link-integrations-better-auth@0.2.0(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(better-auth@1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))))': + dependencies: + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + better-auth: 1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + zod: 4.5.4 + + '@stripe/link-integrations-eve@0.2.0(patch_hash=307b95051f3778ea3fa436732396f804d5cf4bbc8b96192372b6041de15d3517)(eve@0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)))': + dependencies: + '@stripe/link-sdk': 0.9.0 + eve: 0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)) + zod: 4.5.4 + + '@stripe/link-sdk@0.9.0': + dependencies: + zod: 4.5.4 + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -9802,12 +9844,12 @@ snapshots: dependencies: execa: 5.1.1 - '@vercel/connect@2.0.0(ai@7.0.120(zod@4.5.4))(better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))))(eve@0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)))': + '@vercel/connect@2.0.0(ai@7.0.120(zod@4.5.4))(better-auth@1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))))(eve@0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)))': dependencies: '@vercel/oidc': 3.8.5 optionalDependencies: ai: 7.0.120(zod@4.5.4) - better-auth: 1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) + better-auth: 1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) eve: 0.67.2(patch_hash=43c94f73773ada85a0895691ef8e6213fd9518495d2f7febe3a4d4ecd64738cb)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)) '@vercel/container@3.0.0(@vercel/build-utils@14.5.0)': @@ -10293,17 +10335,17 @@ snapshots: baseline-browser-mapping@2.11.19: {} - better-auth@1.7.2(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))): + better-auth@1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))): dependencies: - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0)) - '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(kysely@0.29.5) - '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2) + '@better-auth/drizzle-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0)) + '@better-auth/kysely-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2) '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 + '@better-fetch/fetch': 1.3.2 '@noble/ciphers': 2.3.0 '@noble/hashes': 2.3.0 better-call: 1.4.0(zod@4.5.4) @@ -10321,7 +10363,6 @@ snapshots: react-dom: 19.2.8(react@19.2.8) vitest: 4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) transitivePeerDependencies: - - '@cloudflare/workers-types' - '@opentelemetry/api' better-call@1.4.0(zod@4.5.4): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b7db98eb..4f025225 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,4 +22,5 @@ allowBuilds: sharp: true patchedDependencies: "@linqapp/chat-sdk-adapter@0.5.1": patches/@linqapp__chat-sdk-adapter@0.5.1.patch + "@stripe/link-integrations-eve@0.2.0": patches/@stripe__link-integrations-eve@0.2.0.patch eve@0.67.2: patches/eve@0.67.2.patch diff --git a/shared/environment/env.ts b/shared/environment/env.ts index 06f4f3ad..55630156 100644 --- a/shared/environment/env.ts +++ b/shared/environment/env.ts @@ -85,6 +85,9 @@ export const env = createEnv({ BLOB_STORE_ID: requiredValue.optional(), GOOGLE_CONNECTOR_UID: requiredValue.default("google/open-instinct"), LINQ_CONNECTOR: requiredValue.optional(), + LINK_CLIENT_ID: requiredValue.optional(), + LINK_CLIENT_SECRET: requiredValue.optional(), + STRIPE_PUBLISHABLE_KEY: requiredValue.optional(), LINQ_PHONE_NUMBER: requiredValue .refine( (value) => isE164PhoneNumber(value), diff --git a/tests/integration/link.test.ts b/tests/integration/link.test.ts new file mode 100644 index 00000000..04ad5be9 --- /dev/null +++ b/tests/integration/link.test.ts @@ -0,0 +1,311 @@ +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { eq } from "drizzle-orm"; +import { readFile } from "node:fs/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as schema from "@db/schema"; +import { accessScopeForUser } from "@shared/identity/access-scope"; +import type { ConnectionPrincipal } from "eve/connections"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("Link wallet integration", () => { + it("connects an existing phone user, isolates callbacks, refreshes encrypted tokens, and revokes on disconnect", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("BETTER_AUTH_URL", "http://localhost:3000"); + vi.stubEnv("LINK_CLIENT_ID", "test-link-client"); + vi.stubEnv("LINK_CLIENT_SECRET", "test-link-secret"); + vi.stubEnv("STRIPE_PUBLISHABLE_KEY", "pk_test_link"); + const client = new PGlite(); + try { + await client.exec( + await readFile( + new URL("../../db/migrations/0001_better-auth.sql", import.meta.url), + "utf8" + ) + ); + await client.exec( + await readFile( + new URL("../../db/migrations/0014_chief_tigra.sql", import.meta.url), + "utf8" + ) + ); + const database = drizzle(client, { schema }); + const Database = await import("@db"); + // SAFETY: PGlite implements the shared Drizzle query-builder and transaction API used by these services. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Only the database driver changes in this integration test. + vi.spyOn(Database, "db", "get").mockReturnValue(database as never); + const { getAuth } = await import("@db/services/auth"); + const link = await import("@db/services/auth/link"); + const { POST } = await import("@app/api/link/route"); + const { linkAuth } = await import("@agent/lib/link-auth"); + const auth = await getAuth(); + const signedIn = await auth.api.verifyPhoneNumber({ + body: { phoneNumber: "+12025550123", code: "123456" }, + returnHeaders: true, + }); + const headers = new Headers({ + origin: "http://localhost:3000", + cookie: signedIn.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "), + }); + const userId = signedIn.response.user.id; + await expect(link.getLinkToken(userId)).rejects.toThrow( + link.LinkConnectionRequiredError + ); + + const principal = { + type: "user", + id: `better-auth:${userId}`, + issuer: "authjs", + attributes: { + workspaceId: accessScopeForUser(`better-auth:${userId}`).workspaceId, + }, + } satisfies ConnectionPrincipal; + const connection = { url: "" }; + const callbackUrl = + "http://localhost:3000/eve/v1/connections/link/callback/eve-attempt/session:auth"; + await expect( + linkAuth.getToken({ principal, connection }) + ).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + await expect( + linkAuth.getToken({ principal: { type: "app" }, connection }) + ).rejects.toMatchObject({ reason: "principal_required" }); + await expect( + linkAuth.getToken({ + principal: { + ...principal, + attributes: { ...principal.attributes, scheduleId: "scheduled" }, + }, + connection, + }) + ).rejects.toMatchObject({ reason: "principal_required" }); + const authorization = await linkAuth.startAuthorization({ + principal, + connection, + callbackUrl, + }); + if (!authorization.resume) + throw new Error("Expected a resumable Link authorization."); + const pending = authorization.resume; + expect(authorization.challenge.url).toBe( + `http://localhost:3000/link?attempt=${pending.attempt}` + ); + await expect( + link.linkAuthorizationCallback("different-user", pending.attempt) + ).rejects.toThrow("expired"); + await expect( + link.startLinkAuthorization(userId, "https://attacker.example/callback") + ).rejects.toThrow("Invalid"); + const expectedCallback = await link.linkAuthorizationCallback( + userId, + pending.attempt + ); + const request = () => + new Request("http://localhost:3000/api/link", { + method: "POST", + headers, + body: new URLSearchParams({ + operation: "connect", + attempt: pending.attempt, + }), + }); + const denied = request(); + denied.headers.set("origin", "https://attacker.example"); + expect((await POST(denied)).status).toBe(403); + + const started = await POST(request()); + expect(started.status).toBe(303); + const destination = new URL(started.headers.get("location") ?? ""); + expect(destination.origin).toBe("https://login.link.com"); + expect(destination.searchParams.get("redirect_uri")).toBe( + "http://localhost:3000/api/auth/callback/link" + ); + expect(destination.searchParams.get("code_challenge")).toBeTruthy(); + updateCookies(headers, started.headers); + + let linkSubject = "link-user"; + let refreshes = 0; + const tokenRequests: URLSearchParams[] = []; + const revokedTokens: (string | null)[] = []; + let revokeSucceeds = false; + const fetchMock = vi.fn(async (input, init) => { + const outgoing = new Request(input, init); + const url = outgoing.url; + const body = new URLSearchParams(await outgoing.text()); + if (url === "https://login.link.com/auth/token") { + tokenRequests.push(body); + if (body.get("grant_type") === "refresh_token") { + refreshes++; + + return Response.json({ + access_token: "access-refreshed", + refresh_token: "refresh-rotated", + expires_in: 3600, + token_type: "Bearer", + scope: "payment_methods.agentic userinfo:read", + }); + } + + return Response.json({ + access_token: "access-original", + refresh_token: "refresh-original", + expires_in: 3600, + token_type: "Bearer", + scope: "payment_methods.agentic userinfo:read", + }); + } + if (url === "https://api.link.com/userinfo") + return Response.json({ + id: linkSubject, + email: "wallet@example.com", + email_verified: true, + name: "Wallet owner", + }); + if (url === "https://login.link.com/auth/revoke") { + revokedTokens.push(body.get("token")); + return new Response(null, { status: revokeSucceeds ? 200 : 503 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const callback = new URL("http://localhost:3000/api/auth/callback/link"); + callback.searchParams.set("code", "oauth-code"); + callback.searchParams.set( + "state", + destination.searchParams.get("state") ?? "" + ); + const completed = await auth.handler(new Request(callback, { headers })); + expect(completed.headers.get("location")).toBe(expectedCallback); + const [saved] = await database.select().from(schema.account); + expect(saved).toMatchObject({ + userId, + issuer: "link", + providerId: "link", + accountId: "link-user", + }); + expect(saved?.accessToken).not.toBe("access-original"); + expect(saved?.refreshToken).not.toBe("refresh-original"); + expect((await link.getLinkToken(userId)).token).toBe("access-original"); + await database + .update(schema.account) + .set({ accessTokenExpiresAt: new Date(0) }) + .where(eq(schema.account.userId, userId)); + expect((await link.getLinkToken(userId)).token).toBe("access-refreshed"); + expect(refreshes).toBe(1); + expect(tokenRequests.map((body) => body.get("client_secret"))).toEqual([ + "test-link-secret", + "test-link-secret", + ]); + expect(tokenRequests[0]?.get("code_verifier")).toBeTruthy(); + expect(tokenRequests[1]?.get("refresh_token")).toBe("refresh-original"); + await expect(link.getLinkToken("different-user")).rejects.toThrow( + link.LinkConnectionRequiredError + ); + expect( + await link.consumeLinkAuthorization("different-user", pending.attempt) + ).toBe(false); + const completion = { + principal, + connection, + callbackUrl, + resume: pending, + callback: { method: "GET", params: { attempt: pending.attempt } }, + }; + await expect( + linkAuth.completeAuthorization({ + ...completion, + callback: { method: "GET", params: { attempt: "wrong-attempt" } }, + }) + ).rejects.toMatchObject({ reason: "invalid_state" }); + expect((await linkAuth.completeAuthorization(completion)).token).toBe( + "access-refreshed" + ); + await expect( + linkAuth.completeAuthorization(completion) + ).rejects.toMatchObject({ reason: "invalid_state" }); + expect(await link.consumeLinkAuthorization(userId, pending.attempt)).toBe( + false + ); + const expired = await link.startLinkAuthorization( + userId, + expectedCallback + ); + await database + .update(schema.verification) + .set({ expiresAt: new Date(0) }); + expect(await link.consumeLinkAuthorization(userId, expired.attempt)).toBe( + false + ); + + // A different wallet cannot silently replace or coexist with this one. + linkSubject = "different-link-user"; + const reconnect = await POST( + new Request("http://localhost:3000/api/link", { + method: "POST", + headers, + body: new URLSearchParams({ operation: "connect" }), + }) + ); + const reconnectUrl = new URL(reconnect.headers.get("location") ?? ""); + updateCookies(headers, reconnect.headers); + callback.searchParams.set( + "state", + reconnectUrl.searchParams.get("state") ?? "" + ); + const rejected = await auth.handler(new Request(callback, { headers })); + expect( + new URL(rejected.headers.get("location") ?? "").searchParams.get( + "error" + ) + ).toBe("authorization_failed"); + expect( + await database + .select({ accountId: schema.account.accountId }) + .from(schema.account) + ).toEqual([{ accountId: "link-user" }]); + // The database also enforces this invariant if concurrent callbacks pass the precheck. + await expect( + database.insert(schema.account).values({ + id: "second-account", + issuer: "link", + accountId: "second-wallet", + providerId: "link", + userId, + updatedAt: new Date(), + }) + ).rejects.toMatchObject({ cause: { code: "23505" } }); + + await expect(link.disconnectLink(userId, headers)).rejects.toThrow( + "Unable to revoke Link access" + ); + expect(await link.getLinkAccount(userId)).toBeDefined(); + revokeSucceeds = true; + await link.disconnectLink(userId, headers); + expect(await link.getLinkAccount(userId)).toBeUndefined(); + expect(revokedTokens).toEqual(["refresh-rotated", "refresh-rotated"]); + expect((await auth.api.getSession({ headers }))?.user.id).toBe(userId); + } finally { + await client.close(); + } + }); +}); + +function updateCookies(headers: Headers, response: Headers) { + const cookies = new Map( + [ + ...(headers.get("cookie") ?? "").split("; "), + ...response.getSetCookie().map((cookie) => cookie.split(";")[0] ?? ""), + ] + .filter(Boolean) + .map((cookie) => [cookie.split("=")[0], cookie]) + ); + headers.set("cookie", [...cookies.values()].join("; ")); +} diff --git a/tests/turbo-config.test.ts b/tests/turbo-config.test.ts index 4a758395..fc201299 100644 --- a/tests/turbo-config.test.ts +++ b/tests/turbo-config.test.ts @@ -9,6 +9,8 @@ const applicationEnvironment = [ "*_CONNECTOR_UID", "KERNEL_*", "LINQ_*", + "LINK_*", + "STRIPE_PUBLISHABLE_KEY", "NODE_ENV", "SECRET_ENCRYPTION_KEY", "VERCEL_*", diff --git a/turbo.json b/turbo.json index 129fb85f..ae536e03 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,8 @@ "*_CONNECTOR_UID", "KERNEL_*", "LINQ_*", + "LINK_*", + "STRIPE_PUBLISHABLE_KEY", "NODE_ENV", "SECRET_ENCRYPTION_KEY", "VERCEL_*" @@ -26,6 +28,8 @@ "*_CONNECTOR_UID", "KERNEL_*", "LINQ_*", + "LINK_*", + "STRIPE_PUBLISHABLE_KEY", "NODE_ENV", "SECRET_ENCRYPTION_KEY", "VERCEL_*" @@ -46,6 +50,8 @@ "*_CONNECTOR_UID", "KERNEL_*", "LINQ_*", + "LINK_*", + "STRIPE_PUBLISHABLE_KEY", "NODE_ENV", "SECRET_ENCRYPTION_KEY", "VERCEL_*" @@ -73,6 +79,8 @@ "*_CONNECTOR_UID", "KERNEL_*", "LINQ_*", + "LINK_*", + "STRIPE_PUBLISHABLE_KEY", "NODE_ENV", "SECRET_ENCRYPTION_KEY", "VERCEL_*" From 3e3c545edbaf59556a6b243753451dacf0d79506 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Wed, 30 Sep 2026 18:39:12 -0400 Subject: [PATCH 2/8] Allow CI startup time for Link integration test --- tests/integration/link.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/link.test.ts b/tests/integration/link.test.ts index 04ad5be9..0f9d80f1 100644 --- a/tests/integration/link.test.ts +++ b/tests/integration/link.test.ts @@ -15,6 +15,7 @@ afterEach(() => { }); describe("Link wallet integration", () => { + // Include PGlite startup and the full OAuth lifecycle on slower CI runners. it("connects an existing phone user, isolates callbacks, refreshes encrypted tokens, and revokes on disconnect", async () => { vi.stubEnv("NODE_ENV", "development"); vi.stubEnv("BETTER_AUTH_URL", "http://localhost:3000"); @@ -295,7 +296,7 @@ describe("Link wallet integration", () => { } finally { await client.close(); } - }); + }, 15_000); }); function updateCookies(headers: Headers, response: Headers) { From 5ef4724402459bd0347f2ac2fe8b9253014fb991 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Thu, 1 Oct 2026 18:20:42 -0400 Subject: [PATCH 3/8] Migrate Eve to 0.69 resumable tasks and lifecycle events --- README.md | 13 ++ agent/channels/eve.ts | 8 + agent/channels/linq.ts | 8 + agent/hooks/background-reply-target.ts | 12 +- agent/hooks/scheduled-run-completion.ts | 24 +-- .../instructions/content/role/interactive.md | 10 +- .../content/role/scheduled-report.md | 2 +- .../content/worker-coordination.md | 2 +- agent/lib/browser-task.ts | 7 - ...google-workspace-generated-clients.test.ts | 1 + .../browser-agent/lib/trace/timeline.ts | 2 +- agent/tools/no_reply.ts | 14 ++ agent/tools/run_browser.ts | 39 ++-- agent/tools/task_cancel.ts | 1 - .../[sessionId]/_components/activity/card.tsx | 11 +- .../_components/activity/index.tsx | 10 +- .../_components/activity/preview.tsx | 2 +- .../_components/activity/trace.tsx | 4 +- .../_components/conversation/index.test.tsx | 9 +- .../chat/[sessionId]/_lib/trace-view.test.ts | 41 +--- .../chat/[sessionId]/_lib/trace-view.ts | 20 +- app/_lib/subagent-sessions.test.ts | 58 +++--- app/_lib/subagent-sessions.ts | 72 ++++--- evals/agent/conversation.eval.ts | 2 +- evals/agent/routing.eval.ts | 6 +- evals/agent/safety.eval.ts | 8 +- evals/agent/schedules.eval.ts | 2 +- evals/browser/benchmark-activity.ts | 2 +- evals/browser/browser.eval.ts | 23 +-- evals/browser/tests/worker-events.test.ts | 172 +++++----------- evals/browser/worker-events.ts | 148 ++------------ package.json | 2 +- ...@stripe__link-integrations-eve@0.2.0.patch | 6 +- patches/README.md | 29 ++- .../{eve@0.67.2.patch => eve@0.69.0.patch} | 9 - pnpm-lock.yaml | 40 ++-- pnpm-workspace.yaml | 2 +- scripts/run-runtime-smoke.ts | 4 - tests/agent-tool-boundaries.test.ts | 4 +- tests/agent/capabilities.test.ts | 12 +- .../channels/eve-message-delivery.test.ts | 24 +++ .../channels/linq-message-delivery.test.ts | 26 +++ tests/agent/hooks/evlog.test.ts | 3 + .../hooks/scheduled-run-completion.test.ts | 53 +---- tests/agent/hooks/session-owner.test.ts | 3 + tests/agent/reply-targets.test.ts | 20 +- .../hooks/browser-trace-telemetry.test.ts | 3 + tests/agent/tools/google-workspace.test.ts | 1 + tests/agent/tools/run-browser.test.ts | 113 +++++++---- tests/agent/tools/schedules.test.ts | 1 + tests/helpers/tool-context.ts | 1 + tests/runtime/fixture/agent/lib/model.ts | 18 +- tests/runtime/fixture/evals/workflow.eval.ts | 192 ++++++------------ tests/worker-input-bubbling.test.ts | 2 +- 54 files changed, 552 insertions(+), 749 deletions(-) delete mode 100644 agent/lib/browser-task.ts create mode 100644 agent/tools/no_reply.ts delete mode 100644 agent/tools/task_cancel.ts rename patches/{eve@0.67.2.patch => eve@0.69.0.patch} (76%) diff --git a/README.md b/README.md index c01de826..9bbca9ac 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,19 @@ instruct the agent not to repeat them in chat. Financial-data tools also require the corresponding Link grant scopes; the default grant requests `payment_methods.agentic` and `userinfo:read`. +## Eve upgrades + +This application uses Eve `0.69.0`. Browser work runs as a resumable task: +`run_browser` returns a task receipt, subsequent calls use `taskId`, and +`task_cancel` stops the task's current work while keeping its browser session +available for another assignment. Task results arrive within the open turn. + +Eve rejects session handoffs across the 0.69 upgrade boundary. Keep an existing +session on its owning deployment until it finishes, and start a new conversation +on the upgraded deployment. This migration does not cancel or reset existing +production sessions. Update remote-agent deployments before their callers when +using remote agents. + ## Local development The **Deploy with Vercel** flow above is the simplest way to run OpenInstinct. It diff --git a/agent/channels/eve.ts b/agent/channels/eve.ts index aa0c5581..73d4e001 100644 --- a/agent/channels/eve.ts +++ b/agent/channels/eve.ts @@ -71,6 +71,14 @@ const channel = eveChannel({ auth: authenticate, events: { async "action.result"(event, _channel, session) { + if ( + event.status === "completed" && + event.result.kind === "tool-result" && + event.result.toolName === "no_reply" + ) { + await finalizeScheduledReportDelivery(session, "suppressed"); + return; + } if ( event.status === "completed" && sendMessageToolResultSchema.safeParse(event.result).success diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index 37c0e296..d6a4bc60 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -68,6 +68,14 @@ export default linqChannel({ credentials, events: { async "action.result"(event, context, session) { + if ( + event.status === "completed" && + event.result.kind === "tool-result" && + event.result.toolName === "no_reply" + ) { + await finalizeScheduledReportDelivery(session, "suppressed"); + return; + } const reaction = reactToMessageToolResultSchema.safeParse(event.result); if (event.status === "completed" && reaction.success) { if (!context.thread) { diff --git a/agent/hooks/background-reply-target.ts b/agent/hooks/background-reply-target.ts index f672f6da..5c4ef9e0 100644 --- a/agent/hooks/background-reply-target.ts +++ b/agent/hooks/background-reply-target.ts @@ -1,17 +1,11 @@ import { defineHook } from "eve/hooks"; import { registerBackgroundReplyTarget } from "@agent/lib/reply-targets"; -import { browserTaskReceiptSchema } from "@agent/lib/browser-task"; export default defineHook({ events: { - "action.result"(event, context) { - if (event.data.status !== "completed") return; - const task = browserTaskReceiptSchema.safeParse(event.data.result); - if (!task.success) return; - registerBackgroundReplyTarget( - task.data.output.taskId, - context.session.auth - ); + "task.started"(event, context) { + if (event.data.name !== "run_browser") return; + registerBackgroundReplyTarget(event.data.taskId, context.session.auth); }, }, }); diff --git a/agent/hooks/scheduled-run-completion.ts b/agent/hooks/scheduled-run-completion.ts index 05ba62f8..69a65398 100644 --- a/agent/hooks/scheduled-run-completion.ts +++ b/agent/hooks/scheduled-run-completion.ts @@ -1,10 +1,8 @@ import { defineHook } from "eve/hooks"; -import { browserTaskReceiptSchema } from "@agent/lib/browser-task"; import { scheduledRunIdentity } from "@agent/lib/schedules/identity"; import { scheduledRunOutcomeSchema } from "@shared/schedules/outcome"; import { completeScheduledAgentRun, - deferScheduledAgentRunCompletion, markScheduledAgentRunStarted, releaseScheduledAgentRun, waitForScheduledAgentRunInput, @@ -62,26 +60,6 @@ export default defineHook({ sessionId: ctx.session.id, }); }, - async "action.result"(event, ctx) { - if (event.data.status !== "completed") return; - const task = browserTaskReceiptSchema.safeParse(event.data.result); - if (!task.success) return; - const identity = scheduledRunIdentity(ctx.session.auth); - if (!identity) return; - const deferred = await deferScheduledAgentRunCompletion( - identity.runId, - identity.leaseToken, - ctx.session.turn.id, - new Date(event.meta.at) - ); - console.info("[scheduled-run] worker delegated background work", { - deferred, - runId: identity.runId, - sessionId: ctx.session.id, - taskId: task.data.output.taskId, - turnId: ctx.session.turn.id, - }); - }, async "message.completed"(event, ctx) { const identity = scheduledRunIdentity(ctx.session.auth); if (!identity) return; @@ -102,7 +80,7 @@ export default defineHook({ logDeadLetterReportQueued(status, identity.runId, ctx.session.id); return; } - const message = event.data.message?.trim().slice(0, 4_000); + const message = event.data.message.trim().slice(0, 4_000); const outcome = scheduledRunOutcomeSchema.parse( message ? { diff --git a/agent/instructions/content/role/interactive.md b/agent/instructions/content/role/interactive.md index 9fc676ad..b91066ee 100644 --- a/agent/instructions/content/role/interactive.md +++ b/agent/instructions/content/role/interactive.md @@ -56,15 +56,15 @@ The main conversation is the control plane. Coordinate the user's work there and - Eve and Linq own read receipts, typing indicators, delivery state, authorization prompts, and approval/input cards. Let those native control-plane features operate normally; do not duplicate them as prose unless the user needs an explanation. - After a `send_message` or `react_to_message` call, never repeat or summarize it in assistant text. If the runtime requires terminal assistant text after the last delivery, emit only `DELIVERY_COMPLETE`; ordinary assistant text is not delivered to Linq. - The worker's structured result is coordinator-facing only. Rewrite it into a concise user-facing response; never imply that the worker spoke to the user. -- Start a background worker without a separate preamble. Once its working receipt arrives, send one short acknowledgment saying what is underway. Treat the receipt as acceptance, not completion. -- Keep intermediate background-task wakes silent unless the user must act. When the worker settles, compose the result according to the iMessage composition rules, including a separate follow-up message when the user has a distinct choice or approval to make. -- End the turn whenever the root conversation genuinely needs an answer. Before surfacing a `Needs user input:` blocker for routine contact, traveler, or address fields, confirm the worker explicitly reported checking compatible vault items. If it did not, continue the same worker once and instruct it to call `list_vault` and `fill_from_vault`; only surface the question if no compatible item exists or secure fill still fails. After the user replies, continue that worker with its `agentId` and the supplied answer so it retains its browser state and context. If the answer is an OTP, pass it immediately without echoing it in assistant text. +- Start a browser task without a separate preamble. Once its working receipt arrives, send one short acknowledgment saying what is underway. Treat the receipt as acceptance, not completion. +- Tasks report results within the open turn. Keep intermediate updates silent unless the user must act. When the worker settles, compose the result according to the iMessage composition rules, including a separate follow-up message when the user has a distinct choice or approval to make. +- End the turn whenever the root conversation genuinely needs an answer. Before surfacing a `Needs user input:` blocker for routine contact, traveler, or address fields, confirm the worker explicitly reported checking compatible vault items. If it did not, continue the same worker once and instruct it to call `list_vault` and `fill_from_vault`; only surface the question if no compatible item exists or secure fill still fails. After the user replies, continue that worker with its `taskId` and the supplied answer so it retains its browser state and context. If the answer is an OTP, pass it immediately without echoing it in assistant text. # Worker coordination - Delegate to `browser-agent` only when the task requires interaction with a known site, authenticated or browser-local state, visual inspection, form entry, or content that `web_search` and `web_fetch` could not retrieve. Do not use a generic agent copy or any browser-execution tool yourself. When public discovery is needed before interaction, complete it with `web_search` first and include the useful target URLs and facts in the worker assignment. - Worker images are private, user-scoped artifacts. Ask the worker for and include visual artifacts when they materially help the user verify a completed outcome, compare genuinely visual options, or understand a concrete result. Choose the smallest useful set: one image for a single result, or two to four distinct images when a compact gallery adds real comparison or verification value. Useful cases include showing a purchased item or safe, non-sensitive confirmation state and comparing clothing, food, product, or other visually differentiated options. Put two to four selected artifact references in the same `send_message` call so Linq can present them as one native media gallery; keep its text to one short caption, identify images by order only when the mapping matters, and use a separate short selection question when needed. Do not use a gallery for text-heavy comparisons such as flights, schedules, or prices unless the images materially improve the decision. Skip decorative, repetitive, text-only, or sensitive visuals. Render every selected artifact in order with its exact descriptor as `![label](/artifacts/id)`. Never invent an artifact, change its id or URL, expose a private Blob URL, or claim an internal screenshot was delivered. - When a visually selected product is unavailable, treat images as part of the replacement outcome even if the user does not ask for them again. Find the strongest viable alternatives, ask the worker for one clear artifact per recommendation, and return a compact two-to-four-image gallery with the concrete availability details. -- When the worker returns a missing vault item, call `request_vault_setup` and give its link to the user before ending the turn; do not surface any live browser URL from that result. For a purchase decision, authentication challenge after vault fill, unresolved CAPTCHA after Kernel's managed solver wait, ambiguous choice, or human-takeover blocker, ask the user in the root conversation. Preserve the worker's `agentId` and live browser URL when available, then continue that same parked worker after the user responds so it re-reads the current page before acting. -- Treat a new user message as current steering. Preserve unrelated work. When it revises an active worker task, cancel its `run_browser` task with `task_cancel`, wait for cancellation to settle, then call `run_browser` with the parked worker's `agentId` and the complete revised assignment. Do not send another workflow call to a worker that is still running. Use `task_cancel` when the user wants work stopped without a replacement assignment. Reuse an available worker only when its prior browser state and context remain useful. Cancellation is cooperative and does not roll back external effects, so do not promise atomic interruption. +- When the worker returns a missing vault item, call `request_vault_setup` and give its link to the user before ending the turn; do not surface any live browser URL from that result. For a purchase decision, authentication challenge after vault fill, unresolved CAPTCHA after Kernel's managed solver wait, ambiguous choice, or human-takeover blocker, ask the user in the root conversation. Preserve the worker's `taskId` and live browser URL when available, then continue that same parked worker after the user responds so it re-reads the current page before acting. +- Treat a new user message as current steering. Preserve unrelated work. When it revises an active worker task, cancel its `run_browser` task with `task_cancel`, wait for cancellation to settle, then call `run_browser` with the parked worker's `taskId` and the complete revised assignment. Do not send another workflow call to a worker that is still running. Use `task_cancel` when the user wants work stopped without a replacement assignment. Reuse an available worker only when its prior browser state and context remain useful. Cancellation is cooperative and does not roll back external effects, so do not promise atomic interruption. - Do not create overlapping workers for the same assignment. Do not delegate non-browser work merely to create activity. diff --git a/agent/instructions/content/role/scheduled-report.md b/agent/instructions/content/role/scheduled-report.md index 1bd3c8c0..30048410 100644 --- a/agent/instructions/content/role/scheduled-report.md +++ b/agent/instructions/content/role/scheduled-report.md @@ -7,6 +7,6 @@ You are OpenInstinct evaluating the completed outcome of a background scheduled - Consider the outcome together with the current conversation and the time for which it was scheduled. - This turn exists only to report the outcome, resume its run with context already present in the conversation, or create a secure setup link for a vault item the run needs. Never invoke another agent, alter a schedule or profile, read or change vault contents, access an account, or perform any other external action. - When the run is waiting for input, answer it with `schedules-answer` if the existing conversation resolves the request clearly, then do not send a message. If it needs a supported vault item, call `request_vault_setup` with only the safe metadata in the request, then call `send_message` exactly once with the returned setup link and ask the user to reply when finished. For any other unresolved request, call `send_message` exactly once with its question. Preserve the internal run ID in context for a later `schedules-answer` call, and never guess or expose it to the user. -- For a completed outcome, call `send_message` exactly once only when it is still useful, actionable, time-sensitive, or materially changes what the user knows. Otherwise finish with exactly `` and do not call `send_message`. +- For a completed outcome, call `send_message` exactly once only when it is still useful, actionable, time-sensitive, or materially changes what the user knows. Otherwise call `no_reply` to end the turn silently. - Rewrite useful information as a natural message from OpenInstinct. Never mention the internal worker, handoff, reporting state, or implementation details. - After `send_message`, emit only `DELIVERY_COMPLETE` if the runtime requires terminal assistant text. Never repeat or summarize the delivered message. diff --git a/agent/instructions/content/worker-coordination.md b/agent/instructions/content/worker-coordination.md index 2558ffa4..f4bed94e 100644 --- a/agent/instructions/content/worker-coordination.md +++ b/agent/instructions/content/worker-coordination.md @@ -2,6 +2,6 @@ - Use `run_browser` to give `browser-agent` one bounded browser outcome, the exact target URL when known, all relevant non-secret context, the user's constraints, and any exact transaction approval already granted. The worker does not see the parent conversation. Preserve the requested visual deliverable in the assignment: treat a photo, pic, or image of a specific webpage item, product, dish, or listing as the item's actual page image, while a screenshot or page view means rendered screen context. When authentication may be required, tell it to return `Needs vault setup:` with safe login metadata if no compatible login exists, without a live-view URL or a request for manual credential entry. Tell it to return `Needs user input:` asking for the code when an OTP blocks progress; do not make live view the OTP path. - Begin every worker `message` with `Task: `, followed by a blank line and the complete assignment. Keep the summary free of credentials and sensitive personal details, and reuse it when continuing the same worker. -- The `run_browser` tool supplies the required structured completion schema on every initial or resumed call, including when passing an existing `agentId`. Supply only the assignment and optional `agentId`; do not supply an `outputSchema`. +- The `run_browser` tool supplies the required structured completion schema on every initial or resumed call, including when passing an existing `taskId`. Supply only the assignment and optional `taskId`; do not supply an `outputSchema`. - The worker must finish each browser assignment by calling Eve's native `final_output` tool exactly once with a result matching the required `outputSchema`, then stop without prose, JSON text, another tool, or a second completion. Treat `success` as achieved only when its message includes a verified outcome. Treat `failure` as a blocker or incomplete outcome, not proof that no progress occurred. - Worker images are private, user-scoped artifacts. Ask for visual artifacts only when they materially help verify an outcome or compare genuinely visual options, and choose the smallest useful set. Preserve each selected artifact's exact descriptor as `![label](/artifacts/id)`; never invent an artifact, change its id or URL, expose a private Blob URL, or claim an internal screenshot was delivered. diff --git a/agent/lib/browser-task.ts b/agent/lib/browser-task.ts deleted file mode 100644 index af0382e3..00000000 --- a/agent/lib/browser-task.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from "zod"; - -export const browserTaskReceiptSchema = z.object({ - kind: z.literal("tool-result"), - toolName: z.literal("run_browser"), - output: z.object({ status: z.literal("working"), taskId: z.string().min(1) }), -}); diff --git a/agent/lib/google-workspace/tests/google-workspace-generated-clients.test.ts b/agent/lib/google-workspace/tests/google-workspace-generated-clients.test.ts index e062e7ee..e16f7155 100644 --- a/agent/lib/google-workspace/tests/google-workspace-generated-clients.test.ts +++ b/agent/lib/google-workspace/tests/google-workspace-generated-clients.test.ts @@ -190,6 +190,7 @@ function toolContext() { async getSandbox() { throw new Error("Sandbox access is outside this focused test."); }, + messages: [], abortSignal: new AbortController().signal, callId: "call-1", getToken, diff --git a/agent/subagents/browser-agent/lib/trace/timeline.ts b/agent/subagents/browser-agent/lib/trace/timeline.ts index 1d60aa54..3eeace52 100644 --- a/agent/subagents/browser-agent/lib/trace/timeline.ts +++ b/agent/subagents/browser-agent/lib/trace/timeline.ts @@ -69,7 +69,7 @@ export function traceTimelineRows(event: HookEvent): TraceTimelineRow[] { ]; } case "message.completed": - return [row("Assistant", event.data.message ?? "")]; + return [row("Assistant", event.data.message)]; case "result.completed": return [row("Final output", compactJson(event.data.result))]; case "input.requested": diff --git a/agent/tools/no_reply.ts b/agent/tools/no_reply.ts new file mode 100644 index 00000000..c420cebf --- /dev/null +++ b/agent/tools/no_reply.ts @@ -0,0 +1,14 @@ +import { defineDynamic } from "eve/tools"; +import { noReply } from "eve/tools/no_reply"; +import { resolveModeValue } from "@agent/lib/mode"; + +const noReplyTool = noReply(); + +export default defineDynamic({ + events: { + "turn.started": (_event, context) => + resolveModeValue(context, { + "scheduled-report": { no_reply: noReplyTool }, + }), + }, +}); diff --git a/agent/tools/run_browser.ts b/agent/tools/run_browser.ts index 2cb01ee7..d85d7a89 100644 --- a/agent/tools/run_browser.ts +++ b/agent/tools/run_browser.ts @@ -4,20 +4,31 @@ import { taskCompletionSchema } from "@agent/subagents/browser-agent/lib/complet export default defineWorkflowTool({ description: - "Execute one bounded browser assignment and return a verified result with optional private image artifacts. Pass agentId to continue a parked worker. The completion schema is supplied automatically.", - inputSchema: z.object({ - message: z.string().min(1), - agentId: z.string().optional(), - }), - execution: "background", - async execute(input, ctx) { + "Execute one bounded browser assignment and return a verified result with optional private image artifacts. Continue the same worker using its taskId. The completion schema is supplied automatically.", + inputSchema: z.object({ message: z.string().min(1) }), + async serve(receive, ctx) { "use workflow"; - const result = await ctx.agent("browser-agent", { - ...input, - outputSchema: z - .record(z.string(), z.json()) - .parse(z.toJSONSchema(taskCompletionSchema)), - }); - return taskCompletionSchema.parse(result); + const worker = ctx.agent("browser-agent"); + /* oxlint-disable eslint/no-await-in-loop -- A resumable task serves sequential calls on one child session. */ + for (;;) { + const { input, abortSignal } = await receive(); + try { + const response = await worker.send(input.message, { + outputSchema: taskCompletionSchema, + signal: abortSignal, + }); + const result = await response.result(); + if (abortSignal.aborted) continue; + if (result.status === "failed") { + throw new Error( + result.error?.message ?? "The browser worker failed." + ); + } + ctx.reply(taskCompletionSchema.parse(result.data)); + } catch (error) { + if (!abortSignal.aborted) throw error; + } + } + /* oxlint-enable eslint/no-await-in-loop */ }, }); diff --git a/agent/tools/task_cancel.ts b/agent/tools/task_cancel.ts deleted file mode 100644 index 66342e81..00000000 --- a/agent/tools/task_cancel.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "eve/tools/task_cancel"; diff --git a/app/(authenticated)/chat/[sessionId]/_components/activity/card.tsx b/app/(authenticated)/chat/[sessionId]/_components/activity/card.tsx index d971e7eb..4ef8b9c0 100644 --- a/app/(authenticated)/chat/[sessionId]/_components/activity/card.tsx +++ b/app/(authenticated)/chat/[sessionId]/_components/activity/card.tsx @@ -81,20 +81,19 @@ export function ActivityCard({
{sessions.map((session) => { - const status = - statuses.get(session.childSessionId) ?? "starting"; + const status = statuses.get(session.sessionId) ?? "starting"; const task = getSubagentTask( - eventsBySession.get(session.childSessionId) ?? [] + eventsBySession.get(session.sessionId) ?? [] ) ?? session.task; return (
{sessions.map((session) => { - const status = statuses.get(session.sessionId) ?? "starting"; + const status = + statuses.get(session.childSessionId) ?? "starting"; const task = getSubagentTask( - eventsBySession.get(session.sessionId) ?? [] + eventsBySession.get(session.childSessionId) ?? [] ) ?? session.task; return ( + } + description={ + linkEnabled + ? "Connect or manage your wallet for purchases approved in Link." + : "Set up Link to enable wallet connections." + } + icon={} + label="Link wallet" + />
); diff --git a/app/(authenticated)/link/page.tsx b/app/(authenticated)/link/page.tsx index 20cda6e8..5f334c7f 100644 --- a/app/(authenticated)/link/page.tsx +++ b/app/(authenticated)/link/page.tsx @@ -2,6 +2,7 @@ import { z } from "zod"; import { getLinkAccount, linkConfigured } from "@db/services/auth/link"; import { requireRequestScope } from "@web/auth/request-scope"; import { Button } from "@web/components/ui/button"; +import { applicationOrigin } from "@shared/environment/origin"; export const metadata = { title: "Link wallet", @@ -13,6 +14,8 @@ export default async function Page({ searchParams }: PageProps<"/link">) { const params = await searchParams; const attempt = z.uuid().safeParse(params.attempt); const configured = linkConfigured(); + const callbackUrl = new URL("/api/auth/callback/link", applicationOrigin()) + .href; const connected = configured && Boolean(await getLinkAccount(scope.userId.slice("better-auth:".length))); @@ -28,6 +31,64 @@ export default async function Page({ searchParams }: PageProps<"/link">) { : "Link is not available on this installation yet."}

+ {!configured && ( +
+ +

+ OpenInstinct works without Link. To enable wallet connections, the + person managing this deployment needs to complete these steps. +

+
    +
  1. +

    + + Register a Link OAuth client with Stripe + + . Include this exact callback URL in the registration: +

    + + {callbackUrl} + +

    + Register each deployment or local development URL you plan to + use. Keep the app’s configured origin consistent with that URL. +

    +
  2. +
  3. +

    + Add these environment variables in your Vercel project’s + Settings, or in .env.local for local development: +

    +
      +
    • + LINK_CLIENT_ID — issued by Stripe +
    • +
    • + LINK_CLIENT_SECRET — issued by Stripe +
    • +
    • + STRIPE_PUBLISHABLE_KEY — from your Stripe account +
    • +
    +

    + Keep the client secret in server settings. Do not paste it into + a conversation or commit it to your repository. +

    +
  4. +
  5. + Redeploy the app, or restart the local server. Return here and + choose Connect Link to authorize your own wallet. +
  6. +
+
+ )} {params.error && (

The wallet connection could not be completed. Try again, or sign in @@ -58,6 +119,23 @@ export default async function Page({ searchParams }: PageProps<"/link">) { )} )} + {configured && ( +

+ +

+ After connecting, ask OpenInstinct to help with a purchase. It will + send you a Link approval request before using payment credentials. + Connecting your wallet does not approve a purchase. +

+

+ You can connect one Link wallet to your OpenInstinct account. To + switch wallets, disconnect the current one first. Disconnecting + revokes OpenInstinct’s access; your phone sign-in still works. +

+
+ )} ); } From c84e598eedf6f569dce2e003f6f1d573c183dd36 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Fri, 2 Oct 2026 11:25:36 -0400 Subject: [PATCH 7/8] Upgrade Link Eve extension to 0.2.4 --- README.md | 2 +- package.json | 2 +- patches/README.md | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 57bc591a..384acc6d 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ the corresponding Link grant scopes; the default grant requests ## Eve compatibility -This branch pins Eve `0.66.3` and the published Link extension `0.2.3`. +This branch pins Eve `0.66.3` and the published Link extension `0.2.4`. The extension's tool contract is supported directly, so it needs no compatibility rebuild. Browser work uses the background workflow and `agentId` continuation APIs supported by this Eve version. diff --git a/package.json b/package.json index 6464dd8c..5692e774 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@streamdown/math": "1.0.2", "@streamdown/mermaid": "1.0.2", "@stripe/link-integrations-better-auth": "0.2.0", - "@stripe/link-integrations-eve": "0.2.3", + "@stripe/link-integrations-eve": "0.2.4", "@t3-oss/env-nextjs": "0.13.11", "@tailwindcss/postcss": "4.3.3", "@tanstack/react-query": "^5.102.6", diff --git a/patches/README.md b/patches/README.md index 0a1b0947..7d31005f 100644 --- a/patches/README.md +++ b/patches/README.md @@ -23,7 +23,7 @@ The application delivery tests exercise that adapter. child's cancellation event. Remove each patch hunk when an upstream release passes its corresponding regression without the hunk. -The published `@stripe/link-integrations-eve@0.2.3` declares tool contract v57, +The published `@stripe/link-integrations-eve@0.2.4` declares tool contract v57, which Eve `0.66.3` supports directly. No Link compatibility patch or local extension rebuild is needed on this branch. The extension and its Link SDK `0.11.0` dependency remain installed from npm. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb59f12..eff34611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,8 +55,8 @@ importers: specifier: 0.2.0 version: 0.2.0(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.2))(better-auth@1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))) '@stripe/link-integrations-eve': - specifier: 0.2.3 - version: 0.2.3(eve@0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4))) + specifier: 0.2.4 + version: 0.2.4(eve@0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4))) '@t3-oss/env-nextjs': specifier: 0.13.11 version: 0.13.11(typescript@6.0.3)(zod@4.5.4) @@ -2905,8 +2905,8 @@ packages: '@better-auth/core': ^1.7.5 better-auth: ^1.7.5 - '@stripe/link-integrations-eve@0.2.3': - resolution: {integrity: sha512-652R78sIdrs+M1rRzc9UWMKPMxEYvi/csaybaUtjZGm6x6BOWOtnwJ5c4h32IcvLGIIohhwBX//AccL0DRKDKQ==} + '@stripe/link-integrations-eve@0.2.4': + resolution: {integrity: sha512-vC/Zx1dEmVUfqskjrVITJrxEZaK7iUXXW4/Z+mSYpsNbZyE9hQdUiORbDM/5qRMnb2Ld5tlf6qEJ/RPAP10RiA==} engines: {node: '>=24'} peerDependencies: eve: '*' @@ -9429,7 +9429,7 @@ snapshots: better-auth: 1.7.5(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(kysely@0.29.5)(pg@8.23.0))(next@16.3.3(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.11(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))) zod: 4.5.4 - '@stripe/link-integrations-eve@0.2.3(eve@0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)))': + '@stripe/link-integrations-eve@0.2.4(eve@0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)))': dependencies: '@stripe/link-sdk': 0.11.0 eve: 0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a80a18f2..01c3bcf0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,8 +2,8 @@ minimumReleaseAgeStrict: true minimumReleaseAge: 1440 minimumReleaseAgeExclude: # Explicitly requested Link upgrade; remove once these releases clear the - # 24-hour gate (2026-10-02 23:22 UTC). Future versions remain age-gated. - - "@stripe/link-integrations-eve@0.2.3" + # 24-hour gate (2026-10-03 15:14 UTC). Future versions remain age-gated. + - "@stripe/link-integrations-eve@0.2.4" - "@stripe/link-sdk@0.11.0" # Eve 0.44.4's current official scaffold resolves the matching AI SDK release cohort. - "@ai-sdk/*" From 539ae850cef44f0d51734021c3fc4ca1bddc27f6 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Fri, 2 Oct 2026 11:55:31 -0400 Subject: [PATCH 8/8] Compose Link approvals with browser card autofill --- .../content/worker-coordination.md | 8 + agent/subagents/browser-agent/instructions.md | 9 + .../browser-agent/lib/autofill/native.ts | 3 + .../lib/autofill/tests/native-payment.test.ts | 163 ++++++++++++ .../browser-agent/tools/fill_from_link.ts | 187 ++++++++++++++ agent/tools/link__retrieve_spend_request.ts | 42 +++ package.json | 1 + pnpm-lock.yaml | 3 + tests/agent-tool-boundaries.test.ts | 2 + .../tools/fill-from-link.test.ts | 239 ++++++++++++++++++ .../tools/link-retrieve-spend-request.test.ts | 57 +++++ 11 files changed, 714 insertions(+) create mode 100644 agent/subagents/browser-agent/lib/autofill/tests/native-payment.test.ts create mode 100644 agent/subagents/browser-agent/tools/fill_from_link.ts create mode 100644 agent/tools/link__retrieve_spend_request.ts create mode 100644 tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts create mode 100644 tests/agent/tools/link-retrieve-spend-request.test.ts diff --git a/agent/instructions/content/worker-coordination.md b/agent/instructions/content/worker-coordination.md index 2558ffa4..53744b2e 100644 --- a/agent/instructions/content/worker-coordination.md +++ b/agent/instructions/content/worker-coordination.md @@ -5,3 +5,11 @@ - The `run_browser` tool supplies the required structured completion schema on every initial or resumed call, including when passing an existing `agentId`. Supply only the assignment and optional `agentId`; do not supply an `outputSchema`. - The worker must finish each browser assignment by calling Eve's native `final_output` tool exactly once with a result matching the required `outputSchema`, then stop without prose, JSON text, another tool, or a second completion. Treat `success` as achieved only when its message includes a verified outcome. Treat `failure` as a blocker or incomplete outcome, not proof that no progress occurred. - Worker images are private, user-scoped artifacts. Ask for visual artifacts only when they materially help verify an outcome or compare genuinely visual options, and choose the smallest useful set. Preserve each selected artifact's exact descriptor as `![label](/artifacts/id)`; never invent an artifact, change its id or URL, expose a private Blob URL, or claim an internal screenshot was delivered. + +# Link purchases + +- Use the official Link extension for wallet access, spend requests, and approvals. Load `link__create-payment-credential` for a purchase or `link__financial-insights` for balances and transactions. Let Eve handle wallet connection; never ask for tokens, card numbers, or security codes, or install the Link CLI. +- For a browser purchase, first have the worker establish the exact merchant URL, items, quantities, options, and final total including tax and shipping. Create a one-time `card` spend request with those details through `link__create_spend_request`, using a stable idempotency key for that purchase. Follow Link's approval URL or required user action, then check the same request with `link__retrieve_spend_request`. Creating a request or receiving a user's chat reply does not establish Link approval. +- Our browser flow retrieves card details only inside `fill_from_link`. Do not request credential expansion, retrieve raw credentials through another tool, or put card details in a worker assignment. After Link reports `approved`, resume the same browser worker with the spend request ID, merchant, items and choices, approved amount in minor currency units, currency, and the user's exact purchase authorization. Tell it to recheck checkout and call `fill_from_link`. +- Shared Payment Tokens, Link Pay Tokens, recurring purchases, and cross-origin payment frames are not supported by this browser bridge. Return the specific limitation instead of inventing a merchant integration or switching payment methods. If Link is unconfigured, direct the user to `/link`; do not claim the wallet is connected. +- A fill is not a completed purchase. Have the worker submit only the authorized checkout and verify a merchant order confirmation. If the result is uncertain, inspect the existing order and spend request; do not create another request or retry the purchase blindly. A changed total or material term requires a corrected request and approval before proceeding. diff --git a/agent/subagents/browser-agent/instructions.md b/agent/subagents/browser-agent/instructions.md index 3abea291..85a1d19e 100644 --- a/agent/subagents/browser-agent/instructions.md +++ b/agent/subagents/browser-agent/instructions.md @@ -23,6 +23,15 @@ You are `browser-agent`, the root coordinator's dedicated browser executor. Comp # Execution +## Link checkout + +- When the coordinator specifies Link, use `fill_from_link` for payment. Do not substitute a saved vault card or ask for payment vault setup. If no approved spend request ID is supplied, preserve the browser and return the exact merchant URL, items, quantities, options, total including tax and shipping, and currency so the coordinator can arrange Link approval. +- With an approved request ID, recheck the purchase and current total, focus a visible card field, and call `fill_from_link` with the browser session ID, spend request ID, observed amount in minor currency units, and lowercase currency. The tool checks the current user's wallet and approved merchant origin. It supports one-time card forms on that origin; stop and report cross-origin payment-frame or unsupported-credential blockers. +- Never inspect, copy, screenshot, or return filled payment values. Check only form validation and non-secret checkout details. The vault retry-once instruction does not apply to Link: after a failed or uncertain fill, report the state without blindly filling again. Return Link connection or approval blockers to the coordinator. +- Filling does not authorize submission. Submit only when the coordinator supplied the user's exact purchase authorization and the checkout still matches. Submit once and verify a merchant order confirmation before reporting success. On a timeout or ambiguous result, preserve the browser and report uncertainty; do not retry the purchase or request another card. + +## Browser operation + - Use `playwright_execute` as the primary browser execution surface. Prefer one bounded program per page state that inspects, performs related safe actions, verifies the meaningful outcome, and returns a compact result. When Playwright is unreliable or semantic interaction is more suitable, inspect with `browser_snapshot`, `browser_text`, or `browser_find`, then use `browser_act` for a short relaxed action plan. `browser_act` dispatches actions and returns the successor state without waiting for model-authored postconditions; do not repeat an action merely because strict causal verification is absent. Use `browser_wait_for` only when the next operation truly depends on a delayed user-visible state. Use current refs only, and snapshot again after navigation, a stale-ref error, or an unavailable successor. - Use `computer_action` only when the page requires visual reasoning or coordinate input that the semantic browser tools cannot express. Never use fixed multi-second sleeps; use `browser_wait_for` with a specific semantic state, URL, title, value, or element condition. - Create one browser and reuse it. Pass a known target as `start_url`. Start read-only; immediately before a saved login is needed, replace it at the same URL with `save_changes: true`, and delete that writer as soon as authentication succeeds so the profile is saved. Only one writable workspace browser may exist. diff --git a/agent/subagents/browser-agent/lib/autofill/native.ts b/agent/subagents/browser-agent/lib/autofill/native.ts index 2eeab78c..59ab04de 100644 --- a/agent/subagents/browser-agent/lib/autofill/native.ts +++ b/agent/subagents/browser-agent/lib/autofill/native.ts @@ -191,6 +191,9 @@ export async function fillWithKernelNativeAutofill({ control.sessionId ); } catch (error) { + // A payment fill may have written values before its response failed. + // Reconcile the checkout instead of retrying another control. + if (kind === "payment") throw error; lastError = error; continue; } diff --git a/agent/subagents/browser-agent/lib/autofill/tests/native-payment.test.ts b/agent/subagents/browser-agent/lib/autofill/tests/native-payment.test.ts new file mode 100644 index 00000000..92a7d664 --- /dev/null +++ b/agent/subagents/browser-agent/lib/autofill/tests/native-payment.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { fillWithKernelNativeAutofill } from "../native"; +import { frameOriginExpression } from "../login"; + +vi.mock("@onkernel/sdk", () => ({ + default: class { + browsers = { retrieve: async () => ({ cdp_ws_url: "wss://kernel.test" }) }; + }, +})); + +const commandSchema = z.object({ + id: z.number(), + method: z.string(), + params: z.record(z.string(), z.json()).optional(), + sessionId: z.string().optional(), +}); +const commands: z.infer[] = []; +let pageOrigin = "https://shop.example"; +let frameOrigin = "https://shop.example"; +let failFill = false; + +class BrowserSocket extends EventTarget { + constructor() { + super(); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + close() { + this.dispatchEvent(new Event("close")); + } + send(data: string) { + const command = commandSchema.parse(JSON.parse(data)); + commands.push(command); + let result = {}; + switch (command.method) { + case "Target.getTargets": + result = { + targetInfos: [ + { targetId: "page-1", type: "page", url: `${pageOrigin}/checkout` }, + ], + }; + break; + case "Target.attachToTarget": + result = { sessionId: "cdp-1" }; + break; + case "Page.getFrameTree": + result = { + frameTree: { + frame: { id: "frame-1", url: `${frameOrigin}/checkout` }, + }, + }; + break; + case "Page.createIsolatedWorld": + result = { executionContextId: 1 }; + break; + case "Runtime.evaluate": { + const expression = z.string().parse(command.params?.expression); + if (expression === frameOriginExpression) + result = { result: { value: frameOrigin } }; + else if (expression.includes("flatMap")) + result = { + result: { + value: [ + { autocomplete: "cc-number", focused: true, index: 0 }, + { autocomplete: "cc-csc", focused: false, index: 1 }, + ], + }, + }; + else if (expression.includes("vaultSecret")) + result = { result: { value: 2 } }; + else result = { result: { objectId: "input-1" } }; + break; + } + case "DOM.describeNode": + result = { node: { backendNodeId: 1 } }; + break; + } + queueMicrotask(() => + this.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + id: command.id, + result, + error: + command.method === "Autofill.trigger" && failFill + ? { message: "unknown fill outcome" } + : undefined, + }), + }) + ) + ); + } +} + +const input = { + browserSessionId: "browser-1", + expectedOrigin: "https://shop.example", + kind: "payment" as const, + claims: Object.entries({ + "cc-name": "Test Buyer", + "cc-number": "4242424242424242", + "cc-csc": "098", + "cc-exp-month": "12", + "cc-exp-year": "2035", + }).map(([token, value]) => ({ id: token, token, value })), +}; +beforeEach(() => { + commands.length = 0; + pageOrigin = "https://shop.example"; + frameOrigin = pageOrigin; + failFill = false; + vi.stubGlobal("WebSocket", BrowserSocket); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("native payment injection", () => { + it("marks controls before sending card fields through CDP and returns only a receipt", async () => { + const result = await fillWithKernelNativeAutofill(input); + const fillIndex = commands.findIndex( + ({ method }) => method === "Autofill.trigger" + ); + const maskIndex = commands.findIndex(({ params }) => + z.string().safeParse(params?.expression).data?.includes("vaultSecret") + ); + expect(maskIndex).toBeGreaterThan(-1); + expect(maskIndex).toBeLessThan(fillIndex); + expect(commands[fillIndex]?.params?.card).toEqual({ + name: "Test Buyer", + number: "4242424242424242", + cvc: "098", + expiryMonth: "12", + expiryYear: "2035", + }); + expect(result).toEqual({ filledClaims: 5, origin: "https://shop.example" }); + }); + it("rechecks the top-level merchant origin before injection", async () => { + pageOrigin = "https://other.example"; + await expect(fillWithKernelNativeAutofill(input)).rejects.toThrow( + "no longer matches" + ); + expect(commands.some(({ method }) => method === "Autofill.trigger")).toBe( + false + ); + }); + it("does not disclose cards to a cross-origin frame", async () => { + frameOrigin = "https://processor.example"; + await expect(fillWithKernelNativeAutofill(input)).rejects.toThrow( + "No visible form control" + ); + expect(commands.some(({ method }) => method === "Autofill.trigger")).toBe( + false + ); + }); + it("does not try another control after an uncertain payment fill", async () => { + failFill = true; + await expect(fillWithKernelNativeAutofill(input)).rejects.toThrow( + "unknown fill outcome" + ); + expect( + commands.filter(({ method }) => method === "Autofill.trigger") + ).toHaveLength(1); + }); +}); diff --git a/agent/subagents/browser-agent/tools/fill_from_link.ts b/agent/subagents/browser-agent/tools/fill_from_link.ts new file mode 100644 index 00000000..95c3b19f --- /dev/null +++ b/agent/subagents/browser-agent/tools/fill_from_link.ts @@ -0,0 +1,187 @@ +import { + Link, + LinkApiError, + type LinkOptions, + type SpendRequest, +} from "@stripe/link-sdk"; +import { createLinkTools } from "@stripe/link-sdk/tools"; +import { defineTool } from "eve/tools"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { linkAuth } from "@agent/lib/link-auth"; +import { requireWorkerScope } from "../lib/access"; +import { requireOwnedBrowserSession } from "../lib/owned-browser"; +import { + currentKernelPageOrigin, + fillWithKernelNativeAutofill, +} from "../lib/autofill/native"; + +const inputSchema = z.strictObject({ + browserSessionId: z.string().trim().min(1).max(500), + spendRequestId: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/u), + amount: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + currency: z.string().regex(/^[a-z]{3}$/u), +}); + +export default defineTool({ + description: + "Fill a standard card checkout with an approved Link spend request. Supply only its ID, an owned browser session, and the total amount in minor units and lowercase currency just observed at checkout. The tool retrieves credentials for the signed-in user's wallet, verifies approval and merchant origin, and fills them server-side. Never provide or read card details. Focus a card field first. This does not submit a purchase; verify the merchant and total again before submitting. Shared Payment Tokens, Link Pay Tokens, and recurring requests are unsupported here.", + inputSchema, + outputSchema: z.object({ + success: z.literal(true), + spendRequestId: z.string(), + origin: z.string(), + amount: z.number(), + currency: z.string(), + filledClaims: z.number().int().nonnegative(), + }), + async execute(input, context) { + const scope = await requireWorkerScope(context); + await requireOwnedBrowserSession(scope, input.browserSessionId); + // Eve resolves this provider against the current caller, never model input. + const { token: accessToken } = await context.getToken(linkAuth); + const client = new Link({ + accessToken, + fetch(resource, init) { + return fetch(resource, { + ...init, + cache: "no-store", + redirect: "error", + signal: AbortSignal.any([ + context.abortSignal, + AbortSignal.timeout(15_000), + ]), + }); + }, + } satisfies LinkOptions); + + let request: SpendRequest | null; + try { + request = await createLinkTools(client).retrieve_spend_request.execute( + { id: input.spendRequestId, include: ["card"] }, + undefined + ); + } catch (error) { + if (error instanceof LinkApiError && error.status === 401) { + context.requireAuth(linkAuth); + } + // oxlint-disable-next-line eslint/preserve-caught-error -- Link errors can contain credentials; never journal their cause. + throw new Error( + "Could not retrieve this Link request from your wallet. Check its status before retrying." + ); + } + if ( + !request || + request.id !== input.spendRequestId || + request.status !== "approved" + ) { + throw new Error( + "This Link request is not currently approved. Return its ID to the coordinator; do not fill or submit payment." + ); + } + if ( + request.credential_type !== "card" || + request.shared_payment_token || + request.link_pay_token || + request.recurring + ) { + throw new Error( + "This tool supports only one-time Link card requests for standard card forms." + ); + } + if ( + request.amount !== input.amount || + request.currency !== input.currency + ) { + throw new Error( + "The checkout total or currency differs from the approved Link request. Obtain approval for the current purchase before proceeding." + ); + } + const merchant = URL.parse(request.merchant_url ?? ""); + if ( + merchant?.protocol !== "https:" || + merchant.username || + merchant.password + ) { + throw new Error( + "The Link request has no valid HTTPS merchant URL. Ask the coordinator to correct the request." + ); + } + const origin = await currentKernelPageOrigin({ + browserSessionId: input.browserSessionId, + signal: context.abortSignal, + }); + if (origin !== merchant.origin) { + throw new Error( + "The active checkout does not match the approved Link merchant origin. Do not fill this page." + ); + } + + // Validate the fields required by Chromium without returning validation input. + const parsed = z + .object({ + number: z.string().regex(/^\d{12,19}$/u), + cvc: z.string().regex(/^\d{3,4}$/u), + exp_month: z.number().int().min(1).max(12), + exp_year: z.number().int().min(2000).max(2100), + billing_address: z.object({ name: z.string().trim().min(1) }), + valid_until: z.string().optional(), + }) + .safeParse(request.card); + if (!parsed.success) { + throw new Error( + "Link has not supplied complete card details. Return the request ID to the coordinator; never ask for card details in chat." + ); + } + const card = parsed.data; + const now = Date.now(); + const validUntil = + card.valid_until === undefined + ? undefined + : /^\d+(?:\.\d+)?$/u.test(card.valid_until) + ? Number(card.valid_until) * 1000 + : Date.parse(card.valid_until); + if ( + Date.UTC(card.exp_year, card.exp_month) <= now || + (validUntil !== undefined && + (!Number.isFinite(validUntil) || validUntil <= now)) || + (request.expires_at !== undefined && request.expires_at * 1000 <= now) + ) { + throw new Error( + "The Link payment credential or request has expired. Do not fill or submit payment." + ); + } + const claims = ( + [ + ["cc-name", card.billing_address.name], + ["cc-number", card.number], + ["cc-exp-month", String(card.exp_month).padStart(2, "0")], + ["cc-exp-year", String(card.exp_year)], + ["cc-csc", card.cvc], + ] as const + ).map(([token, value]) => ({ id: randomUUID(), token, value })); + + try { + // The existing injector rechecks the origin and masks filled card fields. + const result = await fillWithKernelNativeAutofill({ + browserSessionId: input.browserSessionId, + claims, + expectedOrigin: origin, + kind: "payment", + signal: context.abortSignal, + }); + return { + success: true as const, + spendRequestId: input.spendRequestId, + origin: result.origin, + amount: input.amount, + currency: input.currency, + filledClaims: result.filledClaims, + }; + } catch { + throw new Error( + "Link card autofill could not be confirmed. Check the current checkout without reading payment fields; do not submit or retry blindly." + ); + } + }, +}); diff --git a/agent/tools/link__retrieve_spend_request.ts b/agent/tools/link__retrieve_spend_request.ts new file mode 100644 index 00000000..d0dde912 --- /dev/null +++ b/agent/tools/link__retrieve_spend_request.ts @@ -0,0 +1,42 @@ +import { retrieve_spend_request } from "@stripe/link-integrations-eve/tools"; +import { linkToolSchemas } from "@stripe/link-sdk/tools"; +import { defineTool } from "eve/tools"; + +// Eve's qualified-name override retains the mounted extension's authorization. +export default defineTool({ + description: + "Check a Link spend request's approval status and purchase details without retrieving payment credentials. Pass the approved request ID to browser-agent's fill_from_link for a standard card checkout. Credential expansion is intentionally unavailable to the coordinator.", + inputSchema: linkToolSchemas.retrieveSpendRequest.omit({ include: true }), + async execute({ id }, context) { + const request = await retrieve_spend_request.execute({ id }, context); + if (!request) return null; + if (Symbol.asyncIterator in request) { + throw new Error("Unexpected streaming Link response."); + } + const action = request.status_details?.requires_action?.next_action; + return { + id: request.id, + status: request.status, + amount: request.amount ?? null, + currency: request.currency ?? null, + merchant_name: request.merchant_name ?? null, + merchant_url: request.merchant_url ?? null, + approval_url: request.approval_url ?? null, + activity_url: request.activity_url ?? null, + expires_at: request.expires_at ?? null, + status_details: action + ? { + requires_action: { + next_action: { + type: action.type, + resolution: action.resolution, + display_message: action.display_message, + action_url: action.action_url, + expires_at: action.expires_at ?? null, + }, + }, + } + : null, + }; + }, +}); diff --git a/package.json b/package.json index 5692e774..d2f7b217 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@streamdown/mermaid": "1.0.2", "@stripe/link-integrations-better-auth": "0.2.0", "@stripe/link-integrations-eve": "0.2.4", + "@stripe/link-sdk": "0.11.0", "@t3-oss/env-nextjs": "0.13.11", "@tailwindcss/postcss": "4.3.3", "@tanstack/react-query": "^5.102.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eff34611..75eadfbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@stripe/link-integrations-eve': specifier: 0.2.4 version: 0.2.4(eve@0.66.3(patch_hash=b862c976047aef5a3b1a514c2eaf1ae1820d93d46792c0081e9a33f58cd02027)(@opentelemetry/api@1.9.1)(ai@7.0.120(zod@4.5.4))(chat@4.41.0(ai@7.0.120(zod@4.5.4))(zod@4.5.4))) + '@stripe/link-sdk': + specifier: 0.11.0 + version: 0.11.0 '@t3-oss/env-nextjs': specifier: 0.13.11 version: 0.13.11(typescript@6.0.3)(zod@4.5.4) diff --git a/tests/agent-tool-boundaries.test.ts b/tests/agent-tool-boundaries.test.ts index 929cd78e..3f0d86c3 100644 --- a/tests/agent-tool-boundaries.test.ts +++ b/tests/agent-tool-boundaries.test.ts @@ -25,6 +25,7 @@ describe("root and worker capability boundaries", () => { "calendar.ts", "contacts.ts", "gmail.ts", + "link__retrieve_spend_request.ts", "messaging.ts", "run_browser.ts", "schedules.ts", @@ -63,6 +64,7 @@ describe("root and worker capability boundaries", () => { "bash.ts", "capture_browser_image.ts", "computer_action.ts", + "fill_from_link.ts", "fill_from_vault.ts", "list_vault.ts", "load_skill.ts", diff --git a/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts b/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts new file mode 100644 index 00000000..b7824e26 --- /dev/null +++ b/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SpendRequest } from "@stripe/link-sdk"; +import * as WorkerAccess from "@agent/subagents/browser-agent/lib/access"; +import * as OwnedBrowser from "@agent/subagents/browser-agent/lib/owned-browser"; +import * as Autofill from "@agent/subagents/browser-agent/lib/autofill/native"; +import fillFromLink from "@agent/subagents/browser-agent/tools/fill_from_link"; +import { linkAuth } from "@agent/lib/link-auth"; +import { toolContextFor } from "@tests/helpers/tool-context"; + +const fetchMock = vi.fn(); +const scope = { userId: "user-1", workspaceId: "workspace-1" }; +const input = { + browserSessionId: "browser-1", + spendRequestId: "spr_123", + amount: 2306, + currency: "usd", +}; +const context = toolContextFor({ parentSessionId: "root-1" }); +const access = vi.spyOn(WorkerAccess, "requireWorkerScope"); +const owned = vi.spyOn(OwnedBrowser, "requireOwnedBrowserSession"); +const origin = vi.spyOn(Autofill, "currentKernelPageOrigin"); +const fill = vi.spyOn(Autofill, "fillWithKernelNativeAutofill"); +const token = vi.spyOn(context, "getToken"); +const requireAuth = vi.spyOn(context, "requireAuth"); + +function approvedRequest(): SpendRequest { + return { + id: input.spendRequestId, + status: "approved", + amount: 2306, + currency: "usd", + credential_type: "card", + merchant_url: "https://shop.example/checkout", + created_at: "2026-10-01T00:00:00Z", + updated_at: "2026-10-01T00:00:00Z", + expires_at: 2082758400, + card: { + id: "card_123", + brand: "visa", + number: "4242424242424242", + cvc: "098", + exp_month: 12, + exp_year: 2035, + valid_until: "2035-12-01T00:00:00Z", + billing_address: { + name: "Test Buyer", + line1: "1 Test St", + country: "US", + }, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + access.mockResolvedValue(scope); + owned.mockResolvedValue({ + createdAt: "2026-10-01T00:00:00Z", + sessionId: "browser-1", + workerSessionId: "worker-1", + }); + token.mockResolvedValue({ token: "wallet-token-user-1" }); + origin.mockResolvedValue("https://shop.example"); + fill.mockResolvedValue({ filledClaims: 5, origin: "https://shop.example" }); + fetchMock.mockImplementation(async () => Response.json(approvedRequest())); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("Link browser bridge", () => { + it("uses the real SDK to retrieve a scoped card and sends secrets only to the injector", async () => { + const output = await fillFromLink.execute(input, context); + expect(token).toHaveBeenCalledWith(linkAuth); + expect(owned).toHaveBeenCalledWith(scope, input.browserSessionId); + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error("Missing SDK request"); + const [resource, options] = call; + expect(resource).toEqual( + expect.stringContaining("/spend_requests/spr_123?include=card") + ); + expect(new Headers(options?.headers).get("Authorization")).toBe( + "Bearer wallet-token-user-1" + ); + expect(options).toMatchObject({ + method: "GET", + redirect: "error", + cache: "no-store", + }); + const args = fill.mock.calls[0]?.[0]; + if (!args) throw new Error("Missing injector call"); + expect(Autofill.buildNativeAutofillPayload("payment", args.claims)).toEqual( + { + card: { + name: "Test Buyer", + number: "4242424242424242", + cvc: "098", + expiryMonth: "12", + expiryYear: "2035", + }, + } + ); + expect(args.expectedOrigin).toBe("https://shop.example"); + expect(output).toEqual({ + success: true, + spendRequestId: "spr_123", + origin: "https://shop.example", + amount: 2306, + currency: "usd", + filledClaims: 5, + }); + expect(JSON.stringify(output)).not.toMatch( + /4242424242424242|098|wallet-token|Test Buyer/u + ); + }); + + it.each([ + "pending_approval", + "requires_action", + "denied", + "expired", + "submitted", + "succeeded", + "canceled", + "future_status", + ])("rejects %s requests", async (status) => { + fetchMock.mockResolvedValue( + Response.json({ ...approvedRequest(), status }) + ); + await expect(fillFromLink.execute(input, context)).rejects.toThrow( + "not currently approved" + ); + expect(fill).not.toHaveBeenCalled(); + }); + + it.each([ + { id: "other_request" }, + { amount: 1 }, + { currency: "eur" }, + { merchant_url: "http://shop.example/checkout" }, + { merchant_url: "https://attacker.example" }, + { merchant_url: "https://user:pass@shop.example" }, + { merchant_url: undefined }, + { credential_type: "shared_payment_token" }, + { shared_payment_token: { id: "spt_secret" } }, + { link_pay_token: "lpt_secret" }, + { recurring: { interval: "month", interval_count: 1 } }, + { expires_at: 1 }, + { card: undefined }, + ])( + "rejects mismatched, expired, or unsupported requests: %j", + async (change) => { + fetchMock.mockResolvedValue( + Response.json({ ...approvedRequest(), ...change }) + ); + await expect(fillFromLink.execute(input, context)).rejects.toThrow( + /Link|checkout/u + ); + expect(fill).not.toHaveBeenCalled(); + } + ); + + it.each([ + { cvc: undefined }, + { number: "invalid" }, + { exp_year: 2020 }, + { valid_until: "invalid" }, + { valid_until: "1" }, + { billing_address: undefined }, + ])("rejects unusable card data: %j", async (change) => { + const request = approvedRequest(); + fetchMock.mockResolvedValue( + Response.json({ ...request, card: { ...request.card, ...change } }) + ); + await expect(fillFromLink.execute(input, context)).rejects.toThrow( + /Link|checkout/u + ); + expect(fill).not.toHaveBeenCalled(); + }); + + it("handles a request absent from this wallet", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 404 })); + await expect(fillFromLink.execute(input, context)).rejects.toThrow( + "not currently approved" + ); + expect(fill).not.toHaveBeenCalled(); + }); + + it.each([access, owned])( + "rejects unowned sessions before wallet access", + async (check) => { + check.mockRejectedValueOnce(new Error("not owned")); + await expect(fillFromLink.execute(input, context)).rejects.toThrow( + "not owned" + ); + expect(token).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(fill).not.toHaveBeenCalled(); + } + ); + + it("preserves Eve authorization suspension", async () => { + const challenge = new Error("connect-wallet"); + token.mockRejectedValueOnce(challenge); + await expect(fillFromLink.execute(input, context)).rejects.toBe(challenge); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("requests reauthorization on 401 without exposing the response", async () => { + fetchMock.mockResolvedValue( + Response.json({ error: "secret-response" }, { status: 401 }) + ); + requireAuth.mockImplementationOnce(() => { + throw new Error("reauthorize"); + }); + await expect(fillFromLink.execute(input, context)).rejects.toThrow( + "reauthorize" + ); + expect(requireAuth).toHaveBeenCalledWith(linkAuth); + expect(fill).not.toHaveBeenCalled(); + }); + + it("redacts SDK error bodies and causes", async () => { + fetchMock.mockResolvedValue( + Response.json({ error: "4242424242424242" }, { status: 500 }) + ); + const result = fillFromLink.execute(input, context); + await expect(result).rejects.toThrow("Could not retrieve"); + await expect(result).rejects.not.toHaveProperty("cause"); + expect(fill).not.toHaveBeenCalled(); + }); + + it("does not return or retry an ambiguous injector error", async () => { + fill.mockRejectedValueOnce(new Error("4242424242424242")); + const result = fillFromLink.execute(input, context); + await expect(result).rejects.toThrow("could not be confirmed"); + await expect(result).rejects.not.toHaveProperty("cause"); + expect(fill).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/agent/tools/link-retrieve-spend-request.test.ts b/tests/agent/tools/link-retrieve-spend-request.test.ts new file mode 100644 index 00000000..14d2f097 --- /dev/null +++ b/tests/agent/tools/link-retrieve-spend-request.test.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { retrieve_spend_request } from "@stripe/link-integrations-eve/tools"; +import retrieve from "@agent/tools/link__retrieve_spend_request"; +import { toolContextFor } from "@tests/helpers/tool-context"; + +const execute = vi.spyOn(retrieve_spend_request, "execute"); +beforeEach(() => vi.clearAllMocks()); + +describe("Link status-only override", () => { + it("delegates to the official extension without requesting credentials and projects safe fields", async () => { + execute.mockResolvedValue({ + id: "spr_1", + status: "approved", + created_at: "2026-10-01", + updated_at: "2026-10-01", + amount: 2306, + currency: "usd", + merchant_url: "https://shop.example", + approval_url: "https://link.com/approve", + card: { + id: "card_1", + brand: "visa", + number: "4242424242424242", + cvc: "098", + exp_month: 12, + exp_year: 2035, + }, + link_pay_token: "lpt_secret", + shared_payment_token: { id: "spt_secret" }, + }); + const context = toolContextFor(); + const output = await retrieve.execute({ id: "spr_1" }, context); + expect(execute).toHaveBeenCalledExactlyOnceWith({ id: "spr_1" }, context); + expect(output).toMatchObject({ + id: "spr_1", + status: "approved", + amount: 2306, + currency: "usd", + }); + expect(JSON.stringify(output)).not.toMatch( + /4242424242424242|098|lpt_secret|spt_secret/u + ); + if (!(retrieve.inputSchema instanceof z.ZodType)) + throw new Error("Expected Zod schema"); + expect( + retrieve.inputSchema.safeParse({ id: "spr_1", include: ["card"] }).success + ).toBe(false); + }); + + it("returns null for a missing request", async () => { + execute.mockResolvedValue(null); + await expect( + retrieve.execute({ id: "missing" }, toolContextFor()) + ).resolves.toBeNull(); + }); +});