canopycms-cdk 0.0.67 → 0.0.68-int.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,12 @@ const PREFIXES = {
42
42
  public: 'assets',
43
43
  transform: 'assets/t',
44
44
  };
45
+ /**
46
+ * The tag on every object the transform Lambda writes, copied as literals for
47
+ * the same reason as `PREFIXES`. Source of truth is `LAZY_TRANSFORM_TAG` in
48
+ * `packages/canopycms/src/assets/materialize.ts`.
49
+ */
50
+ const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
45
51
  /**
46
52
  * CORS preflight cache duration for presigned-POST uploads from the editor.
47
53
  * Used for the bucket's own CORS rule and, when `uploadBehavior` is on, for
@@ -73,55 +79,39 @@ function toFunctionLiteral(values) {
73
79
  }
74
80
  /** Matches packages/canopycms/src/assets/store-s3.ts's `DEFAULT_MAX_UPLOAD_BYTES`. */
75
81
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
76
- const TRANSFORM_LAMBDA_MEMORY_MB = 1536;
82
+ /** Fits a full-size transform at `MAX_INPUT_PIXELS`; see that constant in canopycms. */
83
+ const TRANSFORM_LAMBDA_MEMORY_MB = 2048;
77
84
  const TRANSFORM_LAMBDA_TIMEOUT = Duration.seconds(30);
78
85
  /**
79
- * Concurrency cap on the transform Lambda.
80
- *
81
- * `/assets/t/*` is reachable by any anonymous viewer through CloudFront, and
82
- * `hash32` is not secret — it appears in every published page's `<img src>`.
83
- * Width and quality are allowlisted precisely to bound how many cache keys one
84
- * asset can have, but `crop` is a 4-decimal float rect (~10^16 values), so a
85
- * scripted loop of unique crops is an unbounded stream of guaranteed
86
- * CloudFront+S3 misses, each a sharp transform on a 1536MB Lambda plus a
87
- * permanently stored S3 object.
88
- *
89
- * A reservation is a CAP carved from the account's concurrency pool, not
90
- * pre-warmed capacity, so it costs nothing when idle (that is
91
- * `provisionedConcurrentExecutions`, which this is not). 10 mirrors the CMS
92
- * Lambda's own reservation: genuine demand is first-render misses only, since
93
- * every already-generated derivative is served by the S3 primary origin without
94
- * invoking this function at all.
86
+ * Concurrency cap on the lazy transform Lambda. `hash32` is public and `crop`
87
+ * allows ~10^16 rects per asset, so a loop of unique crops is an unbounded
88
+ * stream of misses, each a sharp run plus a stored object. A reservation is a
89
+ * cap, not provisioned capacity, so it costs nothing idle.
95
90
  */
96
91
  const TRANSFORM_LAMBDA_RESERVED_CONCURRENCY = 10;
97
92
  /**
98
- * Retention for generated derivatives under `assets/t/`.
99
- *
100
- * Everything under that prefix is REGENERABLE — source assets live under
101
- * `asset-originals/` and are never touched by this rule. Without it the bucket
102
- * keeps every derivative forever, including every object minted by the crop
103
- * amplifier above.
104
- *
105
- * S3 lifecycle is prefix+age based; there is no "expire if not recently read"
106
- * mode. Expiry is self-healing anyway: the next request for an expired
107
- * derivative misses S3, fails over to the transform Lambda, regenerates and
108
- * re-stores it. 180 days is long enough that only genuinely cold or abusive
109
- * objects age out.
93
+ * Lazy mode's expiry for the Lambda's tagged `assets/t/` outputs, which bounds
94
+ * how long the crop loop above keeps what it stores. The next request
95
+ * regenerates an expired object while the Lambda runs.
110
96
  */
111
97
  const TRANSFORM_OUTPUT_RETENTION = Duration.days(180);
