From e3de9e139de0312581d9788ae8fefb7b86c00e3a Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 12:00:56 -0500 Subject: [PATCH 01/19] feat(eve): add Resend Marketplace domain setup Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 119 ++++++++ .../setup/integrations/resend/marketplace.ts | 198 +++++++++++++ .../setup/integrations/resend/setup.test.ts | 69 ++++- .../src/setup/integrations/resend/setup.ts | 165 ++++++++++- research/resend-marketplace-domain-setup.md | 278 ++++++++++++++++++ 5 files changed, 824 insertions(+), 5 deletions(-) create mode 100644 packages/eve/src/setup/integrations/resend/marketplace.test.ts create mode 100644 packages/eve/src/setup/integrations/resend/marketplace.ts create mode 100644 research/resend-marketplace-domain-setup.md diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts new file mode 100644 index 0000000000..0ce0905e7e --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import { + connectResendMarketplaceResource, + listResendMarketplaceResources, + listVercelDomains, + provisionResendMarketplaceResource, + type ResendMarketplaceDeps, +} from "./marketplace.js"; + +function capture(stdout: unknown): ResendMarketplaceDeps["captureVercel"] { + return vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify(stdout), + })); +} + +describe("Resend Marketplace", () => { + it("lists only Resend Marketplace resources", async () => { + const captureVercel = capture({ + stores: [ + { + id: "store_resend", + externalResourceId: "example.com", + name: "resend-agent", + product: { slug: "resend-email", integrationConfigurationId: "icfg_resend" }, + }, + { + id: "store_other", + externalResourceId: "db-1", + name: "database", + product: { slug: "postgres" }, + }, + ], + }); + + await expect( + listResendMarketplaceResources({ + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel }, + }), + ).resolves.toEqual([ + expect.objectContaining({ id: "store_resend", externalResourceId: "example.com" }), + ]); + }); + + it("lists Vercel-owned domains", async () => { + const captureVercel = capture({ + domains: [{ name: "example.com" }, { name: "another.example" }], + }); + + await expect( + listVercelDomains({ + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel }, + }), + ).resolves.toEqual(["example.com", "another.example"]); + }); + + it("provisions Resend with domain metadata and production connection", async () => { + const runVercelCaptureStdout = vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify({ + resource: { + id: "store_resend", + name: "resend-agent", + externalResourceId: "example.com", + }, + installation: { id: "icfg_resend" }, + }), + })); + + await provisionResendMarketplaceResource({ + domain: "example.com", + log: createFakePrompter().prompter.log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { runVercelCaptureStdout }, + }); + + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "integration", + "add", + "resend", + "--metadata", + "domain=example.com", + "--metadata", + "region=us-east-1", + "--environment", + "production", + "--json", + "--scope", + "team", + ], + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("does not reconnect a resource already attached to the linked project", async () => { + const runVercelCaptureStdout = vi.fn(); + await connectResendMarketplaceResource({ + resource: { + id: "store_resend", + externalResourceId: "example.com", + name: "resend-agent", + projectsMetadata: [{ projectId: "project", environments: ["production"] }], + }, + log: createFakePrompter().prompter.log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { runVercelCaptureStdout }, + }); + expect(runVercelCaptureStdout).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts new file mode 100644 index 0000000000..59885aeacb --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -0,0 +1,198 @@ +import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; +import type { VercelProjectReference } from "#setup/project-resolution.js"; +import { captureVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +const ResourceSchema = z.object({ + id: z.string().min(1), + externalResourceId: z.string().min(1), + name: z.string().min(1), + status: z.string().nullish(), + product: z + .object({ + slug: z.string().optional(), + integrationConfigurationId: z.string().optional(), + }) + .optional(), + projectsMetadata: z + .array( + z.object({ + projectId: z.string(), + environments: z.array(z.string()).optional(), + }), + ) + .optional(), +}); +const ResourceListSchema = z.object({ stores: z.array(ResourceSchema) }); +const DomainListSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1) })) }); +const ProvisionedSchema = z.object({ + resource: z.object({ + id: z.string().min(1), + name: z.string().min(1), + externalResourceId: z.string().min(1), + }), + installation: z.object({ id: z.string().min(1) }), + dashboardUrl: z.string().url().optional(), +}); + +/** Resend Marketplace resource visible to the current Vercel team. */ +export type ResendMarketplaceResource = z.infer; + +export interface ResendMarketplaceDeps { + captureVercel: typeof captureVercel; + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +const defaultDeps: ResendMarketplaceDeps = { captureVercel, runVercelCaptureStdout }; + +/** Lists existing Resend Marketplace resources without reading their secrets. */ +export async function listResendMarketplaceResources(input: { + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await deps.captureVercel( + ["api", "/v1/storage/stores", "--scope", input.project.orgId], + { cwd: input.projectRoot, signal: input.signal }, + ); + if (!result.ok) throw new Error("Could not inspect Vercel Marketplace resources."); + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + throw new Error("Vercel returned invalid JSON for Marketplace resources."); + } + const parsed = ResourceListSchema.safeParse(body); + if (!parsed.success) throw new Error("Vercel returned an invalid Marketplace resource list."); + return parsed.data.stores.filter( + (resource) => resource.product?.slug === "resend-email" || resource.product?.slug === "resend", + ); +} + +/** Lists domains owned by the linked Vercel team. */ +export async function listVercelDomains(input: { + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await deps.captureVercel( + ["domains", "list", "--json", "--limit", "100", "--scope", input.project.orgId], + { cwd: input.projectRoot, signal: input.signal }, + ); + if (!result.ok) throw new Error("Could not inspect Vercel domains."); + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + throw new Error("Vercel returned invalid JSON for the domain list."); + } + const parsed = DomainListSchema.safeParse(body); + if (!parsed.success) throw new Error("Vercel returned an invalid domain list."); + return parsed.data.domains.map((domain) => domain.name); +} + +/** Provisions and connects a Resend Marketplace resource through Vercel CLI. */ +export async function provisionResendMarketplaceResource(input: { + domain: string; + log: ChannelSetupLog; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await withPhase(input.log, "Setting up Resend in Vercel Marketplace...", () => + deps.runVercelCaptureStdout( + [ + "integration", + "add", + "resend", + "--metadata", + `domain=${input.domain}`, + "--metadata", + "region=us-east-1", + "--environment", + "production", + "--json", + "--scope", + input.project.orgId, + ], + { + cwd: input.projectRoot, + onOutput: createPromptCommandOutput(input.log), + signal: input.signal, + }, + ), + ); + if (!result.ok) { + throw new Error( + "Resend Marketplace setup was not completed. Finish the browser flow, then rerun `eve add channel/resend`.", + ); + } + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + throw new Error("Vercel returned invalid JSON after Resend Marketplace setup."); + } + const parsed = ProvisionedSchema.safeParse(body); + if (!parsed.success) throw new Error("Vercel returned an invalid Resend Marketplace result."); + return { + id: parsed.data.resource.id, + externalResourceId: parsed.data.resource.externalResourceId, + name: parsed.data.resource.name, + product: { + slug: "resend-email", + integrationConfigurationId: parsed.data.installation.id, + }, + projectsMetadata: [{ projectId: input.project.projectId, environments: ["production"] }], + }; +} + +/** Connects an existing Marketplace resource to the linked project for production. */ +export async function connectResendMarketplaceResource(input: { + resource: ResendMarketplaceResource; + log: ChannelSetupLog; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + if ( + input.resource.projectsMetadata?.some((entry) => entry.projectId === input.project.projectId) + ) { + return; + } + const deps = input.deps ?? defaultDeps; + const result = await withPhase(input.log, "Connecting Resend to this project...", () => + deps.runVercelCaptureStdout( + [ + "integration", + "resource", + "connect", + input.resource.name, + "--environment", + "production", + "--yes", + "--json", + "--scope", + input.project.orgId, + ], + { + cwd: input.projectRoot, + nonInteractive: true, + onOutput: createPromptCommandOutput(input.log), + signal: input.signal, + }, + ), + ); + if (!result.ok) { + throw new Error( + `Could not connect Marketplace resource ${input.resource.name}. Run \`vercel integration resource connect ${input.resource.name} --environment production --yes\`.`, + ); + } +} diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index 36b9e313e6..dce48504a5 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -30,10 +30,21 @@ function deps(): ResendSetupDeps { deriveConnectorSlug: vi.fn(async () => "agent" as never), ensureVercelProject: vi.fn(async () => ({ orgId: "team", projectId: "project" })), listWebhooks: vi.fn(async () => []), + listMarketplaceResources: vi.fn(async () => []), + listDomains: vi.fn(async () => ["example.com"]), + openUrl: vi.fn(), provisionConnector: vi.fn(async () => ({ id: "scl_resend", uid: "api-key/resend-agent", })), + provisionMarketplaceResource: vi.fn(async (input) => ({ + id: "store_resend", + externalResourceId: input.domain, + name: "resend-agent", + product: { slug: "resend-email", integrationConfigurationId: "icfg_resend" }, + projectsMetadata: [{ projectId: "project", environments: ["production"] }], + })), + connectMarketplaceResource: vi.fn(async () => {}), runVercel: vi.fn(async () => true), suggestFromAddress: vi.fn(async () => "eve@example.com"), validateApiKey: vi.fn(async () => {}), @@ -41,7 +52,10 @@ function deps(): ResendSetupDeps { }; } -function context(effects: ResendSetupDeps, select: "connect" | "portable" = "connect") { +function context( + effects: ResendSetupDeps, + select: "marketplace" | "connect" | "portable" = "connect", +) { const fake = createFakePrompter({ single: () => select }); return { effects, @@ -61,6 +75,59 @@ function context(effects: ResendSetupDeps, select: "connect" | "portable" = "con } describe("Resend setup", () => { + it("provisions a Marketplace resource from an existing Vercel domain", async () => { + const effects = deps(); + const setup = context(effects, "marketplace"); + + await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); + expect(effects.provisionMarketplaceResource).toHaveBeenCalledWith( + expect.objectContaining({ domain: "example.com" }), + ); + expect(effects.connectMarketplaceResource).toHaveBeenCalledWith( + expect.objectContaining({ resource: expect.objectContaining({ id: "store_resend" }) }), + ); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/resend.ts", + expect.stringContaining("process.env.RESEND_API_KEY"), + { force: undefined }, + ); + }); + + it("reuses an existing Marketplace resource", async () => { + const effects = deps(); + vi.mocked(effects.listMarketplaceResources).mockResolvedValue([ + { + id: "store_existing", + externalResourceId: "mail.example.com", + name: "resend-existing", + product: { slug: "resend-email", integrationConfigurationId: "icfg_existing" }, + projectsMetadata: [{ projectId: "project", environments: ["production"] }], + }, + ]); + const setup = context(effects, "marketplace"); + + await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); + expect(effects.provisionMarketplaceResource).not.toHaveBeenCalled(); + expect(effects.connectMarketplaceResource).toHaveBeenCalledWith( + expect.objectContaining({ resource: expect.objectContaining({ id: "store_existing" }) }), + ); + }); + + it("hands domain setup off to Vercel web when the team has no domain", async () => { + const effects = deps(); + vi.mocked(effects.listDomains).mockResolvedValue([]); + const setup = context(effects, "marketplace"); + + await expect(setupResend(setup.value, effects)).resolves.toEqual({ kind: "cancelled" }); + expect(effects.openUrl).toHaveBeenCalledWith("https://vercel.com/domains"); + expect(setup.value.ui.prompter.note).toHaveBeenCalledWith( + expect.stringContaining("rerun `eve add channel/resend`"), + "Vercel domain required", + { tone: "warning" }, + ); + expect(effects.provisionMarketplaceResource).not.toHaveBeenCalled(); + }); + it("prefills the agent address from a send-and-receive custom Resend domain", async () => { const effects = deps(); const questions: Question[] = []; diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 498c94c7e1..e410f52526 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -6,6 +6,7 @@ import { ensureVercelProject } from "#setup/flows/ensure-vercel-project.js"; import { runDeployFlow } from "#setup/flows/deploy.js"; import { deriveSlackConnectorSlug } from "#setup/scaffold/index.js"; import { writeTextFile } from "#setup/scaffold/files.js"; +import { openUrl } from "#setup/primitives/open-url.js"; import { runVercel } from "#setup/primitives/run-vercel.js"; import { WizardCancelledError } from "#setup/step.js"; @@ -23,6 +24,13 @@ import { validateResendApiKey, } from "./api.js"; import { provisionResendConnector } from "./connect.js"; +import { + connectResendMarketplaceResource, + listResendMarketplaceResources, + listVercelDomains, + provisionResendMarketplaceResource, + type ResendMarketplaceResource, +} from "./marketplace.js"; export interface ResendSetupDeps { appendEnv: typeof appendEnv; @@ -32,7 +40,12 @@ export interface ResendSetupDeps { deriveConnectorSlug: typeof deriveSlackConnectorSlug; ensureVercelProject: typeof ensureVercelProject; listWebhooks: typeof listResendWebhooks; + listMarketplaceResources: typeof listResendMarketplaceResources; + listDomains: typeof listVercelDomains; + openUrl: typeof openUrl; provisionConnector: typeof provisionResendConnector; + provisionMarketplaceResource: typeof provisionResendMarketplaceResource; + connectMarketplaceResource: typeof connectResendMarketplaceResource; runVercel: typeof runVercel; suggestFromAddress: typeof suggestResendFromAddress; validateApiKey: typeof validateResendApiKey; @@ -47,7 +60,12 @@ const defaultDeps: ResendSetupDeps = { deriveConnectorSlug: deriveSlackConnectorSlug, ensureVercelProject, listWebhooks: listResendWebhooks, + listMarketplaceResources: listResendMarketplaceResources, + listDomains: listVercelDomains, + openUrl, provisionConnector: provisionResendConnector, + provisionMarketplaceResource: provisionResendMarketplaceResource, + connectMarketplaceResource: connectResendMarketplaceResource, runVercel, suggestFromAddress: suggestResendFromAddress, validateApiKey: validateResendApiKey, @@ -104,11 +122,16 @@ export default channel; async function chooseDestination( context: IntegrationSetupContext, -): Promise<"connect" | "portable"> { - if (context.yes) return "connect"; - return context.ui.prompter.select<"connect" | "portable">({ +): Promise<"marketplace" | "connect" | "portable"> { + if (context.yes) return "marketplace"; + return context.ui.prompter.select<"marketplace" | "connect" | "portable">({ message: "How would you like to configure Resend?", options: [ + { + value: "marketplace", + label: "Set up with Vercel Marketplace", + hint: "Create or reuse a Resend domain and project credential", + }, { value: "connect", label: "Set up Vercel Connect", @@ -120,10 +143,143 @@ async function chooseDestination( hint: "Store the API key locally and configure the webhook manually", }, ], - initialValue: context.environment.vercel.kind === "available" ? "connect" : "portable", + initialValue: context.environment.vercel.kind === "available" ? "marketplace" : "portable", + }); +} + +function marketplaceDomain(resource: ResendMarketplaceResource): string | undefined { + const domain = resource.externalResourceId.trim().toLowerCase(); + return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/u.test(domain) ? domain : undefined; +} + +async function chooseMarketplaceResource( + context: IntegrationSetupContext, + resources: readonly ResendMarketplaceResource[], +): Promise { + if (resources.length === 0) return "create"; + if (resources.length === 1 || context.yes) return resources[0]!; + const selected = await context.ui.prompter.select({ + message: "Resend Marketplace resource", + options: [ + ...resources.map((resource) => ({ + value: resource.id, + label: marketplaceDomain(resource) ?? resource.name, + hint: resource.status ?? "Existing Resend resource", + })), + { value: "create", label: "Configure another Vercel domain" }, + ], + initialValue: resources[0]?.id, + }); + return selected === "create" + ? "create" + : (resources.find((resource) => resource.id === selected) ?? "create"); +} + +async function selectMarketplaceDomain( + context: IntegrationSetupContext, + deps: ResendSetupDeps, + project: Awaited>, +): Promise { + const domains = await deps.listDomains({ + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + if (domains.length === 0) { + const url = project.orgId.startsWith("team_") + ? `https://vercel.com/teams/${encodeURIComponent(project.orgId)}/domains` + : "https://vercel.com/domains"; + context.ui.prompter.note( + `Add or purchase a domain in Vercel, then rerun \`eve add channel/resend\`.\n${url}`, + "Vercel domain required", + { tone: "warning" }, + ); + deps.openUrl(url); + return "cancelled"; + } + if (domains.length === 1 || context.yes) return domains[0]!; + return context.ui.prompter.select({ + message: "Domain for Resend", + description: "Resend will open Vercel web to confirm account, billing, and DNS setup.", + options: domains.map((domain) => ({ value: domain, label: domain })), + initialValue: domains[0], }); } +async function setupMarketplace( + context: IntegrationSetupContext, + deps: ResendSetupDeps, +): Promise { + if (context.environment.vercel.kind === "unavailable") { + throw new Error( + "Vercel Marketplace requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", + ); + } + const project = await deps.ensureVercelProject({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + const resources = await deps.listMarketplaceResources({ + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + let resource = await chooseMarketplaceResource(context, resources); + if (resource === "create") { + const domain = await selectMarketplaceDomain(context, deps, project); + if (domain === "cancelled") return { kind: "cancelled" }; + resource = await deps.provisionMarketplaceResource({ + domain, + log: context.ui.prompter.log, + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + } + await deps.connectMarketplaceResource({ + resource, + log: context.ui.prompter.log, + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + const domain = marketplaceDomain(resource); + const fromAddressQuestion = + domain === undefined + ? text({ + key: "resend-from-address", + message: "Agent email address", + required: true, + validate: validateEmail, + }) + : text({ + key: "resend-from-address", + message: "Agent email address", + detected: `eve@${domain}`, + required: true, + validate: validateEmail, + }); + const fromAddress = await context.ui.asker.ask(fromAddressQuestion); + const fromName = await context.ui.asker.ask( + text({ key: "resend-from-name", message: "From name (optional)", required: false }), + ); + await deps.writeTextFile( + join(context.appRoot, "agent/channels/resend.ts"), + channelTemplate({ apiKey: "", fromAddress: fromAddress.trim(), fromName: fromName.trim() }), + { force: context.force }, + ); + context.ui.nextSteps([ + "Complete Resend onboarding and Auto configure DNS in the browser if prompted.", + "Enable receiving for the selected domain, then create an email.received webhook for https:///eve/v1/resend and save its signing secret as RESEND_WEBHOOK_SECRET.", + "Deploy the agent after the webhook secret is configured.", + ]); + return { + kind: "done", + facts: domain === undefined ? undefined : [{ label: "Resend domain", value: domain }], + }; +} + async function writeProductionSecret( context: IntegrationSetupContext, secret: string, @@ -148,6 +304,7 @@ export async function setupResend( ): Promise { try { const destination = await chooseDestination(context); + if (destination === "marketplace") return await setupMarketplace(context, deps); if (destination === "connect" && context.environment.vercel.kind === "unavailable") { throw new Error( "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", diff --git a/research/resend-marketplace-domain-setup.md b/research/resend-marketplace-domain-setup.md new file mode 100644 index 0000000000..a3104c72d5 --- /dev/null +++ b/research/resend-marketplace-domain-setup.md @@ -0,0 +1,278 @@ +--- +issue: TBD +status: proposed +last_updated: "2026-08-04" +--- + +# Resend Marketplace domain setup + +## Recommendation + +Make Vercel Marketplace the preferred domain-provisioning path for `eve add +channel/resend`. Reuse the existing Resend Marketplace product and signed Domain +Connect flow instead of having eve mutate DNS records directly. + +Keep manual Vercel Connect API-key and portable environment-variable paths for +existing Resend accounts and domains outside Marketplace. + +The flow should converge three starting states on one ready configuration: + +```text +A. Resend resource + domain exist ──> select/reuse resource ─────────────┐ + │ +B. Vercel domain exists ────────────> provision Resend resource ────────┤ + │ +C. No domain exists ────────────────> Vercel web domain setup ─────────┤ + └─> resume Resend provisioning ──┘ + │ + signed Domain Connect <───────────┘ + │ + v + verify sending + receiving + │ + v + scaffold eve@ + deploy + webhook +``` + +The outcome should be the same whether the user starts with no email setup, a +Vercel-owned domain, or an existing Resend Marketplace resource. + +## Existing platform capabilities + +The published Marketplace integration uses integration slug `resend` and product +slug `resend-email`. Provisioning requires a domain and region: + +```ts +{ + domain: string; + region: "us-east-1" | "eu-west-1" | "sa-east-1" | "ap-northeast-1"; +} +``` + +Vercel CLI can provision and connect the resource: + +```sh +vercel integration add resend \ + --metadata domain=example.com \ + --metadata region=us-east-1 \ + --environment production \ + --json +``` + +The flow can install the integration, accept terms or hand off to a browser, +provision the resource, connect it to the linked project, synchronize +`RESEND_API_KEY`, and return resource and installation identifiers. The product +advertises project connections, SSO, secret synchronization, and immediate +secret rotation. + +Front already owns Resend Domain Connect templates for sending, receiving, +combined sending and receiving, verification, and click tracking. The combined +`resend.com.mail-send-and-receive` template includes DKIM, outbound MX/SPF, +inbound MX, tracking CNAME, and CAA records. + +## Proposed guided flow + +The Resend setup destination picker should lead with Marketplace: + +```text +How would you like to configure Resend? + +● Set up with Vercel Marketplace + Create or select a Resend account, domain, and project credential + +○ Use an existing Resend API key + Store a full-access key in Vercel Connect + +○ Use portable credentials +``` + +For the Marketplace path: + +1. Require authenticated Vercel CLI access and ensure the directory is linked + to a project. +2. Inventory Resend Marketplace resources and Vercel-owned domains for the + linked team and project using existing Vercel CLI/API surfaces. +3. Classify the starting state and follow the matching path below. Keep eve's + orchestration thin: select or reuse resources locally, and hand off account, + domain, billing, terms, and DNS workflows to Vercel/Resend web surfaces. +4. Converge on one selected Resend resource and configured domain. +5. Open or print the Resend onboarding URL when browser completion is required. + Explain that **Auto configure** applies the provider-signed DNS setup. +6. Poll or re-check resource/domain status after the user completes onboarding. +7. Require both sending and receiving to be enabled before using the domain for + automatic replies. If only sending is configured, direct the user to enable + receiving rather than mutating inbound MX records from eve. +8. Connect the resource to the linked project for production if it is not + already connected, preserving Marketplace ownership of `RESEND_API_KEY`. +9. Prefill `eve@` as the editable agent email address. +10. Continue with channel scaffolding, deployment, webhook setup, and a + send/reply smoke test. + +### A. Existing Resend resource and domain + +List compatible `resend-email` resources, showing their configured domain, +readiness, and whether they are connected to the linked project. Prefer an exact +project connection, then a send-and-receive-ready resource on the same team. +Let the user select when more than one remains. + +Reuse the resource and its domain. Do not reprovision Resend, rotate its key, or +reapply DNS when the resource is already ready. If it is not connected to the +project, add only the production project connection. If its domain is pending or +send-only, resume the provider onboarding/configuration flow instead of creating +another resource. + +### B. Existing Vercel domain, no matching Resend resource + +List domains owned by the linked Vercel account and ask which one to configure. +Prefer a dedicated subdomain such as `mail.example.com` or `agent.example.com`; +do not change apex MX records without explicit user intent. + +Invoke the existing Marketplace CLI provisioning flow with the selected domain, +region, production environment, and JSON output: + +```sh +vercel integration add resend \ + --metadata domain=mail.example.com \ + --metadata region=us-east-1 \ + --environment production \ + --json +``` + +Then complete Resend onboarding and signed Domain Connect configuration. + +### C. No Vercel domain + +Do not implement domain search, pricing, checkout, registration, transfer, or +purchase confirmation in eve. Present a browser handoff to Vercel's domain +surface, where Vercel owns billing, registrant details, availability, renewal +terms, permissions, and purchase recovery. + +The handoff should preserve the linked team and return/resume intent when the web +surface supports it. Otherwise, print the exact Vercel domains URL and tell the +user to rerun `eve add channel/resend` after adding or purchasing a domain. The +CLI may poll for a newly available team domain while the browser is open, but it +must also support a clean exit and later rerun. + +Once a domain appears, resume path B: select a safe email subdomain, provision +the Resend Marketplace resource, complete signed Domain Connect, and verify +sending and receiving. If the user does not want to add a domain, retain the +manual existing-account and portable alternatives rather than blocking all +channel scaffolding. + +A rerun must detect the furthest completed state and resume from there without +duplicating a domain purchase, Marketplace resource, DNS application, or project +connection. + +## DNS ownership and safety + +DNS remains owned by Resend's signed Domain Connect flow. eve must not synthesize +the apply URL or create the records itself because the provider supplies dynamic +DKIM, return-path, inbound MX, priority, region, and tracking values. The signed +flow also proves which provider configuration the records belong to. + +Inbound MX changes can disrupt an existing mail provider. Setup should favor a +dedicated subdomain and clearly warn before configuring a root domain that +already has MX records. Exact conflicts and replacement behavior belong in the +Domain Connect confirmation UI. + +## Credential boundary + +Marketplace currently provisions `RESEND_API_KEY` as a resource secret and +syncs it into connected projects. eve should not read that environment value +back and copy it into Connect; doing so duplicates credential ownership and +breaks Marketplace rotation semantics. + +Before a Native→Connect bridge exists, the Marketplace path may generate the +adapter's standard environment-backed credential behavior: + +```ts +createResendAdapter({ + fromAddress: "eve@example.com", +}); +``` + +A future bridge should let the Marketplace resource issue an app credential to +Connect, after which generated code can use `connectResendApiKey(...)` without +copying a static key. Manual API-key setup remains a separate Connect-backed +fallback. + +Automatic webhook management on the Marketplace path needs one of: + +- a Marketplace resource-token endpoint that can mint a bounded Resend token; +- a Native→Connect resource authorization; +- a Resend Marketplace operation that reconciles the webhook on eve's behalf; +- or a documented manual webhook step. + +Do not access Marketplace resource secrets merely to automate webhook creation. + +## Required coordination with Resend + +Confirm before making Marketplace the default: + +1. Does `resend-email` currently configure sending only or the combined + `resend.com.mail-send-and-receive` service? +2. Can provisioning explicitly request receiving support? +3. Does the product accept a subdomain under a Vercel-owned apex domain, and + can Marketplace create that subdomain without attaching it as a project + domain? +4. What identifier is returned as `externalResourceId`, and can safe resource + metadata expose the configured domain? +5. Can resource discovery reliably match an existing Resend resource to a + domain and project without reading secrets? +6. Is a Marketplace `resourceTokenEndpoint` configured or planned, and what + scopes would its token carry? +7. Can Marketplace provisioning accept or later configure an `email.received` + webhook destination? +8. Can resource status distinguish DNS pending, send-ready, and receive-ready? +9. What is the expected Native→Connect bridge contract and timeline? + +## Repository boundaries + +- **Resend Marketplace integration:** account/resource creation, billing, API-key + lifecycle, provider onboarding, and initiating signed Domain Connect. +- **Front:** domain search/purchase/transfer, Marketplace checkout/resource + selection, and Domain Connect confirmation UI and templates. +- **Vercel API:** Marketplace installation/resource/project connection APIs and + resource-token or Native→Connect bridge primitives. +- **Vercel CLI:** existing `integration add`, resource discovery, connection, + browser handoff, and JSON results used by eve orchestration. +- **Connect:** runtime app-token resolution after a bridge exists, or manual + API-key storage for the fallback path. +- **eve:** thin setup coordination, resource/domain discovery, browser handoffs, + safe selection defaults, generated channel, deployment, webhook reconciliation + when an authorized credential path exists, and smoke-test guidance. eve does + not own domain commerce or DNS mutation. + +## Tests + +At minimum cover: + +- existing compatible resource selection and reuse; +- multiple-resource selection prefers the linked project and ready domain; +- an existing resource already connected to the project is left untouched; +- a pending or send-only existing resource resumes setup without duplication; +- existing-domain selection and dedicated-subdomain recommendation; +- no-domain path opens or prints the Vercel web domain flow without implementing + availability, pricing, checkout, or purchase in eve; +- browser cancellation and later rerun are clean; +- a domain added through Vercel web resumes Marketplace provisioning on rerun; +- Marketplace provisioning arguments and parsed JSON result; +- browser/terms handoff and cancellation; +- send-only status does not proceed as reply-ready; +- send-and-receive status prefills `eve@`; +- no direct DNS mutation or unsigned Domain Connect URL construction; +- no Marketplace secret readback or copy into Connect; +- reruns do not duplicate resources, DNS setup, or project connections; +- partial failure reports domain, resource, and installation IDs with recovery + commands; +- manual Connect and portable fallbacks remain available. + +## Rollout + +Start behind an explicit Marketplace option while confirming receiving and +resource-token behavior with Resend. The first implementation should orchestrate +existing CLI discovery/provisioning plus browser handoffs, not reproduce +Marketplace checkout or domain management in eve. Promote Marketplace to the +recommended first option once a selected domain can be proven send-and-receive +ready and the webhook can be configured without copying its static Marketplace +secret. From 0e6de445c7b45e3a800d8b353b2d903b4ee91dec Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 12:02:04 -0500 Subject: [PATCH 02/19] fix(eve): use canonical Vercel domains handoff Signed-off-by: benpankow --- packages/eve/src/setup/integrations/resend/setup.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index e410f52526..243e7bec9a 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -186,9 +186,7 @@ async function selectMarketplaceDomain( signal: context.signal, }); if (domains.length === 0) { - const url = project.orgId.startsWith("team_") - ? `https://vercel.com/teams/${encodeURIComponent(project.orgId)}/domains` - : "https://vercel.com/domains"; + const url = "https://vercel.com/domains"; context.ui.prompter.note( `Add or purchase a domain in Vercel, then rerun \`eve add channel/resend\`.\n${url}`, "Vercel domain required", From ac865e822336634ed6d909ba4428fc8625122fd3 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 12:06:11 -0500 Subject: [PATCH 03/19] fix(eve): parse mixed Marketplace stores Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 5 +++++ .../setup/integrations/resend/marketplace.ts | 22 ++++++++++++++----- .../src/setup/integrations/resend/setup.ts | 3 ++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index 0ce0905e7e..e6708f2621 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -32,6 +32,11 @@ describe("Resend Marketplace", () => { name: "database", product: { slug: "postgres" }, }, + { + id: "store_blob", + name: "blob-without-external-id", + type: "blob", + }, ], }); diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index 59885aeacb..c0cd46374e 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -5,13 +5,15 @@ import { z } from "zod"; const ResourceSchema = z.object({ id: z.string().min(1), - externalResourceId: z.string().min(1), + externalResourceId: z.string().min(1).optional(), name: z.string().min(1), status: z.string().nullish(), + metadata: z.object({ domain: z.string().min(1).optional() }).optional(), product: z .object({ slug: z.string().optional(), integrationConfigurationId: z.string().optional(), + integration: z.object({ slug: z.string().optional() }).optional(), }) .optional(), projectsMetadata: z @@ -23,7 +25,7 @@ const ResourceSchema = z.object({ ) .optional(), }); -const ResourceListSchema = z.object({ stores: z.array(ResourceSchema) }); +const ResourceListSchema = z.object({ stores: z.array(z.unknown()) }); const DomainListSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1) })) }); const ProvisionedSchema = z.object({ resource: z.object({ @@ -66,9 +68,18 @@ export async function listResendMarketplaceResources(input: { } const parsed = ResourceListSchema.safeParse(body); if (!parsed.success) throw new Error("Vercel returned an invalid Marketplace resource list."); - return parsed.data.stores.filter( - (resource) => resource.product?.slug === "resend-email" || resource.product?.slug === "resend", - ); + const resources: ResendMarketplaceResource[] = []; + for (const candidate of parsed.data.stores) { + const resource = ResourceSchema.safeParse(candidate); + if (!resource.success) continue; + if ( + resource.data.product?.slug === "resend-email" || + resource.data.product?.integration?.slug === "resend" + ) { + resources.push(resource.data); + } + } + return resources; } /** Lists domains owned by the linked Vercel team. */ @@ -144,6 +155,7 @@ export async function provisionResendMarketplaceResource(input: { return { id: parsed.data.resource.id, externalResourceId: parsed.data.resource.externalResourceId, + metadata: { domain: input.domain }, name: parsed.data.resource.name, product: { slug: "resend-email", diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 243e7bec9a..54daa4febd 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -148,7 +148,8 @@ async function chooseDestination( } function marketplaceDomain(resource: ResendMarketplaceResource): string | undefined { - const domain = resource.externalResourceId.trim().toLowerCase(); + const domain = (resource.metadata?.domain ?? resource.externalResourceId)?.trim().toLowerCase(); + if (domain === undefined) return undefined; return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/u.test(domain) ? domain : undefined; } From 4339bed0fa26e6a9c3360c9ec2e89b7aa889d40b Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 12:09:10 -0500 Subject: [PATCH 04/19] fix(eve): show Marketplace discovery progress Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 4 +++ .../setup/integrations/resend/marketplace.ts | 2 +- .../src/setup/integrations/resend/setup.ts | 28 ++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index e6708f2621..5bb6a4a2d0 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -63,6 +63,10 @@ describe("Resend Marketplace", () => { deps: { captureVercel }, }), ).resolves.toEqual(["example.com", "another.example"]); + expect(captureVercel).toHaveBeenCalledWith( + ["domains", "list", "--format", "json", "--limit", "100", "--scope", "team"], + expect.objectContaining({ cwd: "/project" }), + ); }); it("provisions Resend with domain metadata and production connection", async () => { diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index c0cd46374e..d42cb45837 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -91,7 +91,7 @@ export async function listVercelDomains(input: { }): Promise { const deps = input.deps ?? defaultDeps; const result = await deps.captureVercel( - ["domains", "list", "--json", "--limit", "100", "--scope", input.project.orgId], + ["domains", "list", "--format", "json", "--limit", "100", "--scope", input.project.orgId], { cwd: input.projectRoot, signal: input.signal }, ); if (!result.ok) throw new Error("Could not inspect Vercel domains."); diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 54daa4febd..d4d0cb93c0 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -9,6 +9,7 @@ import { writeTextFile } from "#setup/scaffold/files.js"; import { openUrl } from "#setup/primitives/open-url.js"; import { runVercel } from "#setup/primitives/run-vercel.js"; import { WizardCancelledError } from "#setup/step.js"; +import { withSpinner } from "#setup/with-spinner.js"; import type { IntegrationSetupContext, @@ -181,11 +182,13 @@ async function selectMarketplaceDomain( deps: ResendSetupDeps, project: Awaited>, ): Promise { - const domains = await deps.listDomains({ - projectRoot: context.appRoot, - project, - signal: context.signal, - }); + const domains = await withSpinner(context.ui.prompter, "Checking Vercel domains...", () => + deps.listDomains({ + projectRoot: context.appRoot, + project, + signal: context.signal, + }), + ); if (domains.length === 0) { const url = "https://vercel.com/domains"; context.ui.prompter.note( @@ -219,11 +222,16 @@ async function setupMarketplace( prompter: context.ui.prompter, signal: context.signal, }); - const resources = await deps.listMarketplaceResources({ - projectRoot: context.appRoot, - project, - signal: context.signal, - }); + const resources = await withSpinner( + context.ui.prompter, + "Checking Resend Marketplace resources...", + () => + deps.listMarketplaceResources({ + projectRoot: context.appRoot, + project, + signal: context.signal, + }), + ); let resource = await chooseMarketplaceResource(context, resources); if (resource === "create") { const domain = await selectMarketplaceDomain(context, deps, project); From 864af31a5dd185d574cc7d6149a18d802c413e60 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 12:13:50 -0500 Subject: [PATCH 05/19] fix(eve): make Resend domains searchable Signed-off-by: benpankow --- .../setup/integrations/resend/setup.test.ts | 48 ++++++++++++++++++- .../src/setup/integrations/resend/setup.ts | 27 +++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index dce48504a5..032213dbfb 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -56,7 +56,12 @@ function context( effects: ResendSetupDeps, select: "marketplace" | "connect" | "portable" = "connect", ) { - const fake = createFakePrompter({ single: () => select }); + const fake = createFakePrompter({ + single: (options) => + options.message === "How would you like to configure Resend?" + ? select + : options.options[0]!.value, + }); return { effects, value: { @@ -113,6 +118,47 @@ describe("Resend setup", () => { ); }); + it("offers a searchable domain picker and Vercel web handoff", async () => { + const effects = deps(); + vi.mocked(effects.listDomains).mockResolvedValue([ + "alpha.example", + "beta.example", + "gamma.example", + "delta.example", + "epsilon.example", + "zeta.example", + ]); + let domainPicker: unknown; + const fake = createFakePrompter({ + single: (options) => { + if (options.message === "How would you like to configure Resend?") return "marketplace"; + domainPicker = options; + return "__add-vercel-domain__"; + }, + }); + const setup = context(effects, "marketplace"); + const value = { + ...setup.value, + ui: createIntegrationSetupUi({ asker: setup.value.ui.asker, prompter: fake.prompter }), + }; + + await expect(setupResend(value, effects)).resolves.toEqual({ kind: "cancelled" }); + expect(domainPicker).toEqual( + expect.objectContaining({ + message: "Domain for Resend", + search: true, + placeholder: "type to filter domains", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "__add-vercel-domain__", + trailingAction: true, + }), + ]), + }), + ); + expect(effects.openUrl).toHaveBeenCalledWith("https://vercel.com/domains"); + }); + it("hands domain setup off to Vercel web when the team has no domain", async () => { const effects = deps(); vi.mocked(effects.listDomains).mockResolvedValue([]); diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index d4d0cb93c0..40ec11e660 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -177,6 +177,8 @@ async function chooseMarketplaceResource( : (resources.find((resource) => resource.id === selected) ?? "create"); } +const ADD_VERCEL_DOMAIN = "__add-vercel-domain__"; + async function selectMarketplaceDomain( context: IntegrationSetupContext, deps: ResendSetupDeps, @@ -189,7 +191,7 @@ async function selectMarketplaceDomain( signal: context.signal, }), ); - if (domains.length === 0) { + const openDomainSetup = (): "cancelled" => { const url = "https://vercel.com/domains"; context.ui.prompter.note( `Add or purchase a domain in Vercel, then rerun \`eve add channel/resend\`.\n${url}`, @@ -198,14 +200,29 @@ async function selectMarketplaceDomain( ); deps.openUrl(url); return "cancelled"; - } - if (domains.length === 1 || context.yes) return domains[0]!; - return context.ui.prompter.select({ + }; + if (domains.length === 0) return openDomainSetup(); + if (context.yes) return domains[0]!; + const selected = await context.ui.prompter.select({ message: "Domain for Resend", description: "Resend will open Vercel web to confirm account, billing, and DNS setup.", - options: domains.map((domain) => ({ value: domain, label: domain })), + search: true, + placeholder: "type to filter domains", + options: [ + ...domains.map((domain, index) => ({ + value: domain, + label: domain, + featured: index < 5, + })), + { + value: ADD_VERCEL_DOMAIN, + label: "Add or purchase a domain in Vercel", + trailingAction: true, + }, + ], initialValue: domains[0], }); + return selected === ADD_VERCEL_DOMAIN ? openDomainSetup() : selected; } async function setupMarketplace( From f7c976d632342bfdd4e054c714614495d80b16db Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 12:17:03 -0500 Subject: [PATCH 06/19] fix(eve): feature Vercel domain handoff Signed-off-by: benpankow --- .../eve/src/setup/integrations/resend/setup.test.ts | 13 ++++++++++--- packages/eve/src/setup/integrations/resend/setup.ts | 11 ++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index 032213dbfb..c76bf49013 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -60,7 +60,7 @@ function context( single: (options) => options.message === "How would you like to configure Resend?" ? select - : options.options[0]!.value, + : (options.initialValue ?? options.options[0]!.value), }); return { effects, @@ -148,12 +148,19 @@ describe("Resend setup", () => { message: "Domain for Resend", search: true, placeholder: "type to filter domains", - options: expect.arrayContaining([ + options: [ expect.objectContaining({ value: "__add-vercel-domain__", + featured: true, trailingAction: true, }), - ]), + expect.objectContaining({ value: "alpha.example", featured: true }), + expect.objectContaining({ value: "beta.example", featured: true }), + expect.objectContaining({ value: "gamma.example", featured: true }), + expect.objectContaining({ value: "delta.example", featured: true }), + expect.objectContaining({ value: "epsilon.example", featured: false }), + expect.objectContaining({ value: "zeta.example", featured: false }), + ], }), ); expect(effects.openUrl).toHaveBeenCalledWith("https://vercel.com/domains"); diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 40ec11e660..26ac6737ef 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -209,16 +209,17 @@ async function selectMarketplaceDomain( search: true, placeholder: "type to filter domains", options: [ - ...domains.map((domain, index) => ({ - value: domain, - label: domain, - featured: index < 5, - })), { value: ADD_VERCEL_DOMAIN, label: "Add or purchase a domain in Vercel", + featured: true, trailingAction: true, }, + ...domains.map((domain, index) => ({ + value: domain, + label: domain, + featured: index < 4, + })), ], initialValue: domains[0], }); From ced4503096e13f60e7683f226cabf904d839fe89 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 14:05:34 -0500 Subject: [PATCH 07/19] feat(eve): wait for Resend Marketplace setup Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 52 ++++++++++++++++++- .../setup/integrations/resend/marketplace.ts | 49 +++++++++++++++-- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index 5bb6a4a2d0..6a3b73c873 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -16,6 +16,19 @@ function capture(stdout: unknown): ResendMarketplaceDeps["captureVercel"] { })); } +function deps(input: { + captureVercel?: ResendMarketplaceDeps["captureVercel"]; + runVercelCaptureStdout?: ResendMarketplaceDeps["runVercelCaptureStdout"]; + delay?: ResendMarketplaceDeps["delay"]; +}): ResendMarketplaceDeps { + return { + captureVercel: input.captureVercel ?? capture({ stores: [] }), + runVercelCaptureStdout: + input.runVercelCaptureStdout ?? vi.fn(async () => ({ ok: false, stdout: "" })), + delay: input.delay ?? vi.fn(async () => {}), + }; +} + describe("Resend Marketplace", () => { it("lists only Resend Marketplace resources", async () => { const captureVercel = capture({ @@ -87,7 +100,7 @@ describe("Resend Marketplace", () => { log: createFakePrompter().prompter.log, projectRoot: "/project", project: { orgId: "team", projectId: "project" }, - deps: { runVercelCaptureStdout }, + deps: deps({ runVercelCaptureStdout }), }); expect(runVercelCaptureStdout).toHaveBeenCalledWith( @@ -109,6 +122,43 @@ describe("Resend Marketplace", () => { ); }); + it("polls for the resource after Marketplace hands setup to the browser", async () => { + const runVercelCaptureStdout = vi.fn(async () => ({ ok: false, stdout: "" })); + const captureVercel = vi + .fn() + .mockResolvedValueOnce({ ok: true, stdout: JSON.stringify({ stores: [] }) }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + stores: [ + { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + metadata: { domain: "mail.example.com" }, + product: { slug: "resend-email" }, + }, + ], + }), + }); + const delay = vi.fn(async () => {}); + const log = createFakePrompter().prompter.log; + + await expect( + provisionResendMarketplaceResource({ + domain: "mail.example.com", + log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: deps({ captureVercel, runVercelCaptureStdout, delay }), + pollIntervalMs: 1, + pollTimeoutMs: 1_000, + }), + ).resolves.toMatchObject({ id: "store_resend" }); + expect(delay).toHaveBeenCalledOnce(); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); + }); + it("does not reconnect a resource already attached to the linked project", async () => { const runVercelCaptureStdout = vi.fn(); await connectResendMarketplaceResource({ diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index d42cb45837..51f600ea00 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -1,3 +1,5 @@ +import { setTimeout as delay } from "node:timers/promises"; + import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; import type { VercelProjectReference } from "#setup/project-resolution.js"; import { captureVercel, runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; @@ -43,9 +45,17 @@ export type ResendMarketplaceResource = z.infer; export interface ResendMarketplaceDeps { captureVercel: typeof captureVercel; runVercelCaptureStdout: typeof runVercelCaptureStdout; + delay(ms: number, signal?: AbortSignal): Promise; } -const defaultDeps: ResendMarketplaceDeps = { captureVercel, runVercelCaptureStdout }; +const defaultDeps: ResendMarketplaceDeps = { + captureVercel, + runVercelCaptureStdout, + delay: (ms, signal) => delay(ms, undefined, { signal }), +}; + +const MARKETPLACE_POLL_INTERVAL_MS = 3_000; +const MARKETPLACE_POLL_TIMEOUT_MS = 10 * 60_000; /** Lists existing Resend Marketplace resources without reading their secrets. */ export async function listResendMarketplaceResources(input: { @@ -113,7 +123,9 @@ export async function provisionResendMarketplaceResource(input: { projectRoot: string; project: VercelProjectReference; signal?: AbortSignal; - deps?: Pick; + deps?: ResendMarketplaceDeps; + pollIntervalMs?: number; + pollTimeoutMs?: number; }): Promise { const deps = input.deps ?? defaultDeps; const result = await withPhase(input.log, "Setting up Resend in Vercel Marketplace...", () => @@ -140,8 +152,37 @@ export async function provisionResendMarketplaceResource(input: { ), ); if (!result.ok) { - throw new Error( - "Resend Marketplace setup was not completed. Finish the browser flow, then rerun `eve add channel/resend`.", + input.log.info( + `Complete Resend setup in the browser for ${input.domain}. This can take several minutes.`, + ); + input.log.info( + "You can safely stop waiting and rerun `eve add channel/resend`; setup will reuse the new Marketplace resource.", + ); + const deadline = Date.now() + (input.pollTimeoutMs ?? MARKETPLACE_POLL_TIMEOUT_MS); + const pollIntervalMs = input.pollIntervalMs ?? MARKETPLACE_POLL_INTERVAL_MS; + return withPhase( + input.log, + "Waiting for Resend Marketplace setup in the browser...", + async () => { + while (Date.now() < deadline) { + input.signal?.throwIfAborted(); + const resources = await listResendMarketplaceResources({ + projectRoot: input.projectRoot, + project: input.project, + signal: input.signal, + deps, + }); + const resource = resources.find( + (candidate) => candidate.metadata?.domain?.toLowerCase() === input.domain.toLowerCase(), + ); + if (resource !== undefined) return resource; + await deps.delay(pollIntervalMs, input.signal); + } + throw new Error( + `Resend Marketplace setup is still pending for ${input.domain}. Finish it in the browser, then rerun \`eve add channel/resend\`; the existing resource will be reused.`, + ); + }, + { kind: "external-action", emphasis: "browser" }, ); } let body: unknown; From d3b925e5992ecbf4529a8b20a5c39e36573f815f Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 14:09:28 -0500 Subject: [PATCH 08/19] feat(eve): track Resend DNS readiness Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 43 ++++++++++++++ .../setup/integrations/resend/marketplace.ts | 56 +++++++++++++++++++ .../setup/integrations/resend/setup.test.ts | 4 ++ .../src/setup/integrations/resend/setup.ts | 14 +++++ 4 files changed, 117 insertions(+) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index 6a3b73c873..b7a524c027 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -6,6 +6,7 @@ import { listResendMarketplaceResources, listVercelDomains, provisionResendMarketplaceResource, + waitForResendMarketplaceDomain, type ResendMarketplaceDeps, } from "./marketplace.js"; @@ -159,6 +160,48 @@ describe("Resend Marketplace", () => { expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); }); + it("tracks DNS verification until the Marketplace resource becomes ready", async () => { + const captureVercel = capture({ + stores: [ + { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + status: "available", + externalResourceStatus: "ready", + metadata: { domain: "mail.example.com" }, + product: { slug: "resend-email" }, + }, + ], + }); + const delay = vi.fn(async () => {}); + const log = createFakePrompter().prompter.log; + + await expect( + waitForResendMarketplaceDomain({ + resource: { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + status: "onboarding", + externalResourceStatus: "onboarding", + metadata: { domain: "mail.example.com" }, + product: { slug: "resend-email" }, + }, + domain: "mail.example.com", + log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel, delay }, + pollIntervalMs: 1, + pollTimeoutMs: 1_000, + }), + ).resolves.toMatchObject({ status: "available", externalResourceStatus: "ready" }); + expect(delay).toHaveBeenCalledOnce(); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("configuring DNS")); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); + }); + it("does not reconnect a resource already attached to the linked project", async () => { const runVercelCaptureStdout = vi.fn(); await connectResendMarketplaceResource({ diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index 51f600ea00..0e8dd3a1f3 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -10,6 +10,7 @@ const ResourceSchema = z.object({ externalResourceId: z.string().min(1).optional(), name: z.string().min(1), status: z.string().nullish(), + externalResourceStatus: z.string().nullish(), metadata: z.object({ domain: z.string().min(1).optional() }).optional(), product: z .object({ @@ -56,6 +57,8 @@ const defaultDeps: ResendMarketplaceDeps = { const MARKETPLACE_POLL_INTERVAL_MS = 3_000; const MARKETPLACE_POLL_TIMEOUT_MS = 10 * 60_000; +const DOMAIN_READY_POLL_TIMEOUT_MS = 15 * 60_000; +const READY_RESOURCE_STATUSES = new Set(["active", "available", "ready"]); /** Lists existing Resend Marketplace resources without reading their secrets. */ export async function listResendMarketplaceResources(input: { @@ -206,6 +209,59 @@ export async function provisionResendMarketplaceResource(input: { }; } +/** Whether Resend reports both its Marketplace resource and external domain ready. */ +export function isResendMarketplaceResourceReady(resource: ResendMarketplaceResource): boolean { + if (!READY_RESOURCE_STATUSES.has(resource.status ?? "")) return false; + const externalStatus = resource.externalResourceStatus; + return externalStatus == null || READY_RESOURCE_STATUSES.has(externalStatus); +} + +/** Waits for Resend and its DNS verification to become ready, supporting safe reruns on timeout. */ +export async function waitForResendMarketplaceDomain(input: { + resource: ResendMarketplaceResource; + domain: string; + log: ChannelSetupLog; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; + pollIntervalMs?: number; + pollTimeoutMs?: number; +}): Promise { + if (isResendMarketplaceResourceReady(input.resource)) return input.resource; + const deps = input.deps ?? defaultDeps; + const deadline = Date.now() + (input.pollTimeoutMs ?? DOMAIN_READY_POLL_TIMEOUT_MS); + const pollIntervalMs = input.pollIntervalMs ?? MARKETPLACE_POLL_INTERVAL_MS; + input.log.info( + `Resend is configuring DNS for ${input.domain}. Verification can take several minutes.`, + ); + input.log.info( + "You can safely stop waiting and rerun `eve add channel/resend`; setup will resume from this resource.", + ); + return withPhase( + input.log, + `Waiting for Resend domain DNS (${input.domain})...`, + async () => { + while (Date.now() < deadline) { + input.signal?.throwIfAborted(); + await deps.delay(pollIntervalMs, input.signal); + const resources = await listResendMarketplaceResources({ + projectRoot: input.projectRoot, + project: input.project, + signal: input.signal, + deps, + }); + const current = resources.find((candidate) => candidate.id === input.resource.id); + if (current !== undefined && isResendMarketplaceResourceReady(current)) return current; + } + throw new Error( + `Resend is still verifying DNS for ${input.domain}. Finish any requested DNS setup in Resend, then rerun \`eve add channel/resend\`; setup will reuse this resource.`, + ); + }, + { kind: "external-action", emphasis: "browser" }, + ); +} + /** Connects an existing Marketplace resource to the linked project for production. */ export async function connectResendMarketplaceResource(input: { resource: ResendMarketplaceResource; diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index c76bf49013..f0ad1b7b99 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -45,6 +45,7 @@ function deps(): ResendSetupDeps { projectsMetadata: [{ projectId: "project", environments: ["production"] }], })), connectMarketplaceResource: vi.fn(async () => {}), + waitForMarketplaceDomain: vi.fn(async (input) => input.resource), runVercel: vi.fn(async () => true), suggestFromAddress: vi.fn(async () => "eve@example.com"), validateApiKey: vi.fn(async () => {}), @@ -91,6 +92,9 @@ describe("Resend setup", () => { expect(effects.connectMarketplaceResource).toHaveBeenCalledWith( expect.objectContaining({ resource: expect.objectContaining({ id: "store_resend" }) }), ); + expect(effects.waitForMarketplaceDomain).toHaveBeenCalledWith( + expect.objectContaining({ domain: "example.com" }), + ); expect(effects.writeTextFile).toHaveBeenCalledWith( "/project/agent/channels/resend.ts", expect.stringContaining("process.env.RESEND_API_KEY"), diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 26ac6737ef..d98c2b6808 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -30,6 +30,7 @@ import { listResendMarketplaceResources, listVercelDomains, provisionResendMarketplaceResource, + waitForResendMarketplaceDomain, type ResendMarketplaceResource, } from "./marketplace.js"; @@ -47,6 +48,7 @@ export interface ResendSetupDeps { provisionConnector: typeof provisionResendConnector; provisionMarketplaceResource: typeof provisionResendMarketplaceResource; connectMarketplaceResource: typeof connectResendMarketplaceResource; + waitForMarketplaceDomain: typeof waitForResendMarketplaceDomain; runVercel: typeof runVercel; suggestFromAddress: typeof suggestResendFromAddress; validateApiKey: typeof validateResendApiKey; @@ -67,6 +69,7 @@ const defaultDeps: ResendSetupDeps = { provisionConnector: provisionResendConnector, provisionMarketplaceResource: provisionResendMarketplaceResource, connectMarketplaceResource: connectResendMarketplaceResource, + waitForMarketplaceDomain: waitForResendMarketplaceDomain, runVercel, suggestFromAddress: suggestResendFromAddress, validateApiKey: validateResendApiKey, @@ -269,6 +272,17 @@ async function setupMarketplace( project, signal: context.signal, }); + const resourceDomain = marketplaceDomain(resource); + if (resourceDomain !== undefined) { + resource = await deps.waitForMarketplaceDomain({ + resource, + domain: resourceDomain, + log: context.ui.prompter.log, + projectRoot: context.appRoot, + project, + signal: context.signal, + }); + } const domain = marketplaceDomain(resource); const fromAddressQuestion = domain === undefined From 2ab70c534e3615af68203c79bae38194455907b7 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 14:36:34 -0500 Subject: [PATCH 09/19] fix(eve): connect Marketplace resource with JSON format Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 34 +++++++++++++++++++ .../setup/integrations/resend/marketplace.ts | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index b7a524c027..d2873830ce 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -202,6 +202,40 @@ describe("Resend Marketplace", () => { expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); }); + it("connects an existing resource using the Vercel CLI JSON format flag", async () => { + const runVercelCaptureStdout = vi.fn(async () => ({ + ok: true, + stdout: JSON.stringify({ connected: true }), + })); + await connectResendMarketplaceResource({ + resource: { + id: "store_resend", + externalResourceId: "provider-id", + name: "resend-agent", + }, + log: createFakePrompter().prompter.log, + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { runVercelCaptureStdout }, + }); + expect(runVercelCaptureStdout).toHaveBeenCalledWith( + [ + "integration", + "resource", + "connect", + "resend-agent", + "--environment", + "production", + "--yes", + "--format", + "json", + "--scope", + "team", + ], + expect.objectContaining({ cwd: "/project", nonInteractive: true }), + ); + }); + it("does not reconnect a resource already attached to the linked project", async () => { const runVercelCaptureStdout = vi.fn(); await connectResendMarketplaceResource({ diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index 0e8dd3a1f3..3f51ff69f2 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -287,7 +287,8 @@ export async function connectResendMarketplaceResource(input: { "--environment", "production", "--yes", - "--json", + "--format", + "json", "--scope", input.project.orgId, ], From 5661fa1fa848726ea3760c4798e34f034cfccece Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 14:40:50 -0500 Subject: [PATCH 10/19] fix(eve): poll live Resend resource status Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 35 ++++++++---- .../setup/integrations/resend/marketplace.ts | 55 ++++++++++++++++--- 2 files changed, 73 insertions(+), 17 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index d2873830ce..97a39aa844 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -5,6 +5,7 @@ import { connectResendMarketplaceResource, listResendMarketplaceResources, listVercelDomains, + inspectResendMarketplaceResource, provisionResendMarketplaceResource, waitForResendMarketplaceDomain, type ResendMarketplaceDeps, @@ -115,7 +116,8 @@ describe("Resend Marketplace", () => { "region=us-east-1", "--environment", "production", - "--json", + "--format", + "json", "--scope", "team", ], @@ -160,19 +162,32 @@ describe("Resend Marketplace", () => { expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); }); - it("tracks DNS verification until the Marketplace resource becomes ready", async () => { + it("reads live provider status with integration resource inspect", async () => { const captureVercel = capture({ - stores: [ - { + resource: { id: "store_resend", name: "resend-agent", status: "available" }, + }); + await expect( + inspectResendMarketplaceResource({ + resource: { id: "store_resend", externalResourceId: "provider-id", name: "resend-agent", - status: "available", - externalResourceStatus: "ready", - metadata: { domain: "mail.example.com" }, - product: { slug: "resend-email" }, + status: "onboarding", }, - ], + projectRoot: "/project", + project: { orgId: "team", projectId: "project" }, + deps: { captureVercel }, + }), + ).resolves.toMatchObject({ status: "available" }); + expect(captureVercel).toHaveBeenCalledWith( + ["integration", "resource", "inspect", "resend-agent", "--format", "json", "--scope", "team"], + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("tracks DNS verification until the live Marketplace resource becomes ready", async () => { + const captureVercel = capture({ + resource: { id: "store_resend", name: "resend-agent", status: "available" }, }); const delay = vi.fn(async () => {}); const log = createFakePrompter().prompter.log; @@ -196,7 +211,7 @@ describe("Resend Marketplace", () => { pollIntervalMs: 1, pollTimeoutMs: 1_000, }), - ).resolves.toMatchObject({ status: "available", externalResourceStatus: "ready" }); + ).resolves.toMatchObject({ status: "available" }); expect(delay).toHaveBeenCalledOnce(); expect(log.info).toHaveBeenCalledWith(expect.stringContaining("configuring DNS")); expect(log.info).toHaveBeenCalledWith(expect.stringContaining("safely stop waiting")); diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index 3f51ff69f2..9b587d7009 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -30,6 +30,13 @@ const ResourceSchema = z.object({ }); const ResourceListSchema = z.object({ stores: z.array(z.unknown()) }); const DomainListSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1) })) }); +const InspectedResourceSchema = z.object({ + resource: z.object({ + id: z.string().min(1), + name: z.string().min(1), + status: z.string().min(1), + }), +}); const ProvisionedSchema = z.object({ resource: z.object({ id: z.string().min(1), @@ -143,7 +150,8 @@ export async function provisionResendMarketplaceResource(input: { "region=us-east-1", "--environment", "production", - "--json", + "--format", + "json", "--scope", input.project.orgId, ], @@ -209,11 +217,44 @@ export async function provisionResendMarketplaceResource(input: { }; } -/** Whether Resend reports both its Marketplace resource and external domain ready. */ +/** Reads live provider-backed status instead of the eventually consistent store summary. */ +export async function inspectResendMarketplaceResource(input: { + resource: ResendMarketplaceResource; + projectRoot: string; + project: VercelProjectReference; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await deps.captureVercel( + [ + "integration", + "resource", + "inspect", + input.resource.name, + "--format", + "json", + "--scope", + input.project.orgId, + ], + { cwd: input.projectRoot, signal: input.signal }, + ); + if (!result.ok) return undefined; + let body: unknown; + try { + body = JSON.parse(result.stdout) as unknown; + } catch { + return undefined; + } + const parsed = InspectedResourceSchema.safeParse(body); + return parsed.success + ? { ...input.resource, id: parsed.data.resource.id, status: parsed.data.resource.status } + : undefined; +} + +/** Whether Resend reports its Marketplace resource ready. */ export function isResendMarketplaceResourceReady(resource: ResendMarketplaceResource): boolean { - if (!READY_RESOURCE_STATUSES.has(resource.status ?? "")) return false; - const externalStatus = resource.externalResourceStatus; - return externalStatus == null || READY_RESOURCE_STATUSES.has(externalStatus); + return READY_RESOURCE_STATUSES.has(resource.status ?? ""); } /** Waits for Resend and its DNS verification to become ready, supporting safe reruns on timeout. */ @@ -245,13 +286,13 @@ export async function waitForResendMarketplaceDomain(input: { while (Date.now() < deadline) { input.signal?.throwIfAborted(); await deps.delay(pollIntervalMs, input.signal); - const resources = await listResendMarketplaceResources({ + const current = await inspectResendMarketplaceResource({ + resource: input.resource, projectRoot: input.projectRoot, project: input.project, signal: input.signal, deps, }); - const current = resources.find((candidate) => candidate.id === input.resource.id); if (current !== undefined && isResendMarketplaceResourceReady(current)) return current; } throw new Error( From b10fc46fb0ede80f04189e8164088b7393bd2553 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 14:49:10 -0500 Subject: [PATCH 11/19] feat(eve): configure Marketplace Resend webhook Signed-off-by: benpankow --- .../resend/marketplace-webhook.test.ts | 70 +++++++++ .../resend/marketplace-webhook.ts | 134 ++++++++++++++++++ .../setup/integrations/resend/setup.test.ts | 13 ++ .../src/setup/integrations/resend/setup.ts | 64 ++++++++- 4 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts create mode 100644 packages/eve/src/setup/integrations/resend/marketplace-webhook.ts diff --git a/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts new file mode 100644 index 0000000000..666c5c3ee8 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + deleteMarketplaceResendWebhooks, + reconcileMarketplaceResendWebhook, + type MarketplaceWebhookDeps, +} from "./marketplace-webhook.js"; + +const RESULT_PREFIX = "EVE_RESEND_RESULT="; + +function result(value: unknown): string { + return `${RESULT_PREFIX}${Buffer.from(JSON.stringify(value), "utf8").toString("base64url")}\n`; +} + +describe("Resend Marketplace webhook helper", () => { + it("runs reconciliation with production Marketplace environment variables", async () => { + const runVercelCaptureStdout = vi.fn( + async () => ({ + ok: true, + stdout: result({ id: "wh_new", signingSecret: "whsec_new", previousIds: ["wh_old"] }), + }), + ); + + await expect( + reconcileMarketplaceResendWebhook({ + endpoint: "https://agent.test/eve/v1/resend", + projectRoot: "/project", + orgId: "team", + deps: { runVercelCaptureStdout }, + }), + ).resolves.toEqual({ + id: "wh_new", + signingSecret: "whsec_new", + previousIds: ["wh_old"], + }); + + const [args, options] = runVercelCaptureStdout.mock.calls[0]!; + expect(args.slice(0, 7)).toEqual([ + "env", + "run", + "--environment", + "production", + "--scope", + "team", + "--", + ]); + expect(args).toContain("reconcile"); + expect(args).toContain("https://agent.test/eve/v1/resend"); + expect(args.join(" ")).not.toContain("whsec_new"); + expect(options).toMatchObject({ cwd: "/project", nonInteractive: true }); + }); + + it("runs deletion without exposing credentials", async () => { + const runVercelCaptureStdout = vi.fn( + async () => ({ + ok: true, + stdout: result({ deleted: ["wh_old"] }), + }), + ); + await deleteMarketplaceResendWebhooks({ + ids: ["wh_old"], + projectRoot: "/project", + orgId: "team", + deps: { runVercelCaptureStdout } satisfies MarketplaceWebhookDeps, + }); + expect(runVercelCaptureStdout.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining(["delete", "wh_old"]), + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts b/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts new file mode 100644 index 0000000000..1b0156cee7 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts @@ -0,0 +1,134 @@ +import { runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +const RESULT_PREFIX = "EVE_RESEND_RESULT="; + +const ReconcileResultSchema = z.object({ + id: z.string().min(1), + signingSecret: z.string().min(1), + previousIds: z.array(z.string().min(1)), +}); + +/** Newly created Resend webhook plus exact-match webhooks it supersedes. */ +export type MarketplaceWebhookReconciliation = z.infer; + +export interface MarketplaceWebhookDeps { + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +const defaultDeps: MarketplaceWebhookDeps = { runVercelCaptureStdout }; + +const WEBHOOK_HELPER = String.raw` +const action = process.argv[1]; +const values = process.argv.slice(2); +const key = process.env.RESEND_API_KEY; +if (!key) throw new Error("Marketplace did not provide RESEND_API_KEY to production."); +const headers = { Authorization: "Bearer " + key, "Content-Type": "application/json" }; +async function request(path, init = {}) { + const response = await fetch("https://api.resend.com" + path, { ...init, headers }); + if (!response.ok) throw new Error("Resend webhook request failed with HTTP " + response.status + "."); + if (response.status === 204) return undefined; + return response.json(); +} +function normalize(value) { + const url = new URL(value); + url.hash = ""; + if (url.pathname !== "/") url.pathname = url.pathname.replace(/\/+$/, ""); + return url.href; +} +if (action === "reconcile") { + const endpoint = values[0]; + if (!endpoint) throw new Error("Webhook endpoint is required."); + const listed = await request("/webhooks"); + const webhooks = Array.isArray(listed?.data) ? listed.data : []; + const previousIds = webhooks + .filter((webhook) => typeof webhook?.id === "string" && typeof webhook?.endpoint === "string" && normalize(webhook.endpoint) === normalize(endpoint)) + .map((webhook) => webhook.id); + const createdBody = await request("/webhooks", { + method: "POST", + body: JSON.stringify({ endpoint, events: ["email.received"] }), + }); + const created = createdBody?.data ?? createdBody; + if (typeof created?.id !== "string" || typeof created?.signing_secret !== "string") { + throw new Error("Resend did not return the new webhook signing secret."); + } + console.log(${JSON.stringify(RESULT_PREFIX)} + Buffer.from(JSON.stringify({ id: created.id, signingSecret: created.signing_secret, previousIds }), "utf8").toString("base64url")); +} else if (action === "delete") { + for (const id of values) await request("/webhooks/" + encodeURIComponent(id), { method: "DELETE" }); + console.log(${JSON.stringify(RESULT_PREFIX)} + Buffer.from(JSON.stringify({ deleted: values }), "utf8").toString("base64url")); +} else { + throw new Error("Unknown Resend webhook helper action."); +} +`; + +function parseResult(stdout: string): unknown { + const line = stdout.split(/\r?\n/u).findLast((candidate) => candidate.startsWith(RESULT_PREFIX)); + if (line === undefined) throw new Error("Resend webhook setup returned no result."); + try { + return JSON.parse( + Buffer.from(line.slice(RESULT_PREFIX.length), "base64url").toString("utf8"), + ) as unknown; + } catch { + throw new Error("Resend webhook setup returned an invalid result."); + } +} + +async function runMarketplaceWebhookHelper(input: { + args: string[]; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps?: MarketplaceWebhookDeps; +}): Promise { + const deps = input.deps ?? defaultDeps; + const result = await deps.runVercelCaptureStdout( + [ + "env", + "run", + "--environment", + "production", + "--scope", + input.orgId, + "--", + process.execPath, + "--input-type=module", + "--eval", + WEBHOOK_HELPER, + ...input.args, + ], + { cwd: input.projectRoot, nonInteractive: true, signal: input.signal }, + ); + if (!result.ok) { + throw new Error( + "Could not configure the Resend webhook with the Marketplace project credential.", + ); + } + return parseResult(result.stdout); +} + +/** Creates a replacement webhook using Marketplace's production credential. */ +export async function reconcileMarketplaceResendWebhook(input: { + endpoint: string; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps?: MarketplaceWebhookDeps; +}): Promise { + const parsed = ReconcileResultSchema.safeParse( + await runMarketplaceWebhookHelper({ ...input, args: ["reconcile", input.endpoint] }), + ); + if (!parsed.success) throw new Error("Resend webhook setup returned an invalid webhook."); + return parsed.data; +} + +/** Deletes webhooks using Marketplace's production credential. */ +export async function deleteMarketplaceResendWebhooks(input: { + ids: readonly string[]; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps?: MarketplaceWebhookDeps; +}): Promise { + if (input.ids.length === 0) return; + await runMarketplaceWebhookHelper({ ...input, args: ["delete", ...input.ids] }); +} diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index f0ad1b7b99..f868e3bedd 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -45,6 +45,12 @@ function deps(): ResendSetupDeps { projectsMetadata: [{ projectId: "project", environments: ["production"] }], })), connectMarketplaceResource: vi.fn(async () => {}), + reconcileMarketplaceWebhook: vi.fn(async () => ({ + id: "wh_marketplace", + signingSecret: "whsec_marketplace", + previousIds: [], + })), + deleteMarketplaceWebhooks: vi.fn(async () => {}), waitForMarketplaceDomain: vi.fn(async (input) => input.resource), runVercel: vi.fn(async () => true), suggestFromAddress: vi.fn(async () => "eve@example.com"), @@ -100,6 +106,13 @@ describe("Resend setup", () => { expect.stringContaining("process.env.RESEND_API_KEY"), { force: undefined }, ); + expect(effects.reconcileMarketplaceWebhook).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: "https://agent.test/eve/v1/resend" }), + ); + expect(effects.runVercel).toHaveBeenCalledWith( + ["env", "add", "RESEND_WEBHOOK_SECRET", "production", "--force", "--yes"], + expect.objectContaining({ stdin: "whsec_marketplace" }), + ); }); it("reuses an existing Marketplace resource", async () => { diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index d98c2b6808..937326c4fe 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -25,6 +25,10 @@ import { validateResendApiKey, } from "./api.js"; import { provisionResendConnector } from "./connect.js"; +import { + deleteMarketplaceResendWebhooks, + reconcileMarketplaceResendWebhook, +} from "./marketplace-webhook.js"; import { connectResendMarketplaceResource, listResendMarketplaceResources, @@ -48,6 +52,8 @@ export interface ResendSetupDeps { provisionConnector: typeof provisionResendConnector; provisionMarketplaceResource: typeof provisionResendMarketplaceResource; connectMarketplaceResource: typeof connectResendMarketplaceResource; + reconcileMarketplaceWebhook: typeof reconcileMarketplaceResendWebhook; + deleteMarketplaceWebhooks: typeof deleteMarketplaceResendWebhooks; waitForMarketplaceDomain: typeof waitForResendMarketplaceDomain; runVercel: typeof runVercel; suggestFromAddress: typeof suggestResendFromAddress; @@ -69,6 +75,8 @@ const defaultDeps: ResendSetupDeps = { provisionConnector: provisionResendConnector, provisionMarketplaceResource: provisionResendMarketplaceResource, connectMarketplaceResource: connectResendMarketplaceResource, + reconcileMarketplaceWebhook: reconcileMarketplaceResendWebhook, + deleteMarketplaceWebhooks: deleteMarketplaceResendWebhooks, waitForMarketplaceDomain: waitForResendMarketplaceDomain, runVercel, suggestFromAddress: suggestResendFromAddress, @@ -308,14 +316,62 @@ async function setupMarketplace( channelTemplate({ apiKey: "", fromAddress: fromAddress.trim(), fromName: fromName.trim() }), { force: context.force }, ); + const deployed = await deps.deploy({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + interactive: true, + signal: context.signal, + }); + if (deployed.kind !== "deployed" || deployed.productionUrl === undefined) { + throw new Error( + `Resend Marketplace resource ${resource.name} is ready, but setup could not determine the production URL. Run \`vercel deploy --prod\`, then rerun \`eve add channel/resend\`.`, + ); + } + const endpoint = new URL("/eve/v1/resend", deployed.productionUrl).href; + const webhook = await deps.reconcileMarketplaceWebhook({ + endpoint, + projectRoot: context.appRoot, + orgId: project.orgId, + signal: context.signal, + }); + try { + await writeProductionSecret(context, webhook.signingSecret, deps); + const redeployed = await deps.deploy({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + interactive: true, + signal: context.signal, + }); + if (redeployed.kind !== "deployed") throw new Error("Production redeploy was cancelled."); + } catch (error) { + await deps + .deleteMarketplaceWebhooks({ + ids: [webhook.id], + projectRoot: context.appRoot, + orgId: project.orgId, + signal: context.signal, + }) + .catch(() => {}); + throw error; + } + await deps + .deleteMarketplaceWebhooks({ + ids: webhook.previousIds.filter((id) => id !== webhook.id), + projectRoot: context.appRoot, + orgId: project.orgId, + signal: context.signal, + }) + .catch(() => {}); context.ui.nextSteps([ - "Complete Resend onboarding and Auto configure DNS in the browser if prompted.", - "Enable receiving for the selected domain, then create an email.received webhook for https:///eve/v1/resend and save its signing secret as RESEND_WEBHOOK_SECRET.", - "Deploy the agent after the webhook secret is configured.", + `Resend endpoint: ${endpoint}`, + `Send an email to ${fromAddress.trim()} and reply to smoke-test the conversation.`, ]); return { kind: "done", - facts: domain === undefined ? undefined : [{ label: "Resend domain", value: domain }], + facts: [ + ...(domain === undefined ? [] : [{ label: "Resend domain", value: domain }]), + { label: "Resend webhook", value: endpoint, kind: "url" as const }, + ], }; } From cf17456118cee6343b888e17bb5f3c2415abf2a4 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 15:06:53 -0500 Subject: [PATCH 12/19] feat(eve): restore durable Resend reply context Signed-off-by: benpankow --- packages/eve/package.json | 5 ++ .../channels/chat-sdk/chatSdkChannel.ts | 15 +++- .../src/public/channels/resend/index.test.ts | 70 ++++++++++++++++ .../eve/src/public/channels/resend/index.ts | 83 +++++++++++++++++++ .../src/setup/integrations/resend/setup.ts | 2 + 5 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 packages/eve/src/public/channels/resend/index.test.ts create mode 100644 packages/eve/src/public/channels/resend/index.ts diff --git a/packages/eve/package.json b/packages/eve/package.json index e262d6a2c8..c4a020f9e9 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -241,6 +241,11 @@ "import": "./dist/src/public/channels/photon/index.js", "default": "./dist/src/public/channels/photon/index.js" }, + "./channels/resend": { + "types": "./dist/src/public/channels/resend/index.d.ts", + "import": "./dist/src/public/channels/resend/index.js", + "default": "./dist/src/public/channels/resend/index.js" + }, "./channels/github": { "types": "./dist/src/public/channels/github/index.d.ts", "import": "./dist/src/public/channels/github/index.js", diff --git a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts index 516cd531ec..66a7e067cc 100644 --- a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts +++ b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts @@ -175,6 +175,14 @@ export interface ChatSdkChannelConfig< readonly webhook?: Omit; /** Optional Eve event handlers. Supplied handlers replace built-in defaults. */ readonly events?: ChatSdkChannelEvents; + /** + * Restores adapter-owned transient context from eve's durable serialized + * thread before an outbound workflow event uses the reconstructed thread. + */ + readonly restoreAdapterContext?: (input: { + readonly adapter: Adapter; + readonly thread: SerializedThread; + }) => void; /** * Prefix for default Eve HITL button action ids. Change this if your Chat SDK * app already uses the `eve_input:` prefix. @@ -290,7 +298,7 @@ export function chatSdkChannel( state, streaming, streamingEditIntervalMs, - thread: threadFromState(bot, state), + thread: threadFromState(bot, state, config.restoreAdapterContext), }; }, // Register both methods on each adapter's webhook path. Providers such as X @@ -594,12 +602,15 @@ function metadataFromState(state: ChatSdkChannelState): ChatSdkInstrumentationMe function threadFromState( bot: Chat, state: ChatSdkChannelState, + restoreAdapterContext: ChatSdkChannelConfig["restoreAdapterContext"], ): Thread | null { if (!state.thread) return null; try { const serialized = state.thread; + const adapter = bot.getAdapter(serialized.adapterName); + restoreAdapterContext?.({ adapter, thread: serialized }); return new ThreadImpl({ - adapter: bot.getAdapter(serialized.adapterName), + adapter, channelId: serialized.channelId, channelVisibility: serialized.channelVisibility, currentMessage: serialized.currentMessage diff --git a/packages/eve/src/public/channels/resend/index.test.ts b/packages/eve/src/public/channels/resend/index.test.ts new file mode 100644 index 0000000000..47376558e1 --- /dev/null +++ b/packages/eve/src/public/channels/resend/index.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +import { restoreResendReplyContext } from "./index.js"; + +describe("restoreResendReplyContext", () => { + it("seeds subject and reply message ids from a durable serialized message", () => { + const trackMessage = vi.fn(); + const trackSubject = vi.fn(); + const adapter = { + name: "resend", + threadResolver: { trackMessage, trackSubject }, + }; + restoreResendReplyContext({ + adapter: adapter as never, + thread: { + _type: "chat:Thread", + adapterName: "resend", + channelId: "resend:ben@example.com", + id: "resend:ben@example.com:root", + isDM: false, + currentMessage: { + _type: "chat:Message", + id: "email-1", + threadId: "resend:ben@example.com:root", + text: "hello", + formatted: { type: "root", children: [] }, + raw: { + subject: "Eve test", + messageId: "", + headers: { References: " " }, + }, + author: { + userId: "ben@example.com", + userName: "ben@example.com", + fullName: "Ben", + isBot: false, + isMe: false, + isSystem: false, + }, + metadata: { dateSent: new Date().toISOString(), edited: false }, + attachments: [], + isMention: true, + links: [], + }, + }, + }); + + expect(trackSubject).toHaveBeenCalledWith("resend:ben@example.com:root", "Eve test"); + expect(trackMessage.mock.calls.map((call) => call[1])).toEqual([ + "", + "", + "", + ]); + }); + + it("ignores adapters that do not expose the experimental resolver", () => { + expect(() => + restoreResendReplyContext({ + adapter: { name: "resend" } as never, + thread: { + _type: "chat:Thread", + adapterName: "resend", + channelId: "resend:user@example.com", + id: "resend:user@example.com:root", + isDM: false, + }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/eve/src/public/channels/resend/index.ts b/packages/eve/src/public/channels/resend/index.ts new file mode 100644 index 0000000000..6406332a99 --- /dev/null +++ b/packages/eve/src/public/channels/resend/index.ts @@ -0,0 +1,83 @@ +import type { Adapter, SerializedThread } from "#compiled/chat/index.js"; + +interface ResendRawReplyContext { + messageId: string; + subject: string; + headers?: Record; +} + +interface ResendThreadResolver { + trackMessage(threadId: string, messageId: string): void; + trackSubject(threadId: string, subject: string): void; +} + +interface ResendAdapterWithResolver extends Adapter { + threadResolver?: ResendThreadResolver; +} + +function rawReplyContext(thread: SerializedThread): ResendRawReplyContext | undefined { + const raw = thread.currentMessage?.raw; + if (typeof raw !== "object" || raw === null) return undefined; + const record = raw as Record; + if (typeof record.messageId !== "string" || typeof record.subject !== "string") { + return undefined; + } + const headers = + typeof record.headers === "object" && record.headers !== null + ? Object.fromEntries( + Object.entries(record.headers).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : undefined; + const context: ResendRawReplyContext = { + messageId: record.messageId, + subject: record.subject, + }; + if (headers !== undefined) context.headers = headers; + return context; +} + +function referenceMessageIds(headers: Record | undefined): string[] { + const references = headers?.References ?? headers?.references; + if (!references) return []; + const trimmed = references.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return parsed.filter((value): value is string => typeof value === "string"); + } + } catch { + // Malformed provider headers fall back to RFC whitespace parsing below. + } + } + return trimmed.split(/\s+/u).filter(Boolean); +} + +/** + * Restores the official Resend adapter's reply metadata from eve's durable + * serialized Chat SDK message. This experimental bridge keeps workflow replies + * in the inbound email thread until the adapter exposes a public restoration API. + */ +export function restoreResendReplyContext(input: { + readonly adapter: Adapter; + readonly thread: SerializedThread; +}): void { + if (input.adapter.name !== "resend") return; + const context = rawReplyContext(input.thread); + if (context === undefined) return; + const resolver = (input.adapter as ResendAdapterWithResolver).threadResolver; + if ( + resolver === undefined || + typeof resolver.trackMessage !== "function" || + typeof resolver.trackSubject !== "function" + ) { + return; + } + resolver.trackSubject(input.thread.id, context.subject); + for (const messageId of referenceMessageIds(context.headers)) { + resolver.trackMessage(input.thread.id, messageId); + } + resolver.trackMessage(input.thread.id, context.messageId); +} diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 937326c4fe..912060e1ec 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -105,6 +105,7 @@ function channelTemplate(input: { import { createResendAdapter } from "@resend/chat-sdk-adapter"; ${input.connectorUid ? 'import { connectResendApiKey } from "@vercel/connect/eve";\n' : ""}import type { Message, Thread } from "chat"; import { chatSdkChannel, messageToUserContent } from "eve/channels/chat-sdk"; +import { restoreResendReplyContext } from "eve/channels/resend"; export const { bot, channel, send } = chatSdkChannel({ userName: ${JSON.stringify(input.fromName || "Email Agent")}, @@ -117,6 +118,7 @@ export const { bot, channel, send } = chatSdkChannel({ }, state: createMemoryState(), streaming: false, + restoreAdapterContext: restoreResendReplyContext, }); bot.onNewMention(async (thread: Thread, message: Message) => { From 1319844a480fb58856d6ef264c8ce754787186dc Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 15:17:25 -0500 Subject: [PATCH 13/19] fix(eve): persist Resend reply context explicitly Signed-off-by: benpankow --- .../channels/chat-sdk/chatSdkChannel.ts | 17 ++++- .../src/public/channels/resend/index.test.ts | 67 ++++++++++--------- .../eve/src/public/channels/resend/index.ts | 20 ++++-- .../setup/integrations/resend/setup.test.ts | 31 +++++++++ .../src/setup/integrations/resend/setup.ts | 17 ++++- 5 files changed, 108 insertions(+), 44 deletions(-) diff --git a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts index 66a7e067cc..39befe15fe 100644 --- a/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts +++ b/packages/eve/src/public/channels/chat-sdk/chatSdkChannel.ts @@ -63,6 +63,8 @@ const ActiveWebhookKey = new ContextKey("chat-sdk.active-w */ export interface ChatSdkChannelState extends Record { thread: SerializedThread | null; + /** Adapter-specific JSON captured at inbound dispatch for durable restoration. */ + adapterContext?: unknown; /** Message id of the in-flight streamed assistant post (edit fallback). */ anchorMessageId?: string | null; /** @@ -179,8 +181,13 @@ export interface ChatSdkChannelConfig< * Restores adapter-owned transient context from eve's durable serialized * thread before an outbound workflow event uses the reconstructed thread. */ + readonly captureAdapterContext?: (input: { + readonly adapter: Adapter; + readonly thread: SerializedThread; + }) => unknown; readonly restoreAdapterContext?: (input: { readonly adapter: Adapter; + readonly context: unknown; readonly thread: SerializedThread; }) => void; /** @@ -275,6 +282,7 @@ export function chatSdkChannel( } await bridgeSend( bot, + config.captureAdapterContext, { inputResponses: [response] }, { auth: config.resolveInputAuth ? await config.resolveInputAuth(event) : null, @@ -339,7 +347,7 @@ export function chatSdkChannel( bot, channel, send(input, options) { - return bridgeSend(bot, input, options); + return bridgeSend(bot, config.captureAdapterContext, input, options); }, }; } @@ -552,6 +560,7 @@ async function postFailure( async function bridgeSend( bot: Chat, + captureAdapterContext: ChatSdkChannelConfig["captureAdapterContext"], input: ChatSdkSendInput, options: ChatSdkSendOptions, ): Promise { @@ -562,10 +571,12 @@ async function bridgeSend( ); } const thread = serializeThread(bot, options.thread, options.adapterName); + const adapter = bot.getAdapter(thread.adapterName); + const adapterContext = captureAdapterContext?.({ adapter, thread }); const sendOptions: SendOptions = { auth: options.auth ?? null, continuationToken: thread.id, - state: { thread }, + state: adapterContext === undefined ? { thread } : { adapterContext, thread }, }; if (options.callback) { sendOptions.callback = options.callback; @@ -608,7 +619,7 @@ function threadFromState( try { const serialized = state.thread; const adapter = bot.getAdapter(serialized.adapterName); - restoreAdapterContext?.({ adapter, thread: serialized }); + restoreAdapterContext?.({ adapter, context: state.adapterContext, thread: serialized }); return new ThreadImpl({ adapter, channelId: serialized.channelId, diff --git a/packages/eve/src/public/channels/resend/index.test.ts b/packages/eve/src/public/channels/resend/index.test.ts index 47376558e1..66f7481771 100644 --- a/packages/eve/src/public/channels/resend/index.test.ts +++ b/packages/eve/src/public/channels/resend/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { restoreResendReplyContext } from "./index.js"; +import { captureResendReplyContext, restoreResendReplyContext } from "./index.js"; describe("restoreResendReplyContext", () => { it("seeds subject and reply message ids from a durable serialized message", () => { @@ -10,41 +10,41 @@ describe("restoreResendReplyContext", () => { name: "resend", threadResolver: { trackMessage, trackSubject }, }; - restoreResendReplyContext({ - adapter: adapter as never, - thread: { - _type: "chat:Thread", - adapterName: "resend", - channelId: "resend:ben@example.com", - id: "resend:ben@example.com:root", - isDM: false, - currentMessage: { - _type: "chat:Message", - id: "email-1", - threadId: "resend:ben@example.com:root", - text: "hello", - formatted: { type: "root", children: [] }, - raw: { - subject: "Eve test", - messageId: "", - headers: { References: " " }, - }, - author: { - userId: "ben@example.com", - userName: "ben@example.com", - fullName: "Ben", - isBot: false, - isMe: false, - isSystem: false, - }, - metadata: { dateSent: new Date().toISOString(), edited: false }, - attachments: [], - isMention: true, - links: [], + const thread = { + _type: "chat:Thread", + adapterName: "resend", + channelId: "resend:ben@example.com", + id: "resend:ben@example.com:root", + isDM: false, + currentMessage: { + _type: "chat:Message", + id: "email-1", + threadId: "resend:ben@example.com:root", + text: "hello", + formatted: { type: "root", children: [] }, + raw: { + subject: "Eve test", + messageId: "", + headers: { References: " " }, }, + author: { + userId: "ben@example.com", + userName: "ben@example.com", + fullName: "Ben", + isBot: false, + isMe: false, + isSystem: false, + }, + metadata: { dateSent: new Date().toISOString(), edited: false }, + attachments: [], + isMention: true, + links: [], }, - }); + }; + const context = captureResendReplyContext({ adapter: adapter as never, thread }); + restoreResendReplyContext({ adapter: adapter as never, context, thread }); + expect(context).toMatchObject({ messageId: "", subject: "Eve test" }); expect(trackSubject).toHaveBeenCalledWith("resend:ben@example.com:root", "Eve test"); expect(trackMessage.mock.calls.map((call) => call[1])).toEqual([ "", @@ -57,6 +57,7 @@ describe("restoreResendReplyContext", () => { expect(() => restoreResendReplyContext({ adapter: { name: "resend" } as never, + context: undefined, thread: { _type: "chat:Thread", adapterName: "resend", diff --git a/packages/eve/src/public/channels/resend/index.ts b/packages/eve/src/public/channels/resend/index.ts index 6406332a99..98027d01a9 100644 --- a/packages/eve/src/public/channels/resend/index.ts +++ b/packages/eve/src/public/channels/resend/index.ts @@ -15,8 +15,8 @@ interface ResendAdapterWithResolver extends Adapter { threadResolver?: ResendThreadResolver; } -function rawReplyContext(thread: SerializedThread): ResendRawReplyContext | undefined { - const raw = thread.currentMessage?.raw; +function rawReplyContext(value: unknown): ResendRawReplyContext | undefined { + const raw = value; if (typeof raw !== "object" || raw === null) return undefined; const record = raw as Record; if (typeof record.messageId !== "string" || typeof record.subject !== "string") { @@ -55,17 +55,27 @@ function referenceMessageIds(headers: Record | undefined): strin return trimmed.split(/\s+/u).filter(Boolean); } +/** Captures the inbound Resend raw message into eve's durable channel state. */ +export function captureResendReplyContext(input: { + readonly adapter: Adapter; + readonly thread: SerializedThread; +}): ResendRawReplyContext | undefined { + if (input.adapter.name !== "resend") return undefined; + return rawReplyContext(input.thread.currentMessage?.raw); +} + /** * Restores the official Resend adapter's reply metadata from eve's durable - * serialized Chat SDK message. This experimental bridge keeps workflow replies - * in the inbound email thread until the adapter exposes a public restoration API. + * channel state. This experimental bridge keeps workflow replies in the inbound + * email thread until the adapter exposes a public restoration API. */ export function restoreResendReplyContext(input: { readonly adapter: Adapter; + readonly context: unknown; readonly thread: SerializedThread; }): void { if (input.adapter.name !== "resend") return; - const context = rawReplyContext(input.thread); + const context = rawReplyContext(input.context); if (context === undefined) return; const resolver = (input.adapter as ResendAdapterWithResolver).threadResolver; if ( diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index f868e3bedd..6c64e39ff7 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -198,6 +198,37 @@ describe("Resend setup", () => { expect(effects.provisionMarketplaceResource).not.toHaveBeenCalled(); }); + it("defaults the optional sender name to Eve", async () => { + const effects = deps(); + const questions: Question[] = []; + const setup = context(effects, "portable"); + const value = { + ...setup.value, + ui: { + ...setup.value.ui, + asker: { + ask: async (question: Question) => { + questions.push(question as Question); + if (question.key === "resend-api-key") return "re_secret" as T; + if (question.key === "resend-from-address") return "eve@example.com" as T; + return question.detected as T; + }, + askMany: async () => [], + }, + }, + }; + + await expect(setupResend(value, effects)).resolves.toEqual({ kind: "done" }); + expect(questions).toContainEqual( + expect.objectContaining({ key: "resend-from-name", detected: "Eve" }), + ); + expect(effects.writeTextFile).toHaveBeenCalledWith( + "/project/agent/channels/resend.ts", + expect.stringContaining('fromName: "Eve"'), + { force: undefined }, + ); + }); + it("prefills the agent address from a send-and-receive custom Resend domain", async () => { const effects = deps(); const questions: Question[] = []; diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 912060e1ec..929dd913db 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -105,7 +105,7 @@ function channelTemplate(input: { import { createResendAdapter } from "@resend/chat-sdk-adapter"; ${input.connectorUid ? 'import { connectResendApiKey } from "@vercel/connect/eve";\n' : ""}import type { Message, Thread } from "chat"; import { chatSdkChannel, messageToUserContent } from "eve/channels/chat-sdk"; -import { restoreResendReplyContext } from "eve/channels/resend"; +import { captureResendReplyContext, restoreResendReplyContext } from "eve/channels/resend"; export const { bot, channel, send } = chatSdkChannel({ userName: ${JSON.stringify(input.fromName || "Email Agent")}, @@ -118,6 +118,7 @@ export const { bot, channel, send } = chatSdkChannel({ }, state: createMemoryState(), streaming: false, + captureAdapterContext: captureResendReplyContext, restoreAdapterContext: restoreResendReplyContext, }); @@ -311,7 +312,12 @@ async function setupMarketplace( }); const fromAddress = await context.ui.asker.ask(fromAddressQuestion); const fromName = await context.ui.asker.ask( - text({ key: "resend-from-name", message: "From name (optional)", required: false }), + text({ + key: "resend-from-name", + message: "From name (optional)", + detected: "Eve", + required: false, + }), ); await deps.writeTextFile( join(context.appRoot, "agent/channels/resend.ts"), @@ -447,7 +453,12 @@ export async function setupResend( const fromAddress = (await context.ui.asker.ask(fromAddressQuestion)).trim(); const fromName = ( await context.ui.asker.ask( - text({ key: "resend-from-name", message: "From name (optional)", required: false }), + text({ + key: "resend-from-name", + message: "From name (optional)", + detected: "Eve", + required: false, + }), ) ).trim(); From cb75e4bb6d52d8d265e08f30989e75f8e7daec4c Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 15:22:10 -0500 Subject: [PATCH 14/19] fix(eve): default Resend sender name to Eve Signed-off-by: benpankow --- packages/eve/src/setup/integrations/resend/setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 929dd913db..e3fc225c32 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -108,12 +108,12 @@ import { chatSdkChannel, messageToUserContent } from "eve/channels/chat-sdk"; import { captureResendReplyContext, restoreResendReplyContext } from "eve/channels/resend"; export const { bot, channel, send } = chatSdkChannel({ - userName: ${JSON.stringify(input.fromName || "Email Agent")}, + userName: ${JSON.stringify(input.fromName || "Eve")}, adapters: { resend: createResendAdapter({ apiKey: ${apiKey}, fromAddress: ${JSON.stringify(input.fromAddress)}, - fromName: ${JSON.stringify(input.fromName || "Email Agent")}, + fromName: ${JSON.stringify(input.fromName || "Eve")}, }), }, state: createMemoryState(), From 63d1a12ffde8c83c4822e04cb3e33ca4065df1be Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 16:11:19 -0500 Subject: [PATCH 15/19] fix(eve): use temporary OAuth for Resend setup Signed-off-by: benpankow --- .../resend/marketplace-oauth.test.ts | 57 ++++++ .../integrations/resend/marketplace-oauth.ts | 167 ++++++++++++++++ .../resend/marketplace-webhook.test.ts | 75 ++++---- .../resend/marketplace-webhook.ts | 178 ++++++++---------- .../setup/integrations/resend/setup.test.ts | 13 +- .../src/setup/integrations/resend/setup.ts | 20 +- 6 files changed, 359 insertions(+), 151 deletions(-) create mode 100644 packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts create mode 100644 packages/eve/src/setup/integrations/resend/marketplace-oauth.ts diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts new file mode 100644 index 0000000000..6258bfd247 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createFakePrompter } from "#internal/testing/fake-prompter.js"; +import { authorizeResendMarketplaceSetup, type MarketplaceOAuthDeps } from "./marketplace-oauth.js"; + +function effects(outputs: Array<{ ok: boolean; stdout: string }>): MarketplaceOAuthDeps { + return { + runVercelCaptureStdout: vi.fn(async () => outputs.shift() ?? { ok: true, stdout: "{}" }), + }; +} + +describe("Resend Marketplace setup OAuth", () => { + it("authorizes full_access and removes the temporary connector after cleanup", async () => { + const deps = effects([ + { + ok: true, + stdout: JSON.stringify({ + id: "scl_setup", + uid: "oauth/eve-resend-setup", + supportedSubjectTypes: ["user"], + }), + }, + { ok: true, stdout: JSON.stringify({ token: "oauth_secret" }) }, + { ok: true, stdout: JSON.stringify({ deleted: 1 }) }, + { ok: true, stdout: JSON.stringify({ removed: true }) }, + ]); + + const authorization = await authorizeResendMarketplaceSetup({ + log: createFakePrompter().prompter.log, + projectRoot: "/project", + orgId: "team", + deps, + }); + expect(authorization.accessToken).toBe("oauth_secret"); + await authorization.cleanup(); + + const calls = vi.mocked(deps.runVercelCaptureStdout).mock.calls.map((call) => call[0]); + expect(calls[1]).toEqual( + expect.arrayContaining([ + "connect", + "token", + "oauth/eve-resend-setup", + "--subject", + "user", + "--scopes", + "full_access", + "--yes", + ]), + ); + expect(calls[2]).toEqual( + expect.arrayContaining(["connect", "revoke-tokens", "--my-tokens", "--yes"]), + ); + expect(calls[3]).toEqual( + expect.arrayContaining(["connect", "remove", "--disconnect-all", "--yes"]), + ); + }); +}); diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts new file mode 100644 index 0000000000..b7d55d29f7 --- /dev/null +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; + +import { createPromptCommandOutput, type ChannelSetupLog, withPhase } from "#setup/cli/index.js"; +import { runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; +import { z } from "zod"; + +const ConnectorSchema = z.object({ + id: z.string().min(1), + uid: z.string().min(1), + supportedSubjectTypes: z.array(z.string()).optional(), +}); +const TokenSchema = z.object({ token: z.string().min(1) }); + +export interface MarketplaceOAuthDeps { + runVercelCaptureStdout: typeof runVercelCaptureStdout; +} + +const defaultDeps: MarketplaceOAuthDeps = { runVercelCaptureStdout }; + +function parseJson(stdout: string, description: string): unknown { + try { + return JSON.parse(stdout) as unknown; + } catch { + throw new Error(`Vercel returned invalid JSON for ${description}.`); + } +} + +async function cleanupSetupConnector(input: { + connectorUid: string; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps: MarketplaceOAuthDeps; +}): Promise { + await input.deps.runVercelCaptureStdout( + [ + "connect", + "revoke-tokens", + input.connectorUid, + "--my-tokens", + "--yes", + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, nonInteractive: true, signal: input.signal }, + ); + const removed = await input.deps.runVercelCaptureStdout( + [ + "connect", + "remove", + input.connectorUid, + "--disconnect-all", + "--yes", + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, nonInteractive: true, signal: input.signal }, + ); + if (!removed.ok) { + throw new Error( + `Could not remove temporary connector ${input.connectorUid}. Run \`vercel connect remove ${input.connectorUid} --disconnect-all --yes\`.`, + ); + } +} + +/** Temporary user OAuth authorization used only to configure Resend webhooks. */ +export interface ResendSetupAuthorization { + readonly accessToken: string; + readonly connectorUid: string; + cleanup(): Promise; +} + +/** Authorizes Resend full access through Connect without reading project environment secrets. */ +export async function authorizeResendMarketplaceSetup(input: { + log: ChannelSetupLog; + projectRoot: string; + orgId: string; + signal?: AbortSignal; + deps?: MarketplaceOAuthDeps; +}): Promise { + const deps = input.deps ?? defaultDeps; + const onOutput = createPromptCommandOutput(input.log); + const name = `eve-resend-setup-${randomUUID().slice(0, 8)}`; + const created = await withPhase( + input.log, + "Creating a temporary Resend authorization...", + () => + deps.runVercelCaptureStdout( + [ + "connect", + "create", + "api.resend.com", + "--name", + name, + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, onOutput, signal: input.signal }, + ), + { kind: "external-action", emphasis: "browser" }, + ); + if (!created.ok) { + throw new Error("Could not create the temporary Resend OAuth connector."); + } + const connector = ConnectorSchema.safeParse( + parseJson(created.stdout, "the temporary Resend connector"), + ); + if (!connector.success || !connector.data.supportedSubjectTypes?.includes("user")) { + throw new Error("The temporary Resend connector does not support user OAuth authorization."); + } + + try { + const tokenResult = await withPhase( + input.log, + "Authorize Resend full access in the browser...", + () => + deps.runVercelCaptureStdout( + [ + "connect", + "token", + connector.data.uid, + "--subject", + "user", + "--scopes", + "full_access", + "--yes", + "--format", + "json", + "--scope", + input.orgId, + ], + { cwd: input.projectRoot, onOutput, signal: input.signal }, + ), + { kind: "external-action", emphasis: "browser" }, + ); + if (!tokenResult.ok) throw new Error("Resend OAuth authorization was not completed."); + const token = TokenSchema.safeParse(parseJson(tokenResult.stdout, "the Resend OAuth token")); + if (!token.success) throw new Error("Vercel returned an invalid Resend OAuth token."); + return { + accessToken: token.data.token, + connectorUid: connector.data.uid, + cleanup: () => + cleanupSetupConnector({ + connectorUid: connector.data.uid, + projectRoot: input.projectRoot, + orgId: input.orgId, + signal: input.signal, + deps, + }), + }; + } catch (error) { + await cleanupSetupConnector({ + connectorUid: connector.data.uid, + projectRoot: input.projectRoot, + orgId: input.orgId, + signal: input.signal, + deps, + }).catch(() => {}); + throw error; + } +} diff --git a/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts index 666c5c3ee8..b04d037359 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace-webhook.test.ts @@ -3,30 +3,31 @@ import { describe, expect, it, vi } from "vitest"; import { deleteMarketplaceResendWebhooks, reconcileMarketplaceResendWebhook, - type MarketplaceWebhookDeps, } from "./marketplace-webhook.js"; -const RESULT_PREFIX = "EVE_RESEND_RESULT="; - -function result(value: unknown): string { - return `${RESULT_PREFIX}${Buffer.from(JSON.stringify(value), "utf8").toString("base64url")}\n`; +function response(body: unknown, status = 200): Response { + return new Response(status === 204 ? null : JSON.stringify(body), { status }); } -describe("Resend Marketplace webhook helper", () => { - it("runs reconciliation with production Marketplace environment variables", async () => { - const runVercelCaptureStdout = vi.fn( - async () => ({ - ok: true, - stdout: result({ id: "wh_new", signingSecret: "whsec_new", previousIds: ["wh_old"] }), - }), - ); +describe("Resend Marketplace webhook setup", () => { + it("reconciles an email.received webhook with the temporary OAuth token", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + response({ + data: [ + { id: "wh_old", endpoint: "https://agent.test/eve/v1/resend/" }, + { id: "wh_other", endpoint: "https://other.test/webhook" }, + ], + }), + ) + .mockResolvedValueOnce(response({ id: "wh_new", signing_secret: "whsec_new" })); await expect( reconcileMarketplaceResendWebhook({ + accessToken: "oauth_secret", endpoint: "https://agent.test/eve/v1/resend", - projectRoot: "/project", - orgId: "team", - deps: { runVercelCaptureStdout }, + deps: { fetch }, }), ).resolves.toEqual({ id: "wh_new", @@ -34,37 +35,25 @@ describe("Resend Marketplace webhook helper", () => { previousIds: ["wh_old"], }); - const [args, options] = runVercelCaptureStdout.mock.calls[0]!; - expect(args.slice(0, 7)).toEqual([ - "env", - "run", - "--environment", - "production", - "--scope", - "team", - "--", - ]); - expect(args).toContain("reconcile"); - expect(args).toContain("https://agent.test/eve/v1/resend"); - expect(args.join(" ")).not.toContain("whsec_new"); - expect(options).toMatchObject({ cwd: "/project", nonInteractive: true }); - }); - - it("runs deletion without exposing credentials", async () => { - const runVercelCaptureStdout = vi.fn( - async () => ({ - ok: true, - stdout: result({ deleted: ["wh_old"] }), + expect(fetch.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer oauth_secret" }, + }); + expect(fetch.mock.calls[1]?.[1]?.body).toBe( + JSON.stringify({ + endpoint: "https://agent.test/eve/v1/resend", + events: ["email.received"], }), ); + }); + + it("deletes webhooks without putting the OAuth token in the URL", async () => { + const fetch = vi.fn(async () => response(undefined, 204)); await deleteMarketplaceResendWebhooks({ + accessToken: "oauth_secret", ids: ["wh_old"], - projectRoot: "/project", - orgId: "team", - deps: { runVercelCaptureStdout } satisfies MarketplaceWebhookDeps, + deps: { fetch }, }); - expect(runVercelCaptureStdout.mock.calls[0]?.[0]).toEqual( - expect.arrayContaining(["delete", "wh_old"]), - ); + expect(fetch.mock.calls[0]?.[0]).toBe("https://api.resend.com/webhooks/wh_old"); + expect(String(fetch.mock.calls[0]?.[0])).not.toContain("oauth_secret"); }); }); diff --git a/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts b/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts index 1b0156cee7..72935cdb68 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace-webhook.ts @@ -1,134 +1,110 @@ -import { runVercelCaptureStdout } from "#setup/primitives/run-vercel.js"; import { z } from "zod"; -const RESULT_PREFIX = "EVE_RESEND_RESULT="; - -const ReconcileResultSchema = z.object({ +const WebhookSchema = z.object({ id: z.string().min(1), - signingSecret: z.string().min(1), - previousIds: z.array(z.string().min(1)), + endpoint: z.string().url(), }); +const WebhookListSchema = z.object({ data: z.array(WebhookSchema) }); +const CreatedWebhookSchema = z.union([ + z.object({ id: z.string().min(1), signing_secret: z.string().min(1) }), + z.object({ + data: z.object({ id: z.string().min(1), signing_secret: z.string().min(1) }), + }), +]); /** Newly created Resend webhook plus exact-match webhooks it supersedes. */ -export type MarketplaceWebhookReconciliation = z.infer; +export interface MarketplaceWebhookReconciliation { + id: string; + signingSecret: string; + previousIds: string[]; +} export interface MarketplaceWebhookDeps { - runVercelCaptureStdout: typeof runVercelCaptureStdout; + fetch: typeof fetch; } -const defaultDeps: MarketplaceWebhookDeps = { runVercelCaptureStdout }; +const defaultDeps: MarketplaceWebhookDeps = { fetch }; -const WEBHOOK_HELPER = String.raw` -const action = process.argv[1]; -const values = process.argv.slice(2); -const key = process.env.RESEND_API_KEY; -if (!key) throw new Error("Marketplace did not provide RESEND_API_KEY to production."); -const headers = { Authorization: "Bearer " + key, "Content-Type": "application/json" }; -async function request(path, init = {}) { - const response = await fetch("https://api.resend.com" + path, { ...init, headers }); - if (!response.ok) throw new Error("Resend webhook request failed with HTTP " + response.status + "."); - if (response.status === 204) return undefined; - return response.json(); -} -function normalize(value) { - const url = new URL(value); - url.hash = ""; - if (url.pathname !== "/") url.pathname = url.pathname.replace(/\/+$/, ""); - return url.href; -} -if (action === "reconcile") { - const endpoint = values[0]; - if (!endpoint) throw new Error("Webhook endpoint is required."); - const listed = await request("/webhooks"); - const webhooks = Array.isArray(listed?.data) ? listed.data : []; - const previousIds = webhooks - .filter((webhook) => typeof webhook?.id === "string" && typeof webhook?.endpoint === "string" && normalize(webhook.endpoint) === normalize(endpoint)) - .map((webhook) => webhook.id); - const createdBody = await request("/webhooks", { - method: "POST", - body: JSON.stringify({ endpoint, events: ["email.received"] }), - }); - const created = createdBody?.data ?? createdBody; - if (typeof created?.id !== "string" || typeof created?.signing_secret !== "string") { - throw new Error("Resend did not return the new webhook signing secret."); +async function request( + accessToken: string, + path: string, + init: RequestInit, + deps: MarketplaceWebhookDeps, +): Promise { + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + if (init.body !== undefined) headers["Content-Type"] = "application/json"; + let response: Response; + try { + response = await deps.fetch(`https://api.resend.com${path}`, { ...init, headers }); + } catch { + throw new Error("Could not reach Resend while configuring the webhook."); } - console.log(${JSON.stringify(RESULT_PREFIX)} + Buffer.from(JSON.stringify({ id: created.id, signingSecret: created.signing_secret, previousIds }), "utf8").toString("base64url")); -} else if (action === "delete") { - for (const id of values) await request("/webhooks/" + encodeURIComponent(id), { method: "DELETE" }); - console.log(${JSON.stringify(RESULT_PREFIX)} + Buffer.from(JSON.stringify({ deleted: values }), "utf8").toString("base64url")); -} else { - throw new Error("Unknown Resend webhook helper action."); -} -`; - -function parseResult(stdout: string): unknown { - const line = stdout.split(/\r?\n/u).findLast((candidate) => candidate.startsWith(RESULT_PREFIX)); - if (line === undefined) throw new Error("Resend webhook setup returned no result."); + if (!response.ok) { + throw new Error(`Resend webhook request failed with HTTP ${response.status}.`); + } + if (response.status === 204) return undefined; try { - return JSON.parse( - Buffer.from(line.slice(RESULT_PREFIX.length), "base64url").toString("utf8"), - ) as unknown; + return (await response.json()) as unknown; } catch { - throw new Error("Resend webhook setup returned an invalid result."); + throw new Error("Resend returned an invalid webhook response."); } } -async function runMarketplaceWebhookHelper(input: { - args: string[]; - projectRoot: string; - orgId: string; - signal?: AbortSignal; - deps?: MarketplaceWebhookDeps; -}): Promise { - const deps = input.deps ?? defaultDeps; - const result = await deps.runVercelCaptureStdout( - [ - "env", - "run", - "--environment", - "production", - "--scope", - input.orgId, - "--", - process.execPath, - "--input-type=module", - "--eval", - WEBHOOK_HELPER, - ...input.args, - ], - { cwd: input.projectRoot, nonInteractive: true, signal: input.signal }, - ); - if (!result.ok) { - throw new Error( - "Could not configure the Resend webhook with the Marketplace project credential.", - ); - } - return parseResult(result.stdout); +function normalizedEndpoint(value: string): string { + const url = new URL(value); + url.hash = ""; + if (url.pathname !== "/") url.pathname = url.pathname.replace(/\/+$/u, ""); + return url.href; } -/** Creates a replacement webhook using Marketplace's production credential. */ +/** Creates a replacement webhook with a temporary Resend OAuth token. */ export async function reconcileMarketplaceResendWebhook(input: { + accessToken: string; endpoint: string; - projectRoot: string; - orgId: string; signal?: AbortSignal; deps?: MarketplaceWebhookDeps; }): Promise { - const parsed = ReconcileResultSchema.safeParse( - await runMarketplaceWebhookHelper({ ...input, args: ["reconcile", input.endpoint] }), + const deps = input.deps ?? defaultDeps; + const listed = WebhookListSchema.safeParse( + await request(input.accessToken, "/webhooks", { method: "GET", signal: input.signal }, deps), + ); + if (!listed.success) throw new Error("Resend returned an invalid webhook list."); + const previousIds = listed.data.data + .filter( + (webhook) => normalizedEndpoint(webhook.endpoint) === normalizedEndpoint(input.endpoint), + ) + .map((webhook) => webhook.id); + const createdResult = CreatedWebhookSchema.safeParse( + await request( + input.accessToken, + "/webhooks", + { + method: "POST", + body: JSON.stringify({ endpoint: input.endpoint, events: ["email.received"] }), + signal: input.signal, + }, + deps, + ), ); - if (!parsed.success) throw new Error("Resend webhook setup returned an invalid webhook."); - return parsed.data; + if (!createdResult.success) throw new Error("Resend returned an invalid created webhook."); + const created = "data" in createdResult.data ? createdResult.data.data : createdResult.data; + return { id: created.id, signingSecret: created.signing_secret, previousIds }; } -/** Deletes webhooks using Marketplace's production credential. */ +/** Deletes webhooks with a temporary Resend OAuth token. */ export async function deleteMarketplaceResendWebhooks(input: { + accessToken: string; ids: readonly string[]; - projectRoot: string; - orgId: string; signal?: AbortSignal; deps?: MarketplaceWebhookDeps; }): Promise { - if (input.ids.length === 0) return; - await runMarketplaceWebhookHelper({ ...input, args: ["delete", ...input.ids] }); + const deps = input.deps ?? defaultDeps; + for (const id of input.ids) { + await request( + input.accessToken, + `/webhooks/${encodeURIComponent(id)}`, + { method: "DELETE", signal: input.signal }, + deps, + ); + } } diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index 6c64e39ff7..8e6dec991d 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -45,6 +45,11 @@ function deps(): ResendSetupDeps { projectsMetadata: [{ projectId: "project", environments: ["production"] }], })), connectMarketplaceResource: vi.fn(async () => {}), + authorizeMarketplaceSetup: vi.fn(async () => ({ + accessToken: "oauth_marketplace", + connectorUid: "oauth/eve-resend-setup", + cleanup: vi.fn(async () => {}), + })), reconcileMarketplaceWebhook: vi.fn(async () => ({ id: "wh_marketplace", signingSecret: "whsec_marketplace", @@ -106,8 +111,14 @@ describe("Resend setup", () => { expect.stringContaining("process.env.RESEND_API_KEY"), { force: undefined }, ); + expect(effects.authorizeMarketplaceSetup).toHaveBeenCalledWith( + expect.objectContaining({ orgId: "team" }), + ); expect(effects.reconcileMarketplaceWebhook).toHaveBeenCalledWith( - expect.objectContaining({ endpoint: "https://agent.test/eve/v1/resend" }), + expect.objectContaining({ + accessToken: "oauth_marketplace", + endpoint: "https://agent.test/eve/v1/resend", + }), ); expect(effects.runVercel).toHaveBeenCalledWith( ["env", "add", "RESEND_WEBHOOK_SECRET", "production", "--force", "--yes"], diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index e3fc225c32..a602170286 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -25,6 +25,7 @@ import { validateResendApiKey, } from "./api.js"; import { provisionResendConnector } from "./connect.js"; +import { authorizeResendMarketplaceSetup } from "./marketplace-oauth.js"; import { deleteMarketplaceResendWebhooks, reconcileMarketplaceResendWebhook, @@ -52,6 +53,7 @@ export interface ResendSetupDeps { provisionConnector: typeof provisionResendConnector; provisionMarketplaceResource: typeof provisionResendMarketplaceResource; connectMarketplaceResource: typeof connectResendMarketplaceResource; + authorizeMarketplaceSetup: typeof authorizeResendMarketplaceSetup; reconcileMarketplaceWebhook: typeof reconcileMarketplaceResendWebhook; deleteMarketplaceWebhooks: typeof deleteMarketplaceResendWebhooks; waitForMarketplaceDomain: typeof waitForResendMarketplaceDomain; @@ -75,6 +77,7 @@ const defaultDeps: ResendSetupDeps = { provisionConnector: provisionResendConnector, provisionMarketplaceResource: provisionResendMarketplaceResource, connectMarketplaceResource: connectResendMarketplaceResource, + authorizeMarketplaceSetup: authorizeResendMarketplaceSetup, reconcileMarketplaceWebhook: reconcileMarketplaceResendWebhook, deleteMarketplaceWebhooks: deleteMarketplaceResendWebhooks, waitForMarketplaceDomain: waitForResendMarketplaceDomain, @@ -336,12 +339,17 @@ async function setupMarketplace( ); } const endpoint = new URL("/eve/v1/resend", deployed.productionUrl).href; - const webhook = await deps.reconcileMarketplaceWebhook({ - endpoint, + const authorization = await deps.authorizeMarketplaceSetup({ + log: context.ui.prompter.log, projectRoot: context.appRoot, orgId: project.orgId, signal: context.signal, }); + const webhook = await deps.reconcileMarketplaceWebhook({ + accessToken: authorization.accessToken, + endpoint, + signal: context.signal, + }); try { await writeProductionSecret(context, webhook.signingSecret, deps); const redeployed = await deps.deploy({ @@ -354,22 +362,22 @@ async function setupMarketplace( } catch (error) { await deps .deleteMarketplaceWebhooks({ + accessToken: authorization.accessToken, ids: [webhook.id], - projectRoot: context.appRoot, - orgId: project.orgId, signal: context.signal, }) .catch(() => {}); + await authorization.cleanup().catch(() => {}); throw error; } await deps .deleteMarketplaceWebhooks({ + accessToken: authorization.accessToken, ids: webhook.previousIds.filter((id) => id !== webhook.id), - projectRoot: context.appRoot, - orgId: project.orgId, signal: context.signal, }) .catch(() => {}); + await authorization.cleanup(); context.ui.nextSteps([ `Resend endpoint: ${endpoint}`, `Send an email to ${fromAddress.trim()} and reply to smoke-test the conversation.`, From 44f699a417a64e1356f3c891e1919702a94ffd9c Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 16:33:03 -0500 Subject: [PATCH 16/19] fix(eve): let Connect resolve setup user Signed-off-by: benpankow --- .../eve/src/setup/integrations/resend/marketplace-oauth.test.ts | 2 -- packages/eve/src/setup/integrations/resend/marketplace-oauth.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts index 6258bfd247..ea5527cfb7 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts @@ -40,8 +40,6 @@ describe("Resend Marketplace setup OAuth", () => { "connect", "token", "oauth/eve-resend-setup", - "--subject", - "user", "--scopes", "full_access", "--yes", diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts index b7d55d29f7..52e6f7c9c0 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts @@ -125,8 +125,6 @@ export async function authorizeResendMarketplaceSetup(input: { "connect", "token", connector.data.uid, - "--subject", - "user", "--scopes", "full_access", "--yes", From 8390d3304c2df2425260263c656782a8491ba667 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 16:50:19 -0500 Subject: [PATCH 17/19] feat(eve): authorize existing Resend accounts Signed-off-by: benpankow --- .../resend/marketplace-oauth.test.ts | 33 ++++++- .../integrations/resend/marketplace-oauth.ts | 57 +++++++++++- .../setup/integrations/resend/setup.test.ts | 34 ++++++++ .../src/setup/integrations/resend/setup.ts | 87 ++++++++++++++----- 4 files changed, 188 insertions(+), 23 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts index ea5527cfb7..dc9d6d8615 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.test.ts @@ -1,15 +1,46 @@ import { describe, expect, it, vi } from "vitest"; import { createFakePrompter } from "#internal/testing/fake-prompter.js"; -import { authorizeResendMarketplaceSetup, type MarketplaceOAuthDeps } from "./marketplace-oauth.js"; +import { + authorizeResendMarketplaceSetup, + createResendApiKey, + deleteResendApiKey, + type MarketplaceOAuthDeps, +} from "./marketplace-oauth.js"; function effects(outputs: Array<{ ok: boolean; stdout: string }>): MarketplaceOAuthDeps { return { + fetch: vi.fn(), runVercelCaptureStdout: vi.fn(async () => outputs.shift() ?? { ok: true, stdout: "{}" }), }; } describe("Resend Marketplace setup OAuth", () => { + it("creates and deletes a dedicated full-access API key", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "key_1", token: "re_dedicated" }), { status: 200 }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect( + createResendApiKey({ + accessToken: "oauth_secret", + name: "eve · weather", + deps: { fetch }, + }), + ).resolves.toEqual({ id: "key_1", token: "re_dedicated" }); + expect(fetch.mock.calls[0]?.[1]?.body).toBe( + JSON.stringify({ name: "eve · weather", permission: "full_access" }), + ); + await deleteResendApiKey({ + accessToken: "oauth_secret", + id: "key_1", + deps: { fetch }, + }); + expect(fetch.mock.calls[1]?.[0]).toBe("https://api.resend.com/api-keys/key_1"); + }); it("authorizes full_access and removes the temporary connector after cleanup", async () => { const deps = effects([ { diff --git a/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts index 52e6f7c9c0..9878cef029 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace-oauth.ts @@ -10,12 +10,17 @@ const ConnectorSchema = z.object({ supportedSubjectTypes: z.array(z.string()).optional(), }); const TokenSchema = z.object({ token: z.string().min(1) }); +const ApiKeySchema = z.object({ + id: z.string().min(1), + token: z.string().min(1), +}); export interface MarketplaceOAuthDeps { + fetch: typeof fetch; runVercelCaptureStdout: typeof runVercelCaptureStdout; } -const defaultDeps: MarketplaceOAuthDeps = { runVercelCaptureStdout }; +const defaultDeps: MarketplaceOAuthDeps = { fetch, runVercelCaptureStdout }; function parseJson(stdout: string, description: string): unknown { try { @@ -67,7 +72,55 @@ async function cleanupSetupConnector(input: { } } -/** Temporary user OAuth authorization used only to configure Resend webhooks. */ +/** Dedicated Resend API key created with a temporary setup authorization. */ +export interface CreatedResendApiKey { + readonly id: string; + readonly token: string; +} + +/** Creates a full-access API key for durable runtime use. */ +export async function createResendApiKey(input: { + accessToken: string; + name: string; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const response = await (input.deps ?? defaultDeps).fetch("https://api.resend.com/api-keys", { + method: "POST", + headers: { + Authorization: `Bearer ${input.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: input.name.slice(0, 50), permission: "full_access" }), + signal: input.signal, + }); + if (!response.ok) throw new Error(`Resend API-key creation failed with HTTP ${response.status}.`); + const parsed = ApiKeySchema.safeParse((await response.json()) as unknown); + if (!parsed.success) throw new Error("Resend returned an invalid API key."); + return parsed.data; +} + +/** Deletes a setup-created API key after a later failure. */ +export async function deleteResendApiKey(input: { + accessToken: string; + id: string; + signal?: AbortSignal; + deps?: Pick; +}): Promise { + const response = await (input.deps ?? defaultDeps).fetch( + `https://api.resend.com/api-keys/${encodeURIComponent(input.id)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${input.accessToken}` }, + signal: input.signal, + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error(`Resend API-key cleanup failed with HTTP ${response.status}.`); + } +} + +/** Temporary user OAuth authorization used only during guided setup. */ export interface ResendSetupAuthorization { readonly accessToken: string; readonly connectorUid: string; diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index 8e6dec991d..d25bde4859 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -50,6 +50,8 @@ function deps(): ResendSetupDeps { connectorUid: "oauth/eve-resend-setup", cleanup: vi.fn(async () => {}), })), + createApiKey: vi.fn(async () => ({ id: "key_resend", token: "re_generated" })), + deleteApiKey: vi.fn(async () => {}), reconcileMarketplaceWebhook: vi.fn(async () => ({ id: "wh_marketplace", signingSecret: "whsec_marketplace", @@ -317,6 +319,36 @@ describe("Resend setup", () => { ); }); + it("uses existing-account OAuth to create the runtime API key without prompting", async () => { + const effects = deps(); + const questions: Question[] = []; + const setup = context(effects, "connect"); + const value = { + ...setup.value, + ui: { + ...setup.value.ui, + asker: { + ask: async (question: Question) => { + questions.push(question as Question); + if (question.key === "resend-from-name") return "Eve" as T; + return question.detected as T; + }, + askMany: async () => [], + }, + }, + }; + + await expect(setupResend(value, effects)).resolves.toMatchObject({ kind: "done" }); + expect(questions.map((question) => question.key)).not.toContain("resend-api-key"); + expect(effects.createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "oauth_marketplace" }), + ); + expect(effects.validateApiKey).toHaveBeenCalledWith("re_generated", undefined); + expect(effects.provisionConnector).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: "re_generated" }), + ); + }); + it("uses one normalized key and orders deploy, webhook, env, and redeploy", async () => { const effects = deps(); const events: string[] = []; @@ -345,6 +377,7 @@ describe("Resend setup", () => { return true; }); + vi.mocked(effects.createApiKey).mockResolvedValue({ id: "key_resend", token: "re_secret" }); const setup = context(effects); await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); expect(events).toEqual([ @@ -365,6 +398,7 @@ describe("Resend setup", () => { it("compensates a newly created webhook when saving the secret fails", async () => { const effects = deps(); vi.mocked(effects.runVercel).mockResolvedValue(false); + vi.mocked(effects.createApiKey).mockResolvedValue({ id: "key_resend", token: "re_secret" }); const setup = context(effects); await expect(setupResend(setup.value, effects)).rejects.toThrow("may persist"); expect(effects.deleteWebhook).toHaveBeenCalledWith("re_secret", "wh_new", undefined); diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index a602170286..bf5a8b562c 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -25,7 +25,11 @@ import { validateResendApiKey, } from "./api.js"; import { provisionResendConnector } from "./connect.js"; -import { authorizeResendMarketplaceSetup } from "./marketplace-oauth.js"; +import { + authorizeResendMarketplaceSetup, + createResendApiKey, + deleteResendApiKey, +} from "./marketplace-oauth.js"; import { deleteMarketplaceResendWebhooks, reconcileMarketplaceResendWebhook, @@ -54,6 +58,8 @@ export interface ResendSetupDeps { provisionMarketplaceResource: typeof provisionResendMarketplaceResource; connectMarketplaceResource: typeof connectResendMarketplaceResource; authorizeMarketplaceSetup: typeof authorizeResendMarketplaceSetup; + createApiKey: typeof createResendApiKey; + deleteApiKey: typeof deleteResendApiKey; reconcileMarketplaceWebhook: typeof reconcileMarketplaceResendWebhook; deleteMarketplaceWebhooks: typeof deleteMarketplaceResendWebhooks; waitForMarketplaceDomain: typeof waitForResendMarketplaceDomain; @@ -78,6 +84,8 @@ const defaultDeps: ResendSetupDeps = { provisionMarketplaceResource: provisionResendMarketplaceResource, connectMarketplaceResource: connectResendMarketplaceResource, authorizeMarketplaceSetup: authorizeResendMarketplaceSetup, + createApiKey: createResendApiKey, + deleteApiKey: deleteResendApiKey, reconcileMarketplaceWebhook: reconcileMarketplaceResendWebhook, deleteMarketplaceWebhooks: deleteMarketplaceResendWebhooks, waitForMarketplaceDomain: waitForResendMarketplaceDomain, @@ -147,18 +155,18 @@ async function chooseDestination( options: [ { value: "marketplace", - label: "Set up with Vercel Marketplace", - hint: "Create or reuse a Resend domain and project credential", + label: "Set up with a Vercel domain", + hint: "Configure Resend, DNS, and project credentials for a domain in Vercel", }, { value: "connect", - label: "Set up Vercel Connect", - hint: "Provision credentials, deploy, and configure the webhook", + label: "Use an existing Resend account", + hint: "Sign in to create a dedicated credential for this agent", }, { value: "portable", - label: "Use portable credentials", - hint: "Store the API key locally and configure the webhook manually", + label: "Configure manually", + hint: "Use environment variables and configure the webhook yourself", }, ], initialValue: context.environment.vercel.kind === "available" ? "marketplace" : "portable", @@ -418,23 +426,51 @@ export async function setupResend( if (destination === "marketplace") return await setupMarketplace(context, deps); if (destination === "connect" && context.environment.vercel.kind === "unavailable") { throw new Error( - "Vercel Connect requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", + "Using an existing Resend account requires an authenticated Vercel CLI. Run `vercel login`, then retry Resend setup.", ); } - const instructions = [ - "Use a full-access Resend API key. Setup needs webhook access, and the adapter fetches received-email contents.", - "Create a key: https://resend.com/api-keys", - ]; - if (context.ui.prompter.acknowledge) { - await context.ui.prompter.acknowledge({ message: "Resend API key", lines: instructions }); + let setupAuthorization: Awaited> | undefined; + let createdApiKey: Awaited> | undefined; + let apiKey: string; + if (destination === "connect") { + const project = await deps.ensureVercelProject({ + appRoot: context.appRoot, + prompter: context.ui.prompter, + signal: context.signal, + }); + setupAuthorization = await deps.authorizeMarketplaceSetup({ + log: context.ui.prompter.log, + projectRoot: context.appRoot, + orgId: project.orgId, + signal: context.signal, + }); + createdApiKey = await deps.createApiKey({ + accessToken: setupAuthorization.accessToken, + name: `eve · ${await deps.deriveConnectorSlug(context.appRoot)}`, + signal: context.signal, + }); + apiKey = createdApiKey.token; } else { - context.ui.prompter.log.info(instructions.join("\n")); + const instructions = [ + "Use a full-access Resend API key. Setup needs webhook access, and the adapter fetches received-email contents.", + "Create a key: https://resend.com/api-keys", + ]; + if (context.ui.prompter.acknowledge) { + await context.ui.prompter.acknowledge({ message: "Resend API key", lines: instructions }); + } else { + context.ui.prompter.log.info(instructions.join("\n")); + } + apiKey = ( + await context.ui.asker.ask( + text({ + key: "resend-api-key", + message: "Resend API key", + required: true, + sensitive: true, + }), + ) + ).trim(); } - const apiKey = ( - await context.ui.asker.ask( - text({ key: "resend-api-key", message: "Resend API key", required: true, sensitive: true }), - ) - ).trim(); await deps.validateApiKey(apiKey, context.signal); const suggestedFromAddress = await deps.suggestFromAddress(apiKey, context.signal); const defaultFromAddress = suggestedFromAddress ?? "onboarding@resend.dev"; @@ -547,12 +583,23 @@ export async function setupResend( } } } + await setupAuthorization?.cleanup(); context.ui.nextSteps([ `Resend endpoint: ${endpoint}`, `Send from ${fromAddress}; configure a receiving domain in Resend, then send an email and reply to smoke-test the thread.`, ]); return { kind: "done", facts: [{ label: "Resend webhook", value: endpoint, kind: "url" }] }; } catch (error) { + if (createdApiKey !== undefined && setupAuthorization !== undefined) { + await deps + .deleteApiKey({ + accessToken: setupAuthorization.accessToken, + id: createdApiKey.id, + signal: context.signal, + }) + .catch(() => {}); + await setupAuthorization.cleanup().catch(() => {}); + } const reason = error instanceof Error ? error.message : String(error); throw new Error( `${reason}\nResend connector ${connector.uid} may persist. Inspect it with \`vercel connect list\` and re-run \`eve add channel/resend\` to recover.`, From 771d7b1224b63eccb9b6141041dc89b5fbf8fc34 Mon Sep 17 00:00:00 2001 From: benpankow Date: Tue, 4 Aug 2026 16:59:59 -0500 Subject: [PATCH 18/19] fix(eve): store Resend credentials in project env Signed-off-by: benpankow --- .changeset/fuzzy-melons-email.md | 2 +- apps/docs/registry.json | 7 +-- .../setup/integrations/resend/setup.test.ts | 20 ++----- .../src/setup/integrations/resend/setup.ts | 55 ++++++++----------- 4 files changed, 31 insertions(+), 53 deletions(-) diff --git a/.changeset/fuzzy-melons-email.md b/.changeset/fuzzy-melons-email.md index 6da2accbf2..056531e5ce 100644 --- a/.changeset/fuzzy-melons-email.md +++ b/.changeset/fuzzy-melons-email.md @@ -2,4 +2,4 @@ "eve": patch --- -Add guided Resend email setup through `eve add channel/resend`, with portable credentials or a generic Vercel Connect API-key connector, deployment, and webhook reconciliation. +Add guided Resend email setup through `eve add channel/resend`, with Vercel-domain provisioning, existing-account authorization, manual credentials, deployment, and webhook reconciliation. diff --git a/apps/docs/registry.json b/apps/docs/registry.json index b60ee54852..984da783f5 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -1723,12 +1723,7 @@ "type": "registry:item", "title": "Resend", "description": "Send and receive threaded email through Resend via the Chat SDK.", - "dependencies": [ - "chat", - "@resend/chat-sdk-adapter", - "@chat-adapter/state-memory", - "@vercel/connect" - ], + "dependencies": ["chat", "@resend/chat-sdk-adapter", "@chat-adapter/state-memory"], "meta": { "eve": { "setup": { diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index d25bde4859..5012d435af 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -33,10 +33,6 @@ function deps(): ResendSetupDeps { listMarketplaceResources: vi.fn(async () => []), listDomains: vi.fn(async () => ["example.com"]), openUrl: vi.fn(), - provisionConnector: vi.fn(async () => ({ - id: "scl_resend", - uid: "api-key/resend-agent", - })), provisionMarketplaceResource: vi.fn(async (input) => ({ id: "store_resend", externalResourceId: input.domain, @@ -344,8 +340,9 @@ describe("Resend setup", () => { expect.objectContaining({ accessToken: "oauth_marketplace" }), ); expect(effects.validateApiKey).toHaveBeenCalledWith("re_generated", undefined); - expect(effects.provisionConnector).toHaveBeenCalledWith( - expect.objectContaining({ apiKey: "re_generated" }), + expect(effects.runVercel).toHaveBeenCalledWith( + ["env", "add", "RESEND_API_KEY", "production", "--force", "--yes"], + expect.objectContaining({ stdin: "re_generated" }), ); }); @@ -355,10 +352,6 @@ describe("Resend setup", () => { vi.mocked(effects.validateApiKey).mockImplementation(async (key) => { events.push(`validate:${key}`); }); - vi.mocked(effects.provisionConnector).mockImplementation(async (input) => { - events.push(`connector:${input.apiKey}`); - return { id: "scl_resend", uid: "api-key/resend-agent" }; - }); vi.mocked(effects.deploy).mockReset(); vi.mocked(effects.deploy).mockImplementation(async () => { events.push("deploy"); @@ -382,7 +375,7 @@ describe("Resend setup", () => { await expect(setupResend(setup.value, effects)).resolves.toMatchObject({ kind: "done" }); expect(events).toEqual([ "validate:re_secret", - "connector:re_secret", + "env:re_secret", "deploy", "webhook:re_secret", "env:whsec_secret", @@ -390,14 +383,14 @@ describe("Resend setup", () => { ]); expect(effects.writeTextFile).toHaveBeenCalledWith( "/project/agent/channels/resend.ts", - expect.stringContaining('connectResendApiKey("api-key/resend-agent")'), + expect.stringContaining("process.env.RESEND_API_KEY"), { force: undefined }, ); }); it("compensates a newly created webhook when saving the secret fails", async () => { const effects = deps(); - vi.mocked(effects.runVercel).mockResolvedValue(false); + vi.mocked(effects.runVercel).mockResolvedValueOnce(true).mockResolvedValueOnce(false); vi.mocked(effects.createApiKey).mockResolvedValue({ id: "key_resend", token: "re_secret" }); const setup = context(effects); await expect(setupResend(setup.value, effects)).rejects.toThrow("may persist"); @@ -411,7 +404,6 @@ describe("Resend setup", () => { expect(effects.appendEnv).toHaveBeenCalledWith("/project/.env.local", { RESEND_API_KEY: "re_secret", }); - expect(effects.provisionConnector).not.toHaveBeenCalled(); expect(effects.writeTextFile).toHaveBeenCalledWith( "/project/agent/channels/resend.ts", expect.stringContaining("process.env.RESEND_API_KEY"), diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index bf5a8b562c..399874c1bd 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -24,7 +24,6 @@ import { suggestResendFromAddress, validateResendApiKey, } from "./api.js"; -import { provisionResendConnector } from "./connect.js"; import { authorizeResendMarketplaceSetup, createResendApiKey, @@ -54,7 +53,6 @@ export interface ResendSetupDeps { listMarketplaceResources: typeof listResendMarketplaceResources; listDomains: typeof listVercelDomains; openUrl: typeof openUrl; - provisionConnector: typeof provisionResendConnector; provisionMarketplaceResource: typeof provisionResendMarketplaceResource; connectMarketplaceResource: typeof connectResendMarketplaceResource; authorizeMarketplaceSetup: typeof authorizeResendMarketplaceSetup; @@ -80,7 +78,6 @@ const defaultDeps: ResendSetupDeps = { listMarketplaceResources: listResendMarketplaceResources, listDomains: listVercelDomains, openUrl, - provisionConnector: provisionResendConnector, provisionMarketplaceResource: provisionResendMarketplaceResource, connectMarketplaceResource: connectResendMarketplaceResource, authorizeMarketplaceSetup: authorizeResendMarketplaceSetup, @@ -99,22 +96,10 @@ function validateEmail(value: string): string | null { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) ? null : "Enter a complete email address."; } -function channelTemplate(input: { - apiKey: string; - connectorUid?: string; - fromAddress: string; - fromName: string; -}): string { - const apiKey = input.connectorUid - ? `connectResendApiKey(${JSON.stringify(input.connectorUid)})` - : `() => { - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) throw new Error("RESEND_API_KEY is required."); - return Promise.resolve(apiKey); - }`; +function channelTemplate(input: { fromAddress: string; fromName: string }): string { return `import { createMemoryState } from "@chat-adapter/state-memory"; import { createResendAdapter } from "@resend/chat-sdk-adapter"; -${input.connectorUid ? 'import { connectResendApiKey } from "@vercel/connect/eve";\n' : ""}import type { Message, Thread } from "chat"; +import type { Message, Thread } from "chat"; import { chatSdkChannel, messageToUserContent } from "eve/channels/chat-sdk"; import { captureResendReplyContext, restoreResendReplyContext } from "eve/channels/resend"; @@ -122,7 +107,11 @@ export const { bot, channel, send } = chatSdkChannel({ userName: ${JSON.stringify(input.fromName || "Eve")}, adapters: { resend: createResendAdapter({ - apiKey: ${apiKey}, + apiKey: () => { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) throw new Error("RESEND_API_KEY is required."); + return Promise.resolve(apiKey); + }, fromAddress: ${JSON.stringify(input.fromAddress)}, fromName: ${JSON.stringify(input.fromName || "Eve")}, }), @@ -332,7 +321,7 @@ async function setupMarketplace( ); await deps.writeTextFile( join(context.appRoot, "agent/channels/resend.ts"), - channelTemplate({ apiKey: "", fromAddress: fromAddress.trim(), fromName: fromName.trim() }), + channelTemplate({ fromAddress: fromAddress.trim(), fromName: fromName.trim() }), { force: context.force }, ); const deployed = await deps.deploy({ @@ -514,7 +503,7 @@ export async function setupResend( }); await deps.writeTextFile( join(context.appRoot, "agent/channels/resend.ts"), - channelTemplate({ apiKey, fromAddress, fromName }), + channelTemplate({ fromAddress, fromName }), { force: context.force }, ); context.ui.nextSteps([ @@ -524,23 +513,25 @@ export async function setupResend( return { kind: "done" }; } - const project = await deps.ensureVercelProject({ + await deps.ensureVercelProject({ appRoot: context.appRoot, prompter: context.ui.prompter, signal: context.signal, }); - const connector = await deps.provisionConnector({ - apiKey, - log: context.ui.prompter.log, - project, - projectRoot: context.appRoot, - slug: `resend-${await deps.deriveConnectorSlug(context.appRoot)}`, - signal: context.signal, - }); try { + const savedApiKey = await deps.runVercel( + ["env", "add", "RESEND_API_KEY", "production", "--force", "--yes"], + { + cwd: context.appRoot, + nonInteractive: true, + signal: context.signal, + stdin: apiKey, + }, + ); + if (!savedApiKey) throw new Error("Could not save RESEND_API_KEY to Vercel production."); await deps.writeTextFile( join(context.appRoot, "agent/channels/resend.ts"), - channelTemplate({ apiKey, connectorUid: connector.uid, fromAddress, fromName }), + channelTemplate({ fromAddress, fromName }), { force: context.force }, ); const deployed = await deps.deploy({ @@ -551,7 +542,7 @@ export async function setupResend( }); if (deployed.kind !== "deployed" || deployed.productionUrl === undefined) { throw new Error( - `Connector ${connector.uid} is ready, but setup could not determine the production URL. Run \`vercel deploy --prod\`, then re-run Resend setup.`, + "The Resend credential is ready, but setup could not determine the production URL. Run `vercel deploy --prod`, then rerun Resend setup.", ); } const endpoint = new URL("/eve/v1/resend", deployed.productionUrl).href; @@ -602,7 +593,7 @@ export async function setupResend( } const reason = error instanceof Error ? error.message : String(error); throw new Error( - `${reason}\nResend connector ${connector.uid} may persist. Inspect it with \`vercel connect list\` and re-run \`eve add channel/resend\` to recover.`, + `${reason}\nThe generated Resend API key may persist in the project's production environment. Rerun \`eve add channel/resend\` to recover.`, ); } } catch (error) { From babc380225fde440d0ad4ef160e8a26e08d65ff9 Mon Sep 17 00:00:00 2001 From: benpankow Date: Wed, 5 Aug 2026 11:28:50 -0500 Subject: [PATCH 19/19] feat(eve): recommend linked project email domain Signed-off-by: benpankow --- .../integrations/resend/marketplace.test.ts | 33 +++++++++++-- .../setup/integrations/resend/marketplace.ts | 48 ++++++++++++++----- .../setup/integrations/resend/setup.test.ts | 6 ++- .../src/setup/integrations/resend/setup.ts | 15 ++++-- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/eve/src/setup/integrations/resend/marketplace.test.ts b/packages/eve/src/setup/integrations/resend/marketplace.test.ts index 97a39aa844..14487a6371 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.test.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.test.ts @@ -66,10 +66,29 @@ describe("Resend Marketplace", () => { ]); }); - it("lists Vercel-owned domains", async () => { - const captureVercel = capture({ - domains: [{ name: "example.com" }, { name: "another.example" }], - }); + it("prioritizes useful production aliases before Vercel-owned domains", async () => { + const captureVercel = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + domains: [{ name: "example.com" }, { name: "another.example" }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + stdout: JSON.stringify({ + targets: { + production: { + alias: [ + "resend-eve-test.playground-vercel.tools", + "resend-eve-test.vercel.app", + "resend-eve-test-git-main.preview.example.com", + ], + }, + }, + }), + }); await expect( listVercelDomains({ @@ -77,7 +96,11 @@ describe("Resend Marketplace", () => { project: { orgId: "team", projectId: "project" }, deps: { captureVercel }, }), - ).resolves.toEqual(["example.com", "another.example"]); + ).resolves.toEqual([ + "resend-eve-test.playground-vercel.tools", + "example.com", + "another.example", + ]); expect(captureVercel).toHaveBeenCalledWith( ["domains", "list", "--format", "json", "--limit", "100", "--scope", "team"], expect.objectContaining({ cwd: "/project" }), diff --git a/packages/eve/src/setup/integrations/resend/marketplace.ts b/packages/eve/src/setup/integrations/resend/marketplace.ts index 9b587d7009..9def447214 100644 --- a/packages/eve/src/setup/integrations/resend/marketplace.ts +++ b/packages/eve/src/setup/integrations/resend/marketplace.ts @@ -30,6 +30,13 @@ const ResourceSchema = z.object({ }); const ResourceListSchema = z.object({ stores: z.array(z.unknown()) }); const DomainListSchema = z.object({ domains: z.array(z.object({ name: z.string().min(1) })) }); +const ProjectSchema = z.object({ + targets: z + .object({ + production: z.object({ alias: z.array(z.string()).optional() }).optional(), + }) + .optional(), +}); const InspectedResourceSchema = z.object({ resource: z.object({ id: z.string().min(1), @@ -102,7 +109,7 @@ export async function listResendMarketplaceResources(input: { return resources; } -/** Lists domains owned by the linked Vercel team. */ +/** Lists project production aliases and domains owned by the linked Vercel team. */ export async function listVercelDomains(input: { projectRoot: string; project: VercelProjectReference; @@ -110,20 +117,39 @@ export async function listVercelDomains(input: { deps?: Pick; }): Promise { const deps = input.deps ?? defaultDeps; - const result = await deps.captureVercel( - ["domains", "list", "--format", "json", "--limit", "100", "--scope", input.project.orgId], - { cwd: input.projectRoot, signal: input.signal }, - ); - if (!result.ok) throw new Error("Could not inspect Vercel domains."); - let body: unknown; + const [domainsResult, projectResult] = await Promise.all([ + deps.captureVercel( + ["domains", "list", "--format", "json", "--limit", "100", "--scope", input.project.orgId], + { cwd: input.projectRoot, signal: input.signal }, + ), + deps.captureVercel( + [ + "api", + `/v9/projects/${input.project.projectId}?teamId=${input.project.orgId}`, + "--scope", + input.project.orgId, + ], + { cwd: input.projectRoot, signal: input.signal }, + ), + ]); + if (!domainsResult.ok) throw new Error("Could not inspect Vercel domains."); + let domainsBody: unknown; + let projectBody: unknown; try { - body = JSON.parse(result.stdout) as unknown; + domainsBody = JSON.parse(domainsResult.stdout) as unknown; + projectBody = projectResult.ok ? (JSON.parse(projectResult.stdout) as unknown) : undefined; } catch { throw new Error("Vercel returned invalid JSON for the domain list."); } - const parsed = DomainListSchema.safeParse(body); - if (!parsed.success) throw new Error("Vercel returned an invalid domain list."); - return parsed.data.domains.map((domain) => domain.name); + const domains = DomainListSchema.safeParse(domainsBody); + if (!domains.success) throw new Error("Vercel returned an invalid domain list."); + const project = ProjectSchema.safeParse(projectBody); + const aliases = project.success ? (project.data.targets?.production?.alias ?? []) : []; + const usefulAliases = aliases.filter( + (alias) => + !alias.endsWith(".vercel.app") && !alias.includes("-git-") && !alias.includes(".preview."), + ); + return [...new Set([...usefulAliases, ...domains.data.domains.map((domain) => domain.name)])]; } /** Provisions and connects a Resend Marketplace resource through Vercel CLI. */ diff --git a/packages/eve/src/setup/integrations/resend/setup.test.ts b/packages/eve/src/setup/integrations/resend/setup.test.ts index 5012d435af..47d130dcd2 100644 --- a/packages/eve/src/setup/integrations/resend/setup.test.ts +++ b/packages/eve/src/setup/integrations/resend/setup.test.ts @@ -175,12 +175,16 @@ describe("Resend setup", () => { search: true, placeholder: "type to filter domains", options: [ + expect.objectContaining({ + value: "alpha.example", + featured: true, + hint: "Current production domain · recommended", + }), expect.objectContaining({ value: "__add-vercel-domain__", featured: true, trailingAction: true, }), - expect.objectContaining({ value: "alpha.example", featured: true }), expect.objectContaining({ value: "beta.example", featured: true }), expect.objectContaining({ value: "gamma.example", featured: true }), expect.objectContaining({ value: "delta.example", featured: true }), diff --git a/packages/eve/src/setup/integrations/resend/setup.ts b/packages/eve/src/setup/integrations/resend/setup.ts index 399874c1bd..59d0cff852 100644 --- a/packages/eve/src/setup/integrations/resend/setup.ts +++ b/packages/eve/src/setup/integrations/resend/setup.ts @@ -216,26 +216,33 @@ async function selectMarketplaceDomain( return "cancelled"; }; if (domains.length === 0) return openDomainSetup(); - if (context.yes) return domains[0]!; + const recommended = domains[0]!; + if (context.yes) return recommended; const selected = await context.ui.prompter.select({ message: "Domain for Resend", description: "Resend will open Vercel web to confirm account, billing, and DNS setup.", search: true, placeholder: "type to filter domains", options: [ + { + value: recommended, + label: recommended, + hint: "Current production domain · recommended", + featured: true, + }, { value: ADD_VERCEL_DOMAIN, label: "Add or purchase a domain in Vercel", featured: true, trailingAction: true, }, - ...domains.map((domain, index) => ({ + ...domains.slice(1).map((domain, index) => ({ value: domain, label: domain, - featured: index < 4, + featured: index < 3, })), ], - initialValue: domains[0], + initialValue: recommended, }); return selected === ADD_VERCEL_DOMAIN ? openDomainSetup() : selected; }