canopycms-cdk 0.0.66-int.82 → 0.0.66-int.84

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.
@@ -107,16 +107,54 @@ export interface AssetSupportProps {
107
107
  */
108
108
  readonly transformLogGroupName?: string;
109
109
  }
110
+ /**
111
+ * The two CloudFront path patterns the asset system's behaviors are keyed
112
+ * under, derived from `PREFIXES` above (the single source of truth for these
113
+ * prefixes in this file) rather than spelled out again. Exported so
114
+ * `cms-distribution.ts`'s synth-time ordering guard can recognize them
115
+ * without a second, driftable copy of the literal strings.
116
+ */
117
+ export declare const ASSETS_PATH_PATTERN: string;
118
+ export declare const ASSETS_TRANSFORM_PATH_PATTERN: string;
119
+ /**
120
+ * The literal property names of `AssetCloudFrontBehaviors`
121
+ * (`assets`/`assetsTransform`). It is a natural but broken mistake to spread
122
+ * `assetBehaviors()`'s return value directly into `additionalBehaviors` (a
123
+ * `Record<pathPattern, BehaviorOptions>`) - that type-checks and deploys
124
+ * clean, but synthesizes two CloudFront behaviors matching the literal path
125
+ * patterns `assets` and `assetsTransform`, which nothing ever requests,
126
+ * instead of the real `/assets/*` and `/assets/t/*` patterns. Use
127
+ * `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
128
+ * instead. Exported so `cms-distribution.ts`'s synth-time guard can
129
+ * recognize the mistake and name it in its error.
130
+ */
131
+ export declare const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS: readonly ["assets", "assetsTransform"];
110
132
  /**
111
133
  * The two CloudFront behavior configs the asset system needs, keyed by the
112
134
  * path pattern they belong under. Each value is a full `BehaviorOptions`
113
- * (origin included), so a consumer can use it either way:
135
+ * (origin included).
136
+ *
137
+ * Prefer `AssetSupport.attachTo(distribution)` or `CanopyCmsDistribution`'s
138
+ * `assetSupport` prop over consuming this directly - both encode the
139
+ * required attachment order in exactly one place instead of asking every
140
+ * caller to reproduce it correctly. This return value remains useful as an
141
+ * ESCAPE HATCH for a bespoke `new cloudfront.Distribution(...)` assembled
142
+ * entirely inline (its `additionalBehaviors` is fixed at construction, so
143
+ * there is no distribution yet to call `addBehavior` on) - but manual use
144
+ * must still preserve the ordering shown below, and
145
+ * `CanopyCmsDistribution`'s synth-time guard (`mergeBehaviors`) actively
146
+ * rejects three ways this goes wrong: `/assets/*` listed before
147
+ * `/assets/t/*`; the literal keys `assets`/`assetsTransform` (see
148
+ * `ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS`) from spreading this object directly
149
+ * into a `Record`; or an asset pattern listed here at all while
150
+ * `CanopyCmsDistribution`'s `assetSupport` prop is also passed. See
151
+ * `assertNoAssetBehaviorOrderingHazards`'s doc comment for the full list.
114
152
  *
115
153
  * ```ts
116
154
  * const behaviors = assetSupport.assetBehaviors()
117
155
  *
118
- * // Building a new distribution. CloudFront matches path patterns in the
119
- * // order they're listed and stops at the first match, so the more
156
+ * // Building a new distribution inline. CloudFront matches path patterns in
157
+ * // the order they're listed and stops at the first match, so the more
120
158
  * // specific '/assets/t/*' MUST come before '/assets/*' - otherwise the
121
159
  * // broader S3-only pattern swallows transform requests first and they
122
160
  * // 403 with no Lambda fallback.
@@ -127,12 +165,6 @@ export interface AssetSupportProps {
127
165
  * '/assets/*': behaviors.assets,
128
166
  * },
129
167
  * })
130
- *
131
- * // Adding to an existing distribution: the same first-match-wins ordering
132
- * // applies to the resulting CacheBehaviors list, so call addBehavior for
133
- * // '/assets/t/*' before '/assets/*' here too.
134
- * distribution.addBehavior('/assets/t/*', behaviors.assetsTransform.origin, behaviors.assetsTransform)
135
- * distribution.addBehavior('/assets/*', behaviors.assets.origin, behaviors.assets)
136
168
  * ```
137
169
  */