112
98
  /**
113
- * `/assets/t/*`-specific cache TTLs, custom rather than the managed
114
- * `CACHING_OPTIMIZED` used for plain `/assets/*`, whose 1-second MIN TTL is the
115
- * bug this policy exists to avoid. The transform Lambda's oversized-output path
116
- * (handler.ts) returns a `Cache-Control: no-store` 302 to the canonical S3 key;
117
- * ANY nonzero min TTL caches that redirect regardless of the origin's
118
- * `no-store`, and re-serving it while CloudFront's hit for the canonical key
119
- * still 403s/404s off S3 falls back to the Lambda again - a self-sustaining
120
- * redirect loop. `minTtl: 0` honors the origin's own `Cache-Control`
121
- * immediately; `maxTtl`/`defaultTtl` stay generous so the normal case (an
122
- * immutable 200 with a real `max-age`) still caches well.
99
+ * Lazy mode's `/assets/t/*` cache TTLs. The Lambda answers an oversized output
100
+ * with a `no-store` 302 to the canonical key, and any nonzero min TTL caches
101
+ * that redirect regardless, looping it back to the Lambda while the key still
102
+ * misses. `minTtl: 0` honors the origin's own `Cache-Control`.
123
103
  */
124
104
  const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
105
+ /** Props that configure only the transform Lambda, refused without `lazyPublicTransforms`. */
106
+ const LAZY_ONLY_PROPS = [
107
+ 'transformOutputRetention',
108
+ 'transformReservedConcurrency',
109
+ 'transformRole',
110
+ 'transformLogGroupName',
111
+ 'transformLogRetention',
112
+ ];
113
+ /** S3 statuses that send a read to `replicaBucket`. A miss (403) never does. */
114
+ const REPLICA_FAILOVER_STATUS_CODES = [500, 502, 503, 504];
125
115
  const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
126
116
  const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
