Skip to content

Specification: AWS Infrastructure for Item Image Upload

Implementation specification for the AWS Infrastructure project (Phase 1 of Item Image Upload). This document defines what to build — CDK construct interfaces, stack modifications, cross-stack exports, and the verification script — following the patterns documented in design.md section 3.

For AWS resource requirements (what the resources must do), see the AWS Specification. For project scope and success criteria, see goal.md.


File: src/main/cdk/constructs/storage/image-asset-bucket.ts

Pattern source: UploadBucket in constructs/storage/public-upload-bucket.ts.

Purpose: Persistent S3 bucket for image assets with OAC access for CloudFront and a presigning IAM role for upload credential generation.

import * as purpose from "arda/utils/purpose";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ec2 from "aws-cdk-lib/aws-ec2";
export interface Configuration {
readonly locator: purpose.Locator;
// Browser origins allowed for CORS (presigned POST).
// Empty list = VPC-only; undefined = allow all.
readonly appUrls?: string[];
}
export interface Props extends Configuration {
// ARN of the EKS pod role that will assume the presigning role.
readonly bucketClientRoleArn: string;
// Logging bucket for S3 server access logs.
readonly loggingBucket: s3.Bucket;
// VPC endpoint for S3 — used in bucket policy conditions when
// appUrls is an empty list (VPC-only mode).
readonly s3VpcEndpoint: ec2.IGatewayVpcEndpoint;
}
export interface Built {
readonly bucket: s3.Bucket;
readonly arnForObjects: string;
readonly preSigningRole: iam.Role;
}
AspectUploadBucketImageAssetBucket
Versioningfalsetrue
Removal policyDESTROYRETAIN
Lifecycle expiration1-day TTLNone (persistent)
Lifecycle rulesExpire + intelligent tieringAbort incomplete multipart after 1 day only
CORS methodsPUT, POSTPOST only (presigned POST)
Bucket name${lcFqn}-partition-upload-bucket${lcFqn}-image-assets-bucket
Presigning role name${fqn}-UploadPreSigningRole${fqn}-ImageUploadPreSigningRole
OAC policyNones3:GetObject grant for CloudFront OAC principal

Beyond the standard presigning-role policies (inherited from the UploadBucket pattern), add:

  • OAC statement: Allow s3:GetObject for the CloudFront service principal with condition aws:SourceArn matching the distribution ARN. This is set after the CDN construct creates the distribution — the bucket construct exposes the bucket resource for the CDN construct to call bucket.addToResourcePolicy().

Per AWS-FR-006 (encryption, versioning), AWS-FR-007 (block public access), AWS-FR-005 (lifecycle rules):

{
bucketName: `${lcFqn}-image-assets-bucket`,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // AWS-FR-007
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
enforceSSL: true,
encryption: s3.BucketEncryption.S3_MANAGED, // AWS-FR-006
versioned: true, // AWS-FR-006
removalPolicy: cdk.RemovalPolicy.RETAIN, // AWS-NFR-003
lifecycleRules: [{
id: "abort-incomplete-multipart", // AWS-FR-005
abortIncompleteMultipartUploadAfter: cdk.Duration.days(1),
}],
serverAccessLogsBucket: props.loggingBucket,
serverAccessLogsPrefix: `${bucketName}-access-logs/`,
cors: /* see CORS Configuration below */
}

When appUrls is defined (browser-direct uploads enabled), apply CORS:

cors: [{
allowedMethods: [s3.HttpMethods.POST],
allowedOrigins: props.appUrls,
allowedHeaders: [
"Content-Type",
"x-amz-meta-*",
"x-amz-server-side-encryption",
],
maxAge: 3600, // 1 hour
}]

This allows the SPA to submit presigned POST forms directly to S3. The allowedHeaders list covers the metadata fields required by the presigned POST policy conditions (AWS-FR-003). When appUrls is an empty list, CORS is omitted (VPC-only mode).