138
170
  export interface AssetCloudFrontBehaviors {
@@ -165,9 +197,11 @@ export interface AssetCloudFrontBehaviors {
165
197
  * (custom name/retention/removal policy instead of the
166
198
  * CloudFormation-implicit `/aws/lambda/<function-name>` group), and its
167
199
  * OAC-locked Function URL.
168
- * - `assetBehaviors()`, the two CloudFront behavior configs a consuming
169
- * distribution attaches (see `AssetCloudFrontBehaviors`'s doc comment for
170
- * both attachment shapes).
200
+ * - `attachTo(distribution)`, which attaches the two CloudFront behaviors a
201
+ * consuming distribution needs in the only safe order (see its doc comment
202
+ * for why the order is the whole point); `assetBehaviors()` is the escape
203
+ * hatch for a distribution built entirely by hand (see
204
+ * `AssetCloudFrontBehaviors`'s doc comment).
171
205
  * - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
172
206
  * principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
173
207
  */
@@ -185,8 +219,77 @@ export declare class AssetSupport extends Construct {
185
219
  private readonly behaviors;
186
220
  constructor(scope: Construct, id: string, props: AssetSupportProps);
187
221
  private buildBehaviors;
188
- /** The two CloudFront behavior configs this system needs - see `AssetCloudFrontBehaviors`'s doc comment for both attachment shapes. */
222
+ /**
223
+ * The two CloudFront behavior configs this system needs.
224
+ *
225
+ * If you use this rather than `attachTo` -- which now takes an `overrides`
226
+ * parameter, so needing per-behavior options is no longer a reason to fall
227
+ * back here -- you own the ordering, and you should assert it: read the
228
+ * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
229
+ * object, since the property is about emitted order.
230
+ *
231
+ * Prefer `attachTo(distribution)` or `CanopyCmsDistribution`'s
232
+ * `assetSupport` prop, which attach these to a distribution in the only
233
+ * safe order automatically. This method exists as the escape hatch for a
234
+ * distribution assembled entirely by hand - see `AssetCloudFrontBehaviors`'s
235
+ * doc comment for that shape and its ordering requirements.
236
+ */
189
237
  assetBehaviors(): AssetCloudFrontBehaviors;
238
+ /**
239
+ * Attach both asset behaviors to a concrete CloudFront distribution, in the
240
+ * only safe order.
241
+ *
242
+ * THIS METHOD EXISTS BECAUSE THE ORDER IS THE WHOLE POINT.
243
+ * `assetBehaviors()` returns `{ assets, assetsTransform }` with no path
244
+ * pattern attached at all (see that method's and `AssetCloudFrontBehaviors`'s
245
+ * doc comments) - so nothing stops a caller from attaching the two in
246
+ * either order. CloudFront matches path patterns in the order given and
247
+ * stops at the first match. `/assets/*` is a broader, S3-only pattern that
248
+ * also matches every `/assets/t/*` request; `/assets/t/*` is an origin
249
+ * group that fails over to the transform Lambda on a miss. Attach
250
+ * `/assets/*` first and every never-yet-computed transform gets a
251
+ * permanent 403 (an OAC-signed S3 miss reports 403) while already-computed
252
+ * transforms keep working - silent, launch-delayed, and permanent.
253
+ * `'/assets/*'` also sorts BEFORE `'/assets/t/*'` lexicographically (`*` =
254
+ * 0x2A, `t` = 0x74), so alphabetizing the keys reproduces exactly this
255
+ * failure, with no synth or deploy error to catch it.
256
+ *
257
+ * `overrides` is merged into BOTH behaviors, and exists because without it
258
+ * this method is unusable by exactly the adopters who most need the ordering
259
+ * guarantee. A distribution that runs a viewer-request function on every
260
+ * behavior - tier basic-auth, most commonly - needs the asset behaviors to
261
+ * carry that same `functionAssociations`, or `/assets/*` is anonymously
262
+ * readable on an authenticated tier. Before this parameter existed such an
263
+ * adopter had to fall back to `assetBehaviors()` plus two hand-ordered
264
+ * `addBehavior` calls: the precise shape this method was added to eliminate,
265
+ * re-entered while believing ordering was handled upstream, which is worse
266
+ * than never having had the method. (`responseHeadersPolicy` is the same
267
+ * story for a repo with a shared security-headers policy.)
268
+ *
269
+ * Merged into both rather than per-behavior on purpose: applying one set to
270
+ * both is what preserves the ordering guarantee as the only thing this
271
+ * method decides. A caller who genuinely needs the two behaviors to differ
272
+ * has left this method's remit and should use `assetBehaviors()` - and keep
273
+ * their own ordering assertion.
274
+ *
275
+ * Typed `Partial<AddBehaviorOptions>` because that is exactly what
276
+ * `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
277
+ * POSITIONAL argument there, so `BehaviorOptions` (which is
278
+ * `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
279
+ * silently ignores. Measured, because the first version of this comment
280
+ * claimed the opposite: widening the parameter and passing an `origin`
281
+ * override changes nothing in the emitted template, so this narrowing
282
+ * prevents a confusing no-op rather than a broken origin group.
283
+ *
284
+ * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
285
+ * method on that class, not on `IDistribution` (what an imported/looked-up
286
+ * distribution reference gives you). For a distribution built entirely
287
+ * inline (its `additionalBehaviors` fixed at construction, with no
288
+ * distribution yet to call `addBehavior` on), call `assetBehaviors()`
289
+ * directly instead and list `/assets/t/*` before `/assets/*` yourself - see
290
+ * `AssetCloudFrontBehaviors`'s doc comment.
291
+ */
292
+ attachTo(distribution: cloudfront.Distribution, overrides?: Partial<cloudfront.AddBehaviorOptions>): void;
190
293
  /**
191
294
  * Grant the CMS/editor principal (the CMS Lambda's role, typically) the
192
295
  * exact permissions `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts)
@@ -111,6 +111,46 @@ const TRANSFORM_OUTPUT_RETENTION = Duration.days(180);
111
111
  const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
112
112
  const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
113
113
  const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
114
+ /**
115
+ * Distributions that already carry the asset behaviors, tracked at MODULE level
116
+ * rather than per instance.
117
+ *
118
+ * Per-instance (the first version of this) only caught the same `AssetSupport`
119
+ * attaching twice. Two `AssetSupport` constructs attaching to one distribution
120
+ * slipped through and synthesized
121
+ * ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] - the identical
122
+ * duplicate-path-pattern deploy failure the guard exists to convert into a
123
+ * synth error. There is no legitimate form of that: both instances attach the
124
+ * same two patterns, so the second is always wrong regardless of which
125
+ * construct owns it.
126
+ *
127
+ * A `WeakSet` keyed on the distribution object, so it holds no reference that
128
+ * would outlive the construct tree and cannot leak across CDK apps in a test
129
+ * process.
130
+ */
131
+ const distributionsWithAssetBehaviors = new WeakSet();
132
+ /**
133
+ * The two CloudFront path patterns the asset system's behaviors are keyed
134
+ * under, derived from `PREFIXES` above (the single source of truth for these
135
+ * prefixes in this file) rather than spelled out again. Exported so
136
+ * `cms-distribution.ts`'s synth-time ordering guard can recognize them
137
+ * without a second, driftable copy of the literal strings.
138
+ */
139
+ export const ASSETS_PATH_PATTERN = `/${PREFIXES.public}/*`;
140
+ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
141
+ /**
142
+ * The literal property names of `AssetCloudFrontBehaviors`
143
+ * (`assets`/`assetsTransform`). It is a natural but broken mistake to spread
144
+ * `assetBehaviors()`'s return value directly into `additionalBehaviors` (a
145
+ * `Record<pathPattern, BehaviorOptions>`) - that type-checks and deploys
146
+ * clean, but synthesizes two CloudFront behaviors matching the literal path
147
+ * patterns `assets` and `assetsTransform`, which nothing ever requests,
148
+ * instead of the real `/assets/*` and `/assets/t/*` patterns. Use
149
+ * `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
150
+ * instead. Exported so `cms-distribution.ts`'s synth-time guard can
151
+ * recognize the mistake and name it in its error.
152
+ */
153
+ export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
114
154
  /**
115
155
  * Per-site CDK construct for the asset/media delivery system (see
116
156
  * `.claude/future-tasks/assets-media-system.md` for the full design record).
@@ -123,9 +163,11 @@ const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
123
163
  * (custom name/retention/removal policy instead of the
124
164
  * CloudFormation-implicit `/aws/lambda/<function-name>` group), and its
125
165
  * OAC-locked Function URL.
126
- * - `assetBehaviors()`, the two CloudFront behavior configs a consuming
127
- * distribution attaches (see `AssetCloudFrontBehaviors`'s doc comment for
128
- * both attachment shapes).
166
+ * - `attachTo(distribution)`, which attaches the two CloudFront behaviors a
167
+ * consuming distribution needs in the only safe order (see its doc comment
168
+ * for why the order is the whole point); `assetBehaviors()` is the escape
169
+ * hatch for a distribution built entirely by hand (see
170
+ * `AssetCloudFrontBehaviors`'s doc comment).
129
171
  * - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
130
172
  * principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
131
173
  */
@@ -317,10 +359,122 @@ export class AssetSupport extends Construct {
317
359
  };
318
360
  return { assets, assetsTransform };
319
361
  }
320
- /** The two CloudFront behavior configs this system needs - see `AssetCloudFrontBehaviors`'s doc comment for both attachment shapes. */
362
+ /**
363
+ * The two CloudFront behavior configs this system needs.
364
+ *
365
+ * If you use this rather than `attachTo` -- which now takes an `overrides`
366
+ * parameter, so needing per-behavior options is no longer a reason to fall
367
+ * back here -- you own the ordering, and you should assert it: read the
368
+ * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
369
+ * object, since the property is about emitted order.
370
+ *
371
+ * Prefer `attachTo(distribution)` or `CanopyCmsDistribution`'s
372
+ * `assetSupport` prop, which attach these to a distribution in the only
373
+ * safe order automatically. This method exists as the escape hatch for a
374
+ * distribution assembled entirely by hand - see `AssetCloudFrontBehaviors`'s
375
+ * doc comment for that shape and its ordering requirements.
376
+ */
321
377
  assetBehaviors() {
322
378
  return this.behaviors;
323
379
  }
380
+ /**
381
+ * Attach both asset behaviors to a concrete CloudFront distribution, in the
382
+ * only safe order.
383
+ *
384
+ * THIS METHOD EXISTS BECAUSE THE ORDER IS THE WHOLE POINT.
385
+ * `assetBehaviors()` returns `{ assets, assetsTransform }` with no path
386
+ * pattern attached at all (see that method's and `AssetCloudFrontBehaviors`'s
387
+ * doc comments) - so nothing stops a caller from attaching the two in
388
+ * either order. CloudFront matches path patterns in the order given and
389
+ * stops at the first match. `/assets/*` is a broader, S3-only pattern that
390
+ * also matches every `/assets/t/*` request; `/assets/t/*` is an origin
391
+ * group that fails over to the transform Lambda on a miss. Attach
392
+ * `/assets/*` first and every never-yet-computed transform gets a
393
+ * permanent 403 (an OAC-signed S3 miss reports 403) while already-computed
394
+ * transforms keep working - silent, launch-delayed, and permanent.
395
+ * `'/assets/*'` also sorts BEFORE `'/assets/t/*'` lexicographically (`*` =
396
+ * 0x2A, `t` = 0x74), so alphabetizing the keys reproduces exactly this
397
+ * failure, with no synth or deploy error to catch it.
398
+ *
399
+ * `overrides` is merged into BOTH behaviors, and exists because without it
400
+ * this method is unusable by exactly the adopters who most need the ordering
401
+ * guarantee. A distribution that runs a viewer-request function on every
402
+ * behavior - tier basic-auth, most commonly - needs the asset behaviors to
403
+ * carry that same `functionAssociations`, or `/assets/*` is anonymously
404
+ * readable on an authenticated tier. Before this parameter existed such an
405
+ * adopter had to fall back to `assetBehaviors()` plus two hand-ordered
406
+ * `addBehavior` calls: the precise shape this method was added to eliminate,
407
+ * re-entered while believing ordering was handled upstream, which is worse
408
+ * than never having had the method. (`responseHeadersPolicy` is the same
409
+ * story for a repo with a shared security-headers policy.)
410
+ *
411
+ * Merged into both rather than per-behavior on purpose: applying one set to
412
+ * both is what preserves the ordering guarantee as the only thing this
413
+ * method decides. A caller who genuinely needs the two behaviors to differ
414
+ * has left this method's remit and should use `assetBehaviors()` - and keep
415
+ * their own ordering assertion.
416
+ *
417
+ * Typed `Partial<AddBehaviorOptions>` because that is exactly what
418
+ * `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
419
+ * POSITIONAL argument there, so `BehaviorOptions` (which is
420
+ * `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
421
+ * silently ignores. Measured, because the first version of this comment
422
+ * claimed the opposite: widening the parameter and passing an `origin`
423
+ * override changes nothing in the emitted template, so this narrowing
424
+ * prevents a confusing no-op rather than a broken origin group.
425
+ *
426
+ * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
427
+ * method on that class, not on `IDistribution` (what an imported/looked-up
428
+ * distribution reference gives you). For a distribution built entirely
429
+ * inline (its `additionalBehaviors` fixed at construction, with no
430
+ * distribution yet to call `addBehavior` on), call `assetBehaviors()`
431
+ * directly instead and list `/assets/t/*` before `/assets/*` yourself - see
432
+ * `AssetCloudFrontBehaviors`'s doc comment.
433
+ */
434
+ attachTo(distribution, overrides) {
435
+ // `addBehavior` does not dedupe, and these calls bypass
436
+ // `CanopyCmsDistribution`'s own synth-time guard because they run after
437
+ // that distribution is constructed. So attaching twice -- passing the
438
+ // `assetSupport` prop AND calling this yourself, most likely, since the
439
+ // prop's doc comment describes them as equivalent -- synthesizes
440
+ // ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] and fails at
441
+ // deploy time when CloudFront rejects the duplicate patterns. Refuse it
442
+ // here instead, where the message can say which two routes collided.
443
+ if (distributionsWithAssetBehaviors.has(distribution)) {
444
+ throw new Error(`AssetSupport: attachTo() was already called for this distribution. Each pattern ` +
445
+ `would be attached twice and CloudFront rejects duplicate path patterns at deploy ` +
446
+ `time. This usually means the distribution was given CanopyCmsDistribution's ` +
447
+ `\`assetSupport\` prop (which calls attachTo for you) as well as an explicit ` +
448
+ `attachTo() call -- keep one.`);
449
+ }
450
+ distributionsWithAssetBehaviors.add(distribution);
451
+ // Drop explicitly-`undefined` keys before merging. A spread copies own
452
+ // enumerable keys INCLUDING ones whose value is undefined, so
453
+ // `{ ...transformRest, ...{ cachePolicy: undefined } }` deletes the
454
+ // construct's choice and lets CDK substitute its own default - which is a
455
+ // DIFFERENT default. Measured: `cachePolicy: undefined` swaps this
456
+ // behavior's custom policy (TRANSFORM_CACHE_MIN_TTL = 0, which exists
457
+ // solely to stop the oversized-output redirect loop documented above) for
458
+ // the managed CACHING_OPTIMIZED and its 1-second min TTL; and
459
+ // `viewerProtocolPolicy: undefined` downgrades BOTH behaviors from
460
+ // redirect-to-https to allow-all, serving assets over plain HTTP.
461
+ //
462
+ // This is not a contrived input - it is what forwarding optional props
463
+ // produces: `attachTo(dist, { cachePolicy: props.maybePolicy })` with the
464
+ // prop unset. It typechecks (every AddBehaviorOptions field is already
465
+ // optional), synthesizes and deploys clean.
466
+ const definedOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined));
467
+ const { origin: transformOrigin, ...transformRest } = this.behaviors.assetsTransform;
468
+ const { origin: assetsOrigin, ...assetsRest } = this.behaviors.assets;
469
+ distribution.addBehavior(ASSETS_TRANSFORM_PATH_PATTERN, transformOrigin, {
470
+ ...transformRest,
471
+ ...definedOverrides,
472
+ });
473
+ distribution.addBehavior(ASSETS_PATH_PATTERN, assetsOrigin, {
474
+ ...assetsRest,
475
+ ...definedOverrides,
476
+ });
477
+ }
324
478
  /**
325
479
  * Grant the CMS/editor principal (the CMS Lambda's role, typically) the
326
480
  * exact permissions `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts)
@@ -1,5 +1,6 @@
1
1
  import { Construct } from 'constructs';
2
2
  import { Duration, aws_cloudfront as cloudfront, aws_certificatemanager as acm, aws_route53 as route53, aws_lambda as lambda } from 'aws-cdk-lib';
3
+ import type { AssetSupport } from './asset-support.js';
3
4
  export interface CanopyCmsDistributionProps {
4
5
  /** Lambda Function URL from CanopyCmsService */
5
6
  functionUrl: lambda.FunctionUrl;
@@ -49,8 +50,45 @@ export interface CanopyCmsDistributionProps {
49
50
  * overridden key takes YOUR position in this object, not the position the
50
51
  * default held. (A plain object spread would do the opposite; see
51
52
  * `mergeBehaviors`.)
53
+ *
54
+ * Prefer the `assetSupport` prop over wiring `AssetSupport`'s behaviors in
55
+ * here by hand — but if you do it by hand anyway, `mergeBehaviors` throws
56
+ * at synth if it sees any of the three AssetSupport footguns: `/assets/*`
57
+ * listed before `/assets/t/*`; the literal keys `assets`/`assetsTransform`
58
+ * (from spreading `assetBehaviors()`'s return value directly into this
59
+ * object instead of keying it by path pattern); or an asset pattern listed
60
+ * here at all while the `assetSupport` prop is also passed (see that
61
+ * prop's own doc comment). See `assertNoAssetBehaviorOrderingHazards`'s
62
+ * doc comment for the full list.
52
63
  */
53
64
  additionalBehaviors?: Record<string, cloudfront.BehaviorOptions>;
65
+ /**
66
+ * Attach `AssetSupport`'s CloudFront behaviors (`/assets/*` and
67
+ * `/assets/t/*`) to the distribution this construct builds, in the only
68
+ * safe order - see `AssetSupport.attachTo()`'s doc comment for why the
69
+ * order matters. Calls `assetSupport.attachTo(distribution)` for you right
70
+ * after construction, with nothing to forget.
71
+ *
72
+ * NOT fully equivalent to calling `attachTo` yourself: this path cannot pass
73
+ * that method's `overrides` argument. If the asset behaviors need
74
+ * per-behavior options - a viewer-request function for tier auth being the
75
+ * motivating case, since without it `/assets/*` is anonymously readable on
76
+ * an authenticated tier - drop this prop and call
77
+ * `assetSupport.attachTo(this.distribution, { ... })` after construction.
78
+ *
79
+ * Prefer this over passing `assetSupport.assetBehaviors()` through
80
+ * `additionalBehaviors` by hand - that stays available as an escape hatch
81
+ * (e.g. for behaviors that are not from `AssetSupport` at all), and this
82
+ * construct's synth-time guard (`mergeBehaviors`) still checks it for the
83
+ * three ways that manual wiring is known to go wrong: see
84
+ * `additionalBehaviors`'s own doc comment.
85
+ *
86
+ * No ordering logic lives in this construct beyond calling this method -
87
+ * ordering is encoded exactly once, in `AssetSupport.attachTo()` itself.
88
+ *
89
+ * @default - no asset behaviors are attached
90
+ */
91
+ assetSupport?: AssetSupport;
54
92
  }
55
93
  /**
56
94
  * Optional CDK construct for CanopyCMS CloudFront distribution.
@@ -66,6 +104,8 @@ export interface CanopyCmsDistributionProps {
66
104
  * - Cache policies: no-cache for /api/* and /edit*, cache /_next/static/*
67
105
  * - Viewer-request CloudFront Function setting x-forwarded-host (redirect-URL
68
106
  * derivation behind the Host-stripping OAC origin)
107
+ * - `AssetSupport`'s `/assets/*` and `/assets/t/*` behaviors, in the required
108
+ * order, when the `assetSupport` prop is passed
69
109
  */
70
110
  export declare class CanopyCmsDistribution extends Construct {
71
111
  /** The CloudFront distribution */
@@ -1,6 +1,7 @@
1
1
  import { Construct } from 'constructs';
2
2
  import { Duration, Stack, Token, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_certificatemanager as acm, aws_route53 as route53, aws_route53_targets as targets, } from 'aws-cdk-lib';
3
3
  import { DEFAULT_CMS_LAMBDA_TIMEOUT, MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT } from './cms-service.js';
4
+ import { ASSETS_PATH_PATTERN, ASSETS_TRANSFORM_PATH_PATTERN, ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS, } from './asset-support.js';
4
5
  /**
5
6
  * Merge caller behaviors over this construct's own, preserving THE CALLER'S
6
7
  * ordering for every key they supply.
@@ -16,11 +17,120 @@ import { DEFAULT_CMS_LAMBDA_TIMEOUT, MAX_CLOUDFRONT_ORIGIN_READ_TIMEOUT } from '
16
17
  * Dropping collided defaults FIRST means the caller's own object contributes
17
18
  * its keys in its own order.
18
19
  */
19
- function mergeBehaviors(defaults, caller) {
20
+ function mergeBehaviors(defaults, caller, attachingAssetSupport) {
20
21
  if (!caller)
21
22
  return defaults;
22
- const uncollided = Object.fromEntries(Object.entries(defaults).filter(([pattern]) => !(pattern in caller)));
23
- return { ...uncollided, ...caller };
23
+ // Compare NORMALIZED keys here too. CloudFront treats a leading '/' as
24
+ // optional, so a caller passing `'_next/static/*'` did not displace this
25
+ // construct's own `'/_next/static/*'` default and both were emitted -- two
26
+ // behaviors CloudFront reads as the same pattern, which it rejects at
27
+ // deploy. Same reason the hazard checks below normalize.
28
+ const callerPatterns = new Set(Object.keys(caller).map(normalizePathPattern));
29
+ const uncollided = Object.fromEntries(Object.entries(defaults).filter(([pattern]) => !callerPatterns.has(normalizePathPattern(pattern))));
30
+ const normalizedCallerKeys = Object.keys(caller).map(normalizePathPattern);
31
+ const duplicateCallerPattern = normalizedCallerKeys.find((pattern, i) => normalizedCallerKeys.indexOf(pattern) !== i);
32
+ if (duplicateCallerPattern !== undefined) {
33
+ throw new Error(`CanopyCmsDistribution: additionalBehaviors has two keys that CloudFront reads as the ` +
34
+ `same path pattern (${JSON.stringify(duplicateCallerPattern)} with and without its ` +
35
+ `leading '/'). CloudFront rejects duplicate path patterns at deploy time. Keep one ` +
36
+ `spelling.`);
37
+ }
38
+ const merged = { ...uncollided, ...caller };
39
+ assertNoAssetBehaviorOrderingHazards(merged, attachingAssetSupport);
40
+ return merged;
41
+ }
42
+ /**
43
+ * Guards against the three AssetSupport CloudFront-behavior hazards a manually
44
+ * assembled `additionalBehaviors` can introduce (see
45
+ * `AssetSupport.attachTo()`'s doc comment for the full mechanism):
46
+ *
47
+ * 1. The literal keys `assets`/`assetsTransform` present in `merged` mean
48
+ * `assetSupport.assetBehaviors()`'s return value was spread directly into
49
+ * `additionalBehaviors` (a `Record<pathPattern, BehaviorOptions>`) instead
50
+ * of being attached via `assetSupport.attachTo(distribution)` or this
51
+ * construct's `assetSupport` prop. This type-checks and deploys clean,
52
+ * synthesizing two behaviors matching the literal path patterns `assets`
53
+ * and `assetsTransform`, which nothing ever requests.
54
+ * 2. `/assets/*` listed before `/assets/t/*` means CloudFront's first-match-
55
+ * wins ordering serves every transform request off the broader, S3-only
56
+ * `/assets/*` behavior, permanently 403ing any derivative that has not
57
+ * already been computed (an OAC-signed S3 miss reports 403).
58
+ * 3. Either asset pattern present in `merged` WHILE the `assetSupport` prop is
59
+ * also passed means both wiring routes are active at once, so each pattern
60
+ * is attached twice. This is the migration mistake specifically - keeping a
61
+ * hand-wired block while adopting the prop - and hazard 2 cannot catch it,
62
+ * because the hand-written order is usually the correct one.
63
+ *
64
+ * `Object.keys` insertion order is spec-guaranteed for string keys like
65
+ * these (none are integer-like array-index strings), so comparing index
66
+ * order here is sound.
67
+ *
68
+ * SCOPE LIMIT: this protects only callers going through
69
+ * `CanopyCmsDistribution`'s own `additionalBehaviors` merge. It cannot help a
70
+ * bespoke `new cloudfront.Distribution(...)` assembled elsewhere -
71
+ * `AssetSupport.attachTo()` is the answer there.
72
+ */
73
+ /**
74
+ * CloudFront treats the leading `/` on a path pattern as optional -- `assets/*`
75
+ * and `/assets/*` match exactly the same requests, and AWS's own console and
76
+ * docs frequently show the slash-less spelling. The checks below therefore
77
+ * compare NORMALIZED keys: without this, writing `{ 'assets/*': ..., 'assets/t/*': ... }`
78
+ * walked past all three hazards and synthesized the broad-pattern-first order
79
+ * this guard exists to refuse -- silently, which is the worst of the failure
80
+ * modes here.
81
+ */
82
+ function normalizePathPattern(pattern) {
83
+ return pattern.startsWith('/') ? pattern.slice(1) : pattern;
84
+ }
85
+ /** See the three-hazard list documented above `normalizePathPattern`. */
86
+ function assertNoAssetBehaviorOrderingHazards(merged, attachingAssetSupport) {
87
+ const keys = Object.keys(merged).map(normalizePathPattern);
88
+ const assetsPattern = normalizePathPattern(ASSETS_PATH_PATTERN);
89
+ const transformPattern = normalizePathPattern(ASSETS_TRANSFORM_PATH_PATTERN);
90
+ const spreadMistakeKeys = ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS.filter((key) => key in merged);
91
+ if (spreadMistakeKeys.length > 0) {
92
+ throw new Error(`CanopyCmsDistribution: additionalBehaviors has literal key(s) ` +
93
+ `${spreadMistakeKeys.map((k) => JSON.stringify(k)).join(', ')}, which is not a ` +
94
+ `CloudFront path pattern. This usually means assetSupport.assetBehaviors()'s return ` +
95
+ `value was spread directly into additionalBehaviors instead of attached via ` +
96
+ `assetSupport.attachTo(distribution) or this construct's \`assetSupport\` prop - either ` +
97
+ `of which attaches the real '${ASSETS_PATH_PATTERN}' and '${ASSETS_TRANSFORM_PATH_PATTERN}' ` +
98
+ `path patterns in the required order.`);
99
+ }
100
+ // Both halves wired at once. `attachTo` runs AFTER the distribution is
101
+ // constructed, so its `addBehavior` calls never pass through this function -
102
+ // meaning a caller who keeps a hand-wired asset block AND adopts the
103
+ // `assetSupport` prop gets each pattern attached twice. Measured: CDK does
104
+ // not object, and synthesizes CacheBehaviors
105
+ // ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'], which CloudFront
106
+ // then rejects at deploy time for the duplicate path patterns. That is a
107
+ // late failure of exactly the kind this guard exists to convert into a synth
108
+ // one, and it is the most likely mistake during migration: the correct
109
+ // hand-written order does NOT trip the check below, so nothing else would
110
+ // catch it.
111
+ if (attachingAssetSupport) {
112
+ const alsoWiredByHand = [ASSETS_PATH_PATTERN, ASSETS_TRANSFORM_PATH_PATTERN].filter((pattern) => keys.includes(normalizePathPattern(pattern)));
113
+ if (alsoWiredByHand.length > 0) {
114
+ throw new Error(`CanopyCmsDistribution: the \`assetSupport\` prop attaches ` +
115
+ `'${ASSETS_TRANSFORM_PATH_PATTERN}' and '${ASSETS_PATH_PATTERN}' itself, but ` +
116
+ `additionalBehaviors already lists ${alsoWiredByHand.map((p) => `'${p}'`).join(' and ')}. ` +
117
+ `Each pattern would be attached twice and CloudFront rejects duplicate path patterns at ` +
118
+ `deploy time. Remove the asset entries from additionalBehaviors and keep the ` +
119
+ `\`assetSupport\` prop, which also guarantees the required order.`);
120
+ }
121
+ }
122
+ const assetsIndex = keys.indexOf(assetsPattern);
123
+ const transformIndex = keys.indexOf(transformPattern);
124
+ if (assetsIndex !== -1 && transformIndex !== -1 && assetsIndex < transformIndex) {
125
+ throw new Error(`CanopyCmsDistribution: additionalBehaviors lists '${ASSETS_PATH_PATTERN}' before ` +
126
+ `'${ASSETS_TRANSFORM_PATH_PATTERN}'. CloudFront matches path patterns in the order given ` +
127
+ `and stops at the first match, so every '${ASSETS_TRANSFORM_PATH_PATTERN}' request would ` +
128
+ `be served by the broader, S3-only '${ASSETS_PATH_PATTERN}' behavior and never fail over ` +
129
+ `to the transform Lambda - a permanent 403 on any derivative that has not already been ` +
130
+ `computed. List '${ASSETS_TRANSFORM_PATH_PATTERN}' first, or use ` +
131
+ `assetSupport.attachTo(distribution) / this construct's \`assetSupport\` prop, which get ` +
132
+ `the order right automatically.`);
133
+ }
24
134
  }
25
135
  /**
26
136
  * Optional CDK construct for CanopyCMS CloudFront distribution.
@@ -36,6 +146,8 @@ function mergeBehaviors(defaults, caller) {
36
146
  * - Cache policies: no-cache for /api/* and /edit*, cache /_next/static/*
37
147
  * - Viewer-request CloudFront Function setting x-forwarded-host (redirect-URL
38
148
  * derivation behind the Host-stripping OAC origin)
149
+ * - `AssetSupport`'s `/assets/*` and `/assets/t/*` behaviors, in the required
150
+ * order, when the `assetSupport` prop is passed
39
151
  */
40
152
  export class CanopyCmsDistribution extends Construct {
41
153
  constructor(scope, id, props) {
@@ -160,8 +272,15 @@ export class CanopyCmsDistribution extends Construct {
160
272
  cachePolicy: staticCachePolicy,
161
273
  viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
162
274
  },
163
- }, props.additionalBehaviors),
275
+ }, props.additionalBehaviors, props.assetSupport !== undefined),
164
276
  });