127
117
  /**
@@ -457,15 +447,8 @@ function buildUploadBehavior(scope, options, label) {
457
447
  * ```
458
448
  *
459
449
  * Separate from `AssetSupport.uploadBehavior()` because the upload route needs
460
- * the bucket and nothing else, while that construct's CONSTRUCTOR always builds
461
- * the transform Lambda, a log group, a Function URL, a role and prefix grants.
462
- * Those grants never reach the BUCKET policy: `Grant.addToPrincipalOrResource`
463
- * stops after the identity statement for a same-account grantee, and
464
- * `addToResourcePolicy` on a bucket CDK does not own is a no-op - an owned
465
- * bucket, one imported by name, and one imported by ARN in another account all
466
- * emit zero `AWS::S3::BucketPolicy`, so a cross-account adopter writes the
467
- * resource half on the bucket's own side. Prefer the method when you already
468
- * have an `AssetSupport`; both funnel into the same builder and cannot drift.
450
+ * the bucket and nothing else. Prefer the method when you already have an
451
+ * `AssetSupport`; both funnel into the same builder and cannot drift.
469
452
  *
470
453
  * No bucket CORS rule is written and none is needed: the edge supplies
471
454
  * `Access-Control-Allow-Origin`, and S3's ACCEPTANCE of a presigned POST is
@@ -494,12 +477,12 @@ export function assetUploadBehavior(scope, options) {
494
477
  * Wires:
495
478
  *
496
479
  * - The bucket's asset-prefix lifecycle rule + CORS (standalone mode only).
497
- * - The transform Lambda (`../../lambda/asset-transform/handler.ts`, built
498
- * via `pnpm run build:lambda` - see that script's doc comment for the
499
- * no-Docker sharp bundling approach), its dedicated CloudWatch log group
500
- * (custom name/retention/removal policy instead of the
501
- * CloudFormation-implicit `/aws/lambda/<function-name>` group), and its
502
- * OAC-locked Function URL.
480
+ * - The public read path: `/assets/*` and `/assets/t/*` served from S3 (plus
481
+ * `replicaBucket` on 5xx). It computes nothing; derivatives come from
482
+ * `canopycms materialize-assets`.
483
+ * - With `lazyPublicTransforms`, the transform Lambda
484
+ * (`../../lambda/asset-transform/handler.ts`, built by `build:lambda`), its
485
+ * log group and its OAC-locked Function URL, behind `/assets/t/*`.
503
486
  * - `attachTo(distribution)`, which attaches the two CloudFront behaviors a
504
487
  * consuming distribution needs in the only safe order (see its doc comment
505
488
  * for why the order is the whole point); `assetBehaviors()` is the escape
@@ -514,15 +497,33 @@ export function assetUploadBehavior(scope, options) {
514
497
  export class AssetSupport extends Construct {
515
498
  constructor(scope, id, props) {
516
499
  super(scope, id);
517
- // Fail closed before anything else: refuse to build a stack around a
518
- // transform Lambda whose code asset was never verified to contain the
519
- // linux/arm64 sharp binary. `pnpm test` (whose canopycms-cdk suite rebuilds
520
- // that directory as a --skip-native fixture) and any partially-failed build
521
- // leave a sharp-less bundle on disk, and a later in-repo `cdk deploy` ships
522
- // it - a Lambda that throws at cold start on the first image request, a
523
- // long way from the cause. In the construct rather than at one deploy
524
- // entrypoint, so future entrypoints inherit the protection.
525
- if ((props.requireDeployableBundle ?? true) &&
500
+ const lazy = props.lazyPublicTransforms ?? false;
501
+ if (!lazy) {
502
+ // A retention silently ignored here is the dangerous case: materialized derivatives are
503
+ // kept forever, so the adopter would believe in an expiry that does not exist.
504
+ const lambdaOnly = LAZY_ONLY_PROPS.filter((name) => props[name] !== undefined);
505
+ if (lambdaOnly.length > 0) {
506
+ throw new Error(`AssetSupport: ${lambdaOnly.join(', ')} only appl${lambdaOnly.length === 1 ? 'ies' : 'y'} ` +
507
+ 'with `lazyPublicTransforms: true`. Without it there is no transform Lambda and ' +
508
+ 'materialized derivatives under assets/t/ are kept forever. Remove the prop(s), or ' +
509
+ 'set `lazyPublicTransforms: true` if you mean to compute derivatives on request.');
510
+ }
511
+ }
512
+ else if (props.bucket && props.transformOutputRetention === undefined) {
513
+ throw new Error('AssetSupport: `lazyPublicTransforms` on a BYO `bucket` needs an explicit ' +
514
+ '`transformOutputRetention`. This construct cannot write lifecycle rules on a bucket ' +
515
+ 'it did not create, and anonymous callers can mint objects under assets/t/ in lazy ' +
516
+ 'mode. Add an expiry rule for the assets/t/ prefix and the canopy-transform=lazy tag ' +
517
+ '(on a versioned bucket, also expiring noncurrent versions) to your bucket and pass ' +
518
+ 'its duration as `transformOutputRetention`, or drop ' +
519
+ '`lazyPublicTransforms` and run `canopycms materialize-assets` in your release ' +
520
+ 'pipeline instead.');
521
+ }
522
+ // Fail closed: a bundle without the marker has no linux/arm64 sharp and
523
+ // throws at cold start on the first image request, far from the cause.
524
+ // `pnpm test` leaves exactly such a fixture on disk.
525
+ if (lazy &&
526
+ (props.requireDeployableBundle ?? true) &&
526
527
  !existsSync(path.join(transformAssetDir, DEPLOYABLE_MARKER))) {
527
528
  throw new Error(`The transform Lambda code asset at ${transformAssetDir} carries no ${DEPLOYABLE_MARKER} ` +
528
529
  'marker, so it was not built with its native sharp binary and must not be deployed. ' +
@@ -568,23 +569,30 @@ export class AssetSupport extends Construct {
568
569
  versioned: props.versioned ?? false,
569
570
  removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN,
570
571
  autoDeleteObjects: props.autoDeleteObjects ?? false,
571
- // `asset-staging/` expires after a day, and generated derivatives
572
- // under `assets/t/` after `transformOutputRetention`. Originals, meta
573
- // and the public prefix are kept forever by design (content-addressed,
574
- // immutable - see the design record's "Storage" section).
572
+ // Everything else is content-addressed and kept forever, materialized
573
+ // derivatives included; only the Lambda's tagged outputs expire. On a
574
+ // versioned bucket an expiry only adds a delete marker, so each rule
575
+ // also expires noncurrent versions or it would free nothing.
575
576
  lifecycleRules: [
576
577
  {
577
578
  id: 'expire-asset-staging',
578
579
  enabled: true,
579
580
  prefix: `${PREFIXES.staging}/`,
580
581
  expiration: Duration.days(1),
582
+ noncurrentVersionExpiration: Duration.days(1),
581
583
  },
582
- {
583
- id: 'expire-transform-outputs',
584
- enabled: true,
585
- prefix: `${PREFIXES.transform}/`,
586
- expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
587
- },
584
+ ...(lazy
585
+ ? [
586
+ {
587
+ id: 'expire-transform-outputs',
588
+ enabled: true,
589
+ prefix: `${PREFIXES.transform}/`,
590
+ tagFilters: { [LAZY_TRANSFORM_TAG.key]: LAZY_TRANSFORM_TAG.value },
591
+ expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
592
+ noncurrentVersionExpiration: Duration.days(1),
593
+ },
594
+ ]
595
+ : []),
588
596
  ],
589
597
  // Omitted entirely when `editorOrigins` is absent - the upload is then
590
598
  // going through `uploadBehavior`, which supplies ACAO at the edge, and
@@ -602,81 +610,13 @@ export class AssetSupport extends Construct {
602
610
  : undefined,
603
611
  });
604
612
  }
605
- // Dedicated CloudWatch log group for the transform Lambda's stdout/stderr,
606
- // custom-named and NOT the CloudFormation-implicit
607
- // `/aws/lambda/<function name>`: CDK does not manage that group at all
608
- // (infinite retention, and `cdk destroy` leaves it behind), and Lambda
609
- // auto-creates it on first invoke OUTSIDE CloudFormation, after which a CDK
610
- // `LogGroup` using that exact name fails `CreateLogGroup` with "already
611
- // exists" and blocks every future `cdk deploy`. Same convention as
612
- // `CanopyCmsService`'s log groups (cms-service.ts).
613
- this.transformLogGroup = new logs.LogGroup(this, 'TransformFunctionLogs', {
614
- logGroupName: props.transformLogGroupName ?? `/canopycms/${Stack.of(this).stackName}/transform`,
615
- retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
616
- removalPolicy: RemovalPolicy.DESTROY,
617
- });
618
- // Re-attach what CDK silently drops for a caller-supplied role. MUST run
619
- // for every passed role - see that function's doc comment. This function is
620
- // not VPC-attached, so it takes basic execution only and must NOT be
621
- // collapsed into the CMS Lambda's `vpc: true` call site.
622
- if (props.transformRole) {
623
- attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
613
+ if (lazy) {
614
+ const transform = this.buildTransformLambda(props);
615
+ this.transformFunction = transform.fn;
616
+ this.transformLogGroup = transform.logGroup;
617
+ this.transformFunctionUrl = transform.url;
624
618
  }
625
- this.transformFunction = new lambda.Function(this, 'TransformFunction', {
626
- // Default (unset) leaves CDK to create the execution role, with its own
627
- // managed policies intact. See `transformRole`'s doc comment.
628
- role: props.transformRole,
629
- // Built by `pnpm run build:lambda` (lambda/asset-transform/build.mjs) -
630
- // esbuild bundle + a real linux/arm64 `npm install sharp` alongside it,
631
- // no Docker. `cdk synth`/`deploy` need that script run first; it is NOT
632
- // run automatically here.
633
- code: lambda.Code.fromAsset(transformAssetDir),
634
- handler: 'handler.handler',
635
- // nodejs20.x is deprecated and CDK's CloudFormation validation fails synth
636
- // on it. The esbuild bundle (lambda/asset-transform/build.mjs) targets
637
- // node22 to match; move the two together.
638
- runtime: lambda.Runtime.NODEJS_22_X,
639
- architecture: lambda.Architecture.ARM_64,
640
- memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
641
- timeout: TRANSFORM_LAMBDA_TIMEOUT,
642
- // See the constant's doc comment: this is an anonymous, uncapped compute
643
- // and storage amplifier without it.
644
- reservedConcurrentExecutions: props.transformReservedConcurrency ?? TRANSFORM_LAMBDA_RESERVED_CONCURRENCY,
645
- // Pass the pre-created group via `logGroup`, NOT `logRetention` (CDK
646
- // throws LogRetentionLogGroupConflict/ConflictingLogPolicyOptions if
647
- // both are set on the same function) - the removal policy lives on the
648
- // LogGroup construct above instead.
649
- logGroup: this.transformLogGroup,
650
- environment: {
651
- ASSET_BUCKET: this.bucket.bucketName,
652
- },
653
- });
654
- // Explicit, scoped grant - NOT a reliance on the auto-created execution
655
- // role's AWSLambdaBasicExecutionRole managed policy, which CDK attaches
656
- // regardless of `logGroup` and never adjusts for it (passing `logGroup`
657
- // only points the function's LoggingConfig at this group; it grants no
658
- // IAM). That policy's logs:CreateLogStream/logs:PutLogEvents statement is
659
- // scoped to `arn:aws:logs:*:*:log-group:/aws/lambda/*:*`, so it grants
660
- // nothing for a custom-named group. Without this grantWrite, log delivery
661
- // fails its permission check with no error surfaced anywhere - logs simply
662
- // vanish.
663
- this.transformLogGroup.grantWrite(this.transformFunction);
664
- // Originals are what it transforms; `asset-meta/{hash32}.json` carries the
665
- // kind/ext it must look up before it can transform anything, so meta is
666
- // read too.
667
- this.bucket.grantRead(this.transformFunction, `${PREFIXES.originals}/*`);
668
- this.bucket.grantRead(this.transformFunction, `${PREFIXES.meta}/*`);
669
- // Write access to `assets/*` covers both the public prefix and
670
- // `assets/t/*` (transform outputs nest under it) in one grant.
671
- this.bucket.grantPut(this.transformFunction, `${PREFIXES.public}/*`);
672
- // AWS_IAM (not NONE): only reachable through CloudFront's Origin Access
673
- // Control, matching CanopyCmsService/CanopyCmsDistribution's existing
674
- // Function URL pattern (see cms-service.ts / cms-distribution.ts) -
675
- // direct hits to the Function URL are rejected.
676
- this.transformFunctionUrl = this.transformFunction.addFunctionUrl({
677
- authType: lambda.FunctionUrlAuthType.AWS_IAM,
678
- });
679
- this.behaviors = this.buildBehaviors();
619
+ this.behaviors = this.buildBehaviors(props.replicaBucket);
680
620
  // Validated eagerly, built lazily (see `upload`'s comment) - the accessor
681
621
  // may never be called, and a bad `allowedOrigins` should not wait for it
682
622
  // to find out. The guards themselves are shared with
@@ -735,25 +675,79 @@ export class AssetSupport extends Construct {
735
675
  },
736
676
  });
737
677
  }
738
- buildBehaviors() {
739
- const s3Origin = origins.S3BucketOrigin.withOriginAccessControl(this.bucket);
740
- // readTimeout passed EXPLICITLY, matching the transform Lambda's own
741
- // timeout. Unset, CloudFront applies its 30s service default, which
742
- // happens to equal TRANSFORM_LAMBDA_TIMEOUT today -- an accidental match,
743
- // not an asserted one: raising the Lambda's timeout alone would silently
744
- // start 504ing the slow transforms the raise was meant to allow.
745
- const transformLambdaOrigin = origins.FunctionUrlOrigin.withOriginAccessControl(this.transformFunctionUrl, { readTimeout: TRANSFORM_LAMBDA_TIMEOUT });
678
+ buildTransformLambda(props) {
679
+ // Custom-named, never `/aws/lambda/<function name>`: Lambda auto-creates that
680
+ // group outside CloudFormation on first invoke, after which a CDK `LogGroup`
681
+ // of the same name fails every deploy with "already exists". Same convention
682
+ // as `CanopyCmsService`'s log groups.
683
+ const logGroup = new logs.LogGroup(this, 'TransformFunctionLogs', {
684
+ logGroupName: props.transformLogGroupName ?? `/canopycms/${Stack.of(this).stackName}/transform`,
685
+ retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
686
+ removalPolicy: RemovalPolicy.DESTROY,
687
+ });
688
+ // Re-attach what CDK silently drops for a caller-supplied role. This function
689
+ // is not VPC-attached, so it takes basic execution only.
690
+ if (props.transformRole) {
691
+ attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
692
+ }
693
+ const fn = new lambda.Function(this, 'TransformFunction', {
694
+ role: props.transformRole,
695
+ // Built by `build:lambda` (lambda/asset-transform/build.mjs), not here.
696
+ code: lambda.Code.fromAsset(transformAssetDir),
697
+ handler: 'handler.handler',
698
+ // build.mjs targets node22 to match; move the two together.
699
+ runtime: lambda.Runtime.NODEJS_22_X,
700
+ architecture: lambda.Architecture.ARM_64,
701
+ memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
702
+ timeout: TRANSFORM_LAMBDA_TIMEOUT,
703
+ reservedConcurrentExecutions: props.transformReservedConcurrency ?? TRANSFORM_LAMBDA_RESERVED_CONCURRENCY,
704
+ // `logGroup`, not `logRetention`: CDK refuses both on one function.
705
+ logGroup,
706
+ environment: {
707
+ ASSET_BUCKET: this.bucket.bucketName,
708
+ },
709
+ });
710
+ // The managed basic-execution policy's log statement covers only
711
+ // `/aws/lambda/*`, so without this a custom-named group silently gets no logs.
712
+ logGroup.grantWrite(fn);
713
+ // `grantRead` includes s3:ListBucket, so an unknown hash is a 404 rather than
714
+ // a 403 the store throws on, and `readOriginal` can list when the meta's ext
715
+ // misses. `assets/*` covers `assets/t/*`, and `grantPut` includes the
716
+ // s3:PutObjectTagging a tagged PutObject needs.
717
+ this.bucket.grantRead(fn, `${PREFIXES.originals}/*`);
718
+ this.bucket.grantRead(fn, `${PREFIXES.meta}/*`);
719
+ this.bucket.grantPut(fn, `${PREFIXES.public}/*`);
720
+ // AWS_IAM: reachable only through CloudFront's OAC.
721
+ const url = fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM });
722
+ return { fn, logGroup, url };
723
+ }
724
+ buildBehaviors(replicaBucket) {
725
+ // READ only, so a miss is 403: without s3:ListBucket, S3 does not reveal
726
+ // whether a key exists. LIST would make it 404 but lets any behavior that
727
+ // reaches the bucket root list it, and CDK warns on every synth.
728
+ const readOrigin = (bucket) => origins.S3BucketOrigin.withOriginAccessControl(bucket);
729
+ const primary = readOrigin(this.bucket);
730
+ // Materialized mode gives both behaviors this one object, so the distribution
731
+ // emits one origin (or one origin group), not two. Lazy mode's `/assets/t/*`
732
+ // group binds `primary` again, as a second origin.
733
+ const read = replicaBucket
734
+ ? new origins.OriginGroup({
735
+ primaryOrigin: primary,
736
+ fallbackOrigin: readOrigin(replicaBucket),
737
+ fallbackStatusCodes: REPLICA_FAILOVER_STATUS_CODES,
738
+ })
739
+ : primary;
746
740
  const assets = {
747
- origin: s3Origin,
741
+ origin: read,
748
742
  viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
749
743
  cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
750
744
  compress: true,
751
745
  };
752
- // Custom (not managed CACHING_OPTIMIZED) - see TRANSFORM_CACHE_MIN_TTL's
753
- // doc comment for why this behavior specifically needs minTtl: 0.
754
- // Directives live entirely in the path (no query string), and the
755
- // response never varies by cookie/request header, so nothing is
756
- // forwarded into the cache key.
746
+ if (!this.transformFunctionUrl) {
747
+ return { assets, assetsTransform: { ...assets } };
748
+ }
749
+ // Directives live in the path and the response varies by nothing else, so
750
+ // nothing enters the cache key.
757
751
  const transformCachePolicy = new cloudfront.CachePolicy(this, 'AssetsTransformCachePolicy', {
758
752
  minTtl: TRANSFORM_CACHE_MIN_TTL,
759
753
  defaultTtl: TRANSFORM_CACHE_DEFAULT_TTL,
@@ -765,14 +759,13 @@ export class AssetSupport extends Construct {
765
759
  cookieBehavior: cloudfront.CacheCookieBehavior.none(),
766
760
  });
767
761
  const assetsTransform = {
762
+ // The group's one fallback slot is the Lambda, so no replica here. An
763
+ // OAC miss is 403; 404 is listed too for a bucket whose policy grants list.
768
764
  origin: new origins.OriginGroup({
769
- primaryOrigin: s3Origin,
770
- fallbackOrigin: transformLambdaOrigin,
771
- // A signed OAC origin reports a miss as 403 (not 404, since it
772
- // never reveals object existence) - CloudFront's own S3 origin
773
- // handling can still surface 404 in some paths, so both are
774
- // configured. Confirmed working end-to-end by the sandbox spike
775
- // (.claude/future-tasks/resolved/assets-media-system.md's SPIKE RESULT).
765
+ primaryOrigin: primary,
766
+ // readTimeout explicit: CloudFront's 30s default only happens to equal
767
+ // the Lambda's timeout, and raising one alone would 504 slow transforms.
768
+ fallbackOrigin: origins.FunctionUrlOrigin.withOriginAccessControl(this.transformFunctionUrl, { readTimeout: TRANSFORM_LAMBDA_TIMEOUT }),
776
769
  fallbackStatusCodes: [403, 404],
777
770
  }),
778
771
  viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
@@ -817,9 +810,8 @@ export class AssetSupport extends Construct {
817
810
  * site's cookies and any cached basic-auth credential are live hazards
818
811
  * there, stripped by policy and function rather than never sent at all.
819
812
  * - NOT one shared distribution serving asset reads AND writes for every
820
- * environment. That means one `AssetSupport`, which owns the transform
821
- * Lambda shipping inside this package, so a `canopycms-cdk` bump would move
822
- * every environment's asset pipeline at once. Only the upload route moves;
813
+ * environment. That means one `AssetSupport`, so a `canopycms-cdk` bump
814
+ * would move every environment's asset pipeline at once. Only the upload route moves;
823
815
  * it depends on the bucket and nothing else, the same property that lets
824
816
  * `assetUploadBehavior` build it from a bucket alone.
825
817
  */
