diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 701339bce..9e308d1de 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -146,53 +146,12 @@ async function main() { // Extract credentials from deployed state for this target const targetState = (deployedState as Record)?.targets as - | Record> - | undefined; + Record> | undefined; const targetResources = target ? (targetState?.[target.name]?.resources as Record | undefined) : undefined; const credentials = targetResources?.credentials as - | Record - | undefined; - - // Payment credential provider ARNs live in the same credentials map as identity credentials - const paymentCredentials = credentials; - - const paymentSpec = specAny.payments?.length - ? specAny.payments.map( - (p: { - name: string; - description?: string; - authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; - authorizerConfiguration?: unknown; - autoPayment?: boolean; - paymentToolAllowlist?: string[]; - networkPreferences?: string[]; - connectors: { name: string; provider?: string; credentialName: string }[]; - }) => ({ - name: p.name, - description: p.description, - authorizerType: p.authorizerType, - authorizerConfiguration: p.authorizerConfiguration, - autoPayment: p.autoPayment, - paymentToolAllowlist: p.paymentToolAllowlist, - networkPreferences: p.networkPreferences, - connectors: p.connectors.map(c => { - const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; - if (!credentialProviderArn) { - // Fail fast with an actionable message rather than passing an empty - // ARN that fails opaquely server-side at CreatePaymentConnector. - throw new Error( - `Payment connector "${c.name}" on manager "${p.name}" references credential ` + - `"${c.credentialName}", but no deployed credential provider was found for it. ` + - `Run \`agentcore deploy\` so the credential provider is created first.` - ); - } - return { name: c.name, provider: c.provider, credentialProviderArn }; - }), - }) - ) - : undefined; + Record | undefined; new AgentCoreStack(app, stackName, { spec, @@ -200,7 +159,6 @@ async function main() { credentials, connectorParametersByFile, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, - paymentSpec, env, description: target ? `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})` diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 3dac0669d..9592561d2 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,15 +1,12 @@ import { AgentCoreApplication, AgentCoreMcp, - AgentCorePaymentManager, - AgentCorePaymentConnector, + AgentCorePayments, type AgentCoreProjectSpec, type AgentCoreMcpSpec, - type CustomJWTAuthorizerConfig, type HarnessDeploymentConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; -import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; /** @@ -19,23 +16,6 @@ import { Construct } from 'constructs'; */ export type HarnessConfig = HarnessDeploymentConfig; -export interface PaymentConnectorSpec { - name: string; - provider: 'CoinbaseCDP' | 'StripePrivy'; - credentialProviderArn: string; -} - -export interface PaymentSpec { - name: string; - description?: string; - authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; - authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; - autoPayment?: boolean; - paymentToolAllowlist?: string[]; - networkPreferences?: string[]; - connectors: PaymentConnectorSpec[]; -} - export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -58,30 +38,6 @@ export interface AgentCoreStackProps extends StackProps { * connectorConfigFile path. Forwarded to AgentCoreApplication. */ connectorParametersByFile?: Record>; - /** - * Payment specifications with resolved credential provider ARNs. - */ - paymentSpec?: PaymentSpec[]; -} - -function toCdkId(name: string): string { - return name.replace(/_/g, ''); -} - -/** - * Decide whether a deployed runtime should receive payment env vars + IAM grants. - * Payments today only ships a runtime shim for Python HTTP runtimes; injecting - * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes - * would surface env vars they cannot consume and would dilute least-privilege - * IAM grants for runtimes that never call ProcessPayment. - */ -function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { - if (agent.protocol && agent.protocol !== 'HTTP') { - return false; - } - const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; - const entrypointFile = entrypoint.split(':')[0] ?? ''; - return entrypointFile.endsWith('.py'); } /** @@ -97,7 +53,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -112,6 +68,11 @@ export class AgentCoreStack extends Stack { appProps.credentials = credentials; } this.application = new AgentCoreApplication(this, 'Application', appProps as any); + new AgentCorePayments(this, 'Payments', { + spec, + credentials, + agentCoreApplication: this.application, + }); // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { @@ -124,122 +85,6 @@ export class AgentCoreStack extends Stack { }); } - // Create payment infrastructure via CFN constructs - if (paymentSpec && paymentSpec.length > 0) { - for (const payment of paymentSpec) { - const mgrId = toCdkId(payment.name); - const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { - projectName: spec.name, - name: payment.name, - authorizerType: payment.authorizerType, - description: payment.description, - authorizerConfiguration: payment.authorizerConfiguration, - tags: spec.tags, - }); - - const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`; - - // Wire env vars from construct output tokens into eligible agent environments only. - // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that - // can consume these env vars, and giving them sts:AssumeRole on the - // ProcessPaymentRole would broaden the privilege surface unnecessarily. - for (const env of this.application.environments.values()) { - if (!isPaymentEligibleAgent(env.agent)) { - continue; - } - env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn); - env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn); - - // Grant runtime execution role permission to assume the ProcessPaymentRole. - // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the - // caller still needs sts:AssumeRole on its own role to perform the assumption. - env.runtime.role.addToPrincipalPolicy( - new iam.PolicyStatement({ - actions: ['sts:AssumeRole'], - resources: [manager.processPaymentRoleArn], - }) - ); - - // Grant payment data-plane actions directly to the runtime role. - // - // NOTE: This deviates from the canonical role model in the AgentCore Payments - // beta guide, which assigns Get/List/Create instrument+session actions to a - // separate ManagementRole and limits the agent's role to ProcessPayment only. - // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) - // calls GetPaymentInstrument internally during the 402 auto-pay path, so the - // runtime role needs read access. CreatePaymentSession is included so - // `agentcore invoke --auto-session` works without a separate ManagementRole - // call. Tighten this if the SDK is updated to accept pre-fetched instrument - // details and split create-session into a backend-only flow. - env.runtime.role.addToPrincipalPolicy( - new iam.PolicyStatement({ - actions: [ - 'bedrock-agentcore:GetPaymentInstrument', - 'bedrock-agentcore:ListPaymentInstruments', - 'bedrock-agentcore:GetPaymentInstrumentBalance', - 'bedrock-agentcore:GetPaymentSession', - 'bedrock-agentcore:ListPaymentSessions', - 'bedrock-agentcore:CreatePaymentSession', - 'bedrock-agentcore:ProcessPayment', - ], - resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`], - }) - ); - - if (payment.autoPayment !== undefined) { - env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment)); - } - if (payment.paymentToolAllowlist) { - env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(',')); - } - if (payment.networkPreferences) { - env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(',')); - } - if (payment.authorizerType === 'CUSTOM_JWT') { - env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer'); - } - } - - // Create connectors for this manager - for (const connector of payment.connectors) { - const connId = toCdkId(connector.name); - const conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { - projectName: spec.name, - paymentManager: manager, - connectorName: connector.name, - connectorType: connector.provider, - credentialProviderArn: connector.credentialProviderArn, - }); - - // Wire first connector's ID as env var (eligible agents only) - if (connector === payment.connectors[0]) { - for (const env of this.application.environments.values()) { - if (!isPaymentEligibleAgent(env.agent)) continue; - env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId); - } - } - - new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { - value: conn.paymentConnectorId, - }); - } - - // CFN Outputs for post-deploy state parsing - new CfnOutput(this, `Payment${mgrId}ManagerArn`, { - value: manager.paymentManagerArn, - }); - new CfnOutput(this, `Payment${mgrId}ManagerId`, { - value: manager.paymentManagerId, - }); - new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { - value: manager.processPaymentRoleArn, - }); - new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { - value: manager.resourceRetrievalRoleArn, - }); - } - } - // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 0ac28f946..407a29fbd 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -23,7 +23,7 @@ "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "0.1.0-alpha.45", + "@aws/agentcore-cdk": "0.1.0-alpha.49", "aws-cdk-lib": "~2.266.0", "constructs": "~10.7.0" } diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 8db318ada..2db16484f 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -1,6 +1,29 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; -import { AgentCoreStack } from '../lib/cdk-stack'; +import { Match, Template } from 'aws-cdk-lib/assertions'; + +const originalCwd = process.cwd(); +const originalInitCwd = process.env.INIT_CWD; +const testRoot = mkdtempSync(join(tmpdir(), 'agentcore-cdk-test-')); +const testConfigDir = join(testRoot, 'agentcore'); +let AgentCoreStack: typeof import('../lib/cdk-stack').AgentCoreStack; + +beforeAll(async () => { + process.chdir(testRoot); + process.env.INIT_CWD = testRoot; + mkdirSync(testConfigDir, { recursive: true }); + writeFileSync(join(testConfigDir, 'agentcore.json'), '{}'); + ({ AgentCoreStack } = await import('../lib/cdk-stack')); +}); + +afterAll(() => { + process.chdir(originalCwd); + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + rmSync(testRoot, { recursive: true, force: true }); +}); test('AgentCoreStack synthesizes with empty spec', () => { const app = new cdk.App(); @@ -29,3 +52,145 @@ test('AgentCoreStack synthesizes with empty spec', () => { Description: 'Name of the CloudFormation Stack', }); }); + +test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [ + { + authorizerType: 'PaymentCredentialProvider', + name: 'coinbase', + provider: 'CoinbaseCDP', + }, + ], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'Manual', + provider: 'CoinbaseCDP', + credentialName: 'coinbase', + }, + { + name: 'Quick', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + ], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + credentials: { + coinbase: { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', + }, + }, + }); + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 2); + template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { + ConnectorName: 'Manual', + ProvisionMode: Match.absent(), + }); + template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { + ConnectorName: 'Quick', + ConnectorType: 'CoinbaseCDP', + ProvisionMode: 'QUICK_CREATE', + CredentialProviderConfigurations: [], + }); + expect(Object.keys(template.findOutputs('*')).some(key => key.includes('AuthorizationUrl'))).toBe(true); +}); + +test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'foo_bar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'foobar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'A', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'B', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'BC', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'AB', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'C', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'M1AC1B', + authorizerType: 'AWS_IAM', + connectors: [], + }, + ], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + }); + + Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 5); +}); diff --git a/src/core/project/envLocal.test.ts b/src/core/project/envLocal.test.ts index 44834806d..37d8cb4ca 100644 --- a/src/core/project/envLocal.test.ts +++ b/src/core/project/envLocal.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { parseEnv } from "node:util"; @@ -20,6 +20,26 @@ async function tempRoot(): Promise { } const ENTRY = { key: "SECRET", value: "v", comment: "c" }; +const testPosix = process.platform === "win32" ? test.skip : test; + +testPosix("creates and replaces the secrets file with owner-only permissions", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + + await file.insertIfNew([ENTRY]); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); + + await chmod(file.path, 0o644); + await file.insertIfNew([ENTRY]); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); + + await chmod(file.path, 0o644); + await file.insertIfNew([{ key: "SECOND", value: "v", comment: "c" }]); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); + + await file.rollback(); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); +}); test("rollback deletes the file it created", async () => { const root = await tempRoot(); diff --git a/src/core/project/envLocal.ts b/src/core/project/envLocal.ts index 5774dde36..32143f3db 100644 --- a/src/core/project/envLocal.ts +++ b/src/core/project/envLocal.ts @@ -1,4 +1,4 @@ -import { rm } from "node:fs/promises"; +import { chmod, rm } from "node:fs/promises"; import { join } from "node:path"; import { atomicWrite, readTextFile } from "../../io"; import { InputValidationError } from "../../errors"; @@ -8,6 +8,7 @@ import type { EnvLocalEntry } from "../../handlers/project/types"; export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local"); const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/; +const SECRET_FILE_MODE = 0o600; /** * The project's `.env.local` secrets file, edited transactionally. `insertIfNew` @@ -35,6 +36,7 @@ export class EnvLocalFile { */ async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> { const existing = await this.readOrNull(); + if (existing !== null) await this.enforcePermissions(); const existingKeys = new Set( (existing ?? "") .split("\n") @@ -58,7 +60,7 @@ export class EnvLocalFile { if (written.length > 0) { this.snapshot = existing; - await atomicWrite(this.path, content); + await atomicWrite(this.path, content, { mode: SECRET_FILE_MODE }); } return { written, skipped }; } @@ -67,7 +69,7 @@ export class EnvLocalFile { async rollback(): Promise { if (this.snapshot === undefined) return; if (this.snapshot === null) await rm(this.path, { force: true }); - else await atomicWrite(this.path, this.snapshot); + else await atomicWrite(this.path, this.snapshot, { mode: SECRET_FILE_MODE }); } private async readOrNull(): Promise { @@ -78,6 +80,10 @@ export class EnvLocalFile { throw error; } } + + private async enforcePermissions(): Promise { + if (process.platform !== "win32") await chmod(this.path, SECRET_FILE_MODE); + } } /** diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 042c6a13a..cd3921a12 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -32,6 +32,7 @@ import { CredentialSchema } from "../../projectSchemas/credential"; import { MemorySchema } from "../../projectSchemas/memory"; import { EvaluatorSchema } from "../../projectSchemas/evaluator"; import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; +import { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; import { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; import { enclosingProjectRoot, projectSpecPath } from "./fsUtils"; import { @@ -161,7 +162,7 @@ export class FsProjectManager implements ProjectManager { yield { message: `Reading project spec file at '${agentCoreSpecPath}'` }; const projectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); - const existingResources = projectSpec[projectSpecKey]; + const existingResources = projectSpec[projectSpecKey] ?? []; if (input.resourceType === "gateway-target") { // Current L3 outputs are keyed only by Target name, so names must remain // project-unique until those outputs include the parent Gateway. @@ -190,6 +191,20 @@ export class FsProjectManager implements ProjectManager { `a policy with name '${input.resourceConfig.name}' already exists in policy engine '${engine.name}'`, ); } + } else if (input.resourceType === "payment-connector") { + const manager = projectSpec.payments?.find( + (candidate) => candidate.name === input.managerName, + ); + if (!manager) { + throw new InputValidationError( + `payment manager '${input.managerName}' does not exist in this project`, + ); + } + if (manager.connectors.some((connector) => connector.name === input.resourceConfig.name)) { + throw new InputValidationError( + `a payment connector with name '${input.resourceConfig.name}' already exists in manager '${input.managerName}'`, + ); + } } else if (existingResources.find((resource) => resource.name === input.resourceConfig.name)) { throw new InputValidationError( `a ${input.resourceType} with name '${input.resourceConfig.name}' already exists`, @@ -261,6 +276,11 @@ export class FsProjectManager implements ProjectManager { case "gateway": projectSpec.agentCoreGateways.push(input.resourceConfig); break; + case "payment-manager": { + projectSpec.payments ??= []; + projectSpec.payments.push(parseResource(PaymentManagerSchema, input.resourceConfig)); + break; + } case "policy-engine": { projectSpec.policyEngines.push(parseResource(PolicyEngineSchema, input.resourceConfig)); for (const gatewayName of input.attachGateways?.names ?? []) { @@ -303,6 +323,13 @@ export class FsProjectManager implements ProjectManager { projectSpec.agentCoreGateways[gatewayIndex]!.targets.push(input.resourceConfig); break; } + case "payment-connector": { + const manager = projectSpec.payments!.find( + (candidate) => candidate.name === input.managerName, + )!; + manager.connectors.push(parseResource(PaymentConnectorSchema, input.resourceConfig)); + break; + } default: { const unhandled: never = input; throw new NotImplementedError(`unsupported project resource: ${String(unhandled)}`); @@ -400,9 +427,19 @@ export class FsProjectManager implements ProjectManager { gateways[gatewayIndex] = { ...gateway, targets }; } newSpec = { ...existingProjectSpec, agentCoreGateways: gateways }; + } else if (input.resourceType === "payment-connector") { + const payments = [...(existingProjectSpec.payments ?? [])]; + const managerIndex = payments.findIndex((manager) => manager.name === input.managerName); + if (managerIndex >= 0) { + const manager = payments[managerIndex]!; + const connectors = manager.connectors.filter((connector) => connector.name !== input.name); + removed = connectors.length !== manager.connectors.length; + payments[managerIndex] = { ...manager, connectors }; + } + newSpec = { ...existingProjectSpec, payments }; } else { const projectSpecKey = toProjectSpecKey(input.resourceType); - const existingResources = existingProjectSpec[projectSpecKey]; + const existingResources = existingProjectSpec[projectSpecKey] ?? []; const newResources = existingResources.filter((resource) => resource.name !== input.name); removed = newResources.length !== existingResources.length; newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; @@ -537,6 +574,9 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "policy-engine": case "policy": return "policyEngines"; + case "payment-manager": + case "payment-connector": + return "payments"; } } diff --git a/src/handlers/project/add/credentials/index.ts b/src/handlers/project/add/credentials/index.ts index 179ee66c3..c78c69033 100644 --- a/src/handlers/project/add/credentials/index.ts +++ b/src/handlers/project/add/credentials/index.ts @@ -2,6 +2,7 @@ import { Router } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { createAddApiKeyCredentialHandler } from "./api-key"; import { createAddOauthCredentialHandler } from "./oauth"; +import { createAddPaymentCredentialHandler } from "./payment"; export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router { const credentials = new Router( @@ -10,5 +11,6 @@ export function createAddCredentialsHandler(config: AddProjectResourceConfig): R ); credentials.handler(createAddApiKeyCredentialHandler(config)); credentials.handler(createAddOauthCredentialHandler(config)); + credentials.handler(createAddPaymentCredentialHandler(config)); return credentials; } diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts new file mode 100644 index 000000000..13bfe7322 --- /dev/null +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { join } from "node:path"; +import { createPaymentProjectTestHarness } from "../../payment-test-support"; + +const { cleanup, inProject, projectSpec, run } = + createPaymentProjectTestHarness("payment-credential"); + +afterEach(cleanup); + +function coinbaseApiKeySecret(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + const key = privateKey.export({ format: "jwk" }); + return Buffer.concat([ + Buffer.from(key.d!, "base64url"), + Buffer.from(key.x!, "base64url"), + ]).toString("base64"); +} + +describe("project add credentials payment", () => { + test.each(["CoinbaseCDP", "StripePrivy"] as const)( + "adds a reusable %s payment credential", + async (provider) => { + const projectRoot = await inProject(); + + const io = await run([ + "add", + "credentials", + "payment", + "--name", + `${provider.toLowerCase()}-credential`, + "--provider", + provider, + ]); + + expect((await projectSpec(projectRoot)).credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: `${provider.toLowerCase()}-credential`, + provider, + }, + ]); + expect(io.stderr()).toContain(`added credential '${provider.toLowerCase()}-credential'`); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + const prefix = `AGENTCORE_CREDENTIAL_${provider.toUpperCase()}_CREDENTIAL`; + const suffixes = + provider === "CoinbaseCDP" + ? ["API_KEY_ID", "API_KEY_SECRET", "WALLET_SECRET"] + : ["APP_ID", "APP_SECRET", "AUTHORIZATION_PRIVATE_KEY", "AUTHORIZATION_ID"]; + for (const suffix of suffixes) { + expect(env).toContain(`${prefix}_${suffix}=\n`); + expect(io.stderr()).toContain(`Set ${prefix}_${suffix} in agentcore/.env.local`); + } + }, + ); + + test("stores source-aware Coinbase credential values outside the project schema", async () => { + const projectRoot = await inProject(); + const apiKeySecretPath = join(projectRoot, "api-key-secret.txt"); + const walletSecretPath = join(projectRoot, "wallet-secret.txt"); + const apiKeySecret = coinbaseApiKeySecret(); + const walletSecret = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + await Bun.write(apiKeySecretPath, `${apiKeySecret}\n`); + await Bun.write(walletSecretPath, `${walletSecret}\n`); + + await run([ + "add", + "credentials", + "payment", + "--name", + "coinbase-prod", + "--provider", + "CoinbaseCDP", + "--api-key-id", + "api-key-id", + "--api-key-secret", + `file://${apiKeySecretPath}`, + "--wallet-secret", + `file://${walletSecretPath}`, + ]); + + expect((await projectSpec(projectRoot)).credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase-prod", + provider: "CoinbaseCDP", + }, + ]); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_ID='api-key-id'"); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET='${apiKeySecret}'`); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='${walletSecret}'`); + }); + + test("normalizes Coinbase key whitespace before persistence", async () => { + const projectRoot = await inProject(); + const apiKeySecretPath = join(projectRoot, "api-key-secret-padded.txt"); + const walletSecretPath = join(projectRoot, "wallet-secret-padded.txt"); + const apiKeySecret = coinbaseApiKeySecret(); + const walletSecret = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + await Bun.write(apiKeySecretPath, ` ${apiKeySecret} \n`); + await Bun.write(walletSecretPath, ` ${walletSecret} \n`); + + await run([ + "add", + "credentials", + "payment", + "--name", + "coinbase-padded", + "--provider", + "CoinbaseCDP", + "--api-key-id", + "coinbase_key", + "--api-key-secret", + `file://${apiKeySecretPath}`, + "--wallet-secret", + `file://${walletSecretPath}`, + ]); + + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PADDED_API_KEY_SECRET='${apiKeySecret}'`); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PADDED_WALLET_SECRET='${walletSecret}'`); + expect(env).not.toContain(`' ${apiKeySecret} '`); + }); + + test("normalizes the documented Stripe authorization key prefix", async () => { + const projectRoot = await inProject(); + const privateKeyPath = join(projectRoot, "private-key.txt"); + const privateKey = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + await Bun.write(privateKeyPath, `wallet-auth:${privateKey}\n`); + + await run([ + "add", + "credentials", + "payment", + "--name", + "stripe-prod", + "--provider", + "StripePrivy", + "--authorization-private-key", + `file://${privateKeyPath}`, + ]); + + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain( + `AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_PRIVATE_KEY='${privateKey}'`, + ); + expect(env).not.toContain("wallet-auth:"); + }); + + test.each([ + [ + "inline Coinbase API key secret", + ["--provider", "CoinbaseCDP", "--api-key-secret", "inline-secret"], + "must come from stdin", + ], + [ + "Stripe option with Coinbase", + ["--provider", "CoinbaseCDP", "--app-id", "privy-app"], + "not valid with --provider CoinbaseCDP", + ], + [ + "Coinbase option with Stripe", + ["--provider", "StripePrivy", "--api-key-id", "coinbase-key"], + "not valid with --provider StripePrivy", + ], + [ + "invalid Coinbase API key ID", + ["--provider", "CoinbaseCDP", "--api-key-id", "invalid key!"], + "apiKeyId", + ], + ])("rejects %s without mutating the project", async (_label, flags, message) => { + const projectRoot = await inProject(); + + await expect( + run(["add", "credentials", "payment", "--name", "payment-credential", ...flags]), + ).rejects.toThrow(message); + + expect((await projectSpec(projectRoot)).credentials ?? []).toEqual([]); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).not.toContain("AGENTCORE_CREDENTIAL_PAYMENT_CREDENTIAL"); + }); + + test.each([ + ["missing name", ["--provider", "CoinbaseCDP"], "required option '--name"], + ["missing provider", ["--name", "payment-credential"], "required option '--provider"], + [ + "unsupported provider", + ["--name", "payment-credential", "--provider", "Unsupported"], + "Invalid value for option '--provider'", + ], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + + await expect(run(["add", "credentials", "payment", ...flags])).rejects.toThrow(message); + expect((await projectSpec(projectRoot)).credentials ?? []).toEqual([]); + }); + + test("rejects duplicate names across credential types", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "api-key", "--name", "shared"]); + + await expect( + run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]), + ).rejects.toThrow("already exists"); + + expect((await projectSpec(projectRoot)).credentials).toHaveLength(1); + }); + + test("rejects credentials that generate overlapping environment variables", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "api-key", "--name", "stripe_app_id"]); + + await expect( + run(["add", "credentials", "payment", "--name", "stripe", "--provider", "StripePrivy"]), + ).rejects.toThrow("environment variable"); + + expect((await projectSpec(projectRoot)).credentials).toHaveLength(1); + }); +}); diff --git a/src/handlers/project/add/credentials/payment/index.ts b/src/handlers/project/add/credentials/payment/index.ts new file mode 100644 index 000000000..ba750c93d --- /dev/null +++ b/src/handlers/project/add/credentials/payment/index.ts @@ -0,0 +1,45 @@ +import z from "zod"; +import { InputValidationError } from "../../../../../errors"; +import { PaymentProviderSchema } from "../../../../../projectSchemas/payment"; +import { createHandler, flag } from "../../../../../router"; +import type { AddProjectResourceConfig } from "../../types"; +import { addCredentialToProject } from "../shared"; +import { paymentCredentialInputFlags, resolvePaymentCredentialEnvEntries } from "./input"; + +export const createAddPaymentCredentialHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "payment", + description: "add a payment credential provider to the current project", + flags: [ + flag("name", "the name of the credential provider", z.string().optional()), + flag( + "provider", + "the payment provider: CoinbaseCDP or StripePrivy", + PaymentProviderSchema.optional(), + ), + ...paymentCredentialInputFlags, + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if (!flags.provider) { + throw new InputValidationError("required option '--provider ' not specified"); + } + + const envEntries = await resolvePaymentCredentialEnvEntries({ + name: flags.name, + provider: flags.provider, + flags, + io: config.io, + }); + await addCredentialToProject(ctx, config, { + resourceConfig: { + authorizerType: "PaymentCredentialProvider", + name: flags.name, + provider: flags.provider, + }, + envEntries, + }); + }, + }); diff --git a/src/handlers/project/add/credentials/payment/input.ts b/src/handlers/project/add/credentials/payment/input.ts new file mode 100644 index 000000000..038e3b41d --- /dev/null +++ b/src/handlers/project/add/credentials/payment/input.ts @@ -0,0 +1,176 @@ +import z from "zod"; +import { InputValidationError } from "../../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../../io"; +import type { PaymentProvider } from "../../../../../projectSchemas/payment"; +import { flag } from "../../../../../router"; +import type { EnvLocalEntry } from "../../../types"; +import { credentialEnvVarName } from "../shared"; +import { + stripWalletAuthPrefix, + validateAppSecret, + validateApiKeySecret, + validateAuthorizationPrivateKey, + validatePaymentIdentifier, + validateWalletSecret, +} from "./validation"; + +export const paymentCredentialInputFlags = [ + flag("api-key-id", "Coinbase CDP API key ID", z.string().optional()), + flag( + "api-key-secret", + "Coinbase CDP API key secret (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag( + "wallet-secret", + "Coinbase CDP wallet secret (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag("app-id", "Privy application ID", z.string().optional()), + flag( + "app-secret", + "Privy application secret (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag( + "authorization-private-key", + "Stripe/Privy authorization private key (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag("authorization-id", "Stripe/Privy authorization identifier", z.string().optional()), +] as const; + +export type PaymentCredentialInputFlags = { + "api-key-id"?: string; + "api-key-secret"?: string; + "wallet-secret"?: string; + "app-id"?: string; + "app-secret"?: string; + "authorization-private-key"?: string; + "authorization-id"?: string; +}; + +const COINBASE_FLAGS = ["api-key-id", "api-key-secret", "wallet-secret"] as const; +const STRIPE_FLAGS = [ + "app-id", + "app-secret", + "authorization-private-key", + "authorization-id", +] as const; + +export function hasPaymentCredentialInput(flags: PaymentCredentialInputFlags): boolean { + return [...COINBASE_FLAGS, ...STRIPE_FLAGS].some((name) => flags[name] !== undefined); +} + +function normalizedIdentifier(name: string, value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + const validation = validatePaymentIdentifier(name, normalized); + if (validation !== true) throw new InputValidationError(validation); + return normalized; +} + +export async function resolvePaymentCredentialEnvEntries(input: { + name: string; + provider: PaymentProvider; + flags: PaymentCredentialInputFlags; + io: AppIO; +}): Promise { + const { name, provider, flags, io } = input; + const invalidFlags = (provider === "CoinbaseCDP" ? STRIPE_FLAGS : COINBASE_FLAGS).filter( + (flagName) => flags[flagName] !== undefined, + ); + if (invalidFlags.length > 0) { + throw new InputValidationError( + `${invalidFlags.map((flagName) => `--${flagName}`).join(", ")} ${ + invalidFlags.length === 1 ? "is" : "are" + } not valid with --provider ${provider}`, + ); + } + + const resolver = new SourceResolver({ stdin: io.stdin }); + if (provider === "StripePrivy") { + const appId = normalizedIdentifier("appId", flags["app-id"]); + const authorizationId = normalizedIdentifier("authorizationId", flags["authorization-id"]); + const appSecret = await resolver.resolveSecret("app-secret", flags["app-secret"]); + if (appSecret !== undefined) { + const validation = validateAppSecret(appSecret); + if (validation !== true) throw new InputValidationError(validation); + } + const resolvedAuthorizationPrivateKey = await resolver.resolveSecret( + "authorization-private-key", + flags["authorization-private-key"], + ); + const authorizationPrivateKey = + resolvedAuthorizationPrivateKey === undefined + ? undefined + : stripWalletAuthPrefix(resolvedAuthorizationPrivateKey); + if (authorizationPrivateKey !== undefined) { + const validation = validateAuthorizationPrivateKey(authorizationPrivateKey); + if (validation !== true) throw new InputValidationError(validation); + } + return [ + { + key: credentialEnvVarName(name, "_APP_ID"), + value: appId, + comment: `Privy application ID for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_APP_SECRET"), + value: appSecret, + comment: `Privy application secret for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_AUTHORIZATION_PRIVATE_KEY"), + value: authorizationPrivateKey, + comment: `Stripe/Privy authorization private key for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_AUTHORIZATION_ID"), + value: authorizationId, + comment: `Stripe/Privy authorization ID for payment credential provider '${name}' (set before deploy)`, + }, + ]; + } + + const apiKeyId = normalizedIdentifier("apiKeyId", flags["api-key-id"]); + const resolvedApiKeySecret = await resolver.resolveSecret( + "api-key-secret", + flags["api-key-secret"], + ); + const resolvedWalletSecret = await resolver.resolveSecret( + "wallet-secret", + flags["wallet-secret"], + ); + const apiKeySecret = resolvedApiKeySecret?.trim(); + const walletSecret = resolvedWalletSecret?.trim(); + if (apiKeySecret !== undefined) { + const validation = validateApiKeySecret(apiKeySecret); + if (validation !== true) throw new InputValidationError(validation); + } + if (walletSecret !== undefined) { + const validation = validateWalletSecret(walletSecret); + if (validation !== true) throw new InputValidationError(validation); + } + return [ + { + key: credentialEnvVarName(name, "_API_KEY_ID"), + value: apiKeyId, + comment: `Coinbase CDP API key ID for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_API_KEY_SECRET"), + value: apiKeySecret, + comment: `Coinbase CDP API key secret for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_WALLET_SECRET"), + value: walletSecret, + comment: `Coinbase CDP wallet secret for payment credential provider '${name}' (set before deploy)`, + }, + ]; +} diff --git a/src/handlers/project/add/credentials/payment/validation.test.ts b/src/handlers/project/add/credentials/payment/validation.test.ts new file mode 100644 index 000000000..ac72406b4 --- /dev/null +++ b/src/handlers/project/add/credentials/payment/validation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { + stripWalletAuthPrefix, + validateAppSecret, + validateApiKeySecret, + validateAuthorizationPrivateKey, + validatePaymentIdentifier, + validateWalletSecret, +} from "./validation"; + +const ed25519Key = Buffer.alloc(64, 0x41).toString("base64"); +const p256Key = Buffer.alloc(138, 0x41).toString("base64"); + +describe("payment credential key validation", () => { + test("accepts supported Coinbase key formats", () => { + expect(validateApiKeySecret(ed25519Key)).toBe(true); + expect(validateWalletSecret(p256Key)).toBe(true); + }); + + test("rejects invalid Coinbase key formats", () => { + expect(validateApiKeySecret("not-base64")).toContain("Ed25519"); + expect(validateApiKeySecret(Buffer.alloc(48, 0x41).toString("base64"))).toContain("length"); + expect(validateWalletSecret(ed25519Key)).toContain("P-256"); + }); + + test("accepts and normalizes a prefixed Stripe authorization key", () => { + expect(validateAuthorizationPrivateKey(`wallet-auth:${p256Key}`)).toBe(true); + expect(stripWalletAuthPrefix(`wallet-auth:${p256Key}`)).toBe(p256Key); + }); + + test("rejects invalid Stripe authorization keys", () => { + expect(validateAuthorizationPrivateKey("wallet-auth:not-base64")).toContain("base64"); + }); + + test("rejects identifiers outside the Payment service contract", () => { + expect(validatePaymentIdentifier("apiKeyId", "valid_key-1")).toBe(true); + expect(validatePaymentIdentifier("apiKeyId", "invalid key!")).toContain("apiKeyId"); + expect(validatePaymentIdentifier("apiKeyId", "a".repeat(513))).toContain("512"); + }); + + test("rejects app secrets outside the Payment service contract", () => { + expect(validateAppSecret("valid+/=_-secret")).toBe(true); + expect(validateAppSecret("invalid!secret")).toContain("appSecret"); + expect(validateAppSecret("a".repeat(2049))).toContain("2048"); + }); +}); diff --git a/src/handlers/project/add/credentials/payment/validation.ts b/src/handlers/project/add/credentials/payment/validation.ts new file mode 100644 index 000000000..9160258eb --- /dev/null +++ b/src/handlers/project/add/credentials/payment/validation.ts @@ -0,0 +1,60 @@ +const BASE64_PATTERN = /^[A-Za-z0-9+/]+=*$/; +const ED25519_KEY_LENGTHS = new Set([32, 64]); +const P256_MIN_BYTES = 100; +const P256_MAX_BYTES = 200; +const WALLET_AUTH_PREFIX = "wallet-auth:"; +const PAYMENT_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/; +const APP_SECRET_PATTERN = /^[A-Za-z0-9+/=_\-\s]*$/; + +function decodedBase64Length(value: string): number | undefined { + if (!BASE64_PATTERN.test(value)) return undefined; + return Buffer.from(value, "base64").length; +} + +export function validateApiKeySecret(value: string): true | string { + const length = decodedBase64Length(value.trim()); + if (length === undefined) return "apiKeySecret must be a base64-encoded Ed25519 private key"; + if (!ED25519_KEY_LENGTHS.has(length)) { + return "apiKeySecret must be a base64-encoded Ed25519 private key (unexpected length)"; + } + return true; +} + +export function validateWalletSecret(value: string): true | string { + const length = decodedBase64Length(value.trim()); + if (length === undefined) return "walletSecret must be a base64-encoded EC P-256 private key"; + if (length < P256_MIN_BYTES || length > P256_MAX_BYTES) { + return "walletSecret must be a base64-encoded EC P-256 private key (unexpected length)"; + } + return true; +} + +export function stripWalletAuthPrefix(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith(WALLET_AUTH_PREFIX) + ? trimmed.slice(WALLET_AUTH_PREFIX.length) + : trimmed; +} + +export function validateAuthorizationPrivateKey(value: string): true | string { + const length = decodedBase64Length(stripWalletAuthPrefix(value)); + if (length === undefined) return "authorizationPrivateKey must be base64-encoded"; + if (length < P256_MIN_BYTES || length > P256_MAX_BYTES) { + return "authorizationPrivateKey must be a base64-encoded EC P-256 private key (unexpected length)"; + } + return true; +} + +export function validatePaymentIdentifier(name: string, value: string): true | string { + if (value.length < 1 || value.length > 512) return `${name} must be between 1 and 512 characters`; + if (!PAYMENT_IDENTIFIER_PATTERN.test(value)) { + return `${name} must contain only alphanumeric characters, hyphens, and underscores`; + } + return true; +} + +export function validateAppSecret(value: string): true | string { + if (value.length > 2048) return "appSecret must be 2048 characters or less"; + if (!APP_SECRET_PATTERN.test(value)) return "appSecret contains unsupported characters"; + return true; +} diff --git a/src/handlers/project/add/credentials/shared.ts b/src/handlers/project/add/credentials/shared.ts index a9a1445c9..a36abd506 100644 --- a/src/handlers/project/add/credentials/shared.ts +++ b/src/handlers/project/add/credentials/shared.ts @@ -3,11 +3,12 @@ import { InputValidationError } from "../../../../errors"; import { parseSecretReference } from "../../../identity/parser"; import type { AddProjectResourceConfig } from "../types"; import type { AddResourceInput } from "../../types"; +import { + credentialEnvironmentVariableNames, + credentialEnvVarName, +} from "../../../../projectSchemas/credential"; -/** Derives the .env.local variable name a credential's secret is stored under. */ -export function credentialEnvVarName(credentialName: string, suffix = ""): string { - return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`; -} +export { credentialEnvVarName }; /** Parses a secret-reference flag, rejecting a directly supplied secret alongside it. */ export function parseExclusiveSecretRef( @@ -31,18 +32,21 @@ export async function addCredentialToProject( ): Promise { const project = ctx.require(ProjectKey); - // Two names that differ only by '-' vs '_' derive the same environment - // variable, which would silently reuse one secret for both providers. const newName = input.resourceConfig.name; - const clash = project.spec.credentials.find( - (existing) => - existing.name !== newName && - credentialEnvVarName(existing.name) === credentialEnvVarName(newName), - ); - if (clash) { + const existingEnvironmentNames = new Map(); + for (const credential of project.spec.credentials) { + for (const environmentName of credentialEnvironmentVariableNames(credential)) { + existingEnvironmentNames.set(environmentName, credential.name); + } + } + const conflictingEnvironmentName = input.envEntries?.find((entry) => + existingEnvironmentNames.has(entry.key), + )?.key; + if (conflictingEnvironmentName) { + const conflictingName = existingEnvironmentNames.get(conflictingEnvironmentName)!; throw new InputValidationError( - `credential '${newName}' and '${clash.name}' derive the same environment variable name; ` + - "choose a name that differs by more than '-' and '_'", + `credential '${newName}' and '${conflictingName}' derive the same environment variable ` + + `'${conflictingEnvironmentName}'; choose credential names that produce distinct environment variables`, ); } diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 48914aef3..02f0fce60 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -14,6 +14,8 @@ import { createAddGatewayConnectorHandler } from "./gateway-connector"; import { createAddPolicyEngineHandler } from "./policy-engine"; import { createAddPolicyHandler } from "./policy"; import type { AddProjectResourceConfig } from "./types"; +import { createAddPaymentConnectorHandler } from "./payment-connector"; +import { createAddPaymentManagerHandler } from "./payment-manager"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { const projectAdd = new Router("add", "add project resources"); @@ -31,5 +33,7 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.handler(createAddGatewayConnectorHandler(config)); projectAdd.handler(createAddPolicyEngineHandler(config)); projectAdd.handler(createAddPolicyHandler(config)); + projectAdd.handler(createAddPaymentManagerHandler(config)); + projectAdd.handler(createAddPaymentConnectorHandler(config)); return projectAdd; } diff --git a/src/handlers/project/add/payment-connector/index.test.ts b/src/handlers/project/add/payment-connector/index.test.ts new file mode 100644 index 000000000..a4c9ab704 --- /dev/null +++ b/src/handlers/project/add/payment-connector/index.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createPaymentProjectTestHarness } from "../payment-test-support"; + +const { cleanup, inProject, projectSpec, run, writeProjectSpec } = + createPaymentProjectTestHarness("payment-connector"); + +afterEach(cleanup); + +async function addManager() { + await run(["add", "payment-manager", "--name", "payments"]); +} + +async function addCredential(name: string, provider: "CoinbaseCDP" | "StripePrivy") { + await run(["add", "credentials", "payment", "--name", name, "--provider", provider]); +} + +describe("project add payment-connector", () => { + test.each([ + ["CoinbaseCDP", "coinbase"], + ["StripePrivy", "stripe"], + ] as const)("reuses an existing %s credential", async (provider, name) => { + const projectRoot = await inProject(); + await addManager(); + await addCredential(`${name}-credential`, provider); + + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + name, + "--credential", + `${name}-credential`, + ]); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([ + { + name, + provider, + credentialName: `${name}-credential`, + }, + ]); + }); + + test("adds Quick Create without a payment credential", async () => { + const projectRoot = await inProject(); + await addManager(); + + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "coinbase", + "--quick-create", + ]); + + const spec = await projectSpec(projectRoot); + expect(spec.credentials).toEqual([]); + expect(spec.payments[0].connectors).toEqual([ + { + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }, + ]); + }); + + test.each([ + ["missing manager", ["--name", "connector", "--quick-create"], "required option '--manager"], + ["missing name", ["--manager", "payments", "--quick-create"], "required option '--name"], + ["no mode", ["--manager", "payments", "--name", "connector"], "specify exactly one"], + [ + "multiple modes", + [ + "--manager", + "payments", + "--name", + "connector", + "--credential", + "existing", + "--quick-create", + ], + "specify exactly one", + ], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + await addManager(); + + await expect(run(["add", "payment-connector", ...flags])).rejects.toThrow(message); + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + + test("rejects unknown managers and credentials", async () => { + const projectRoot = await inProject(); + await addManager(); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "missing", + "--name", + "connector", + "--quick-create", + ]), + ).rejects.toThrow("does not exist"); + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--credential", + "missing", + ]), + ).rejects.toThrow("does not exist in credentials[]"); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + + test("rejects non-payment credentials", async () => { + const projectRoot = await inProject(); + await addManager(); + await run(["add", "credentials", "api-key", "--name", "api-key"]); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--credential", + "api-key", + ]), + ).rejects.toThrow("not a PaymentCredentialProvider"); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + + test("rejects duplicate connector names", async () => { + const projectRoot = await inProject(); + await addManager(); + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--quick-create", + ]); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--quick-create", + ]), + ).rejects.toThrow("already exists"); + + const spec = await projectSpec(projectRoot); + expect(spec.credentials).toEqual([]); + expect(spec.payments[0].connectors).toHaveLength(1); + }); + + test("rejects provider mismatches in complete project data", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [ + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }, + ]; + spec.payments = [ + { + name: "payments", + connectors: [ + { + name: "stripe", + provider: "StripePrivy", + credentialName: "coinbase", + }, + ], + }, + ]; + await writeProjectSpec(projectRoot, spec); + + await expect(run(["add", "credentials", "api-key", "--name", "trigger"])).rejects.toThrow( + "uses provider", + ); + }); +}); diff --git a/src/handlers/project/add/payment-connector/index.ts b/src/handlers/project/add/payment-connector/index.ts new file mode 100644 index 000000000..9c90f7baf --- /dev/null +++ b/src/handlers/project/add/payment-connector/index.ts @@ -0,0 +1,75 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "payment-connector", + description: "adds a connector to a project payment manager", + flags: [ + flag("manager", "the parent payment manager", z.string().optional()), + flag("name", "the payment connector name", z.string().optional()), + flag("credential", "an existing payment credential to reuse", z.string().optional()), + flag("quick-create", "create a CoinbaseCDP connector through Quick Create", z.boolean()), + ], + handle: async (ctx, flags) => { + if (!flags.manager) { + throw new InputValidationError("required option '--manager ' not specified"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const modes = [flags.credential !== undefined, flags["quick-create"]].filter(Boolean); + if (modes.length !== 1) { + throw new InputValidationError("specify exactly one of '--credential' or '--quick-create'"); + } + + const project = ctx.require(ProjectKey); + let provider: "CoinbaseCDP" | "StripePrivy"; + let credentialName: string | undefined; + + if (flags["quick-create"]) { + provider = "CoinbaseCDP"; + } else { + credentialName = flags.credential!; + const credential = project.spec.credentials.find( + (candidate) => candidate.name === credentialName, + ); + if (!credential) { + throw new InputValidationError( + `credential '${credentialName}' does not exist in credentials[]`, + ); + } + if (credential.authorizerType !== "PaymentCredentialProvider") { + throw new InputValidationError( + `credential '${credentialName}' is a ${credential.authorizerType}, not a PaymentCredentialProvider`, + ); + } + provider = credential.provider; + } + + for await (const event of config.projectManager.addResource(project, { + resourceType: "payment-connector", + managerName: flags.manager, + resourceConfig: flags["quick-create"] + ? { + name: flags.name, + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + } + : { + name: flags.name, + provider, + credentialName: credentialName!, + }, + })) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write( + `added payment connector '${flags.name}' to manager '${flags.manager}' in '${project.name}'\n`, + ); + }, + }); diff --git a/src/handlers/project/add/payment-manager/index.test.ts b/src/handlers/project/add/payment-manager/index.test.ts new file mode 100644 index 000000000..8943d3771 --- /dev/null +++ b/src/handlers/project/add/payment-manager/index.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createPaymentProjectTestHarness } from "../payment-test-support"; + +const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; +const { cleanup, inProject, projectSpec, run } = createPaymentProjectTestHarness("payment-manager"); + +afterEach(cleanup); + +describe("project add payment-manager", () => { + test("adds a manager with materialized defaults", async () => { + const projectRoot = await inProject(); + + const io = await run(["add", "payment-manager", "--name", "payments"]); + + expect((await projectSpec(projectRoot)).payments).toEqual([ + { + name: "payments", + authorizerType: "AWS_IAM", + connectors: [], + autoPayment: true, + defaultSpendLimit: "10.00", + }, + ]); + expect(io.stderr()).toContain("added payment manager 'payments'"); + expect(io.stderr()).toContain("auto-payment is ENABLED"); + expect(io.stderr()).not.toContain("$10.00"); + expect(io.stderr()).not.toContain("spend limit"); + expect(io.stderr()).toContain("does not modify runtime source code"); + }); + + test("maps custom JWT and payment behavior flags", async () => { + const projectRoot = await inProject(); + + const io = await run([ + "add", + "payment-manager", + "--name", + "securePayments", + "--authorizer-type", + "CUSTOM_JWT", + "--discovery-url", + DISCOVERY_URL, + "--allowed-clients", + "client-a", + "client-b", + "--allowed-audience", + "payments", + "--allowed-scopes", + "pay", + "refund", + "--description", + "Secure payments", + "--no-auto-payment", + "--default-spend-limit", + "25.50", + "--tool-allowlist", + "checkout", + "refund", + "--network-preferences", + "eip155:8453", + "eip155:1", + ]); + + expect((await projectSpec(projectRoot)).payments[0]).toEqual({ + name: "securePayments", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: DISCOVERY_URL, + allowedClients: ["client-a", "client-b"], + allowedAudience: ["payments"], + allowedScopes: ["pay", "refund"], + }, + }, + connectors: [], + description: "Secure payments", + autoPayment: false, + defaultSpendLimit: "25.50", + paymentToolAllowlist: ["checkout", "refund"], + networkPreferences: ["eip155:8453", "eip155:1"], + }); + expect(io.stderr()).not.toContain("auto-payment is ENABLED"); + expect(io.stderr()).toContain("does not modify runtime source code"); + }); + + test.each([ + ["missing name", [], "required option '--name"], + [ + "CUSTOM_JWT without discovery URL", + ["--name", "payments", "--authorizer-type", "CUSTOM_JWT"], + "requires --discovery-url", + ], + [ + "JWT fields with AWS_IAM", + ["--name", "payments", "--allowed-scopes", "pay"], + "valid only with CUSTOM_JWT", + ], + [ + "negative default spend limit", + ["--name", "payments", "--default-spend-limit", "-1"], + "non-negative", + ], + [ + "blank default spend limit", + ["--name", "payments", "--default-spend-limit", ""], + "non-negative", + ], + ["invalid name", ["--name", "bad-name"], "alphanumeric"], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + + await expect(run(["add", "payment-manager", ...flags])).rejects.toThrow(message); + expect((await projectSpec(projectRoot)).payments ?? []).toEqual([]); + }); + + test("rejects duplicate manager names", async () => { + const projectRoot = await inProject(); + await run(["add", "payment-manager", "--name", "payments"]); + + await expect(run(["add", "payment-manager", "--name", "payments"])).rejects.toThrow( + "already exists", + ); + expect((await projectSpec(projectRoot)).payments).toHaveLength(1); + }); +}); diff --git a/src/handlers/project/add/payment-manager/index.ts b/src/handlers/project/add/payment-manager/index.ts new file mode 100644 index 000000000..79c03bc24 --- /dev/null +++ b/src/handlers/project/add/payment-manager/index.ts @@ -0,0 +1,111 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { + DEFAULT_AUTO_PAYMENT, + DEFAULT_SPEND_LIMIT, + PaymentAuthorizerTypeSchema, + PaymentSpendLimitSchema, +} from "../../../../projectSchemas/payment"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddPaymentManagerHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "payment-manager", + description: "adds a payment manager to the current project", + flags: [ + flag("name", "the payment manager name", z.string().optional()), + flag( + "authorizer-type", + "payment authorization type", + PaymentAuthorizerTypeSchema.default("AWS_IAM"), + ), + flag( + "discovery-url", + "OIDC discovery URL for CUSTOM_JWT authorization", + z.string().optional(), + ), + flag("allowed-clients", "allowed JWT client IDs", z.array(z.string()).optional()), + flag("allowed-audience", "allowed JWT audiences", z.array(z.string()).optional()), + flag("allowed-scopes", "allowed JWT scopes", z.array(z.string()).optional()), + flag("description", "payment manager description", z.string().optional()), + flag( + "auto-payment", + "automatically settle payment requests", + z.boolean().default(DEFAULT_AUTO_PAYMENT), + ), + flag( + "default-spend-limit", + "default payment-session spend limit", + PaymentSpendLimitSchema.default(DEFAULT_SPEND_LIMIT), + ), + flag( + "tool-allowlist", + "tools eligible for automatic payment", + z.array(z.string()).optional(), + ), + flag("network-preferences", "preferred payment networks", z.array(z.string()).optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const jwtFlags = [ + flags["discovery-url"], + flags["allowed-clients"], + flags["allowed-audience"], + flags["allowed-scopes"], + ]; + if (flags["authorizer-type"] === "CUSTOM_JWT" && !flags["discovery-url"]) { + throw new InputValidationError("CUSTOM_JWT requires --discovery-url"); + } + if ( + flags["authorizer-type"] !== "CUSTOM_JWT" && + jwtFlags.some((value) => value !== undefined) + ) { + throw new InputValidationError("JWT authorization flags are valid only with CUSTOM_JWT"); + } + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.addResource(project, { + resourceType: "payment-manager", + resourceConfig: { + name: flags.name, + authorizerType: flags["authorizer-type"], + authorizerConfiguration: + flags["authorizer-type"] === "CUSTOM_JWT" + ? { + customJWTAuthorizer: { + discoveryUrl: flags["discovery-url"]!, + allowedClients: flags["allowed-clients"], + allowedAudience: flags["allowed-audience"], + allowedScopes: flags["allowed-scopes"], + }, + } + : undefined, + connectors: [], + description: flags.description, + autoPayment: flags["auto-payment"], + defaultSpendLimit: flags["default-spend-limit"], + paymentToolAllowlist: flags["tool-allowlist"], + networkPreferences: flags["network-preferences"], + }, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write(`added payment manager '${flags.name}' to '${project.name}'\n`); + if (flags["auto-payment"]) { + config.io.stderr.write( + `Warning: auto-payment is ENABLED for manager '${flags.name}'. Agents can automatically settle ` + + "402 responses without human approval. Use --no-auto-payment to require manual approval.\n", + ); + } + if (project.spec.runtimes.length > 0) { + config.io.stderr.write( + "Warning: project add payment-manager does not modify runtime source code. " + + "Configure the Payments SDK or plugin in supported runtimes before invoking payment-enabled agents.\n", + ); + } + }, + }); diff --git a/src/handlers/project/add/payment-test-support.ts b/src/handlers/project/add/payment-test-support.ts new file mode 100644 index 000000000..6ef95e707 --- /dev/null +++ b/src/handlers/project/add/payment-test-support.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; + +export function createPaymentProjectTestHarness(directoryPrefix: string) { + const originalCwd = process.cwd(); + const tempDirectories: string[] = []; + + async function run(args: string[]) { + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return io; + } + + async function inProject(name = "TestProject"): Promise { + const directory = await mkdtemp(join(tmpdir(), `agentcore-${directoryPrefix}-`)); + tempDirectories.push(directory); + process.chdir(directory); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; + } + + async function projectSpec(projectRoot: string) { + return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + } + + async function writeProjectSpec(projectRoot: string, spec: unknown): Promise { + await Bun.write( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify(spec, undefined, 2), + ); + } + + async function cleanup(): Promise { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); + } + + return { cleanup, inProject, projectSpec, run, writeProjectSpec }; +} diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index a6423ee05..e791aa481 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -163,11 +163,86 @@ describe("project remove", () => { ).toEqual(["keep"]); }); + test("removes a payment manager with its connectors while preserving reusable credentials", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]); + await run(["add", "payment-manager", "--name", "keep"]); + await run(["add", "payment-manager", "--name", "remove"]); + await run([ + "add", + "payment-connector", + "--manager", + "remove", + "--name", + "connector", + "--credential", + "shared", + ]); + + await run(["remove", "payment-manager", "--name", "remove"]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(agentcoreJson.payments.map((manager: { name: string }) => manager.name)).toEqual([ + "keep", + ]); + expect(agentcoreJson.credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: "shared", + provider: "CoinbaseCDP", + }, + ]); + }); + + test("removes a nested payment connector while preserving siblings and credentials", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]); + await run(["add", "payment-manager", "--name", "payments"]); + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "keep", + "--credential", + "shared", + ]); + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "remove", + "--credential", + "shared", + ]); + + await run(["remove", "payment-connector", "--manager", "payments", "--name", "remove"]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect( + agentcoreJson.payments[0].connectors.map((connector: { name: string }) => connector.name), + ).toEqual(["keep"]); + expect(agentcoreJson.credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: "shared", + provider: "CoinbaseCDP", + }, + ]); + }); + // Verifies that missing required inputs are rejected before calling the manager. test.each<[string, string[]]>([ ["missing resource argument", ["remove", "--name", "x"]], ["missing --name flag", ["remove", "harness"]], ["missing --gateway for a Target", ["remove", "gateway-target", "--name", "target"]], + [ + "missing --manager for a payment connector", + ["remove", "payment-connector", "--name", "connector"], + ], [ "--gateway on a non-Target resource", ["remove", "gateway", "--gateway", "tools", "--name", "tools"], @@ -176,6 +251,10 @@ describe("project remove", () => { "--engine on a non-policy resource", ["remove", "gateway", "--engine", "Guardrails", "--name", "tools"], ], + [ + "--manager on a non-payment-connector resource", + ["remove", "payment-manager", "--manager", "payments", "--name", "payments"], + ], ])("%s", async (_label, args) => { await inProject(); await expect(run(args)).rejects.toBeInstanceOf(InputValidationError); diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 8d5973d92..13f66f669 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -17,6 +17,11 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) flag("name", "name of the resource to remove", z.string().min(1).optional()), flag("gateway", "name of the parent Gateway for a Target", z.string().min(1).optional()), flag("engine", "name of the parent Policy Engine for a Policy", z.string().min(1).optional()), + flag( + "manager", + "name of the parent payment manager for a connector", + z.string().min(1).optional(), + ), ], arguments: [ argument( @@ -31,6 +36,8 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) "gateway-connector", "policy-engine", "policy", + "payment-manager", + "payment-connector", ]) .optional(), ), @@ -49,6 +56,9 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) if (flags.engine && resource !== "policy") { throw new InputValidationError(`--engine is valid only when removing a policy`); } + if (flags.manager && resource !== "payment-connector") { + throw new InputValidationError(`--manager is valid only when removing a payment-connector`); + } const project = ctx.require(ProjectKey); if (resource === "gateway-target" || resource === "gateway-connector") { @@ -66,6 +76,15 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) engineName: flags.engine, name, }); + } else if (resource === "payment-connector") { + if (!flags.manager) { + throw new InputValidationError(`--manager is required option`); + } + await config.projectManager.removeResource(project, { + resourceType: "payment-connector", + managerName: flags.manager, + name, + }); } else { await config.projectManager.removeResource(project, { resourceType: resource, diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c96b2e7af..dc35e6caa 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,5 +1,6 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { CredentialSchema } from "../../projectSchemas/credential"; +import type { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; import type { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; import type { MemorySchema } from "../../projectSchemas/memory"; import type { EvaluatorSchema } from "../../projectSchemas/evaluator"; @@ -195,13 +196,32 @@ export type AddResourceInput = resourceType: "policy"; engineName: string; resourceConfig: z.input; + } + | { + resourceType: "payment-manager"; + resourceConfig: z.input; + } + | { + resourceType: "payment-connector"; + managerName: string; + resourceConfig: z.input; }; export type ProjectResource = AddResourceInput["resourceType"]; export type RemoveResourceInput = | { - resourceType: Exclude; + resourceType: + | "harness" + | "runtime" + | "credential" + | "config-bundle" + | "online-eval" + | "online-insight" + | "memory" + | "gateway" + | "policy-engine" + | "payment-manager"; name: string; } | { @@ -213,6 +233,11 @@ export type RemoveResourceInput = resourceType: "policy"; engineName?: string; name: string; + } + | { + resourceType: "payment-connector"; + managerName: string; + name: string; }; /** diff --git a/src/io/atomicWrite.test.ts b/src/io/atomicWrite.test.ts index a298311f9..71db57f4f 100644 --- a/src/io/atomicWrite.test.ts +++ b/src/io/atomicWrite.test.ts @@ -1,11 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable, Transform } from "node:stream"; import { atomicWrite, atomicWriteStream } from "./atomicWrite"; const dirs: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; afterEach(async () => { await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); }); @@ -37,6 +38,15 @@ test("overwrites an existing file", async () => { expect(await readdir(dir)).toEqual(["out.txt"]); }); +testPosix("creates the replacement file with the requested mode", async () => { + const dir = await tempDir(); + const target = join(dir, "secret.txt"); + + await atomicWrite(target, "secret", { mode: 0o600 }); + + expect((await stat(target)).mode & 0o777).toBe(0o600); +}); + test("cleans up the temp file when rename fails", async () => { const dir = await tempDir(); // Target path is a directory → rename onto it fails, exercising the catch. diff --git a/src/io/atomicWrite.ts b/src/io/atomicWrite.ts index 9d08906f7..07501baea 100644 --- a/src/io/atomicWrite.ts +++ b/src/io/atomicWrite.ts @@ -13,10 +13,18 @@ export interface AtomicWriteStreamOptions { transforms?: Transform[]; } -export async function atomicWrite(path: string, contents: string | Uint8Array): Promise { +export interface AtomicWriteOptions { + mode?: number; +} + +export async function atomicWrite( + path: string, + contents: string | Uint8Array, + options: AtomicWriteOptions = {}, +): Promise { const tempPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); try { - await writeFile(tempPath, contents); + await writeFile(tempPath, contents, { mode: options.mode }); await rename(tempPath, path); } catch (error) { await rm(tempPath, { force: true }); diff --git a/src/projectSchemas/auth.ts b/src/projectSchemas/auth.ts index fff8fcc99..1c8444480 100644 --- a/src/projectSchemas/auth.ts +++ b/src/projectSchemas/auth.ts @@ -6,7 +6,7 @@ export type GatewayAuthorizerType = z.infer; export const RuntimeAuthorizerTypeSchema = z.enum(["AWS_IAM", "CUSTOM_JWT"]); export type RuntimeAuthorizerType = z.infer; const OIDC_WELL_KNOWN_SUFFIX = "/.well-known/openid-configuration"; -const OidcDiscoveryUrlSchema = z +export const OidcDiscoveryUrlSchema = z .string() .url("Must be a valid URL") .refine((url) => url.startsWith("https://"), { @@ -17,6 +17,11 @@ const OidcDiscoveryUrlSchema = z }); const MATCH_VALUE_PATTERN = /^[A-Za-z0-9_.-]+$/; const ALLOWED_SCOPE_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/; +export const AllowedScopeSchema = z + .string() + .min(1) + .max(255) + .regex(ALLOWED_SCOPE_PATTERN, "Scope must be printable ASCII with no spaces or quotes"); const CLAIM_NAME_PATTERN = /^[A-Za-z0-9_.:-]+$/; const RESERVED_CLAIM_NAMES = ["client_id"]; export const ClaimMatchOperatorSchema = z.enum(["EQUALS", "CONTAINS", "CONTAINS_ANY"]); @@ -144,15 +149,7 @@ export const CustomJwtAuthorizerConfigSchema = z discoveryUrl: OidcDiscoveryUrlSchema, allowedAudience: z.array(z.string().min(1)).optional(), allowedClients: z.array(z.string().min(1)).optional(), - allowedScopes: z - .array( - z - .string() - .min(1) - .max(255) - .regex(ALLOWED_SCOPE_PATTERN, "Scope must be printable ASCII with no spaces or quotes"), - ) - .optional(), + allowedScopes: z.array(AllowedScopeSchema).optional(), customClaims: z.array(CustomClaimValidationSchema).min(1).optional(), privateEndpoint: PrivateEndpointSchema.optional(), privateEndpointOverrides: z.array(PrivateEndpointOverrideSchema).max(5).optional(), diff --git a/src/projectSchemas/credential.test.ts b/src/projectSchemas/credential.test.ts index c176ecb4e..f27b4faaf 100644 --- a/src/projectSchemas/credential.test.ts +++ b/src/projectSchemas/credential.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { CredentialSchema } from "./credential"; +import { CredentialSchema, credentialEnvironmentVariableNames } from "./credential"; const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; const SECRET_REF = { @@ -50,6 +50,22 @@ describe("credential schema", () => { clientSecretRef: SECRET_REF, }, ], + [ + "a Coinbase payment credential", + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }, + ], + [ + "a StripePrivy payment credential", + { + authorizerType: "PaymentCredentialProvider", + name: "stripe", + provider: "StripePrivy", + }, + ], ])("accepts %s and retains its fields", (_label, value) => { const result = CredentialSchema.safeParse(value); expect(result.success).toBe(true); @@ -67,6 +83,53 @@ describe("credential schema", () => { expect(result.data).toMatchObject({ vendor: "CustomOauth2" }); }); + it("derives the environment variables used by each credential type", () => { + const environmentNames = (value: Record) => + credentialEnvironmentVariableNames(CredentialSchema.parse(value)); + + expect( + environmentNames({ authorizerType: "ApiKeyCredentialProvider", name: "service-key" }), + ).toEqual(["AGENTCORE_CREDENTIAL_SERVICE_KEY"]); + expect( + environmentNames({ + authorizerType: "ApiKeyCredentialProvider", + name: "service-key", + secretRef: SECRET_REF, + }), + ).toEqual([]); + expect( + environmentNames({ + authorizerType: "OAuthCredentialProvider", + name: "github", + vendor: "GithubOauth2", + providerConfig: { githubOauth2ProviderConfig: { clientId: "client-1" } }, + }), + ).toEqual(["AGENTCORE_CREDENTIAL_GITHUB_CLIENT_SECRET"]); + expect( + environmentNames({ + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }), + ).toEqual([ + "AGENTCORE_CREDENTIAL_COINBASE_API_KEY_ID", + "AGENTCORE_CREDENTIAL_COINBASE_API_KEY_SECRET", + "AGENTCORE_CREDENTIAL_COINBASE_WALLET_SECRET", + ]); + expect( + environmentNames({ + authorizerType: "PaymentCredentialProvider", + name: "stripe", + provider: "StripePrivy", + }), + ).toEqual([ + "AGENTCORE_CREDENTIAL_STRIPE_APP_ID", + "AGENTCORE_CREDENTIAL_STRIPE_APP_SECRET", + "AGENTCORE_CREDENTIAL_STRIPE_AUTHORIZATION_PRIVATE_KEY", + "AGENTCORE_CREDENTIAL_STRIPE_AUTHORIZATION_ID", + ]); + }); + it.each<[string, Record, RegExp]>([ [ "a vendored OAuth credential without a provider config", @@ -124,6 +187,15 @@ describe("credential schema", () => { { authorizerType: "ApiKeyCredentialProvider", name: "bad name!" }, /alphanumeric/, ], + [ + "an unsupported payment provider", + { + authorizerType: "PaymentCredentialProvider", + name: "payment", + provider: "Unsupported", + }, + /provider/, + ], [ "a credential name shorter than 3 characters", { authorizerType: "ApiKeyCredentialProvider", name: "ab" }, diff --git a/src/projectSchemas/credential.ts b/src/projectSchemas/credential.ts index 49db8a4d6..5fe17526b 100644 --- a/src/projectSchemas/credential.ts +++ b/src/projectSchemas/credential.ts @@ -113,3 +113,27 @@ export const CredentialSchema = z.discriminatedUnion("authorizerType", [ PaymentCredentialSchema, ]); export type Credential = z.infer; + +/** Derives the .env.local variable name used for credential material. */ +export function credentialEnvVarName(credentialName: string, suffix = ""): string { + return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`; +} + +/** Returns every .env.local key a credential reserves when it does not use an external secret. */ +export function credentialEnvironmentVariableNames(credential: Credential): string[] { + switch (credential.authorizerType) { + case "ApiKeyCredentialProvider": + return credential.secretRef ? [] : [credentialEnvVarName(credential.name)]; + case "OAuthCredentialProvider": + return credential.clientSecretRef + ? [] + : [credentialEnvVarName(credential.name, "_CLIENT_SECRET")]; + case "PaymentCredentialProvider": { + const suffixes = + credential.provider === "CoinbaseCDP" + ? ["_API_KEY_ID", "_API_KEY_SECRET", "_WALLET_SECRET"] + : ["_APP_ID", "_APP_SECRET", "_AUTHORIZATION_PRIVATE_KEY", "_AUTHORIZATION_ID"]; + return suffixes.map((suffix) => credentialEnvVarName(credential.name, suffix)); + } + } +} diff --git a/src/projectSchemas/payment.test.ts b/src/projectSchemas/payment.test.ts index b7c4c41eb..6a24c94d2 100644 --- a/src/projectSchemas/payment.test.ts +++ b/src/projectSchemas/payment.test.ts @@ -1,5 +1,49 @@ import { describe, expect, it } from "bun:test"; -import { PaymentManagerSchema } from "./payment"; +import { PaymentConnectorSchema, PaymentManagerSchema } from "./payment"; + +describe("payment connector schema", () => { + it("accepts manual connectors with implicit or explicit provisioning mode", () => { + const connector = { + name: "coinbase", + provider: "CoinbaseCDP", + credentialName: "coinbase-credential", + }; + + expect(PaymentConnectorSchema.safeParse(connector).success).toBe(true); + expect( + PaymentConnectorSchema.safeParse({ ...connector, provisionMode: "MANUAL" }).success, + ).toBe(true); + }); + + it("accepts Coinbase Quick Create without a credential", () => { + expect( + PaymentConnectorSchema.safeParse({ + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }).success, + ).toBe(true); + }); + + it("rejects StripePrivy Quick Create and Quick Create credential references", () => { + expect( + PaymentConnectorSchema.safeParse({ + name: "stripe", + provider: "StripePrivy", + provisionMode: "QUICK_CREATE", + }).success, + ).toBe(false); + expect( + PaymentConnectorSchema.safeParse({ + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + credentialName: "unexpected", + }).success, + ).toBe(false); + }); +}); + describe("payment manager custom validation", () => { it("requires JWT authorizer configuration only for CUSTOM_JWT", () => { expect( @@ -20,4 +64,64 @@ describe("payment manager custom validation", () => { }).success, ).toBe(true); }); + + it("rejects duplicate connector names within one manager", () => { + const connector = { + name: "duplicate", + provider: "CoinbaseCDP", + credentialName: "credential", + }; + + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [connector, connector], + }).success, + ).toBe(false); + }); + + it.each([ + [ + "a discovery URL without the OIDC well-known suffix", + { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { discoveryUrl: "https://example.com/discovery" }, + }, + }, + ], + [ + "an invalid scope", + { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: "https://example.com/.well-known/openid-configuration", + allowedScopes: ["scope with spaces"], + }, + }, + }, + ], + ["a description containing punctuation", { description: "Payments!" }], + ["a description longer than 4096 characters", { description: "a".repeat(4097) }], + ["a whitespace-only default spend limit", { defaultSpendLimit: " " }], + ])("rejects %s", (_label, overrides) => { + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [], + ...overrides, + }).success, + ).toBe(false); + }); + + it("accepts an empty persisted spend limit from released projects", () => { + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [], + defaultSpendLimit: "", + }).success, + ).toBe(true); + }); }); diff --git a/src/projectSchemas/payment.ts b/src/projectSchemas/payment.ts index 48e040037..648775173 100644 --- a/src/projectSchemas/payment.ts +++ b/src/projectSchemas/payment.ts @@ -1,3 +1,4 @@ +import { AllowedScopeSchema, OidcDiscoveryUrlSchema } from "./auth"; import { z } from "zod"; export const PaymentProviderSchema = z.enum(["CoinbaseCDP", "StripePrivy"]); export type PaymentProvider = z.infer; @@ -19,12 +20,47 @@ export const PaymentConnectorNameSchema = z /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, "Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)", ); -export const PaymentConnectorSchema = z.object({ + +export const PaymentProvisionModeSchema = z.enum(["MANUAL", "QUICK_CREATE"]); +export type PaymentProvisionMode = z.infer; + +export const ManualPaymentConnectorSchema = z.object({ name: PaymentConnectorNameSchema, provider: PaymentProviderSchema.default("CoinbaseCDP"), + provisionMode: z.literal("MANUAL").optional(), credentialName: z.string().min(1), }); +export type ManualPaymentConnector = z.infer; + +export const QuickCreatePaymentConnectorSchema = z.object({ + name: PaymentConnectorNameSchema, + provider: z.literal("CoinbaseCDP"), + provisionMode: z.literal("QUICK_CREATE"), + credentialName: z.never().optional(), +}); +export type QuickCreatePaymentConnector = z.infer; + +export const PaymentConnectorSchema = z.union([ + QuickCreatePaymentConnectorSchema, + ManualPaymentConnectorSchema, +]); export type PaymentConnector = z.infer; +export const PaymentManagerDescriptionSchema = z + .string() + .min(1) + .max(4096) + .regex( + /^[a-zA-Z0-9\s]+$/, + "Payment manager description must contain only alphanumeric characters and whitespace", + ); +export const PaymentSpendLimitSchema = z + .string() + .refine( + (value) => value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) >= 0, + { + message: "Default spend limit must be a non-negative number", + }, + ); export const PaymentManagerSchema = z .object({ name: PaymentManagerNameSchema, @@ -32,17 +68,19 @@ export const PaymentManagerSchema = z authorizerConfiguration: z .object({ customJWTAuthorizer: z.object({ - discoveryUrl: z.string().url(), - allowedClients: z.array(z.string()).optional(), - allowedAudience: z.array(z.string()).optional(), - allowedScopes: z.array(z.string()).optional(), + discoveryUrl: OidcDiscoveryUrlSchema, + allowedClients: z.array(z.string()).min(1).optional(), + allowedAudience: z.array(z.string()).min(1).optional(), + allowedScopes: z.array(AllowedScopeSchema).min(1).optional(), }), }) .optional(), connectors: z.array(PaymentConnectorSchema).default([]), - description: z.string().optional(), + description: PaymentManagerDescriptionSchema.optional(), autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), - defaultSpendLimit: z.string().default(DEFAULT_SPEND_LIMIT), + defaultSpendLimit: z + .union([z.literal(""), PaymentSpendLimitSchema]) + .default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), }) @@ -58,6 +96,18 @@ export const PaymentManagerSchema = z path: ["authorizerConfiguration"], }); } + + const connectorNames = new Set(); + for (const [index, connector] of data.connectors.entries()) { + if (connectorNames.has(connector.name)) { + ctx.addIssue({ + code: "custom", + message: `Duplicate payment connector name: ${connector.name}`, + path: ["connectors", index, "name"], + }); + } + connectorNames.add(connector.name); + } }); export type PaymentManager = z.infer; export const PaymentAuthorizerTypeSchema = z.enum(["AWS_IAM", "CUSTOM_JWT"]); diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index e70425e13..91a32cb0d 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -244,4 +244,137 @@ describe("project custom validation", () => { }).success, ).toBe(true); }); + + it("validates payment connector credential providers and skips credentials for Quick Create", () => { + const credential = { + authorizerType: "PaymentCredentialProvider" as const, + name: "credential", + provider: "CoinbaseCDP" as const, + }; + const manualPayment = { + name: "manual", + connectors: [ + { + name: "stripe", + provider: "StripePrivy" as const, + credentialName: credential.name, + }, + ], + }; + + expect( + ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [credential], + payments: [manualPayment], + }).success, + ).toBe(false); + + expect( + ProjectSpecSchema.safeParse({ + ...minimalProject, + payments: [ + { + name: "quick", + connectors: [ + { + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }, + ], + }, + ], + }).success, + ).toBe(true); + }); + + it("rejects payment manager names that collide after environment normalization", () => { + const result = ProjectSpecSchema.safeParse({ + ...minimalProject, + payments: [ + { name: "Payments", connectors: [] }, + { name: "payments", connectors: [] }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => + issue.message.includes("payment manager environment name"), + ), + ).toBe(true); + } + }); + + it("allows the same payment connector name under different managers", () => { + const credential = { + authorizerType: "PaymentCredentialProvider" as const, + name: "credential", + provider: "CoinbaseCDP" as const, + }; + const connector = { + name: "shared", + provider: "CoinbaseCDP" as const, + credentialName: credential.name, + }; + + expect( + ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [credential], + payments: [ + { + name: "first", + connectors: [connector], + }, + { + name: "second", + connectors: [connector], + }, + ], + }).success, + ).toBe(true); + }); + + it("rejects credential names that derive the same environment variable prefix", () => { + const result = ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [ + { authorizerType: "ApiKeyCredentialProvider", name: "service-key" }, + { authorizerType: "ApiKeyCredentialProvider", name: "service_key" }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message.includes("environment variable")), + ).toBe(true); + } + }); + + it("rejects different credential types that derive the same environment variable", () => { + const result = ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [ + { authorizerType: "ApiKeyCredentialProvider", name: "stripe_app_id" }, + { + authorizerType: "PaymentCredentialProvider", + name: "stripe", + provider: "StripePrivy", + }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => + issue.message.includes("AGENTCORE_CREDENTIAL_STRIPE_APP_ID"), + ), + ).toBe(true); + } + }); }); diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index 8e83c994e..cd438115d 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -8,7 +8,7 @@ import { } from "./gateway"; import { ABTestSchema } from "./ab-test"; import { ConfigBundleSchema } from "./config-bundle"; -import { CredentialSchema } from "./credential"; +import { CredentialSchema, credentialEnvironmentVariableNames } from "./credential"; import { DatasetSchema } from "./dataset"; import { EvaluatorSchema } from "./evaluator"; import { HarnessRegistryEntrySchema } from "./harness"; @@ -36,6 +36,7 @@ export const ProjectNameSchema = z }); const BUILTIN_EVALUATOR_PREFIX = "Builtin."; const ARN_PREFIX = "arn:"; +const toEnvironmentName = (name: string) => name.replace(/-/g, "_").toUpperCase(); const uniqueNames = (resource: string) => uniqueBy<{ name: string }>( ({ name }) => name, @@ -248,8 +249,42 @@ export const ProjectSpecSchema = z } } } + const credentialEnvironmentNames = new Map(); + for (const [credentialIndex, credential] of spec.credentials.entries()) { + for (const environmentName of credentialEnvironmentVariableNames(credential)) { + const conflictingName = credentialEnvironmentNames.get(environmentName); + if (conflictingName) { + ctx.addIssue({ + code: "custom", + message: + `Credential "${credential.name}" and "${conflictingName}" derive the same environment variable "${environmentName}"; ` + + "choose credential names that produce distinct environment variables", + path: ["credentials", credentialIndex, "name"], + }); + } else { + credentialEnvironmentNames.set(environmentName, credential.name); + } + } + } + const paymentManagerEnvironmentNames = new Map(); for (const [paymentIndex, payment] of (spec.payments ?? []).entries()) { + const environmentName = toEnvironmentName(payment.name); + const conflictingName = paymentManagerEnvironmentNames.get(environmentName); + if (conflictingName) { + ctx.addIssue({ + code: "custom", + message: + `Payment managers "${payment.name}" and "${conflictingName}" derive the same payment manager environment name; ` + + "choose names that differ by more than letter casing", + path: ["payments", paymentIndex, "name"], + }); + } else { + paymentManagerEnvironmentNames.set(environmentName, payment.name); + } + for (const [connectorIndex, connector] of payment.connectors.entries()) { + if (connector.provisionMode === "QUICK_CREATE") continue; + const credential = spec.credentials.find((c) => c.name === connector.credentialName); if (!credential) { ctx.addIssue({ @@ -263,6 +298,14 @@ export const ProjectSpecSchema = z message: `Payment connector "${connector.name}" in manager "${payment.name}" references credential "${connector.credentialName}" which is a ${credential.authorizerType}, not a PaymentCredentialProvider`, path: ["payments", paymentIndex, "connectors", connectorIndex, "credentialName"], }); + } else if (credential.provider !== connector.provider) { + ctx.addIssue({ + code: "custom", + message: + `Payment connector "${connector.name}" in manager "${payment.name}" uses provider "${connector.provider}", ` + + `but credential "${connector.credentialName}" uses provider "${credential.provider}"`, + path: ["payments", paymentIndex, "connectors", connectorIndex, "provider"], + }); } } }