277
+ // Attach AssetSupport's behaviors (if provided), in the only safe order -
278
+ // see AssetSupport.attachTo()'s doc comment. This is the ONLY place this
279
+ // construct deals with the asset behaviors' ordering; the actual ordering
280
+ // logic lives exactly once, inside attachTo() itself. Callers who instead
281
+ // pass `assetSupport.assetBehaviors()` through `additionalBehaviors` by
282
+ // hand are covered by `mergeBehaviors`'s synth-time guard above instead.
283
+ props.assetSupport?.attachTo(this.distribution);
165
284
  // ========================================================================
166
285
  // DNS Records
167
286
  // ========================================================================
@@ -72,8 +72,57 @@ export interface CanopyCmsServiceProps {
72
72
  githubTokenSecretArn?: string;
73
73
  /** Secrets Manager ARN for the Clerk secret key */
74
74
  clerkSecretKeySecretArn?: string;
75
- /** Base branch name (default: 'main') */
75
+ /**
76
+ * The GitHub repository's default branch name (default: 'main').
77
+ *
78
+ * Interpolated straight into a git ref (`refs/heads/{baseBranch}`) and into
79
+ * the worker's `.env` heredoc, so an invalid value is rejected at synth (see
80
+ * `assertValidGitBranchName` above) instead of deploying an instance that
81
+ * boots successfully and then fails. And fail it will, permanently:
82
+ * `verifyBaseBranchExists`
83
+ * (packages/canopycms/src/worker/cms-worker.ts) throws when the named branch
84
+ * does not exist in the cloned `remote.git`, `start()`'s catch records the
85
+ * fatal error, `worker/index.ts` exits 1, and systemd's `Restart=always`
86
+ * repeats that forever — there is no working worker at all until the value
87
+ * is fixed and the instance replaced, and `rebaseActiveBranches` fetches and
88
+ * rebases against the wrong lineage in the meantime.
89
+ *
90
+ * MUST match the shared repo's `canopycms.config.ts`'s `defaultBaseBranch`
91
+ * (both default to 'main' when unset) — the two are resolved by different
92
+ * processes (this stamps the worker's `.env`; the Lambda reads
93
+ * `config.defaultBaseBranch` at request time) with no automatic
94
+ * reconciliation between them. `infrastructure/lib/cms-stack.ts`, as
95
+ * scaffolded by `canopycms init-deploy aws`, derives this prop FROM that same
96
+ * config file at synth time so the two cannot drift for adopters who deploy
97
+ * through the generated stack; a hand-rolled stack must set this explicitly
98
+ * whenever `defaultBaseBranch` is anything other than 'main'.
99
+ */
76
100
  baseBranch?: string;
101
+ /**
102
+ * Explicit settings-branch name, stamped into the worker's
103
+ * `CANOPYCMS_SETTINGS_BRANCH` environment variable (default: unset, in which
104
+ * case the worker falls through to the same computed name the Lambda uses,
105
+ * `canopycms-settings-<deploymentName>` — see `deploymentName` below).
106
+ *
107
+ * Only set this to mirror an adopter-configured `config.settingsBranch` in
108
+ * `canopycms.config.ts`. Leaving both unset (the default) is safe: Lambda and
109
+ * worker then resolve the SAME computed name independently, with nothing to
110
+ * keep in sync. But if the shared config sets `settingsBranch` explicitly
111
+ * and this prop is left unset, the worker keeps resolving the computed name
112
+ * while the Lambda's `getSettingsBranchName` short-circuits on
113
+ * `config.settingsBranch` — so the two own different branches. The PRIMARY
114
+ * settings-push path still reaches GitHub (its task payload carries the
115
+ * Lambda-resolved name), but the worker's per-cycle backstop push
116
+ * (`pushSettingsBranches`) targets the wrong branch and its "foreign settings
117
+ * branch" [SYNC-M3] warning misfires against the deployment's own branch.
118
+ * Validated at synth like `baseBranch` (see `assertValidGitBranchName`),
119
+ * since this is interpolated into a git ref and the worker's `.env` heredoc
120
+ * too. `infrastructure/lib/cms-stack.ts`, as scaffolded by
121
+ * `canopycms init-deploy aws`, derives this prop from the same
122
+ * `canopycms.config.ts` at synth time, so the two cannot drift for adopters
123
+ * who deploy through the generated stack.
124
+ */
125
+ settingsBranch?: string;
77
126
  /**
78
127
  * Deployment name (default: 'prod'). Namespaces the settings branch
79
128
  * (`canopycms-settings-{deploymentName}`) so this stack's CMS Lambda/worker