@@ -853,17 +845,12 @@ export class AssetSupport extends Construct {
853
845
  * Attach both asset behaviors to a concrete CloudFront distribution, in the
854
846
  * only safe order.
855
847
  *
856
- * THE ORDER IS THE WHOLE POINT. CloudFront matches path patterns in the order
857
- * given and stops at the first match. `/assets/*` is a broader, S3-only
858
- * pattern that also matches every `/assets/t/*` request; `/assets/t/*` is an
859
- * origin group that fails over to the transform Lambda on a miss. Attach
860
- * `/assets/*` first and every never-yet-computed transform gets a permanent
861
- * 403 (an OAC-signed S3 miss reports 403) while already-computed transforms
862
- * keep working. `'/assets/*'` also sorts BEFORE `'/assets/t/*'`
863
- * lexicographically (`*` = 0x2A, `t` = 0x74), so alphabetizing the keys
864
- * reproduces exactly this failure with no synth or deploy error to catch it -
865
- * and `assetBehaviors()` attaches no path pattern, so nothing there stops a
866
- * caller getting it wrong.
848
+ * CloudFront stops at the first matching pattern, and `/assets/*` also
849
+ * matches every `/assets/t/*` request. With `lazyPublicTransforms`, attaching
850
+ * it first means no miss ever reaches the transform Lambda, with no synth or
851
+ * deploy error; `'/assets/*'` also sorts first alphabetically. The two
852
+ * behaviors are identical otherwise, and the order is kept so that switching
853
+ * modes changes nothing else.
867
854
  *
868
855
  * `overrides` is merged into BOTH behaviors. The motivating case is a
869
856
  * distribution running a viewer-request function on every behavior - tier
@@ -895,19 +882,10 @@ export class AssetSupport extends Construct {
895
882
  `attachTo() call -- keep one.`);
896
883
  }
