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..d297178c2 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.51", "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..c9cd8eb80 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,69 @@ 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: [], + }); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 74bbf5e0c..de4d43058 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -62,6 +62,10 @@ import { type CdkRunOptions, type CdkRunResult, } from "./cdk/toolkit"; +import { + createPaymentConnectorAuthorizationUrlReporter, + type PaymentConnectorAuthorizationUrlReporter, +} from "./cdk/paymentConnectorAuthorizationUrls"; import { describeStack } from "./cdk/stackReader"; type StackDescriber = typeof describeStack; @@ -106,6 +110,7 @@ export type CdkBackendConfig = { provisionCredentials?: CredentialProvisioner; removePaymentCredentials?: PaymentCredentialRemover; describeStack?: StackDescriber; + reportPaymentConnectorAuthorizationUrls?: PaymentConnectorAuthorizationUrlReporter; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -122,6 +127,7 @@ export class CdkBackend implements ProjectBackend { private readonly provisionCredentials: CredentialProvisioner; private readonly removePaymentCredentials: PaymentCredentialRemover; private readonly describeStack: StackDescriber; + private readonly reportPaymentConnectorAuthorizationUrls: PaymentConnectorAuthorizationUrlReporter; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -150,6 +156,9 @@ export class CdkBackend implements ProjectBackend { describeStack(region, credentials, stackName, (name) => readStack(name, region, credentials), )); + this.reportPaymentConnectorAuthorizationUrls = + config.reportPaymentConnectorAuthorizationUrls ?? + createPaymentConnectorAuthorizationUrlReporter(config.createCloudFormationClient); } // Local prerequisites for synth. Checked before any AWS mutation so a missing @@ -286,6 +295,15 @@ export class CdkBackend implements ProjectBackend { // another's recorded state. await updateTargetState(this.json, project.rootPath, target.name, { stackArn }); + // Reported after the stack is up and its ARN recorded: a Quick Create + // connector is deployed but unusable until someone follows its authorization + // link, and that link expires minutes after the connector is created. + yield* this.reportPaymentConnectorAuthorizationUrls(project, { + stackName: artifact.stackName, + region: target.region, + credentials, + }); + return { outputs }; } diff --git a/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts new file mode 100644 index 000000000..c9bc8f255 --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; +import { GetPaymentConnectorCommand } from "@aws-sdk/client-bedrock-agentcore-control"; +import { ListStackResourcesCommand } from "@aws-sdk/client-cloudformation"; +import type { Project, ProjectEvent } from "../../../../handlers/project/types"; +import { ProjectSpecSchema } from "../../../../projectSchemas/project"; +import type { CreateCloudFormationClient, CreateControlClient } from "../../../types"; +import { createPaymentConnectorAuthorizationUrlReporter } from "./paymentConnectorAuthorizationUrls"; +import type { CdkCredentialProvider } from "./toolkit"; + +const REGION = "us-east-1"; +const STACK = "AgentCore-example-default"; +const CREDENTIALS: CdkCredentialProvider = async () => ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", +}); +const CONNECTOR_ARN = + "arn:aws:bedrock-agentcore:us-east-1:111122223333:" + + "payment-manager/payments-abc123def4/connector/quick-abc123def4"; +const SECOND_CONNECTOR_ARN = + "arn:aws:bedrock-agentcore:us-east-1:111122223333:" + + "payment-manager/payments-abc123def4/connector/second-abc123def4"; + +type Send = (command: unknown) => Promise; + +function client(send: Send): never { + return { send } as never; +} + +function project(quickCreate = true): Project { + const connectors = quickCreate + ? [{ name: "quick", provider: "CoinbaseCDP", provisionMode: "QUICK_CREATE" }] + : []; + return { + name: "example", + rootPath: "/tmp/example", + spec: ProjectSpecSchema.parse({ + name: "example", + version: 1, + payments: [{ name: "payments", authorizerType: "AWS_IAM", connectors }], + }), + }; +} + +async function report(input: Project, stackSend: Send, paymentsSend: Send): Promise { + const createStackClient = (() => client(stackSend)) as CreateCloudFormationClient; + const createPaymentsClient = (() => client(paymentsSend)) as CreateControlClient; + const generator = createPaymentConnectorAuthorizationUrlReporter( + createStackClient, + createPaymentsClient, + )(input, { + stackName: STACK, + region: REGION, + credentials: CREDENTIALS, + }); + const messages: string[] = []; + while (true) { + const next: IteratorResult = await generator.next(); + if (next.done) return messages; + if (next.value.type === "step") messages.push(next.value.message); + } +} + +describe("Quick Create authorization reporting", () => { + test("prints the live authorization URL returned by GetPaymentConnector", async () => { + const messages = await report( + project(), + async (command) => { + expect(command).toBeInstanceOf(ListStackResourcesCommand); + const input = (command as ListStackResourcesCommand).input; + if (!input.NextToken) { + return { + StackResourceSummaries: [ + { ResourceType: "AWS::IAM::Role", PhysicalResourceId: "role" }, + ], + NextToken: "next", + }; + } + return { + StackResourceSummaries: [ + { + ResourceType: "AWS::BedrockAgentCore::PaymentConnector", + PhysicalResourceId: CONNECTOR_ARN, + }, + ], + }; + }, + async (command) => { + expect(command).toBeInstanceOf(GetPaymentConnectorCommand); + expect((command as GetPaymentConnectorCommand).input).toEqual({ + paymentManagerId: "payments-abc123def4", + paymentConnectorId: "quick-abc123def4", + }); + return { + name: "quick", + authorizationUrl: "https://example.com/authorize?request_uri=urn:x", + }; + }, + ); + + expect(messages).toEqual([ + 'Authorize payment connector "quick": https://example.com/authorize?request_uri=urn:x', + ]); + }); + + test("prints nothing when GetPaymentConnector has no authorization URL", async () => { + const messages = await report( + project(), + async () => ({ + StackResourceSummaries: [ + { + ResourceType: "AWS::BedrockAgentCore::PaymentConnector", + PhysicalResourceId: CONNECTOR_ARN, + }, + ], + }), + async () => ({ name: "quick", status: "READY" }), + ); + + expect(messages).toEqual([]); + }); + + test("makes no calls when the project declares no Quick Create connector", async () => { + let called = false; + const messages = await report( + project(false), + async () => { + called = true; + return {}; + }, + async () => { + called = true; + return {}; + }, + ); + + expect(called).toBe(false); + expect(messages).toEqual([]); + }); + + test("ignores unrelated resources and malformed connector physical IDs", async () => { + let paymentCalls = 0; + const messages = await report( + project(), + async () => ({ + StackResourceSummaries: [ + { ResourceType: "AWS::IAM::Role", PhysicalResourceId: CONNECTOR_ARN }, + { + ResourceType: "AWS::BedrockAgentCore::PaymentConnector", + PhysicalResourceId: "not-a-connector-arn", + }, + ], + }), + async () => { + paymentCalls += 1; + return {}; + }, + ); + + expect(paymentCalls).toBe(0); + expect(messages).toEqual([]); + }); + + test("continues reporting URLs when one connector lookup fails", async () => { + let paymentCalls = 0; + const messages = await report( + project(), + async () => ({ + StackResourceSummaries: [ + { + ResourceType: "AWS::BedrockAgentCore::PaymentConnector", + PhysicalResourceId: CONNECTOR_ARN, + }, + { + ResourceType: "AWS::BedrockAgentCore::PaymentConnector", + PhysicalResourceId: SECOND_CONNECTOR_ARN, + }, + ], + }), + async () => { + paymentCalls += 1; + if (paymentCalls === 1) throw new Error("Throttled"); + return { + name: "second", + authorizationUrl: "https://example.com/authorize-second", + }; + }, + ); + + expect(messages).toEqual([ + "Deployed, but payment connector 'quick-abc123def4' authorization URL could not be retrieved: Throttled", + 'Authorize payment connector "second": https://example.com/authorize-second', + ]); + }); + + test("reports retrieval failure without failing the completed deployment", async () => { + const messages = await report( + project(), + async () => { + throw new Error("AccessDenied"); + }, + async () => ({}), + ); + + expect(messages).toEqual([ + "Deployed, but payment connector authorization URLs could not be retrieved: AccessDenied", + ]); + }); +}); diff --git a/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts new file mode 100644 index 000000000..20870812c --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts @@ -0,0 +1,108 @@ +import { + GetPaymentConnectorCommand, + type GetPaymentConnectorCommandOutput, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { ListStackResourcesCommand } from "@aws-sdk/client-cloudformation"; +import type { Project, ProjectEvent } from "../../../../handlers/project/types"; +import { createCloudFormationClient, createControlClient } from "../../../factories"; +import type { CreateCloudFormationClient, CreateControlClient } from "../../../types"; +import type { CdkCredentialProvider } from "./toolkit"; + +const PAYMENT_CONNECTOR_RESOURCE_TYPE = "AWS::BedrockAgentCore::PaymentConnector"; +const PAYMENT_CONNECTOR_ARN = /:payment-manager\/([^/]+)\/connector\/([^/]+)$/; + +type Target = { region: string; credentials: CdkCredentialProvider }; + +export type PaymentConnectorAuthorizationUrlReporter = ( + project: Project, + input: Target & { stackName: string }, +) => AsyncGenerator; + +/** + * Prints each live Quick Create authorization URL after a successful deployment. + * + * CloudFormation scopes discovery to this project's stack. The connector's physical + * ARN contains both IDs required by GetPaymentConnector, which returns the URL only + * while one is available. + */ +export function createPaymentConnectorAuthorizationUrlReporter( + createStackClient: CreateCloudFormationClient = createCloudFormationClient, + createPaymentsClient: CreateControlClient = createControlClient, +): PaymentConnectorAuthorizationUrlReporter { + return async function* reportPaymentConnectorAuthorizationUrls( + project, + { stackName, region, credentials }, + ) { + if (!declaresQuickCreate(project)) return; + + const stackClient = createStackClient({ credentials, region }); + const paymentsClient = createPaymentsClient({ credentials, region }); + + try { + let token: string | undefined; + do { + const page = await stackClient.send( + new ListStackResourcesCommand({ StackName: stackName, NextToken: token }), + ); + for (const resource of page.StackResourceSummaries ?? []) { + if ( + resource.ResourceType !== PAYMENT_CONNECTOR_RESOURCE_TYPE || + !resource.PhysicalResourceId + ) { + continue; + } + + const ids = paymentConnectorIds(resource.PhysicalResourceId); + if (!ids) continue; + + let connector: GetPaymentConnectorCommandOutput; + try { + connector = await paymentsClient.send( + new GetPaymentConnectorCommand({ + paymentManagerId: ids.managerId, + paymentConnectorId: ids.connectorId, + }), + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + yield { + type: "step", + message: + `Deployed, but payment connector '${ids.connectorId}' authorization URL ` + + `could not be retrieved: ${detail}`, + }; + continue; + } + if (!connector.authorizationUrl) continue; + + yield { + type: "step", + message: `Authorize payment connector "${connector.name ?? ids.connectorId}": ${connector.authorizationUrl}`, + }; + } + token = page.NextToken; + } while (token); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + yield { + type: "step", + message: `Deployed, but payment connector authorization URLs could not be retrieved: ${detail}`, + }; + } + }; +} + +function paymentConnectorIds( + physicalResourceId: string, +): { managerId: string; connectorId: string } | undefined { + const match = PAYMENT_CONNECTOR_ARN.exec(physicalResourceId); + const managerId = match?.[1]; + const connectorId = match?.[2]; + return managerId && connectorId ? { managerId, connectorId } : undefined; +} + +function declaresQuickCreate(project: Project): boolean { + return (project.spec.payments ?? []).some((manager) => + manager.connectors.some((connector) => connector.provisionMode === "QUICK_CREATE"), + ); +}