From df86b4f4edc1b2a54e2709cd5631cdb7556d2ba3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 19:20:53 +0000 Subject: [PATCH 1/7] fix(project): deploy payments through the published AgentCorePayments construct The generated CDK app has been carrying its own Payment orchestration since #2146 restored a pre-Quick-Create adapter to unblock scaffolding while AgentCorePayments was unpublished. That construct now ships in @aws/agentcore-cdk 0.1.0-alpha.51, so the vendored loops are obsolete. Quick Create is broken on refactor today: bin/cdk.ts requires every connector to resolve a credentialName to a deployed ARN, and a Quick Create connector deliberately has neither, so it throws before synthesis. The restored adapter also concatenates underscore-stripped manager and connector names into construct IDs, so foo_bar/foobar and A/BC versus AB/C collide, and the tests covering both behaviors were deleted alongside it. bin/cdk.ts now passes the raw spec and the target's deployed credentials straight through, and cdk-stack.ts hands them to AgentCorePayments, which owns connector variant mapping, credential resolution, runtime wiring and outputs inside the upgradeable package. Payment orchestration leaves the frozen scaffold, so future Payment changes reach existing projects through a dependency bump rather than a regenerated app. The generated-CDK tests for manual and Quick Create synthesis and for collision-safe identities are restored. --- src/assets/cdk/bin/cdk.ts | 46 +-------- src/assets/cdk/lib/cdk-stack.ts | 169 ++------------------------------ src/assets/cdk/package.json | 2 +- src/assets/cdk/test/cdk.test.ts | 169 +++++++++++++++++++++++++++++++- 4 files changed, 177 insertions(+), 209 deletions(-) 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..2db16484f 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -1,6 +1,29 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; -import { AgentCoreStack } from '../lib/cdk-stack'; +import { Match, Template } from 'aws-cdk-lib/assertions'; + +const originalCwd = process.cwd(); +const originalInitCwd = process.env.INIT_CWD; +const testRoot = mkdtempSync(join(tmpdir(), 'agentcore-cdk-test-')); +const testConfigDir = join(testRoot, 'agentcore'); +let AgentCoreStack: typeof import('../lib/cdk-stack').AgentCoreStack; + +beforeAll(async () => { + process.chdir(testRoot); + process.env.INIT_CWD = testRoot; + mkdirSync(testConfigDir, { recursive: true }); + writeFileSync(join(testConfigDir, 'agentcore.json'), '{}'); + ({ AgentCoreStack } = await import('../lib/cdk-stack')); +}); + +afterAll(() => { + process.chdir(originalCwd); + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + rmSync(testRoot, { recursive: true, force: true }); +}); test('AgentCoreStack synthesizes with empty spec', () => { const app = new cdk.App(); @@ -29,3 +52,145 @@ test('AgentCoreStack synthesizes with empty spec', () => { Description: 'Name of the CloudFormation Stack', }); }); + +test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [ + { + authorizerType: 'PaymentCredentialProvider', + name: 'coinbase', + provider: 'CoinbaseCDP', + }, + ], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'Manual', + provider: 'CoinbaseCDP', + credentialName: 'coinbase', + }, + { + name: 'Quick', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + ], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + credentials: { + coinbase: { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', + }, + }, + }); + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 2); + template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { + ConnectorName: 'Manual', + ProvisionMode: Match.absent(), + }); + template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { + ConnectorName: 'Quick', + ConnectorType: 'CoinbaseCDP', + ProvisionMode: 'QUICK_CREATE', + CredentialProviderConfigurations: [], + }); + expect(Object.keys(template.findOutputs('*')).some(key => key.includes('AuthorizationUrl'))).toBe(true); +}); + +test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'foo_bar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'foobar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'A', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'B', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'BC', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'AB', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'C', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'M1AC1B', + authorizerType: 'AWS_IAM', + connectors: [], + }, + ], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + }); + + Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 5); +}); From ce1d87f695e7cb2dac545328d4df998f775a8706 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 2 Sep 2026 22:46:10 +0000 Subject: [PATCH 2/7] feat(project): report Quick Create connectors awaiting authorization A Quick Create connector is deployed but unusable until someone follows its authorization link, and deploy said nothing about it. The link had to be dug out of the stack's CloudFormation outputs to be found at all, and it expires about ten minutes after the connector is created, so by the time anyone thought to look the window was usually gone. The project reported a successful deploy either way. Deploy now names each Quick Create connector the spec declares, with its live status and, while one exists, the link and how long it lasts. An expired connector says how to get a new link, which is to recreate it: neither UpdatePaymentConnector nor redeploying an unchanged connector mints another one. The status and the link are read from the Payments service rather than the outputs of the same name. Those outputs are Fn::GetAtt values resolved when the connector was created, so they keep serving a dead link and a stale PENDING_AUTHENTICATION long after the service has moved on to AUTHENTICATION_EXPIRED. Managers are found by their CloudFormation resource type in the project's own stack. Manager names are account-scoped, so matching the spec against the account would confuse two projects that both declare `payments`, and reading the L3's output keys would tie the CLI to names the L3 owns. Connectors then come from the manager, so connector names cannot collide across managers. Reads run after the stack is up and never fail the deploy: a status that cannot be retrieved is reported as such. A project without Quick Create connectors makes no calls at all. --- src/core/project/backends/cdk.ts | 21 ++ .../backends/cdk/paymentConnectorCalls.ts | 85 ++++++ .../backends/cdk/paymentConnectors.test.ts | 275 ++++++++++++++++++ .../project/backends/cdk/paymentConnectors.ts | 154 ++++++++++ 4 files changed, 535 insertions(+) create mode 100644 src/core/project/backends/cdk/paymentConnectorCalls.ts create mode 100644 src/core/project/backends/cdk/paymentConnectors.test.ts create mode 100644 src/core/project/backends/cdk/paymentConnectors.ts diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 74bbf5e0c..22e9a6cb4 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -62,6 +62,11 @@ import { type CdkRunOptions, type CdkRunResult, } from "./cdk/toolkit"; +import { createPaymentConnectorCalls } from "./cdk/paymentConnectorCalls"; +import { + createQuickCreateAuthorizationReporter, + type QuickCreateAuthorizationReporter, +} from "./cdk/paymentConnectors"; import { describeStack } from "./cdk/stackReader"; type StackDescriber = typeof describeStack; @@ -106,6 +111,7 @@ export type CdkBackendConfig = { provisionCredentials?: CredentialProvisioner; removePaymentCredentials?: PaymentCredentialRemover; describeStack?: StackDescriber; + reportQuickCreateAuthorizations?: QuickCreateAuthorizationReporter; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -122,6 +128,7 @@ export class CdkBackend implements ProjectBackend { private readonly provisionCredentials: CredentialProvisioner; private readonly removePaymentCredentials: PaymentCredentialRemover; private readonly describeStack: StackDescriber; + private readonly reportQuickCreateAuthorizations: QuickCreateAuthorizationReporter; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -150,6 +157,11 @@ export class CdkBackend implements ProjectBackend { describeStack(region, credentials, stackName, (name) => readStack(name, region, credentials), )); + this.reportQuickCreateAuthorizations = + config.reportQuickCreateAuthorizations ?? + createQuickCreateAuthorizationReporter( + createPaymentConnectorCalls(config.createCloudFormationClient), + ); } // Local prerequisites for synth. Checked before any AWS mutation so a missing @@ -286,6 +298,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.reportQuickCreateAuthorizations(project, { + stackName: artifact.stackName, + region: target.region, + credentials, + }); + return { outputs }; } diff --git a/src/core/project/backends/cdk/paymentConnectorCalls.ts b/src/core/project/backends/cdk/paymentConnectorCalls.ts new file mode 100644 index 000000000..67d90de8d --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectorCalls.ts @@ -0,0 +1,85 @@ +import { + GetPaymentConnectorCommand, + GetPaymentManagerCommand, + ListPaymentConnectorsCommand, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { ListStackResourcesCommand } from "@aws-sdk/client-cloudformation"; +import { createCloudFormationClient, createControlClient } from "../../../factories"; +import type { CreateCloudFormationClient, CreateControlClient } from "../../../types"; +import type { PaymentConnectorCalls } from "./paymentConnectors"; + +const PAYMENT_MANAGER_RESOURCE_TYPE = "AWS::BedrockAgentCore::PaymentManager"; + +/** + * The AWS reads behind the Quick Create authorization report, kept apart from the + * reporting logic so that logic tests without the SDK — the split `stackReader` + * uses for the same reason. + * + * Clients are created per call rather than cached: these reads happen once at the + * end of a deploy, so a shared connection buys nothing. + */ +export function createPaymentConnectorCalls( + createStackClient: CreateCloudFormationClient = createCloudFormationClient, + createPaymentsClient: CreateControlClient = createControlClient, +): PaymentConnectorCalls { + return { + async listStackManagerIds({ stackName, region, credentials }) { + const client = createStackClient({ credentials, region }); + const ids: string[] = []; + let token: string | undefined; + // Paginated because a project may declare more managers than one page holds. + do { + const page = await client.send( + new ListStackResourcesCommand({ StackName: stackName, NextToken: token }), + ); + for (const resource of page.StackResourceSummaries ?? []) { + if ( + resource.ResourceType === PAYMENT_MANAGER_RESOURCE_TYPE && + resource.PhysicalResourceId + ) { + ids.push(resource.PhysicalResourceId); + } + } + token = page.NextToken; + } while (token); + return ids; + }, + + async getManagerName({ managerId, region, credentials }) { + const response = await createPaymentsClient({ credentials, region }).send( + new GetPaymentManagerCommand({ paymentManagerId: managerId }), + ); + return response.name; + }, + + async listConnectors({ managerId, region, credentials }) { + const client = createPaymentsClient({ credentials, region }); + const connectors: { name?: string; paymentConnectorId?: string; status?: string }[] = []; + let token: string | undefined; + do { + const page = await client.send( + new ListPaymentConnectorsCommand({ paymentManagerId: managerId, nextToken: token }), + ); + for (const summary of page.paymentConnectors ?? []) { + connectors.push({ + name: summary.name, + paymentConnectorId: summary.paymentConnectorId, + status: summary.status, + }); + } + token = page.nextToken; + } while (token); + return connectors; + }, + + async getAuthorizationUrl({ managerId, connectorId, region, credentials }) { + const response = await createPaymentsClient({ credentials, region }).send( + new GetPaymentConnectorCommand({ + paymentManagerId: managerId, + paymentConnectorId: connectorId, + }), + ); + return response.authorizationUrl; + }, + }; +} diff --git a/src/core/project/backends/cdk/paymentConnectors.test.ts b/src/core/project/backends/cdk/paymentConnectors.test.ts new file mode 100644 index 000000000..9d91de26a --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectors.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test"; +import type { Project, ProjectEvent } from "../../../../handlers/project/types"; +import { ProjectSpecSchema } from "../../../../projectSchemas/project"; +import { + createQuickCreateAuthorizationReporter, + type PaymentConnectorCalls, +} from "./paymentConnectors"; +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", +}); + +/** + * A manager as it exists in the fake account, keyed by the physical resource ID the + * stack reports. Tests assert on the reported messages rather than on a call log, so + * how the reporter gets there can change without breaking them. + */ +type FakeManager = { + name: string; + connectors: { name: string; id: string; status: string }[]; + /** The live URL the service hands back, absent once the window has closed. */ + authorizationUrls?: Record; +}; + +function fakeAccount( + managers: Record, + overrides: Partial = {}, +): PaymentConnectorCalls { + return { + listStackManagerIds: async () => Object.keys(managers), + getManagerName: async ({ managerId }) => managers[managerId]?.name, + listConnectors: async ({ managerId }) => + (managers[managerId]?.connectors ?? []).map(({ name, id, status }) => ({ + name, + paymentConnectorId: id, + status, + })), + getAuthorizationUrl: async ({ managerId, connectorId }) => + managers[managerId]?.authorizationUrls?.[connectorId], + ...overrides, + }; +} + +function project(payments: unknown[], credentials: unknown[] = []): Project { + return { + name: "example", + rootPath: "/tmp/example", + spec: ProjectSpecSchema.parse({ name: "example", version: 1, payments, credentials }), + }; +} + +const quickCreate = (name: string) => ({ + name, + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", +}); + +async function report( + calls: PaymentConnectorCalls, + input: Project, +): Promise<{ messages: string[] }> { + const generator = createQuickCreateAuthorizationReporter(calls)(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("hands over the live authorization link and says when it expires", async () => { + const { messages } = await report( + fakeAccount({ + "payments-abc": { + name: "payments", + connectors: [{ name: "quick", id: "quick-xyz", status: "PENDING_AUTHENTICATION" }], + authorizationUrls: { "quick-xyz": "https://example.com/authorize?request_uri=urn:x" }, + }, + }), + project([ + { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('Authorize payment connector "payments/quick"'); + expect(messages[0]).toContain("https://example.com/authorize?request_uri=urn:x"); + expect(messages[0]).toContain("expires 10 minutes"); + }); + + test("tells the user how to get a new link once the window has closed", async () => { + const { messages } = await report( + fakeAccount({ + "payments-abc": { + name: "payments", + connectors: [{ name: "quick", id: "quick-xyz", status: "AUTHENTICATION_EXPIRED" }], + }, + }), + project([ + { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toEqual([ + 'Payment connector "payments/quick" is AUTHENTICATION_EXPIRED. Remove and deploy it, ' + + "then add and deploy it again to generate a new authorization URL.", + ]); + }); + + test("reports a connector that is already authorized", async () => { + const { messages } = await report( + fakeAccount({ + "payments-abc": { + name: "payments", + connectors: [{ name: "quick", id: "quick-xyz", status: "READY" }], + }, + }), + project([ + { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toEqual(['Payment connector "payments/quick" is ready.']); + }); + + test("says so when the service reports pending without a link", async () => { + const { messages } = await report( + fakeAccount({ + "payments-abc": { + name: "payments", + connectors: [{ name: "quick", id: "quick-xyz", status: "PENDING_AUTHENTICATION" }], + }, + }), + project([ + { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toEqual([ + 'Payment connector "payments/quick" is pending authorization, but no authorization URL was returned.', + ]); + }); + + test("makes no calls for a project without Quick Create connectors", async () => { + let called = false; + const calls = fakeAccount( + {}, + { + listStackManagerIds: async () => { + called = true; + return []; + }, + }, + ); + + const { messages } = await report( + calls, + project( + [ + { + name: "payments", + authorizerType: "AWS_IAM", + connectors: [{ name: "manual", provider: "CoinbaseCDP", credentialName: "coinbase" }], + }, + ], + [ + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }, + ], + ), + ); + + expect(called).toBe(false); + expect(messages).toEqual([]); + }); + + test("ignores connectors on the manager that this project did not declare", async () => { + const { messages } = await report( + fakeAccount({ + "payments-abc": { + name: "payments", + connectors: [ + { name: "quick", id: "quick-xyz", status: "READY" }, + { name: "someone-elses", id: "other-xyz", status: "PENDING_AUTHENTICATION" }, + ], + }, + }), + project([ + { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toEqual(['Payment connector "payments/quick" is ready.']); + }); + + test("keeps same-named connectors on different managers apart", async () => { + const { messages } = await report( + fakeAccount({ + "a-1": { + name: "alpha", + connectors: [{ name: "quick", id: "q-a", status: "READY" }], + }, + "b-2": { + name: "beta", + connectors: [{ name: "quick", id: "q-b", status: "AUTHENTICATION_EXPIRED" }], + }, + }), + project([ + { name: "alpha", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + { name: "beta", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toHaveLength(2); + expect(messages[0]).toBe('Payment connector "alpha/quick" is ready.'); + expect(messages[1]).toContain('"beta/quick" is AUTHENTICATION_EXPIRED'); + }); + + test("reports an unreadable status without failing the deploy that already succeeded", async () => { + const { messages } = await report( + fakeAccount( + {}, + { + listStackManagerIds: async () => { + throw new Error("AccessDenied"); + }, + }, + ), + project([ + { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toEqual([ + "Deployed, but the live status of payment connectors could not be retrieved: AccessDenied", + ]); + }); + + test("keeps reporting other managers when one cannot be read", async () => { + const { messages } = await report( + fakeAccount( + { + "a-1": { name: "alpha", connectors: [{ name: "quick", id: "q-a", status: "READY" }] }, + "b-2": { name: "beta", connectors: [{ name: "quick", id: "q-b", status: "READY" }] }, + }, + { + getManagerName: async ({ managerId }) => { + if (managerId === "a-1") throw new Error("Throttled"); + return "beta"; + }, + }, + ), + project([ + { name: "alpha", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + { name: "beta", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, + ]), + ); + + expect(messages).toHaveLength(2); + expect(messages[0]).toContain("could not be retrieved: Throttled"); + expect(messages[1]).toBe('Payment connector "beta/quick" is ready.'); + }); +}); diff --git a/src/core/project/backends/cdk/paymentConnectors.ts b/src/core/project/backends/cdk/paymentConnectors.ts new file mode 100644 index 000000000..ff01aabd6 --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectors.ts @@ -0,0 +1,154 @@ +import type { Project, ProjectEvent } from "../../../../handlers/project/types"; +import type { CdkCredentialProvider } from "./toolkit"; + +/** How long the service leaves a Quick Create authorization link usable. */ +const AUTHORIZATION_WINDOW = "10 minutes"; + +/** The one status that means a link exists for someone to use. */ +const PENDING = "PENDING_AUTHENTICATION"; + +export type PaymentConnectorCalls = { + /** + * The payment managers in *this project's* stack. + * + * Scoped through the stack rather than by matching manager names against the + * account, because manager names are account-scoped: two projects both + * declaring `payments` would otherwise be indistinguishable. + */ + listStackManagerIds: (input: Target & { stackName: string }) => Promise; + /** The manager's service-side name, which pairs it with the project spec. */ + getManagerName: (input: Target & { managerId: string }) => Promise; + /** The manager's connectors. Scoped to the manager, so names cannot collide. */ + listConnectors: ( + input: Target & { managerId: string }, + ) => Promise<{ name?: string; paymentConnectorId?: string; status?: string }[]>; + /** + * The live authorization URL. Deliberately not read from the stack output of + * the same name: that output is an `Fn::GetAtt` resolved when the connector was + * created, so it keeps serving a dead link — and a stale `PENDING` status — + * after the window closes. + */ + getAuthorizationUrl: ( + input: Target & { managerId: string; connectorId: string }, + ) => Promise; +}; + +type Target = { region: string; credentials: CdkCredentialProvider }; + +export type QuickCreateAuthorizationReporter = ( + project: Project, + input: Target & { stackName: string }, +) => AsyncGenerator; + +/** + * Reports the Quick Create connectors a deploy left needing authorization. + * + * Quick Create is inherently two-phase: CloudFormation creates the connector + * pending, someone completes the provider's flow out of band, and only then is it + * usable. The link expires about ten minutes after the connector is created, so a + * deploy that says nothing leaves the project looking successful while the + * connector is unusable — and by the time anyone thinks to look, the window is + * gone. + * + * Reads run after a successful deploy and never fail it: the stack is already up, + * so an unreadable status is reported and the deploy still succeeds. + */ +export function createQuickCreateAuthorizationReporter( + calls: PaymentConnectorCalls, +): QuickCreateAuthorizationReporter { + return async function* reportQuickCreateAuthorizations(project, { stackName, ...target }) { + // Every project without Quick Create pays nothing: no calls are made. + if (!declaresQuickCreate(project)) return; + + let managerIds: string[]; + try { + managerIds = await calls.listStackManagerIds({ ...target, stackName }); + } catch (error) { + yield unreadable("payment connectors", error); + return; + } + + for (const managerId of managerIds) { + try { + yield* reportManager(calls, project, target, managerId); + } catch (error) { + yield unreadable(`payment connectors on manager '${managerId}'`, error); + } + } + }; +} + +async function* reportManager( + calls: PaymentConnectorCalls, + project: Project, + target: Target, + managerId: string, +): AsyncGenerator { + const managerName = await calls.getManagerName({ ...target, managerId }); + if (!managerName) return; + + // Pair the deployed manager with the spec by name so the expected connectors + // are the ones this project declared, not whatever else the manager holds. + const declared = project.spec.payments?.find((manager) => manager.name === managerName); + const quickCreateNames = new Set( + (declared?.connectors ?? []) + .filter((connector) => connector.provisionMode === "QUICK_CREATE") + .map((connector) => connector.name), + ); + if (quickCreateNames.size === 0) return; + + for (const connector of await calls.listConnectors({ ...target, managerId })) { + if (!connector.name || !quickCreateNames.has(connector.name)) continue; + + const label = `${managerName}/${connector.name}`; + // Only a pending connector has a link to hand over; asking for one in any + // other state returns nothing and would read as a failure. + const url = + connector.status === PENDING && connector.paymentConnectorId + ? await calls.getAuthorizationUrl({ + ...target, + managerId, + connectorId: connector.paymentConnectorId, + }) + : undefined; + + yield { type: "step", message: describe(label, connector.status, url) }; + } +} + +function declaresQuickCreate(project: Project): boolean { + return (project.spec.payments ?? []).some((manager) => + manager.connectors.some((connector) => connector.provisionMode === "QUICK_CREATE"), + ); +} + +function unreadable(subject: string, error: unknown): ProjectEvent { + const detail = error instanceof Error ? error.message : String(error); + return { + type: "step", + message: `Deployed, but the live status of ${subject} could not be retrieved: ${detail}`, + }; +} + +/** The line a user acts on. Wording follows the released CLI. */ +function describe(label: string, status: string | undefined, url: string | undefined): string { + if (status === PENDING && url) { + return ( + `Authorize payment connector "${label}": ${url}\n` + + `This link expires ${AUTHORIZATION_WINDOW} after the connector is created.` + ); + } + if (status === PENDING) { + return `Payment connector "${label}" is pending authorization, but no authorization URL was returned.`; + } + if (status === "READY") { + return `Payment connector "${label}" is ready.`; + } + if (status === "AUTHENTICATION_EXPIRED" || status === "AUTHENTICATION_FAILED") { + return ( + `Payment connector "${label}" is ${status}. Remove and deploy it, then add and ` + + `deploy it again to generate a new authorization URL.` + ); + } + return `Payment connector "${label}" status: ${status ?? "unknown"}.`; +} From 5de2e187c812f8a2fc0eaf7df487ceed294fb699 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 3 Sep 2026 15:13:20 +0000 Subject: [PATCH 3/7] fix(project): take the manager id out of the ARN CloudFormation reports The authorization report asked the Payments service about a manager using the physical resource id CloudFormation gives for it, which is the manager's ARN. `paymentManagerId` accepts only the bare identifier and rejects an ARN against its own pattern, so every deploy of a project with a Quick Create connector reported that the connector's status could not be retrieved instead of handing over its authorization link. Only reachable against CloudFormation, so the unit tests could not see it: they fake the service boundary, and the boundary was the defect. --- .../backends/cdk/paymentConnectorCalls.test.ts | 16 ++++++++++++++++ .../backends/cdk/paymentConnectorCalls.ts | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/core/project/backends/cdk/paymentConnectorCalls.test.ts diff --git a/src/core/project/backends/cdk/paymentConnectorCalls.test.ts b/src/core/project/backends/cdk/paymentConnectorCalls.test.ts new file mode 100644 index 000000000..8d9b1f4a4 --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectorCalls.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { paymentManagerId } from "./paymentConnectorCalls"; + +describe("paymentManagerId", () => { + test("takes the identifier out of the ARN CloudFormation reports", () => { + expect( + paymentManagerId( + "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/payments-xlcjhrs0pa", + ), + ).toBe("payments-xlcjhrs0pa"); + }); + + test("passes a bare identifier through", () => { + expect(paymentManagerId("payments-xlcjhrs0pa")).toBe("payments-xlcjhrs0pa"); + }); +}); diff --git a/src/core/project/backends/cdk/paymentConnectorCalls.ts b/src/core/project/backends/cdk/paymentConnectorCalls.ts index 67d90de8d..5e1f6687b 100644 --- a/src/core/project/backends/cdk/paymentConnectorCalls.ts +++ b/src/core/project/backends/cdk/paymentConnectorCalls.ts @@ -10,6 +10,19 @@ import type { PaymentConnectorCalls } from "./paymentConnectors"; const PAYMENT_MANAGER_RESOURCE_TYPE = "AWS::BedrockAgentCore::PaymentManager"; +/** + * The manager ID the Payments API takes, from the physical resource ID + * CloudFormation reports. + * + * CloudFormation reports the manager's ARN, while `paymentManagerId` accepts only + * the bare identifier and rejects an ARN outright. Anything without a slash is + * already an ID and passes through, so this keeps working if the resource ever + * reports one directly. + */ +export function paymentManagerId(physicalResourceId: string): string { + return physicalResourceId.slice(physicalResourceId.lastIndexOf("/") + 1); +} + /** * The AWS reads behind the Quick Create authorization report, kept apart from the * reporting logic so that logic tests without the SDK — the split `stackReader` @@ -37,7 +50,7 @@ export function createPaymentConnectorCalls( resource.ResourceType === PAYMENT_MANAGER_RESOURCE_TYPE && resource.PhysicalResourceId ) { - ids.push(resource.PhysicalResourceId); + ids.push(paymentManagerId(resource.PhysicalResourceId)); } } token = page.NextToken; From 94ea166979c6926abd418f4e0b76d9d6e6ba32bf Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 3 Sep 2026 16:01:34 +0000 Subject: [PATCH 4/7] test(project): drop the assertion on the Quick Create authorization output The generated app does not read this output, and neither does the CLI: the authorization URL is read from the Payments service because it expires. The L3 is removing the output for that reason, since an output holding an attribute that disappears fails every later stack update. Asserting it here only pins a value nothing consumes to whichever L3 version is pinned, so the pin bump can stand alone. --- src/assets/cdk/test/cdk.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 2db16484f..4bc358912 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -117,7 +117,6 @@ test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () ProvisionMode: 'QUICK_CREATE', CredentialProviderConfigurations: [], }); - expect(Object.keys(template.findOutputs('*')).some(key => key.includes('AuthorizationUrl'))).toBe(true); }); test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { From 624de2288eec5b8b571cf0583aa084d77d029264 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 3 Sep 2026 18:38:05 +0000 Subject: [PATCH 5/7] test(project): drop the connector identity test the L3 already owns The generated app hands the whole spec to AgentCorePayments, which derives every connector's construct identity. Feeding it colliding names asserted that derivation, not anything this package decides, and the L3 covers the same foo_bar/foobar case in its own suite. Seventy lines of fixture for one resource count, over code owned elsewhere. The manual and Quick Create synthesis test stays: it is the only check that the generated app passes the spec and the target's deployed credentials to the construct at all, which is exactly what regressed when the vendored Payment loops were restored. --- src/assets/cdk/test/cdk.test.ts | 75 --------------------------------- 1 file changed, 75 deletions(-) diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 4bc358912..c9cd8eb80 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -118,78 +118,3 @@ test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () CredentialProviderConfigurations: [], }); }); - -test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { - const app = new cdk.App(); - const stack = new AgentCoreStack(app, 'TestStack', { - spec: { - name: 'testproject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - configBundles: [], - policyEngines: [], - payments: [ - { - name: 'Payments', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'foo_bar', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - { - name: 'foobar', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'A', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'B', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - { - name: 'BC', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'AB', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'C', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'M1AC1B', - authorizerType: 'AWS_IAM', - connectors: [], - }, - ], - agentCoreGateways: [], - mcpRuntimeTools: [], - unassignedTargets: [], - datasets: [], - knowledgeBases: [], - }, - }); - - Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 5); -}); From cbbf897aff1006596f8e4642aaac33738f6ee90b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 3 Sep 2026 21:45:20 +0000 Subject: [PATCH 6/7] refactor(project): simplify payment authorization URL reporting --- src/core/project/backends/cdk.ts | 21 +- .../paymentConnectorAuthorizationUrls.test.ts | 173 +++++++++++ .../cdk/paymentConnectorAuthorizationUrls.ts | 93 ++++++ .../cdk/paymentConnectorCalls.test.ts | 16 - .../backends/cdk/paymentConnectorCalls.ts | 98 ------- .../backends/cdk/paymentConnectors.test.ts | 275 ------------------ .../project/backends/cdk/paymentConnectors.ts | 154 ---------- 7 files changed, 275 insertions(+), 555 deletions(-) create mode 100644 src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts create mode 100644 src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts delete mode 100644 src/core/project/backends/cdk/paymentConnectorCalls.test.ts delete mode 100644 src/core/project/backends/cdk/paymentConnectorCalls.ts delete mode 100644 src/core/project/backends/cdk/paymentConnectors.test.ts delete mode 100644 src/core/project/backends/cdk/paymentConnectors.ts diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 22e9a6cb4..de4d43058 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -62,11 +62,10 @@ import { type CdkRunOptions, type CdkRunResult, } from "./cdk/toolkit"; -import { createPaymentConnectorCalls } from "./cdk/paymentConnectorCalls"; import { - createQuickCreateAuthorizationReporter, - type QuickCreateAuthorizationReporter, -} from "./cdk/paymentConnectors"; + createPaymentConnectorAuthorizationUrlReporter, + type PaymentConnectorAuthorizationUrlReporter, +} from "./cdk/paymentConnectorAuthorizationUrls"; import { describeStack } from "./cdk/stackReader"; type StackDescriber = typeof describeStack; @@ -111,7 +110,7 @@ export type CdkBackendConfig = { provisionCredentials?: CredentialProvisioner; removePaymentCredentials?: PaymentCredentialRemover; describeStack?: StackDescriber; - reportQuickCreateAuthorizations?: QuickCreateAuthorizationReporter; + reportPaymentConnectorAuthorizationUrls?: PaymentConnectorAuthorizationUrlReporter; }; /** Builds and deploys projects through the scaffolded CDK app. */ @@ -128,7 +127,7 @@ export class CdkBackend implements ProjectBackend { private readonly provisionCredentials: CredentialProvisioner; private readonly removePaymentCredentials: PaymentCredentialRemover; private readonly describeStack: StackDescriber; - private readonly reportQuickCreateAuthorizations: QuickCreateAuthorizationReporter; + private readonly reportPaymentConnectorAuthorizationUrls: PaymentConnectorAuthorizationUrlReporter; constructor(config: CdkBackendConfig) { this.logger = config.logger; @@ -157,11 +156,9 @@ export class CdkBackend implements ProjectBackend { describeStack(region, credentials, stackName, (name) => readStack(name, region, credentials), )); - this.reportQuickCreateAuthorizations = - config.reportQuickCreateAuthorizations ?? - createQuickCreateAuthorizationReporter( - createPaymentConnectorCalls(config.createCloudFormationClient), - ); + this.reportPaymentConnectorAuthorizationUrls = + config.reportPaymentConnectorAuthorizationUrls ?? + createPaymentConnectorAuthorizationUrlReporter(config.createCloudFormationClient); } // Local prerequisites for synth. Checked before any AWS mutation so a missing @@ -301,7 +298,7 @@ export class CdkBackend implements ProjectBackend { // 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.reportQuickCreateAuthorizations(project, { + yield* this.reportPaymentConnectorAuthorizationUrls(project, { stackName: artifact.stackName, region: target.region, credentials, 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..363e270f3 --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts @@ -0,0 +1,173 @@ +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"; + +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("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..f6ea24cf6 --- /dev/null +++ b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts @@ -0,0 +1,93 @@ +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 { 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; + + const connector = await paymentsClient.send( + new GetPaymentConnectorCommand({ + paymentManagerId: ids.managerId, + paymentConnectorId: ids.connectorId, + }), + ); + 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"), + ); +} diff --git a/src/core/project/backends/cdk/paymentConnectorCalls.test.ts b/src/core/project/backends/cdk/paymentConnectorCalls.test.ts deleted file mode 100644 index 8d9b1f4a4..000000000 --- a/src/core/project/backends/cdk/paymentConnectorCalls.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { paymentManagerId } from "./paymentConnectorCalls"; - -describe("paymentManagerId", () => { - test("takes the identifier out of the ARN CloudFormation reports", () => { - expect( - paymentManagerId( - "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/payments-xlcjhrs0pa", - ), - ).toBe("payments-xlcjhrs0pa"); - }); - - test("passes a bare identifier through", () => { - expect(paymentManagerId("payments-xlcjhrs0pa")).toBe("payments-xlcjhrs0pa"); - }); -}); diff --git a/src/core/project/backends/cdk/paymentConnectorCalls.ts b/src/core/project/backends/cdk/paymentConnectorCalls.ts deleted file mode 100644 index 5e1f6687b..000000000 --- a/src/core/project/backends/cdk/paymentConnectorCalls.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - GetPaymentConnectorCommand, - GetPaymentManagerCommand, - ListPaymentConnectorsCommand, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import { ListStackResourcesCommand } from "@aws-sdk/client-cloudformation"; -import { createCloudFormationClient, createControlClient } from "../../../factories"; -import type { CreateCloudFormationClient, CreateControlClient } from "../../../types"; -import type { PaymentConnectorCalls } from "./paymentConnectors"; - -const PAYMENT_MANAGER_RESOURCE_TYPE = "AWS::BedrockAgentCore::PaymentManager"; - -/** - * The manager ID the Payments API takes, from the physical resource ID - * CloudFormation reports. - * - * CloudFormation reports the manager's ARN, while `paymentManagerId` accepts only - * the bare identifier and rejects an ARN outright. Anything without a slash is - * already an ID and passes through, so this keeps working if the resource ever - * reports one directly. - */ -export function paymentManagerId(physicalResourceId: string): string { - return physicalResourceId.slice(physicalResourceId.lastIndexOf("/") + 1); -} - -/** - * The AWS reads behind the Quick Create authorization report, kept apart from the - * reporting logic so that logic tests without the SDK — the split `stackReader` - * uses for the same reason. - * - * Clients are created per call rather than cached: these reads happen once at the - * end of a deploy, so a shared connection buys nothing. - */ -export function createPaymentConnectorCalls( - createStackClient: CreateCloudFormationClient = createCloudFormationClient, - createPaymentsClient: CreateControlClient = createControlClient, -): PaymentConnectorCalls { - return { - async listStackManagerIds({ stackName, region, credentials }) { - const client = createStackClient({ credentials, region }); - const ids: string[] = []; - let token: string | undefined; - // Paginated because a project may declare more managers than one page holds. - do { - const page = await client.send( - new ListStackResourcesCommand({ StackName: stackName, NextToken: token }), - ); - for (const resource of page.StackResourceSummaries ?? []) { - if ( - resource.ResourceType === PAYMENT_MANAGER_RESOURCE_TYPE && - resource.PhysicalResourceId - ) { - ids.push(paymentManagerId(resource.PhysicalResourceId)); - } - } - token = page.NextToken; - } while (token); - return ids; - }, - - async getManagerName({ managerId, region, credentials }) { - const response = await createPaymentsClient({ credentials, region }).send( - new GetPaymentManagerCommand({ paymentManagerId: managerId }), - ); - return response.name; - }, - - async listConnectors({ managerId, region, credentials }) { - const client = createPaymentsClient({ credentials, region }); - const connectors: { name?: string; paymentConnectorId?: string; status?: string }[] = []; - let token: string | undefined; - do { - const page = await client.send( - new ListPaymentConnectorsCommand({ paymentManagerId: managerId, nextToken: token }), - ); - for (const summary of page.paymentConnectors ?? []) { - connectors.push({ - name: summary.name, - paymentConnectorId: summary.paymentConnectorId, - status: summary.status, - }); - } - token = page.nextToken; - } while (token); - return connectors; - }, - - async getAuthorizationUrl({ managerId, connectorId, region, credentials }) { - const response = await createPaymentsClient({ credentials, region }).send( - new GetPaymentConnectorCommand({ - paymentManagerId: managerId, - paymentConnectorId: connectorId, - }), - ); - return response.authorizationUrl; - }, - }; -} diff --git a/src/core/project/backends/cdk/paymentConnectors.test.ts b/src/core/project/backends/cdk/paymentConnectors.test.ts deleted file mode 100644 index 9d91de26a..000000000 --- a/src/core/project/backends/cdk/paymentConnectors.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { Project, ProjectEvent } from "../../../../handlers/project/types"; -import { ProjectSpecSchema } from "../../../../projectSchemas/project"; -import { - createQuickCreateAuthorizationReporter, - type PaymentConnectorCalls, -} from "./paymentConnectors"; -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", -}); - -/** - * A manager as it exists in the fake account, keyed by the physical resource ID the - * stack reports. Tests assert on the reported messages rather than on a call log, so - * how the reporter gets there can change without breaking them. - */ -type FakeManager = { - name: string; - connectors: { name: string; id: string; status: string }[]; - /** The live URL the service hands back, absent once the window has closed. */ - authorizationUrls?: Record; -}; - -function fakeAccount( - managers: Record, - overrides: Partial = {}, -): PaymentConnectorCalls { - return { - listStackManagerIds: async () => Object.keys(managers), - getManagerName: async ({ managerId }) => managers[managerId]?.name, - listConnectors: async ({ managerId }) => - (managers[managerId]?.connectors ?? []).map(({ name, id, status }) => ({ - name, - paymentConnectorId: id, - status, - })), - getAuthorizationUrl: async ({ managerId, connectorId }) => - managers[managerId]?.authorizationUrls?.[connectorId], - ...overrides, - }; -} - -function project(payments: unknown[], credentials: unknown[] = []): Project { - return { - name: "example", - rootPath: "/tmp/example", - spec: ProjectSpecSchema.parse({ name: "example", version: 1, payments, credentials }), - }; -} - -const quickCreate = (name: string) => ({ - name, - provider: "CoinbaseCDP", - provisionMode: "QUICK_CREATE", -}); - -async function report( - calls: PaymentConnectorCalls, - input: Project, -): Promise<{ messages: string[] }> { - const generator = createQuickCreateAuthorizationReporter(calls)(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("hands over the live authorization link and says when it expires", async () => { - const { messages } = await report( - fakeAccount({ - "payments-abc": { - name: "payments", - connectors: [{ name: "quick", id: "quick-xyz", status: "PENDING_AUTHENTICATION" }], - authorizationUrls: { "quick-xyz": "https://example.com/authorize?request_uri=urn:x" }, - }, - }), - project([ - { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toHaveLength(1); - expect(messages[0]).toContain('Authorize payment connector "payments/quick"'); - expect(messages[0]).toContain("https://example.com/authorize?request_uri=urn:x"); - expect(messages[0]).toContain("expires 10 minutes"); - }); - - test("tells the user how to get a new link once the window has closed", async () => { - const { messages } = await report( - fakeAccount({ - "payments-abc": { - name: "payments", - connectors: [{ name: "quick", id: "quick-xyz", status: "AUTHENTICATION_EXPIRED" }], - }, - }), - project([ - { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toEqual([ - 'Payment connector "payments/quick" is AUTHENTICATION_EXPIRED. Remove and deploy it, ' + - "then add and deploy it again to generate a new authorization URL.", - ]); - }); - - test("reports a connector that is already authorized", async () => { - const { messages } = await report( - fakeAccount({ - "payments-abc": { - name: "payments", - connectors: [{ name: "quick", id: "quick-xyz", status: "READY" }], - }, - }), - project([ - { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toEqual(['Payment connector "payments/quick" is ready.']); - }); - - test("says so when the service reports pending without a link", async () => { - const { messages } = await report( - fakeAccount({ - "payments-abc": { - name: "payments", - connectors: [{ name: "quick", id: "quick-xyz", status: "PENDING_AUTHENTICATION" }], - }, - }), - project([ - { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toEqual([ - 'Payment connector "payments/quick" is pending authorization, but no authorization URL was returned.', - ]); - }); - - test("makes no calls for a project without Quick Create connectors", async () => { - let called = false; - const calls = fakeAccount( - {}, - { - listStackManagerIds: async () => { - called = true; - return []; - }, - }, - ); - - const { messages } = await report( - calls, - project( - [ - { - name: "payments", - authorizerType: "AWS_IAM", - connectors: [{ name: "manual", provider: "CoinbaseCDP", credentialName: "coinbase" }], - }, - ], - [ - { - authorizerType: "PaymentCredentialProvider", - name: "coinbase", - provider: "CoinbaseCDP", - }, - ], - ), - ); - - expect(called).toBe(false); - expect(messages).toEqual([]); - }); - - test("ignores connectors on the manager that this project did not declare", async () => { - const { messages } = await report( - fakeAccount({ - "payments-abc": { - name: "payments", - connectors: [ - { name: "quick", id: "quick-xyz", status: "READY" }, - { name: "someone-elses", id: "other-xyz", status: "PENDING_AUTHENTICATION" }, - ], - }, - }), - project([ - { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toEqual(['Payment connector "payments/quick" is ready.']); - }); - - test("keeps same-named connectors on different managers apart", async () => { - const { messages } = await report( - fakeAccount({ - "a-1": { - name: "alpha", - connectors: [{ name: "quick", id: "q-a", status: "READY" }], - }, - "b-2": { - name: "beta", - connectors: [{ name: "quick", id: "q-b", status: "AUTHENTICATION_EXPIRED" }], - }, - }), - project([ - { name: "alpha", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - { name: "beta", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toHaveLength(2); - expect(messages[0]).toBe('Payment connector "alpha/quick" is ready.'); - expect(messages[1]).toContain('"beta/quick" is AUTHENTICATION_EXPIRED'); - }); - - test("reports an unreadable status without failing the deploy that already succeeded", async () => { - const { messages } = await report( - fakeAccount( - {}, - { - listStackManagerIds: async () => { - throw new Error("AccessDenied"); - }, - }, - ), - project([ - { name: "payments", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toEqual([ - "Deployed, but the live status of payment connectors could not be retrieved: AccessDenied", - ]); - }); - - test("keeps reporting other managers when one cannot be read", async () => { - const { messages } = await report( - fakeAccount( - { - "a-1": { name: "alpha", connectors: [{ name: "quick", id: "q-a", status: "READY" }] }, - "b-2": { name: "beta", connectors: [{ name: "quick", id: "q-b", status: "READY" }] }, - }, - { - getManagerName: async ({ managerId }) => { - if (managerId === "a-1") throw new Error("Throttled"); - return "beta"; - }, - }, - ), - project([ - { name: "alpha", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - { name: "beta", authorizerType: "AWS_IAM", connectors: [quickCreate("quick")] }, - ]), - ); - - expect(messages).toHaveLength(2); - expect(messages[0]).toContain("could not be retrieved: Throttled"); - expect(messages[1]).toBe('Payment connector "beta/quick" is ready.'); - }); -}); diff --git a/src/core/project/backends/cdk/paymentConnectors.ts b/src/core/project/backends/cdk/paymentConnectors.ts deleted file mode 100644 index ff01aabd6..000000000 --- a/src/core/project/backends/cdk/paymentConnectors.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Project, ProjectEvent } from "../../../../handlers/project/types"; -import type { CdkCredentialProvider } from "./toolkit"; - -/** How long the service leaves a Quick Create authorization link usable. */ -const AUTHORIZATION_WINDOW = "10 minutes"; - -/** The one status that means a link exists for someone to use. */ -const PENDING = "PENDING_AUTHENTICATION"; - -export type PaymentConnectorCalls = { - /** - * The payment managers in *this project's* stack. - * - * Scoped through the stack rather than by matching manager names against the - * account, because manager names are account-scoped: two projects both - * declaring `payments` would otherwise be indistinguishable. - */ - listStackManagerIds: (input: Target & { stackName: string }) => Promise; - /** The manager's service-side name, which pairs it with the project spec. */ - getManagerName: (input: Target & { managerId: string }) => Promise; - /** The manager's connectors. Scoped to the manager, so names cannot collide. */ - listConnectors: ( - input: Target & { managerId: string }, - ) => Promise<{ name?: string; paymentConnectorId?: string; status?: string }[]>; - /** - * The live authorization URL. Deliberately not read from the stack output of - * the same name: that output is an `Fn::GetAtt` resolved when the connector was - * created, so it keeps serving a dead link — and a stale `PENDING` status — - * after the window closes. - */ - getAuthorizationUrl: ( - input: Target & { managerId: string; connectorId: string }, - ) => Promise; -}; - -type Target = { region: string; credentials: CdkCredentialProvider }; - -export type QuickCreateAuthorizationReporter = ( - project: Project, - input: Target & { stackName: string }, -) => AsyncGenerator; - -/** - * Reports the Quick Create connectors a deploy left needing authorization. - * - * Quick Create is inherently two-phase: CloudFormation creates the connector - * pending, someone completes the provider's flow out of band, and only then is it - * usable. The link expires about ten minutes after the connector is created, so a - * deploy that says nothing leaves the project looking successful while the - * connector is unusable — and by the time anyone thinks to look, the window is - * gone. - * - * Reads run after a successful deploy and never fail it: the stack is already up, - * so an unreadable status is reported and the deploy still succeeds. - */ -export function createQuickCreateAuthorizationReporter( - calls: PaymentConnectorCalls, -): QuickCreateAuthorizationReporter { - return async function* reportQuickCreateAuthorizations(project, { stackName, ...target }) { - // Every project without Quick Create pays nothing: no calls are made. - if (!declaresQuickCreate(project)) return; - - let managerIds: string[]; - try { - managerIds = await calls.listStackManagerIds({ ...target, stackName }); - } catch (error) { - yield unreadable("payment connectors", error); - return; - } - - for (const managerId of managerIds) { - try { - yield* reportManager(calls, project, target, managerId); - } catch (error) { - yield unreadable(`payment connectors on manager '${managerId}'`, error); - } - } - }; -} - -async function* reportManager( - calls: PaymentConnectorCalls, - project: Project, - target: Target, - managerId: string, -): AsyncGenerator { - const managerName = await calls.getManagerName({ ...target, managerId }); - if (!managerName) return; - - // Pair the deployed manager with the spec by name so the expected connectors - // are the ones this project declared, not whatever else the manager holds. - const declared = project.spec.payments?.find((manager) => manager.name === managerName); - const quickCreateNames = new Set( - (declared?.connectors ?? []) - .filter((connector) => connector.provisionMode === "QUICK_CREATE") - .map((connector) => connector.name), - ); - if (quickCreateNames.size === 0) return; - - for (const connector of await calls.listConnectors({ ...target, managerId })) { - if (!connector.name || !quickCreateNames.has(connector.name)) continue; - - const label = `${managerName}/${connector.name}`; - // Only a pending connector has a link to hand over; asking for one in any - // other state returns nothing and would read as a failure. - const url = - connector.status === PENDING && connector.paymentConnectorId - ? await calls.getAuthorizationUrl({ - ...target, - managerId, - connectorId: connector.paymentConnectorId, - }) - : undefined; - - yield { type: "step", message: describe(label, connector.status, url) }; - } -} - -function declaresQuickCreate(project: Project): boolean { - return (project.spec.payments ?? []).some((manager) => - manager.connectors.some((connector) => connector.provisionMode === "QUICK_CREATE"), - ); -} - -function unreadable(subject: string, error: unknown): ProjectEvent { - const detail = error instanceof Error ? error.message : String(error); - return { - type: "step", - message: `Deployed, but the live status of ${subject} could not be retrieved: ${detail}`, - }; -} - -/** The line a user acts on. Wording follows the released CLI. */ -function describe(label: string, status: string | undefined, url: string | undefined): string { - if (status === PENDING && url) { - return ( - `Authorize payment connector "${label}": ${url}\n` + - `This link expires ${AUTHORIZATION_WINDOW} after the connector is created.` - ); - } - if (status === PENDING) { - return `Payment connector "${label}" is pending authorization, but no authorization URL was returned.`; - } - if (status === "READY") { - return `Payment connector "${label}" is ready.`; - } - if (status === "AUTHENTICATION_EXPIRED" || status === "AUTHENTICATION_FAILED") { - return ( - `Payment connector "${label}" is ${status}. Remove and deploy it, then add and ` + - `deploy it again to generate a new authorization URL.` - ); - } - return `Payment connector "${label}" status: ${status ?? "unknown"}.`; -} From 1bf77c64995df51c3e0062ddf33a1ee714561697 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 3 Sep 2026 23:31:04 +0000 Subject: [PATCH 7/7] fix(project): continue after payment connector lookup failures --- .../paymentConnectorAuthorizationUrls.test.ts | 35 +++++++++++++++++++ .../cdk/paymentConnectorAuthorizationUrls.ts | 29 +++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts index 363e270f3..c9bc8f255 100644 --- a/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts +++ b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.test.ts @@ -16,6 +16,9 @@ const CREDENTIALS: CdkCredentialProvider = async () => ({ 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; @@ -157,6 +160,38 @@ describe("Quick Create authorization reporting", () => { 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(), diff --git a/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts index f6ea24cf6..20870812c 100644 --- a/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts +++ b/src/core/project/backends/cdk/paymentConnectorAuthorizationUrls.ts @@ -1,4 +1,7 @@ -import { GetPaymentConnectorCommand } from "@aws-sdk/client-bedrock-agentcore-control"; +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"; @@ -52,12 +55,24 @@ export function createPaymentConnectorAuthorizationUrlReporter( const ids = paymentConnectorIds(resource.PhysicalResourceId); if (!ids) continue; - const connector = await paymentsClient.send( - new GetPaymentConnectorCommand({ - paymentManagerId: ids.managerId, - paymentConnectorId: ids.connectorId, - }), - ); + 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 {