897
884
  new Construct(distribution, ATTACHED_MARKER_ID);
898
- // Drop explicitly-`undefined` keys before merging. A spread copies own
899
- // enumerable keys INCLUDING ones whose value is undefined, so
900
- // `{ ...transformRest, ...{ cachePolicy: undefined } }` deletes the
901
- // construct's choice and lets CDK substitute a DIFFERENT default:
902
- // `cachePolicy: undefined` swaps this behavior's custom policy
903
- // (TRANSFORM_CACHE_MIN_TTL = 0, which exists solely to stop the
904
- // oversized-output redirect loop) for the managed CACHING_OPTIMIZED and its
905
- // 1-second min TTL, and `viewerProtocolPolicy: undefined` downgrades BOTH
906
- // behaviors from redirect-to-https to allow-all, serving assets over plain
907
- // HTTP. Not a contrived input: `attachTo(dist, { cachePolicy:
908
- // props.maybePolicy })` with the prop unset typechecks (every
909
- // AddBehaviorOptions field is already optional), synthesizes and deploys
910
- // clean.
885
+ // Drop explicitly-`undefined` keys: a spread copies them, so forwarding an
886
+ // unset optional prop would replace the construct's choice with CDK's
887
+ // default - lazy mode's minTtl-0 policy with CACHING_OPTIMIZED, and
888
+ // redirect-to-https with allow-all on both behaviors.
911
889
  const definedOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined));