#### Presigning Role
Same structure as `UploadBucket.configurePresignRole()`:
- `s3:PutObject`, `s3:GetObject` with HTTPS + SSE-S3 conditions
- Multipart upload actions
- `s3:ListBucket` for administrative use
- Assumed by `props.bucketClientRoleArn` (EKS pod role)
See [AWS-FR-003](../system-design/results/aws-specification.md) for
presigned POST policy conditions enforced at the S3 level.
****
### 1.2 ImageAssetCdn
**File**: `src/main/cdk/constructs/xgress/image-asset-cdn.ts`
**Pattern source**: [`ApiCloudFront`](https://github.com/Arda-cards/infrastructure/blob/main/src/main/cdk/constructs/xgress/api-cloudfront.ts)
in `constructs/xgress/api-cloudfront.ts`.
**Purpose**: CloudFront distribution with OAC origin to the image asset
bucket, signed cookie access control, and a custom domain.
#### Interfaces
```typescript
import * as purpose from "arda/utils/purpose";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as r53 from "aws-cdk-lib/aws-route53";
export interface Configuration {
readonly locator: purpose.Locator;
// Custom domain: <partition>.<infra>.assets.arda.cards
readonly customDomainName?: string;
readonly tags?: { [key: string]: string };
}
export interface Props extends Configuration {
// The S3 bucket to serve as origin.
readonly imageBucket: s3.IBucket;
// ACM certificate ARN (must be in us-east-1).
readonly certificateArn: string;
// CloudFront key group for signed cookie validation.
readonly keyGroup: cloudfront.IKeyGroup;
// Route53 hosted zone for DNS record creation.
readonly hostedZone?: r53.IHostedZone;
// Logging bucket for CloudFront access logs (optional).
readonly logBucket?: s3.IBucket;
}
export interface Built {
readonly distribution: cloudfront.Distribution;
readonly distributionDomainName: string;
// The custom domain if configured, otherwise the CloudFront domain.
readonly servingDomain: string;
}
AspectApiCloudFrontImageAssetCdn
OriginAPI Gateway HTTP (HttpOrigin)S3 bucket (S3BucketV2Origin with OAC)
Viewer accessOpenTrusted key groups (signed cookies)
Cache policyCACHING_DISABLEDCACHING_OPTIMIZED
Allowed methodsALLOW_ALLALLOW_GET_HEAD
Viewer protocolREDIRECT_TO_HTTPSHTTPS_ONLY
Price classDefaultPRICE_CLASS_100 (US/Canada/Europe)
Custom domainAPI GW domain name<partition>.<infra>.assets.arda.cards
DNS recordNone (handled by DNS stack)Route53 A record (alias to distribution) created by construct
Default TTLN/A (no caching)86400 (24 hours)
Max TTLN/A (no caching)31536000 (1 year)

Per AWS-FR-004:

{
comment: `${fqn}-ImageCDN - Distribution for image assets`,
defaultBehavior: {
origin: new origins.S3BucketV2Origin(props.imageBucket, {
originAccessControl: oac, // created within construct
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
trustedKeyGroups: [props.keyGroup],
},
domainNames: props.customDomainName ? [props.customDomainName] : undefined,
certificate: /* from props.certificateArn */,
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
defaultRootObject: undefined, // no default root — all paths are object keys
}

TTL configuration: The default TTL (86400s / 24 hours) applies when the origin does not send Cache-Control headers. The max TTL (31536000s / 1 year) allows CloudFront to cache objects for up to a year when the origin sends Cache-Control: immutable, max-age=31536000. This is safe because image objects are effectively immutable — each upload generates a new UUID key, so the same key always serves the same content. Replaced images get a new key; the old key’s cache entry expires naturally without invalidation.

The construct creates a cloudfront.S3OriginAccessControl and grants s3:GetObject on the bucket via the OAC’s service principal. This replaces legacy OAI.

If props.hostedZone is provided and props.customDomainName is set, the construct creates a Route53 A record (alias) pointing to the CloudFront distribution.


File: src/main/cdk/constructs/xgress/cloudfront-signing-key-group.ts

Purpose: RSA key pair for CloudFront signed cookie validation. Public key registered in a CloudFront key group; private key stored in Secrets Manager for BFF retrieval at runtime.

import * as purpose from "arda/utils/purpose";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
export interface Configuration {
readonly locator: purpose.Locator;
// Increment to trigger key rotation. A new key pair is generated
// and added to the key group alongside existing keys. Default: 1.
readonly keyVersion?: number;
}
export interface Props extends Configuration {}
export interface Built {
readonly keyGroup: cloudfront.IKeyGroup;
readonly keyPairId: string;
readonly privateKeySecret: secretsmanager.ISecret;
readonly privateKeySecretArn: string;
}

The construct uses a CDK custom resource (Lambda-backed) to generate an RSA 2048-bit key pair at deploy time. This follows the existing pattern in the repo (write-ns-records-to-upstream-dns.ts, cognito-add-tenant.ts) where inline Lambda functions perform operations that CDK cannot express declaratively.

Lambda behavior:

  • OnCreate: Generate RSA 2048-bit key pair using Node.js crypto module (generateKeyPairSync). Store private key PEM in Secrets Manager. Return public key PEM in the custom resource response.
  • OnUpdate: If keyVersion changed, generate a new key pair. Store new private key in a new Secrets Manager secret (versioned name: ${fqn}-ImageCdnSigningKey-v${keyVersion}). Return new public key PEM. The old key remains in the key group until manually removed.
  • OnDelete: No-op (RETAIN policy). Keys are never automatically deleted.

Lambda IAM permissions (least privilege):

  • secretsmanager:CreateSecret on arn:aws:secretsmanager:*:*:secret:${fqn}-ImageCdnSigningKey*
  • secretsmanager:PutSecretValue on the same pattern
  • No secretsmanager:DeleteSecret — deletion is a manual OAM action

Lambda location: Inline in the construct file as a lambda_nodejs bundled function, following the constructs/inline-lambdas/ pattern.

  • Public key: cloudfront.PublicKey created from the PEM returned by the Lambda custom resource.
  • Key group: cloudfront.KeyGroup referencing the public key. Supports multiple items for zero-downtime rotation (AWS-NFR-004).
  • Secret naming: ${fqn}-ImageCdnSigningKey (or ${fqn}-ImageCdnSigningKey-v${keyVersion} when rotated).
  • Removal policy: RETAIN on the secret, key group, and public key — deleting these would break all active signed cookies.

The keyVersion parameter enables zero-downtime rotation:

  1. Increment keyVersion in the partition configuration.
  2. cdk deploy triggers the Lambda to generate a new key pair.
  3. The new public key is added to the key group alongside the existing one. CloudFront accepts cookies signed with either key.
  4. Update the BFF to sign new cookies with the new private key (the BFF reads the secret ARN from CloudFormation exports — the export is updated to point to the new secret).
  5. After the cookie TTL window passes (30 minutes default), all existing cookies have expired. The old key can be removed from the key group and the old secret deleted.

Steps 4-5 are manual OAM actions outside this project’s scope. The construct is designed to support them by keeping the key group open to multiple keys and using versioned secret names.

Per CDN Access Control for the cookie signing design.


File: src/main/cdk/stacks/purpose/partition-bulk-stores.ts

No changes. BulkStoresStack is unchanged from its original state. The ImageAssetBucket is co-located with the CDN in ImageStorageStack (PD-04) to avoid a cross-stack circular dependency caused by CloudFront OAC auto-adding a bucket policy that references the distribution.


File: src/main/cdk/stacks/purpose/image-storage.ts

Decision PD-01: New dedicated stack rather than extending PurposeIngress. The image CDN has a different lifecycle (RETAIN bucket, long-lived caches) from the API ingress, and separating it follows the existing pattern where BulkStoresStack is independent from PurposeIngress.

Decision PD-04: The ImageAssetBucket is co-located in this stack alongside the CDN and signing key group. CDK’s S3BucketOrigin.withOriginAccessControl() auto-adds a bucket policy referencing the distribution — when bucket and distribution are in different stacks, this creates a cyclic CloudFormation reference. Co-locating them eliminates the cycle. Bucket, CDN, and signing keys form a cohesive unit with shared lifecycle (RETAIN).

ExportKeys (all 6 partition exports):

export type ExportKeys =
| "imageAssetBucketArnAPI"
| "imageAssetBucketNameAPI"
| "imagePresignRoleArnAPI"
| "imageCdnDomainAPI"
| "imageCdnSigningKeyIdAPI"
| "imageCdnSigningKeySecretArnAPI";

With export names:

imageAssetBucketArnAPI: {
exportName: `${publishingPrefix}-API-ImageAssetBucketArn`,
description: "Image Asset Bucket ARN",
},
imageAssetBucketNameAPI: {
exportName: `${publishingPrefix}-API-ImageAssetBucketName`,
description: "Image Asset Bucket Name",
},
imagePresignRoleArnAPI: {
exportName: `${publishingPrefix}-API-ImagePresignRoleArn`,
description: "Image Upload Pre-Signing Role ARN",
},
imageCdnDomainAPI: {
exportName: `${publishingPrefix}-API-ImageCdnDomain`,
description: "Image CDN Domain Name",
},
imageCdnSigningKeyIdAPI: {
exportName: `${publishingPrefix}-API-ImageCdnSigningKeyId`,
description: "CloudFront Signing Key Pair ID",
},
imageCdnSigningKeySecretArnAPI: {
exportName: `${publishingPrefix}-API-ImageCdnSigningKeySecretArn`,
description: "Secrets Manager ARN for CloudFront Signing Private Key",
},

File: src/main/cdk/apps/Al1x/partition.ts

Changes to buildPartition():

  1. Import the new stack module.

  2. Instantiate the image storage stack after bulkStores (needs the logging bucket) and using infrastructure imports (hosted zone and certificate from importedStack):

//-------------------------------------------------------------
// Build Image Storage (bucket + CDN + signing keys)
//-------------------------------------------------------------
const imageStorage = new imgStorage.ImageStorageStack(
app,
`${partitionPrefix}-ImageStorage`,
{
locator: partition.locator,
loggingBucket: bulkStores.built.loggingBucket,
bucketClientRoleArn: importedStack.eksImports.podRoleArn.value as string,
s3VpcEndpoint: importedStack.networkImports.s3VpcEndpoint,
certificateArn: importedStack.ingressImports.assetsCertificateArn.value
? (importedStack.ingressImports.assetsCertificateArn.value as string)
: undefined,
hostedZone: importedStack.assetsHostedZone,
...partialStackProps,
},
);
imageStorage.addDependency(bulkStores);
imageStorage.publish();
  1. Order: After bulkStores (needs logging bucket), before or parallel with purposeIngressStack (no dependency between API ingress and image storage).

The image CDN uses a dedicated assets.arda.cards domain family. This requires changes at two levels before the partition-level CDN can use a custom domain.

File: src/main/cdk/stacks/root/root-configuration-stack.ts

Changes:

  1. Add assets.arda.cards public hosted zone (same pattern as io/app/auth).
  2. Extend ExportKeys with assetsZone.
  3. Extend publish() to export the new zone ID.
export type ExportKeys =
| "appZone"
| "ioZone"
| "authZone"
| "assetsZone" // NEW
| "allowCreateNsRecordRole";

File: src/main/cdk/stacks/infrastructure/ingress-stack.ts

Changes:

  1. Add <infra>.assets.arda.cards subdomain zone (same pattern as io/app/auth subdomain zones).
  2. Write NS delegation records to the root assets.arda.cards zone via the existing cross-account role.
  3. Create wildcard ACM certificate *.<infra>.assets.arda.cards with DNS validation against the new subdomain zone.
  4. Extend ExportKeys with assetsDomainName, assetsZoneId, assetsZoneNameServers, assetsCertificateArn.
export type ExportKeys =
// ... existing keys ...
| "assetsDomainName"
| "assetsZoneId"
| "assetsZoneNameServers"
| "assetsCertificateArn";

File: src/main/cdk/apps/Al1x/util.ts

Changes: Import the new assetsHostedZone and assetsCertificateArn from InfrastructureIngress exports. Expose as importedStack.assetsHostedZone and importedStack.ingressImports.assetsCertificateArn.

File: src/main/cdk/platform/ari-configuration.ts

Add the assets domain pattern:

export const ASSETS_DOMAIN_PREFIX = "assets";
export const ASSETS_DOMAIN = `${ASSETS_DOMAIN_PREFIX}.${ROOT_DOMAIN}`;
export function assetsDomain(purpose: purposeUtils.Locator): string {
return `${purposeUtils.subdomain(purpose)}.${ASSETS_DOMAIN}`;
}

The resulting domain is demo.alpha001.assets.arda.cards.

File: infrastructure/deploy-root.sh

Minimal script for root account CDK deployment:

#!/usr/bin/env bash
set -eu
echo ">>>>>>>>> Root Account Deployment"
# Authenticate (SSO or profile)
AWS_DEFAULT_PROFILE="${AWS_DEFAULT_PROFILE:-Admin-PlatformRoot}"
export AWS_DEFAULT_PROFILE
# Bootstrap
account_id="841876193886"
region="us-east-1"
npx cdk bootstrap "aws://${account_id}/${region}"
# Deploy root configuration (hosted zones + NS role)
npx cdk deploy --all --require-approval never \
--app "npx ts-node -r tsconfig-paths/register \
--prefer-ts-exts src/main/cdk/apps/rootConfiguration/r53-zones.ts"
echo ">>>>>>>>> Root deployment complete"

This follows the same CDK invocation pattern as amm.sh step 1.2 but scoped to the single root CDK app. Run manually before the first amm.sh deployment when root zone changes are needed.

  1. Root first: Run deploy-root.sh to create the assets.arda.cards zone. This is a one-time prerequisite.
  2. Infrastructure via amm.sh: The next amm.sh run picks up the InfrastructureIngress changes (subdomain zone + ACM cert) automatically.
  3. Partition via amm.sh: Same run deploys the partition stacks including the new ImageStorageStack (bucket + CDN + signing keys).

File: infrastructure/tools/verify-image-cdn.ts

Purpose: End-to-end validation of the deployed infrastructure. Executes goal.md success criteria 3–7.

npx ts-node tools/verify-image-cdn.ts \
--bucket <bucket-name> \
--cdn-domain <cloudfront-domain> \
--presign-role-arn <arn> \
--signing-key-id <key-pair-id> \
--signing-key-secret-arn <secret-arn> \
--tenant-id <test-tenant-uuid>

All parameters can be derived from CloudFormation exports. A future enhancement can auto-resolve them from the export names.

StepActionExpectedMaps to
1sts:AssumeRole on presigning roleCredentials returnedGoal SC-3
2Generate presigned POST form for key <tenantId>/images/test-<uuid>.jpgForm fields + URLGoal SC-4
3POST test JPEG to S3 using presigned formHTTP 204Goal SC-4
4s3:HeadObject on uploaded keyObject exists, metadata matchesGoal SC-4
5GET from CloudFront without cookiesHTTP 403Goal SC-5
6Retrieve signing private key from Secrets ManagerPEM key
7Generate CloudFront signed cookies (custom policy, scoped to /<tenantId>/*)Three cookie values
8GET from CloudFront with signed cookiesHTTP 200, image contentGoal SC-6
9Generate cookies scoped to a different tenant IDThree cookie values
10GET original image with wrong-tenant cookiesHTTP 403Goal SC-7
11Delete test object from S3Cleanup

Add to infrastructure/package.json devDependencies:

  • @aws-sdk/client-s3 (if not already present)
  • @aws-sdk/client-sts
  • @aws-sdk/client-secrets-manager
  • @aws-sdk/s3-presigned-post (for presigned POST generation)

#FileActionDescription
1src/main/cdk/stacks/root/root-configuration-stack.tsModifyAdd assets.arda.cards hosted zone, extend exports
2src/main/cdk/stacks/infrastructure/ingress-stack.tsModifyAdd <infra>.assets.arda.cards subdomain zone, NS delegation, ACM cert, extend exports
3src/main/cdk/apps/Al1x/util.tsModifyImport assets hosted zone and certificate in ImportingStack
4src/main/cdk/platform/ari-configuration.tsModifyAdd ASSETS_DOMAIN_PREFIX, ASSETS_DOMAIN, and assetsDomain()
5deploy-root.shNewMinimal root account deployment script (profile: Admin-PlatformRoot)
6tools/ci-root-check.jsNewRoot configuration synth check (separate from ci-check.js for infra/partition targets)
#FileActionDescription
7src/main/cdk/constructs/storage/image-asset-bucket.tsNewImageAssetBucket construct (includes presigning role)
8src/main/cdk/constructs/xgress/image-asset-cdn.tsNewImageAssetCdn construct
9src/main/cdk/constructs/xgress/cloudfront-signing-key-group.tsNewCloudFrontSigningKeyGroup construct (Lambda custom resource for RSA key generation)
10src/main/cdk/constructs/inline-lambdas/generate-signing-key.tsNewInline Lambda for RSA key pair generation
11src/main/cdk/stacks/purpose/image-storage.tsNewImageStorageStack (bucket + CDN + signing key group)
12src/main/cdk/apps/Al1x/partition.tsModifyWire new stack into buildPartition()
#FileActionDescription
13jest.config.tsNewJest config with ts-jest preset and arda/* path aliases
14package.jsonModifyAdd test, test:ci scripts; add cdk-nag, @aws-sdk/s3-presigned-post devDependencies
15src/main/cdk/constructs/storage/image-asset-bucket.test.tsNewUnit tests for ImageAssetBucket
16src/main/cdk/constructs/xgress/image-asset-cdn.test.tsNewUnit tests for ImageAssetCdn
17src/main/cdk/constructs/xgress/cloudfront-signing-key-group.test.tsNewUnit tests for CloudFrontSigningKeyGroup
18src/main/cdk/stacks/purpose/image-storage.test.tsNewSnapshot + export tests for ImageStorageStack
#FileActionDescription
19tools/verify-image-cdn.tsNewEnd-to-end verification script
20.github/workflows/ci.yamlModifyAdd npm test step to the build job (after npm run lint). This gates every PR and push with unit tests + cdk-nag.

If not already present (may be created by infrastructure#433):

  • jest.config.ts with ts-jest preset and moduleNameMapper for arda/* path aliases
  • "test" and "test:ci" scripts in package.json
  • cdk-nag AwsSolutions pack added to CDK app entry points

Add npm test to the existing build job in .github/workflows/ci.yaml so that unit tests and cdk-nag gate every PR and push:

# In the build job steps, after lint:
- run: npm run lint
- run: npm test # NEW — runs jest (unit tests + cdk-nag)

This ensures no PR can merge with failing construct tests or cdk-nag violations. The synth-each-cdk-app matrix job continues to validate that all targets synthesize independently.

Each new construct gets co-located unit tests (<construct>.test.ts) using aws-cdk-lib/assertions. Tests verify business logic, not CDK defaults.

ImageAssetBucket tests:

  • Bucket name follows ${lcFqn}-image-assets-bucket pattern
  • Versioning enabled (differs from UploadBucket)
  • Removal policy is RETAIN (differs from UploadBucket)
  • SSE-S3 encryption (AES256)
  • BlockPublicAccess.BLOCK_ALL
  • No expiration lifecycle rule (only abort-incomplete-multipart)
  • CORS allows POST method only (when appUrls defined)
  • CORS absent when appUrls is empty list
  • Presigning role has HTTPS + SSE-S3 conditions on s3:PutObject / s3:GetObject
  • Presigning role includes multipart upload actions
  • Presigning role is assumable by bucketClientRoleArn
  • validateProps() rejects invalid inputs

ImageAssetCdn tests:

  • CloudFront distribution uses S3 OAC origin (not HTTP origin)
  • Viewer protocol policy is HTTPS_ONLY
  • Allowed methods are GET, HEAD only
  • Cache policy is CachingOptimized
  • Price class is PRICE_CLASS_100
  • Trusted key groups configured (signed cookies required)
  • Custom domain set when customDomainName provided
  • Route53 A record created when hostedZone provided
  • validateProps() rejects missing certificate ARN

CloudFrontSigningKeyGroup tests:

  • CloudFront public key resource created
  • CloudFront key group references the public key
  • Secrets Manager secret created for private key
  • Secret removal policy is RETAIN
  • Key group removal policy is RETAIN
  • ImageStorageStack: snapshot captures the full stack including ImageAssetBucket, CDN, and signing key group; all 6 -API-Image* exports present

New constructs must pass AwsSolutionsChecks without unsuppressed findings. Expected suppressions (with documented reasons):

  • AwsSolutions-S1 on the image asset bucket if access logging is directed to the shared logging bucket (already configured)
  • AwsSolutions-CFR4 if CloudFront logging is deferred to a future enhancement

Acceptance criteria are defined in goal.md — Success Criteria (SC-1 through SC-8). The verification script (verify-image-cdn.ts) automates SC-3 through SC-7 as defined in section 3 above.

Additional implementation-level acceptance:

  • All new constructs follow the Configuration → Props → Built pattern per design.md section 3.1.
  • All stacks use the ExportKeys / ExportDefinition / publish() pattern per design.md section 3.2.
  • Naming follows design.md section 3.3.
  • cdk synth produces valid templates for all existing infrastructure and partition targets (npm run ci-check passes).
  • No regressions to existing stacks — all current exports remain unchanged.
  • npm test passes — all unit tests and snapshot tests for new constructs and modified stacks pass.
  • cdk-nag AwsSolutionsChecks passes for all stacks containing new constructs.

Copyright: (c) Arda Systems 2025-2026, All rights reserved