diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 701339bce..eb7062720 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -146,14 +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; + Record | undefined; // Payment credential provider ARNs live in the same credentials map as identity credentials const paymentCredentials = credentials; @@ -168,7 +166,12 @@ async function main() { autoPayment?: boolean; paymentToolAllowlist?: string[]; networkPreferences?: string[]; - connectors: { name: string; provider?: string; credentialName: string }[]; + connectors: { + name: string; + provider?: 'CoinbaseCDP' | 'StripePrivy'; + provisionMode?: 'MANUAL' | 'QUICK_CREATE'; + credentialName?: string; + }[]; }) => ({ name: p.name, description: p.description, @@ -178,6 +181,19 @@ async function main() { paymentToolAllowlist: p.paymentToolAllowlist, networkPreferences: p.networkPreferences, connectors: p.connectors.map(c => { + if (c.provisionMode === 'QUICK_CREATE') { + return { + name: c.name, + provider: 'CoinbaseCDP' as const, + provisionMode: 'QUICK_CREATE' as const, + }; + } + + if (!c.credentialName) { + throw new Error( + `Manual payment connector "${c.name}" on manager "${p.name}" is missing its credential name.` + ); + } const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; if (!credentialProviderArn) { // Fail fast with an actionable message rather than passing an empty @@ -188,7 +204,13 @@ async function main() { `Run \`agentcore deploy\` so the credential provider is created first.` ); } - return { name: c.name, provider: c.provider, credentialProviderArn }; + return { + name: c.name, + provider: c.provider ?? ('CoinbaseCDP' as const), + ...(c.provisionMode && { provisionMode: c.provisionMode }), + credentialName: c.credentialName, + credentialProviderArn, + }; }), }) ) diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 3dac0669d..4ee14258b 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -19,11 +19,23 @@ import { Construct } from 'constructs'; */ export type HarnessConfig = HarnessDeploymentConfig; -export interface PaymentConnectorSpec { +export type ManualPaymentConnectorSpec = { name: string; provider: 'CoinbaseCDP' | 'StripePrivy'; + provisionMode?: 'MANUAL'; + credentialName: string; credentialProviderArn: string; -} +}; + +export type QuickCreatePaymentConnectorSpec = { + name: string; + provider: 'CoinbaseCDP'; + provisionMode: 'QUICK_CREATE'; + credentialName?: never; + credentialProviderArn?: never; +}; + +export type PaymentConnectorSpec = ManualPaymentConnectorSpec | QuickCreatePaymentConnectorSpec; export interface PaymentSpec { name: string; @@ -64,8 +76,12 @@ export interface AgentCoreStackProps extends StackProps { paymentSpec?: PaymentSpec[]; } -function toCdkId(name: string): string { - return name.replace(/_/g, ''); +function paymentManagerCdkId(managerName: string): string { + return `PaymentManagerM${managerName.length}${managerName}`; +} + +function paymentConnectorCdkId(managerName: string, connectorName: string): string { + return `PaymentConnectorM${managerName.length}${managerName}C${connectorName.length}${connectorName}`; } /** @@ -127,8 +143,8 @@ 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}`, { + const managerCdkId = paymentManagerCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, managerCdkId, { projectName: spec.name, name: payment.name, authorizerType: payment.authorizerType, @@ -202,14 +218,27 @@ export class AgentCoreStack extends Stack { // 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, - }); + const connectorCdkId = paymentConnectorCdkId(payment.name, connector.name); + let conn: AgentCorePaymentConnector; + if (connector.provisionMode === 'QUICK_CREATE') { + conn = new AgentCorePaymentConnector(this, connectorCdkId, { + projectName: spec.name, + paymentManager: manager, + connector, + }); + } else { + conn = new AgentCorePaymentConnector(this, connectorCdkId, { + projectName: spec.name, + paymentManager: manager, + connector: { + name: connector.name, + provider: connector.provider, + provisionMode: connector.provisionMode, + credentialName: connector.credentialName, + }, + credentialProviderArn: connector.credentialProviderArn, + }); + } // Wire first connector's ID as env var (eligible agents only) if (connector === payment.connectors[0]) { @@ -219,22 +248,30 @@ export class AgentCoreStack extends Stack { } } - new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { + new CfnOutput(this, `${connectorCdkId}ConnectorId`, { value: conn.paymentConnectorId, }); + if (connector.provisionMode === 'QUICK_CREATE') { + new CfnOutput(this, `${connectorCdkId}ConnectorStatus`, { + value: conn.paymentConnectorStatus, + }); + new CfnOutput(this, `${connectorCdkId}AuthorizationUrl`, { + value: conn.authorizationUrl, + }); + } } // CFN Outputs for post-deploy state parsing - new CfnOutput(this, `Payment${mgrId}ManagerArn`, { + new CfnOutput(this, `${managerCdkId}Arn`, { value: manager.paymentManagerArn, }); - new CfnOutput(this, `Payment${mgrId}ManagerId`, { + new CfnOutput(this, `${managerCdkId}Id`, { value: manager.paymentManagerId, }); - new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { + new CfnOutput(this, `${managerCdkId}ProcessPaymentRoleArn`, { value: manager.processPaymentRoleArn, }); - new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { + new CfnOutput(this, `${managerCdkId}ResourceRetrievalRoleArn`, { value: manager.resourceRetrievalRoleArn, }); } 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..8e086edbc 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -1,7 +1,20 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; +import { Match, Template } from 'aws-cdk-lib/assertions'; import { AgentCoreStack } from '../lib/cdk-stack'; +const testConfigDir = join(process.cwd(), 'agentcore'); + +beforeAll(() => { + mkdirSync(testConfigDir, { recursive: true }); + writeFileSync(join(testConfigDir, 'agentcore.json'), '{}'); +}); + +afterAll(() => { + rmSync(testConfigDir, { recursive: true, force: true }); +}); + test('AgentCoreStack synthesizes with empty spec', () => { const app = new cdk.App(); const stack = new AgentCoreStack(app, 'TestStack', { @@ -29,3 +42,137 @@ 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: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + paymentSpec: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'Manual', + provider: 'CoinbaseCDP', + credentialName: 'coinbase', + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', + }, + { + name: 'Quick', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + ], + }); + 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: [], + }); + template.hasOutput('PaymentConnectorM8PaymentsC5QuickAuthorizationUrl', {}); +}); + +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: [], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + paymentSpec: [ + { + 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: [], + }, + ], + }); + + 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..32855c201 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"; @@ -21,6 +21,21 @@ async function tempRoot(): Promise { const ENTRY = { key: "SECRET", value: "v", comment: "c" }; +test("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([{ 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(); const file = new EnvLocalFile(root); diff --git a/src/core/project/envLocal.ts b/src/core/project/envLocal.ts index 5774dde36..4afb06bb8 100644 --- a/src/core/project/envLocal.ts +++ b/src/core/project/envLocal.ts @@ -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` @@ -58,7 +59,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 +68,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 { 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..d83e35f48 --- /dev/null +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -0,0 +1,181 @@ +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); + +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 = generateKeyPairSync("ed25519") + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + 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 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", + ], + ])("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..1503c92a7 --- /dev/null +++ b/src/handlers/project/add/credentials/payment/input.ts @@ -0,0 +1,151 @@ +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, + validateApiKeySecret, + validateAuthorizationPrivateKey, + 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); +} + +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 appSecret = await resolver.resolveSecret("app-secret", flags["app-secret"]); + 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: flags["app-id"], + 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: flags["authorization-id"], + comment: `Stripe/Privy authorization ID for payment credential provider '${name}' (set before deploy)`, + }, + ]; + } + + const apiKeySecret = await resolver.resolveSecret("api-key-secret", flags["api-key-secret"]); + const walletSecret = await resolver.resolveSecret("wallet-secret", flags["wallet-secret"]); + 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: flags["api-key-id"], + 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..43995a45d --- /dev/null +++ b/src/handlers/project/add/credentials/payment/validation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { + stripWalletAuthPrefix, + validateApiKeySecret, + validateAuthorizationPrivateKey, + validateWalletSecret, +} from "./validation"; + +const ed25519Key = Buffer.alloc(48, 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(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"); + }); +}); 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..26952338b --- /dev/null +++ b/src/handlers/project/add/credentials/payment/validation.ts @@ -0,0 +1,45 @@ +const BASE64_PATTERN = /^[A-Za-z0-9+/]+=*$/; +const ED25519_MIN_BYTES = 32; +const ED25519_MAX_BYTES = 64; +const P256_MIN_BYTES = 100; +const P256_MAX_BYTES = 200; +const WALLET_AUTH_PREFIX = "wallet-auth:"; + +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 (length < ED25519_MIN_BYTES || length > ED25519_MAX_BYTES) { + 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; +} 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..965b3581f --- /dev/null +++ b/src/handlers/project/add/payment-manager/index.test.ts @@ -0,0 +1,118 @@ +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()).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", + ], + ["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..490786fee --- /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, +} 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", + z.string().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 up to the per-session spend limit ($${flags["default-spend-limit"]}) 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..7bd891631 100644 --- a/src/io/atomicWrite.test.ts +++ b/src/io/atomicWrite.test.ts @@ -1,5 +1,5 @@ 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"; @@ -37,6 +37,15 @@ test("overwrites an existing file", async () => { expect(await readdir(dir)).toEqual(["out.txt"]); }); +test("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..84868d55c 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,54 @@ 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 blank default spend limit", { defaultSpendLimit: " " }], + ])("rejects %s", (_label, overrides) => { + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [], + ...overrides, + }).success, + ).toBe(false); + }); }); diff --git a/src/projectSchemas/payment.ts b/src/projectSchemas/payment.ts index 48e040037..2efd03da5 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,39 @@ 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 PaymentManagerSchema = z .object({ name: PaymentManagerNameSchema, @@ -32,17 +60,25 @@ 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 + .string() + .refine( + (value) => value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) >= 0, + { + message: "Default spend limit must be a non-negative number", + }, + ) + .default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), }) @@ -58,6 +94,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"], + }); } } }