912
890
  const { origin: transformOrigin, ...transformRest } = this.behaviors.assetsTransform;
913
891
  const { origin: assetsOrigin, ...assetsRest } = this.behaviors.assets;
@@ -37,9 +37,8 @@ export interface CanopyCmsDistributionProps {
37
37
  *
38
38
  * ORDER MATTERS. CloudFront matches path patterns in the order given, so a
39
39
  * more specific pattern must be listed before a more general one that also
40
- * matches: `/assets/t/*` before `/assets/*`, or every transform request is
41
- * served by the static S3-only behavior and never fails over to the transform
42
- * Lambda.
40
+ * matches: `/assets/t/*` before `/assets/*`, or lazy mode's misses never
41
+ * reach the transform Lambda.
43
42
  *
44
43
  * Keys here override this construct's own behaviors on collision, which is
45
44
  * deliberate — the caller is more specific than the default — and an
@@ -98,17 +97,18 @@ export interface CanopyCmsDistributionProps {
98
97
  /**
99
98
  * Optional CDK construct for CanopyCMS CloudFront distribution.
100
99
  *
101
- * Use this if you don't have existing CloudFront infrastructure.
102
- * If you do, use the functionUrl output from CanopyCmsService
103
- * and wire it into your own CloudFront setup.
100
+ * Use this when the CMS gets a domain of its own. To serve the editor from a
101
+ * distribution you already own, use `CanopyCmsService.attachTo` instead.
104
102
  *
105
103
  * Creates:
106
104
  * - ACM certificate (DNS validated) — unless provided
107
105
  * - CloudFront distribution with Function URL origin
108
106
  * - Route53 A/AAAA alias records
109
- * - Cache policies: no-cache for /api/* and /edit*, cache /_next/static/*
107
+ * - Cache policies: no-cache default behavior, cache /_next/static/*
110
108
  * - Viewer-request CloudFront Function setting x-forwarded-host (redirect-URL
111
109
  * derivation behind the Host-stripping OAC origin)
110
+ * - The editor's response headers policy (framing protection, `noindex`) on
111
+ * both of its own behaviors
112
112
  * - `AssetSupport`'s `/assets/*` and `/assets/t/*` behaviors, in the required
113
113
  * order, when the `assetSupport` prop is passed
114
114
  */