canopycms-cdk 0.0.66-int.86 → 0.0.67-int.88

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.
@@ -79,14 +79,29 @@ export interface AssetSupportProps {
79
79
  * bucket, and that must never appear in a template by accident. Pass
80
80
  * `uploadBehavior: {}` to take the defaults.
81
81
  *
82
- * BYO-BUCKET CAVEAT: this origin addresses the bucket by
83
- * `bucketRegionalDomainName`, and for a bucket imported with
84
- * `Bucket.fromBucketName()` that resolves to the STACK's region, not the
85
- * bucket's. The read path tolerates a mismatch because CloudFront follows
86
- * S3's region redirect on an S3-type origin; this one is a custom origin and
87
- * does not, so S3's 301 reaches the browser, where a redirected cross-origin
88
- * POST surfaces as an opaque network error. Import a cross-region bucket
89
- * with `Bucket.fromBucketAttributes({ region })` so the domain name is right.
82
+ * BYO-BUCKET CAVEATS - three, and the first is about your bucket policy:
83
+ *
84
+ * 1. YOUR BUCKET POLICY IS THE ONLY GATE ON THIS ROUTE. The read behaviors
85
+ * reach the bucket through an OAC-SIGNED origin, so a permissive policy
86
+ * there is still gated by a signature CloudFront adds. This origin cannot
87
+ * be signed (CloudFront never hashes the body, so an OAC-signed origin
88
+ * rejects every multipart POST), so nothing gates it but the policy. The
89
+ * route is contained by rewriting every request to `/`, which leaves an
90
+ * anonymous caller only the bucket-level operations at the root - so what
91
+ * your policy grants anonymously AT THE BUCKET ROOT is what is reachable.
92
+ * A bucket this construct creates refuses all of them (BLOCK_ALL, no
93
+ * public policy); an existing bucket is yours to have got right, and "the
94
+ * read path already works" is not evidence that it is.
95
+ * 2. This origin addresses the bucket by `bucketRegionalDomainName`, and for
96
+ * a bucket imported with `Bucket.fromBucketName()` that resolves to the
97
+ * STACK's region, not the bucket's. The read path tolerates a mismatch
98
+ * because CloudFront follows S3's region redirect on an S3-type origin;
99
+ * this one is a custom origin and does not, so S3's 301 reaches the
100
+ * browser, where a redirected cross-origin POST surfaces as an opaque
101
+ * network error. Import a cross-region bucket with
102
+ * `Bucket.fromBucketAttributes({ region })` so the domain name is right.
103
+ * 3. A DOT in the bucket name breaks this origin specifically - refused at
104
+ * synth, with the reason, in `buildUploadBehavior`.
90
105
  *
91
106
  * @default - no upload behavior is built
92
107
  */
@@ -292,6 +307,111 @@ export interface AssetCloudFrontBehaviors {
292
307
  */
293
308
  readonly assetsTransform: cloudfront.BehaviorOptions;
294
309
  }
310
+ /**
311
+ * Options for `assetUploadBehavior` - everything
312
+ * `AssetSupportProps.uploadBehavior` accepts, plus the bucket that the
313
+ * standalone form has no construct to read it from.
314
+ */
315
+ export interface AssetUploadBehaviorRouteOptions extends AssetUploadBehaviorOptions {
316
+ /**
317
+ * The bucket presigned uploads land in.
318
+ *
319
+ * ALWAYS the BYO case, and the caveat that goes with it is real: this
320
+ * behavior's origin is deliberately OAC-UNSIGNED (it has to be - see
321
+ * `buildUploadBehavior`), so this bucket's own policy is the only thing
322
+ * standing between an anonymous caller and whatever that policy allows at
323
+ * `/`. The URI rewrite means nothing arriving here can address a key, so
324
+ * that is bucket-level operations only, and a bucket with BLOCK_ALL and no
325
+ * public policy refuses all of them - but it is the bucket policy doing the
326
+ * refusing, not this construct.
327
+ */
328
+ readonly bucket: s3.IBucket;
329
+ }
330
+ /**
331
+ * The presigned-upload CloudFront behavior, built from a bucket alone - the
332
+ * infrastructure half of `media.uploadUrl` for callers who have a bucket and
333
+ * no use for an `AssetSupport`.
334
+ *
335
+ * ```ts
336
+ * const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
337
+ * defaultBehavior: assetUploadBehavior(this, { bucket: assetBucket }),
338
+ * })
339
+ * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
340
+ * ```
341
+ *
342
+ * WHY THIS EXISTS SEPARATELY FROM `AssetSupport.uploadBehavior()`. The upload
343
+ * route depends on the bucket and nothing else, which is what lets it live on
344
+ * its own one-route distribution (the topology `AssetSupport.uploadBehavior()`
345
+ * documents and recommends). But `AssetSupport`'s CONSTRUCTOR builds the
346
+ * transform Lambda unconditionally, and with it a log group, a Function URL, a
347
+ * role, and `grantRead`/`grantPut` on three prefixes. Reaching the upload
348
+ * behavior through the class therefore costs an instantiation whose only
349
+ * purpose is to be instantiated.
350
+ *
351
+ * That footprint is cheaper than it looks, and the reason is worth stating
352
+ * because the opposite is easy to assume: those grants do NOT land in the
353
+ * bucket policy. `Grant.addToPrincipalOrResource` (aws-cdk-lib,
354
+ * `aws-iam/lib/grant.ts`) adds the identity statement first and returns right
355
+ * there when two things hold - the identity statement was actually added, AND
356
+ * the grantee's account is known to match the resource's (`TokenComparison`
357
+ * SAME, or both unresolved). Only when one of those fails does it also write a
358
+ * resource statement. A same-account Lambda with a CDK-managed role satisfies
359
+ * both, so its grants go to that function's OWN execution role and nowhere
360
+ * else. Measured on a synthesized template, an owned bucket plus a
361
+ * same-account `AssetSupport` emits no `AWS::S3::BucketPolicy` at all.
362
+ * Deleting the construct therefore takes the whole footprint with it and
363
+ * leaves nothing behind on a shared bucket's policy.
364
+ *
365
+ * Nor does the cross-account shape invert it, which is the obvious next
366
+ * guess and is also wrong. Measured across all three shapes - an owned
367
+ * bucket, one imported by name, and one imported by ARN with an explicitly
368
+ * DIFFERENT account - every one emits zero `AWS::S3::BucketPolicy`. The
369
+ * second half of `addToPrincipalOrResource` does run for an import, but
370
+ * `addToResourcePolicy` on a bucket CDK does not own is a no-op: it cannot
371
+ * write a policy onto a resource it did not create. So a cross-account
372
+ * adopter gets the identity half here and writes the resource half
373
+ * themselves, on the bucket's own side.
374
+ *
375
+ * What it costs instead is legibility: a second `AssetSupport` standing in a
376
+ * stack that has no asset pipeline, existing only so that a method can be
377
+ * called on it, is fine on the day and unexplainable six months later. That
378
+ * is what this function removes, and it matters most for the shape the
379
+ * one-route topology invites - one bucket shared by every environment, owned
380
+ * by a stack that holds no per-environment resources, with reads and
381
+ * transforms staying per-environment.
382
+ *
383
+ * `AssetSupport.uploadBehavior()` remains the right call when you already have
384
+ * an `AssetSupport`; both funnel into the same builder, so the two cannot
385
+ * drift.
386
+ *
387
+ * TWO THINGS TO KNOW ABOUT `scope`. It receives the CloudFront Function and
388
+ * the two policies as children under fixed ids, so calling this twice with
389
+ * the SAME scope throws CDK's duplicate-construct-id error rather than
390
+ * returning a second behavior - one call per scope. And because the children
391
+ * hang off `scope`, moving an EXISTING deployment from
392
+ * `AssetSupport.uploadBehavior()` to this function changes their construct
393
+ * path and therefore their logical IDs, which replaces all three CloudFront
394
+ * resources. There is no reason to make that move on a stack that already has
395
+ * an `AssetSupport`; this function is for the stack that would otherwise have
396
+ * to grow one.
397
+ *
398
+ * TWO THINGS THIS DOES NOT DO, both deliberate:
399
+ *
400
+ * - It writes NO bucket CORS rule, and needs none. The edge supplies
401
+ * `Access-Control-Allow-Origin`; S3's acceptance of a presigned POST and its
402
+ * ADVERTISEMENT of CORS are independent, and only the second is what a
403
+ * bucket CORS rule governs. `AssetSupportProps.editorOrigins` is the prop
404
+ * that writes one, and it belongs to the construct, not to this route.
405
+ * - It registers no synth-time guard for "built but never attached to a
406
+ * distribution". `AssetSupport` has one, but it fires only in standalone
407
+ * mode with no `editorOrigins` - where opting into `uploadBehavior` is what
408
+ * satisfied the "something must supply ACAO" guard, so opting in and
409
+ * stopping leaves a bucket with neither. A caller here always brings their
410
+ * own bucket and never passes through that guard, so there is no invariant
411
+ * to protect: a discarded return value is an obviously unfinished call, not
412
+ * a silently broken one.
413
+ */
414
+ export declare function assetUploadBehavior(scope: Construct, options: AssetUploadBehaviorRouteOptions): cloudfront.BehaviorOptions;
295
415
  /**
296
416
  * Per-site CDK construct for the asset/media delivery system (see
297
417
  * `.claude/future-tasks/assets-media-system.md` for the full design record).
@@ -343,19 +463,23 @@ export declare class AssetSupport extends Construct {
343
463
  * added. Loud, not silent.
344
464
  */
345
465
  private upload?;
346
- constructor(scope: Construct, id: string, props: AssetSupportProps);
347
- private buildBehaviors;
348
466
  /**
349
- * The behavior that accepts the editor's presigned-POST uploads. Every piece
350
- * below is load-bearing; see `uploadBehavior()` for where the result goes.
467
+ * The origin inside `upload`, kept so the validation below can ask whether
468
+ * that behavior was ever attached to a distribution - see `UploadOrigin`.
469
+ * Set by `uploadBehavior()`, i.e. at the same moment as `upload`: the
470
+ * builder is shared with `assetUploadBehavior`, which has no construct to
471
+ * record anything on, so recovering the origin from the returned behavior is
472
+ * this class's job rather than the builder's.
351
473
  */
352
- private buildUploadBehavior;
474
+ private uploadOrigin?;
475
+ constructor(scope: Construct, id: string, props: AssetSupportProps);
476
+ private buildBehaviors;
353
477
  /**
354
478
  * The two CloudFront behavior configs this system needs.
355
479
  *
356
- * If you use this rather than `attachTo` -- which now takes an `overrides`
357
- * parameter, so needing per-behavior options is no longer a reason to fall
358
- * back here -- you own the ordering, and you should assert it: read the
480
+ * If you use this rather than `attachTo` -- which takes an `overrides`
481
+ * parameter, so needing per-behavior options is not a reason to fall back
482
+ * here -- you own the ordering, and you should assert it: read the
359
483
  * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
360
484
  * object, since the property is about emitted order.
361
485
  *
@@ -409,6 +533,13 @@ export declare class AssetSupport extends Construct {
409
533
  * The upload route is the one part of this system with no per-environment
410
534
  * code in it - it depends on the bucket and nothing else - which is why it
411
535
  * is the part that can be split off this way.
536
+ *
537
+ * That same property is why `assetUploadBehavior` exists as a free function:
538
+ * it builds this behavior from a bucket alone, for a caller who wants the
539
+ * one-route distribution above and has no other use for an `AssetSupport`.
540
+ * Prefer this method when you already have one; both funnel into the same
541
+ * builder. See that function for what instantiating the construct purely to
542
+ * reach this method actually costs.
412
543
  */
413
544
  uploadBehavior(): cloudfront.BehaviorOptions;
414
545
  /**
@@ -448,14 +579,11 @@ export declare class AssetSupport extends Construct {
448
579
  * has left this method's remit and should use `assetBehaviors()` - and keep
449
580
  * their own ordering assertion.
450
581
  *
451
- * Typed `Partial<AddBehaviorOptions>` because that is exactly what
452
- * `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
453
- * POSITIONAL argument there, so `BehaviorOptions` (which is
454
- * `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
455
- * silently ignores. Measured, because the first version of this comment
456
- * claimed the opposite: widening the parameter and passing an `origin`
457
- * override changes nothing in the emitted template, so this narrowing
458
- * prevents a confusing no-op rather than a broken origin group.
582
+ * Typed `Partial<AddBehaviorOptions>`, not `BehaviorOptions`, because
583
+ * `addBehavior` takes `origin` positionally - so an `origin` key here would be
584
+ * a silently ignored no-op. (Measured: passing one changes nothing in the
585
+ * emitted template. It is a confusing no-op being prevented, not a broken
586
+ * origin group.)
459
587
  *
460
588
  * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
461
589
  * method on that class, not on `IDistribution` (what an imported/looked-up
@@ -32,17 +32,11 @@ const transformAssetDir = path.join(__dirname, '..', '..', 'lambda', 'asset-tran
32
32
  */
33
33
  const DEPLOYABLE_MARKER = '.deployable';
34
34
  /**
35
- * The four S3 key prefixes the asset system uses, mirrored from
36
- * `packages/canopycms/src/assets/asset-prefixes.ts` (the source of truth).
37
- * Duplicated as plain string literals - not imported from `canopycms` -
38
- * because this construct must synth cleanly for ANY consumer's CDK app
39
- * (e.g. a site's own `infrastructure/` package), which has no reason to
40
- * have `canopycms` itself resolvable from wherever its CDK code runs. The
41
- * transform Lambda (../../lambda/asset-transform/handler.ts), by contrast,
42
- * is bundled at build time from WITHIN this package (where `canopycms` is a
43
- * real workspace devDependency), so it imports the canonical constants
44
- * directly from `canopycms/server` instead of duplicating them - see that
45
- * file's doc comment.
35
+ * The four S3 key prefixes the asset system uses. Source of truth is
36
+ * `packages/canopycms/src/assets/asset-prefixes.ts`; these are copied as
37
+ * literals rather than imported so this construct synths in a consumer CDK app
38
+ * that has no `canopycms` resolvable. (The transform Lambda does import them -
39
+ * it is bundled from inside this package; see its handler's doc comment.)
46
40
  */
47
41
  const PREFIXES = {
48
42
  originals: 'asset-originals',
@@ -141,23 +135,64 @@ const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
141
135
  const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
142
136
  const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
143
137
  /**
144
- * Distributions that already carry the asset behaviors, tracked at MODULE level
145
- * rather than per instance.
138
+ * Id of the marker construct `attachTo` adds to a distribution to record that
139
+ * the asset behaviors are already on it.
146
140
  *
147
- * Per-instance (the first version of this) only caught the same `AssetSupport`
148
- * attaching twice. Two `AssetSupport` constructs attaching to one distribution
149
- * slipped through and synthesized
150
- * ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] - the identical
151
- * duplicate-path-pattern deploy failure the guard exists to convert into a
152
- * synth error. There is no legitimate form of that: both instances attach the
153
- * same two patterns, so the second is always wrong regardless of which
154
- * construct owns it.
141
+ * The fact being recorded is "THIS distribution already carries these two
142
+ * patterns", so it is stored ON that distribution rather than in a module-level
143
+ * registry. Per-INSTANCE state was the first version and was wrong: two
144
+ * different `AssetSupport` constructs attaching to one distribution slipped
145
+ * through and synthesized ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'],
146
+ * the duplicate-path-pattern deploy failure the guard exists to convert into a
147
+ * synth error. There is no legitimate form of that - both instances attach the
148
+ * same two patterns, so the second is always wrong regardless of which construct
149
+ * owns it - so the check has to be keyed on the distribution, not the attacher.
155
150
  *
156
- * A `WeakSet` keyed on the distribution object, so it holds no reference that
157
- * would outlive the construct tree and cannot leak across CDK apps in a test
158
- * process.
151
+ * A module-level `WeakSet` keyed on the distribution object did that correctly
152
+ * (a `cloudfront.Distribution` instance belongs to exactly one construct tree,
153
+ * and `attachTo` takes a concrete instance, so one could never be claimed by a
154
+ * second CDK app). A child construct is preferred anyway because the scoping is
155
+ * then a property of the tree rather than something the reader has to reason
156
+ * about process lifetime to trust - and it is visible in `node.children`, so a
157
+ * test can assert the guard's state instead of only its error.
158
+ *
159
+ * The marker is a bare `Construct`, which emits nothing into the template.
160
+ */
161
+ const ATTACHED_MARKER_ID = 'CanopyAssetBehaviorsAttached';
162
+ /**
163
+ * The upload behavior's origin, which records having been bound to a
164
+ * distribution.
165
+ *
166
+ * This exists so the synth-time validation below can tell "`uploadBehavior()`
167
+ * was CALLED" from "its result actually reached a distribution" - two states a
168
+ * memoized accessor cannot distinguish on its own, and the second is the one
169
+ * that determines whether anything supplies Access-Control-Allow-Origin.
170
+ *
171
+ * CDK calls `bind()` exactly when a `Distribution` takes a behavior: from the
172
+ * constructor for `defaultBehavior` (the topology `uploadBehavior()`
173
+ * recommends), and from `addBehavior` for an additional one. Both shapes, and a
174
+ * distribution in a different stack, are pinned as PASSING cases in
175
+ * asset-support.test.ts - a mechanism that only sees one of them would fail the
176
+ * other two at synth. All three bind eagerly at construction, before any
177
+ * validation runs. A behavior that is built and then dropped never binds.
178
+ *
179
+ * Observing the origin rather than searching the tree for the emitted behavior
180
+ * is what makes the cross-stack case work. Measured: a consuming stack renders
181
+ * the reference as `Fn::ImportValue`, not as the producing stack's resolved ARN,
182
+ * so a tree search would report "not attached" for a perfectly correct
183
+ * cross-stack distribution - a false synth failure, which is worse than the gap
184
+ * it closes.
159
185
  */
160
- const distributionsWithAssetBehaviors = new WeakSet();
186
+ class UploadOrigin extends origins.HttpOrigin {
187
+ constructor() {
188
+ super(...arguments);
189
+ this.attachedToDistribution = false;
190
+ }
191
+ bind(scope, options) {
192
+ this.attachedToDistribution = true;
193
+ return super.bind(scope, options);
194
+ }
195
+ }
161
196
  /**
162
197
  * The two CloudFront path patterns the asset system's behaviors are keyed
163
198
  * under, derived from `PREFIXES` above (the single source of truth for these
@@ -190,6 +225,376 @@ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
190
225
  * `AssetCloudFrontBehaviors`.
191
226
  */
192
227
  export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
228
+ /**
229
+ * The `allowedOrigins` guards, shared by both entry points.
230
+ *
231
+ * These live here rather than inline in `buildUploadBehavior` because
232
+ * `AssetSupport` runs them in its CONSTRUCTOR - eagerly, long before anything
233
+ * builds the behavior - and moving them to the builder would delay the throw
234
+ * to the first `uploadBehavior()` call. `assetUploadBehavior` has no such
235
+ * split and runs them on entry. One implementation, two call sites, so the
236
+ * standalone form cannot quietly accept what the construct refuses.
237
+ *
238
+ * `label` is the options path to name in the message, so each caller's error
239
+ * points at the property the caller actually wrote.
240
+ */
241
+ function validateUploadBehaviorOptions(options, label) {
242
+ // An empty `allowedOrigins` is the same class of mistake as an empty
243
+ // `editorOrigins` and gets the same treatment: CloudFormation rejects
244
+ // `AccessControlAllowOrigins` with no items, so silently falling back to
245
+ // the `['*']` default would turn a forwarded-empty-env-var into either a
246
+ // failed deploy or, worse, a wildcard the caller did not ask for.
247
+ if (options.allowedOrigins?.length === 0) {
248
+ throw new Error(`${label} is an empty array. CloudFront requires at ` +
249
+ 'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
250
+ "silently widen what you asked for. Omit the property to take the `['*']` default, " +
251
+ 'or list the origin(s) the editor is served from.');
252
+ }
253
+ // Two consumers read `allowedOrigins` and they do not share a matcher: the
254
+ // response headers policy (which decides ACAO on the POST response) accepts
255
+ // CloudFront's pattern grammar, including a leftmost-subdomain wildcard
256
+ // like `https://*.preview.example.com`; the preflight responder compiled
257
+ // into the CloudFront Function can only do exact-string comparison, because
258
+ // reimplementing that grammar in an edge function without being able to
259
+ // test it against CloudFront is how the two silently disagree.
260
+ //
261
+ // So a pattern is refused rather than half-honoured. Accepting one would
262
+ // deploy clean and then fail every upload from a matching origin at the
263
+ // PREFLIGHT, with a correct-looking policy sitting right next to it - the
264
+ // same invisible failure the preflight responder was added to remove.
265
+ const wildcardOrigins = (options.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
266
+ const isBareWildcard = options.allowedOrigins?.join() === '*';
267
+ if (wildcardOrigins.length > 0 && !isBareWildcard) {
268
+ throw new Error(`${label} contains a wildcard pattern ` +
269
+ `(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
270
+ `headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
271
+ `CloudFront Function that compares origins exactly - so every upload from a matching ` +
272
+ `origin would fail its preflight while the policy looked correct. Use exactly ` +
273
+ `['*'] to allow any origin, or list each editor origin in full.`);
274
+ }
275
+ }
276
+ /**
277
+ * The behavior that accepts the editor's presigned-POST uploads. Every piece
278
+ * below is load-bearing; see `assetUploadBehavior` and
279
+ * `AssetSupport.uploadBehavior()` for where the result goes.
280
+ *
281
+ * `label` prefixes the one error raised here, so the message names whichever
282
+ * entry point the caller used.
283
+ */
284
+ function buildUploadBehavior(scope, options, label) {
285
+ // A DISTINCT origin for the bucket, with OAC signing OFF - the read
286
+ // behaviors' origin cannot be reused here no matter what this behavior
287
+ // sets, because OAC is a property of the ORIGIN, not the behavior. (On the
288
+ // dedicated distribution `uploadBehavior()` recommends this is the only
289
+ // origin present; the point is that it cannot be the read one.)
290
+ // CloudFront signs origin requests but never hashes
291
+ // the body, so an OAC-signed origin rejects every multipart POST whatever
292
+ // the viewer sent. Measured, that is `400 InvalidArgument`, the body
293
+ // naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
294
+ // ... or a valid sha256 value`. It fails CLOSED, but it is an
295
+ // argument-validation error, NOT the 403 that the Lambda Function URL
296
+ // origin produces in the same situation (docs/deploying-to-aws.md's
297
+ // "CloudFront OAC and request body signing"): there Lambda verifies a
298
+ // SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
299
+ // adopter told to expect 403 goes hunting for a permissions problem that
300
+ // does not exist.
301
+ //
302
+ // `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
303
+ // would also be unsigned, for two reasons. It is the exact shape this
304
+ // whole path was measured against end to end on live AWS resources (204,
305
+ // object landed); and it is the only one of the two that can state the
306
+ // CloudFront->S3 protocol, which for a request carrying a live upload
307
+ // credential in its body should be pinned rather than inferred.
308
+ // `withBucketDefaults()` emits `S3OriginConfig`, which has no
309
+ // `OriginProtocolPolicy` field at all.
310
+ // A dot in the bucket name breaks this origin specifically. S3's wildcard
311
+ // certificate covers one label (`*.s3.<region>.amazonaws.com`), so
312
+ // `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
313
+ // CloudFront answers 502 on every upload. This is a CUSTOM origin doing
314
+ // ordinary TLS validation; the read path uses an S3-type origin, which
315
+ // CloudFront treats differently, so nothing else here would surface it -
316
+ // and dotted names are most likely in exactly the BYO case ("a site's
317
+ // existing content bucket") this behavior is written for. Only checkable
318
+ // when the name is a real literal, which for an imported bucket it is.
319
+ const bucketName = options.bucket.bucketName;
320
+ if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
321
+ throw new Error(`${label} cannot use bucket "${bucketName}" - a dot in the bucket ` +
322
+ `name puts an extra label in its regional domain name, which S3's wildcard ` +
323
+ `certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
324
+ `every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
325
+ `dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
326
+ }
327
+ const uploadOrigin = new UploadOrigin(options.bucket.bucketRegionalDomainName, {
328
+ protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
329
+ });
330
+ // S3's POST Object is only valid at the bucket ROOT - without this rewrite
331
+ // the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
332
+ // behavior: rewriting unconditionally means no request arriving here can
333
+ // address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
334
+ // only the bucket-level operations at `/` (list, create, delete bucket).
335
+ // A bucket this construct creates refuses all of them anonymously
336
+ // (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
337
+ // bucket policy to have got right.
338
+ //
339
+ // Their bucket policy is a WEAKER backstop here than on the read path, and
340
+ // the two must not be read as equivalent: the read behaviors go through an
341
+ // OAC-SIGNED origin, so a too-permissive policy there is still gated by a
342
+ // signature CloudFront adds. This origin is deliberately unsigned (it has
343
+ // to be - see the OAC note at the top of this method), so on a BYO bucket
344
+ // the policy is the ONLY thing standing between an anonymous caller and
345
+ // whatever that policy allows at `/`. Stated where a BYO adopter reads it,
346
+ // in `uploadBehavior()`'s caveat block, not only here.
347
+ //
348
+ // The only thing that can WRITE either way is a request carrying a valid
349
+ // presigned policy, and only to the single key that policy names. Do not
350
+ // make the rewrite conditional; that gives key addressability back.
351
+ //
352
+ // The same function also drops `Authorization`. A site behind HTTP basic
353
+ // auth otherwise sends S3 a credential it cannot parse
354
+ // (`400 InvalidArgument - Unsupported Authorization Type`), and the
355
+ // presigned POST carries its own authority in the body, so nothing on this
356
+ // route ever wants the header. It is dropped HERE, in the function, rather
357
+ // than by naming it in the origin request policy's `allExcept` list below,
358
+ // and that placement is deliberate: CloudFront rejects `Authorization` in
359
+ // an origin request policy's header ALLOWLIST outright ("The parameter
360
+ // Headers contains Authorization that is not allowed"), and whether the
361
+ // same validation fires on an `allExcept` list is not something synth can
362
+ // tell you - it would surface as a failed `cdk deploy` on the whole stack.
363
+ // A viewer-request function has no such ambiguity: `method` is the only
364
+ // read-only field on the request object, headers are freely mutable, and
365
+ // `Authorization` is the header CloudFront's own basic-auth function
366
+ // examples read. Dropping it at the viewer also means it is gone before
367
+ // any policy runs, so the guarantee does not depend on the policy at all.
368
+ // The same function ALSO answers the CORS preflight, and that is not
369
+ // optional decoration - without it this route cannot work at all.
370
+ //
371
+ // The editor's upload is NOT a CORS simple request, though it looks like
372
+ // one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
373
+ // but it assigns `xhr.upload.onprogress` before `send()`, and registering
374
+ // ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
375
+ // from the simple-request rules, independent of method, headers and content
376
+ // type. So every browser upload begins with `OPTIONS /`.
377
+ //
378
+ // Nothing else on this route would answer it. CloudFront does not
379
+ // synthesize preflight responses - a response headers policy supplies
380
+ // values for headers "in responses to CORS preflight requests", i.e. it
381
+ // decorates a response that something else produced - and `ALLOW_ALL`
382
+ // forwards the OPTIONS to S3, which with no CORS configuration answers
383
+ // `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
384
+ // is not a 2xx fails the browser's check whatever headers are attached, so
385
+ // the POST is never sent and the upload dies as an opaque network error.
386
+ //
387
+ // This is why the end-to-end measurements did not catch it: they replayed
388
+ // the POST as a script, which has no preflight. A scripted POST is not an
389
+ // acceptance test for this route; a real browser upload is.
390
+ //
391
+ // Short-circuiting at the viewer also strengthens the containment argument
392
+ // above rather than weakening it - an OPTIONS that never reaches the origin
393
+ // can address nothing.
394
+ const allowedOrigins = options.allowedOrigins ?? ['*'];
395
+ const rewriteToBucketRoot = new cloudfront.Function(scope, 'AssetUploadRewriteFunction', {
396
+ code: cloudfront.FunctionCode.fromInline([
397
+ `var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
398
+ 'function handler(event) {',
399
+ ' var request = event.request;',
400
+ " if (request.method === 'OPTIONS') {",
401
+ ' var headers = {',
402
+ ` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
403
+ " 'access-control-allow-headers': { value: '*' },",
404
+ ` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
405
+ ' };',
406
+ // Echo the caller's own Origin when the list is narrowed: a preflight
407
+ // may only be answered with `*` or the single requesting origin, so a
408
+ // multi-entry list cannot be returned verbatim. No match means no
409
+ // ACAO, and the browser's own check refuses the upload - which is the
410
+ // correct outcome, and a clearer one than a 403 here would be.
411
+ " var origin = request.headers.origin ? request.headers.origin.value : '';",
412
+ " if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
413
+ " headers['access-control-allow-origin'] = { value: '*' };",
414
+ ' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
415
+ " headers['access-control-allow-origin'] = { value: origin };",
416
+ ' }',
417
+ " return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
418
+ ' }',
419
+ " request.uri = '/';",
420
+ ' delete request.headers.authorization;',
421
+ ' return request;',
422
+ '}',
423
+ ].join('\n')),
424
+ });
425
+ // `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
426
+ // dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
427
+ // - the shape this path was measured against. It is NOT that policy
428
+ // overall, and must not be replaced by it: the managed one is documented as
429
+ // "Cookies: All, Query strings: All", so adopting it would forward the
430
+ // editor session cookie to S3, which is one of the two hazards this whole
431
+ // policy exists to remove. Only the header behavior is shared.
432
+ //
433
+ // `Authorization` is handled by the function above, not here.
434
+ //
435
+ // Cookies go through `CookiesConfig`, not the header list (a CloudFront
436
+ // Function does not even see them in `request.headers`), so
437
+ // `cookieBehavior.none()` is what strips them: an upload route mounted on
438
+ // a site's own distribution would otherwise hand S3 - and S3's access logs
439
+ // - the editor session cookie. On the dedicated distribution recommended
440
+ // in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
441
+ // none in the first place; this makes it true either way.
442
+ const originRequestPolicy = new cloudfront.OriginRequestPolicy(scope, 'AssetUploadOriginRequestPolicy', {
443
+ cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
444
+ headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
445
+ // A presigned POST carries everything in the multipart body.
446
+ queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
447
+ });
448
+ // The edge supplies Access-Control-Allow-Origin, scoped to this behavior
449
+ // only. This is the measurement that unblocked the whole design: a
450
+ // response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
451
+ // origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
452
+ // object landed). Acceptance and advertisement are independent in S3 -
453
+ // bucket CORS governs only whether S3 advertises - which is exactly the
454
+ // property that lets this construct avoid writing a bucket CORS rule.
455
+ //
456
+ // `originOverride: true` so the header is ours deterministically even on a
457
+ // bucket that does have its own CORS configuration.
458
+ //
459
+ // AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
460
+ // and this path DOES trigger one - see the CloudFront Function above, which
461
+ // answers it, and which exists because nothing else would. They are set
462
+ // here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
463
+ // two cannot tell a browser different things, and because a response
464
+ // headers policy is documented to decorate responses without saying whether
465
+ // a function-generated one is among them. If it is, `originOverride: true`
466
+ // means the policy's values REPLACE the function's identical ones; if it is
467
+ // not, the function's stand alone. Either way the viewer sees exactly one
468
+ // of each, which is what makes it safe not to know.
469
+ const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(scope, 'AssetUploadResponseHeadersPolicy', {
470
+ corsBehavior: {
471
+ accessControlAllowOrigins: allowedOrigins,
472
+ accessControlAllowCredentials: false,
473
+ accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
474
+ accessControlAllowHeaders: ['*'],
475
+ accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
476
+ originOverride: true,
477
+ },
478
+ });
479
+ return {
480
+ origin: uploadOrigin,
481
+ // A POST is 405 without this. CloudFront offers no narrower set that
482
+ // includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
483
+ // The URI rewrite above is what makes that acceptable.
484
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
485
+ // HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
486
+ // redirects with 301, and a browser turns a 301'd POST into a GET - so
487
+ // an `http://` upload URL would silently become a bodyless GET that this
488
+ // behavior rewrites to `/` and S3 refuses, with the file never sent.
489
+ // HTTPS_ONLY answers 403 instead: same refusal, visible.
490
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
491
+ // Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
492
+ // cache key is also what keeps the origin request policy above legal
493
+ // (CloudFront requires it to be a superset of the cache key).
494
+ cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
495
+ originRequestPolicy,
496
+ responseHeadersPolicy,
497
+ functionAssociations: [
498
+ {
499
+ function: rewriteToBucketRoot,
500
+ eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
501
+ },
502
+ ],
503
+ };
504
+ }
505
+ /**
506
+ * The presigned-upload CloudFront behavior, built from a bucket alone - the
507
+ * infrastructure half of `media.uploadUrl` for callers who have a bucket and
508
+ * no use for an `AssetSupport`.
509
+ *
510
+ * ```ts
511
+ * const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
512
+ * defaultBehavior: assetUploadBehavior(this, { bucket: assetBucket }),
513
+ * })
514
+ * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
515
+ * ```
516
+ *
517
+ * WHY THIS EXISTS SEPARATELY FROM `AssetSupport.uploadBehavior()`. The upload
518
+ * route depends on the bucket and nothing else, which is what lets it live on
519
+ * its own one-route distribution (the topology `AssetSupport.uploadBehavior()`
520
+ * documents and recommends). But `AssetSupport`'s CONSTRUCTOR builds the
521
+ * transform Lambda unconditionally, and with it a log group, a Function URL, a
522
+ * role, and `grantRead`/`grantPut` on three prefixes. Reaching the upload
523
+ * behavior through the class therefore costs an instantiation whose only
524
+ * purpose is to be instantiated.
525
+ *
526
+ * That footprint is cheaper than it looks, and the reason is worth stating
527
+ * because the opposite is easy to assume: those grants do NOT land in the
528
+ * bucket policy. `Grant.addToPrincipalOrResource` (aws-cdk-lib,
529
+ * `aws-iam/lib/grant.ts`) adds the identity statement first and returns right
530
+ * there when two things hold - the identity statement was actually added, AND
531
+ * the grantee's account is known to match the resource's (`TokenComparison`
532
+ * SAME, or both unresolved). Only when one of those fails does it also write a
533
+ * resource statement. A same-account Lambda with a CDK-managed role satisfies
534
+ * both, so its grants go to that function's OWN execution role and nowhere
535
+ * else. Measured on a synthesized template, an owned bucket plus a
536
+ * same-account `AssetSupport` emits no `AWS::S3::BucketPolicy` at all.
537
+ * Deleting the construct therefore takes the whole footprint with it and
538
+ * leaves nothing behind on a shared bucket's policy.
539
+ *
540
+ * Nor does the cross-account shape invert it, which is the obvious next
541
+ * guess and is also wrong. Measured across all three shapes - an owned
542
+ * bucket, one imported by name, and one imported by ARN with an explicitly
543
+ * DIFFERENT account - every one emits zero `AWS::S3::BucketPolicy`. The
544
+ * second half of `addToPrincipalOrResource` does run for an import, but
545
+ * `addToResourcePolicy` on a bucket CDK does not own is a no-op: it cannot
546
+ * write a policy onto a resource it did not create. So a cross-account
547
+ * adopter gets the identity half here and writes the resource half
548
+ * themselves, on the bucket's own side.
549
+ *
550
+ * What it costs instead is legibility: a second `AssetSupport` standing in a
551
+ * stack that has no asset pipeline, existing only so that a method can be
552
+ * called on it, is fine on the day and unexplainable six months later. That
553
+ * is what this function removes, and it matters most for the shape the
554
+ * one-route topology invites - one bucket shared by every environment, owned
555
+ * by a stack that holds no per-environment resources, with reads and
556
+ * transforms staying per-environment.
557
+ *
558
+ * `AssetSupport.uploadBehavior()` remains the right call when you already have
559
+ * an `AssetSupport`; both funnel into the same builder, so the two cannot
560
+ * drift.
561
+ *
562
+ * TWO THINGS TO KNOW ABOUT `scope`. It receives the CloudFront Function and
563
+ * the two policies as children under fixed ids, so calling this twice with
564
+ * the SAME scope throws CDK's duplicate-construct-id error rather than
565
+ * returning a second behavior - one call per scope. And because the children
566
+ * hang off `scope`, moving an EXISTING deployment from
567
+ * `AssetSupport.uploadBehavior()` to this function changes their construct
568
+ * path and therefore their logical IDs, which replaces all three CloudFront
569
+ * resources. There is no reason to make that move on a stack that already has
570
+ * an `AssetSupport`; this function is for the stack that would otherwise have
571
+ * to grow one.
572
+ *
573
+ * TWO THINGS THIS DOES NOT DO, both deliberate:
574
+ *
575
+ * - It writes NO bucket CORS rule, and needs none. The edge supplies
576
+ * `Access-Control-Allow-Origin`; S3's acceptance of a presigned POST and its
577
+ * ADVERTISEMENT of CORS are independent, and only the second is what a
578
+ * bucket CORS rule governs. `AssetSupportProps.editorOrigins` is the prop
579
+ * that writes one, and it belongs to the construct, not to this route.
580
+ * - It registers no synth-time guard for "built but never attached to a
581
+ * distribution". `AssetSupport` has one, but it fires only in standalone
582
+ * mode with no `editorOrigins` - where opting into `uploadBehavior` is what
583
+ * satisfied the "something must supply ACAO" guard, so opting in and
584
+ * stopping leaves a bucket with neither. A caller here always brings their
585
+ * own bucket and never passes through that guard, so there is no invariant
586
+ * to protect: a discarded return value is an obviously unfinished call, not
587
+ * a silently broken one.
588
+ */
589
+ export function assetUploadBehavior(scope, options) {
590
+ validateUploadBehaviorOptions(options, 'assetUploadBehavior: allowedOrigins');
591
+ // Snapshotted for the same reason `AssetSupport` snapshots it - the guard
592
+ // above has just read the array, so holding the caller's reference would let
593
+ // a later `origins.length = 0` walk past a check that already passed. Here
594
+ // the window is only as long as this call, but the cost of closing it is a
595
+ // `slice()`.
596
+ return buildUploadBehavior(scope, { ...options, allowedOrigins: options.allowedOrigins?.slice() }, 'assetUploadBehavior');
597
+ }
193
598
  /**
194
599
  * Per-site CDK construct for the asset/media delivery system (see
195
600
  * `.claude/future-tasks/assets-media-system.md` for the full design record).
@@ -397,39 +802,13 @@ export class AssetSupport extends Construct {
397
802
  authType: lambda.FunctionUrlAuthType.AWS_IAM,
398
803
  });
399
804
  this.behaviors = this.buildBehaviors();
400
- // Validated eagerly, built lazily (see `upload`'s comment). An empty
401
- // `allowedOrigins` is the same class of mistake as an empty
402
- // `editorOrigins` and gets the same treatment: CloudFormation rejects
403
- // `AccessControlAllowOrigins` with no items, so silently falling back to
404
- // the `['*']` default would turn a forwarded-empty-env-var into either a
405
- // failed deploy or, worse, a wildcard the caller did not ask for.
406
- if (props.uploadBehavior?.allowedOrigins?.length === 0) {
407
- throw new Error('AssetSupport: uploadBehavior.allowedOrigins is an empty array. CloudFront requires at ' +
408
- 'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
409
- "silently widen what you asked for. Omit the property to take the `['*']` default, " +
410
- 'or list the origin(s) the editor is served from.');
411
- }
412
- // Two consumers read `allowedOrigins` and they do not share a matcher: the
413
- // response headers policy (which decides ACAO on the POST response) accepts
414
- // CloudFront's pattern grammar, including a leftmost-subdomain wildcard
415
- // like `https://*.preview.example.com`; the preflight responder compiled
416
- // into the CloudFront Function can only do exact-string comparison, because
417
- // reimplementing that grammar in an edge function without being able to
418
- // test it against CloudFront is how the two silently disagree.
419
- //
420
- // So a pattern is refused rather than half-honoured. Accepting one would
421
- // deploy clean and then fail every upload from a matching origin at the
422
- // PREFLIGHT, with a correct-looking policy sitting right next to it - the
423
- // same invisible failure the preflight responder was added to remove.
424
- const wildcardOrigins = (props.uploadBehavior?.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
425
- const isBareWildcard = props.uploadBehavior?.allowedOrigins?.join() === '*';
426
- if (wildcardOrigins.length > 0 && !isBareWildcard) {
427
- throw new Error(`AssetSupport: uploadBehavior.allowedOrigins contains a wildcard pattern ` +
428
- `(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
429
- `headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
430
- `CloudFront Function that compares origins exactly - so every upload from a matching ` +
431
- `origin would fail its preflight while the policy looked correct. Use exactly ` +
432
- `['*'] to allow any origin, or list each editor origin in full.`);
805
+ // Validated eagerly, built lazily (see `upload`'s comment) - the accessor
806
+ // may never be called, and a bad `allowedOrigins` should not wait for it
807
+ // to find out. The guards themselves are shared with
808
+ // `assetUploadBehavior`, which runs them on entry instead; see
809
+ // `validateUploadBehaviorOptions` for what they refuse and why.
810
+ if (props.uploadBehavior) {
811
+ validateUploadBehaviorOptions(props.uploadBehavior, 'AssetSupport: uploadBehavior.allowedOrigins');
433
812
  }
434
813
  // Snapshotted, not aliased. The guard above runs now but `allowedOrigins`
435
814
  // is not READ until the accessor builds the behavior, so holding the
@@ -440,27 +819,45 @@ export class AssetSupport extends Construct {
440
819
  allowedOrigins: props.uploadBehavior.allowedOrigins?.slice(),
441
820
  };
442
821
  // Setting `uploadBehavior` SATISFIES the standalone guard above, but only
443
- // calling `uploadBehavior()` and attaching the result actually produces an
822
+ // ATTACHING the behavior to a distribution actually produces an
444
823
  // Access-Control-Allow-Origin from anywhere. Opting in and stopping there -
445
824
  // which is exactly what copying the README snippet and not finishing looks
446
825
  // like - lands a standalone bucket with no CORS rule and no edge route: the
447
826
  // silent failure the guard's own text calls misleading, reached through the
448
827
  // guard rather than around it.
449
828
  //
829
+ // The condition is ATTACHMENT, not `uploadBehavior()` having been called.
830
+ // Those look equivalent and are not: the accessor memoizes, so a caller who
831
+ // invokes it and loses the value in a refactor sets every flag an accessor
832
+ // can set and still lands in exactly the state above. That shape is if
833
+ // anything likelier than never calling it at all, since it is what a
834
+ // half-finished copy of the README snippet leaves behind. `UploadOrigin`
835
+ // (above) is what makes the difference observable from here.
836
+ //
450
837
  // A synth-time validation is the only place this can be caught, since the
451
838
  // constructor cannot know what the caller will do next. It is the first
452
839
  // `addValidation` in this package; the alternative (checking in the
453
840
  // constructor) cannot express "and nothing used it".
454
841
  this.node.addValidation({
455
842
  validate: () => {
456
- if (props.uploadBehavior && editorOrigins.length === 0 && !props.bucket && !this.upload) {
843
+ if (props.uploadBehavior &&
844
+ editorOrigins.length === 0 &&
845
+ !props.bucket &&
846
+ !this.uploadOrigin?.attachedToDistribution) {
457
847
  return [
458
- 'AssetSupport: `uploadBehavior` was set but uploadBehavior() was never called, so ' +
459
- 'no upload behavior was attached to any distribution and nothing supplies ' +
460
- 'Access-Control-Allow-Origin. With no `editorOrigins` either, a browser upload ' +
461
- 'will land the object in asset-staging/ and still report a network error. Pass ' +
462
- "the behavior to a distribution (see uploadBehavior()'s doc comment), or pass " +
463
- '`editorOrigins` instead.',
848
+ 'AssetSupport: `uploadBehavior` was set but the upload behavior was never attached ' +
849
+ 'to a distribution' +
850
+ (this.upload
851
+ ? ' - uploadBehavior() was called, but its return value was not passed to one'
852
+ : ' - uploadBehavior() was never called') +
853
+ ', so nothing supplies Access-Control-Allow-Origin. With no `editorOrigins` ' +
854
+ 'either, a browser upload will land the object in asset-staging/ and still ' +
855
+ 'report a network error. Pass the behavior to a distribution (see ' +
856
+ "uploadBehavior()'s doc comment), or pass `editorOrigins` instead. If you " +
857
+ 'built the upload route from this bucket with the `assetUploadBehavior` free ' +
858
+ 'function, ACAO IS supplied and this check cannot see it - it observes only ' +
859
+ 'the origin this construct minted. Call uploadBehavior() here instead; you ' +
860
+ 'already have the construct, so the free function buys you nothing.',
464
861
  ];
465
862
  }
466
863
  return [];
@@ -512,227 +909,12 @@ export class AssetSupport extends Construct {
512
909
  };
513
910
  return { assets, assetsTransform };
514
911
  }
515
- /**
516
- * The behavior that accepts the editor's presigned-POST uploads. Every piece
517
- * below is load-bearing; see `uploadBehavior()` for where the result goes.
518
- */
519
- buildUploadBehavior(options) {
520
- // A DISTINCT origin for the bucket, with OAC signing OFF - the read
521
- // behaviors' origin cannot be reused here no matter what this behavior
522
- // sets, because OAC is a property of the ORIGIN, not the behavior. (On the
523
- // dedicated distribution `uploadBehavior()` recommends this is the only
524
- // origin present; the point is that it cannot be the read one.)
525
- // CloudFront signs origin requests but never hashes
526
- // the body, so an OAC-signed origin rejects every multipart POST whatever
527
- // the viewer sent. Measured, that is `400 InvalidArgument`, the body
528
- // naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
529
- // ... or a valid sha256 value`. It fails CLOSED, but it is an
530
- // argument-validation error, NOT the 403 that the Lambda Function URL
531
- // origin produces in the same situation (docs/deploying-to-aws.md's
532
- // "CloudFront OAC and request body signing"): there Lambda verifies a
533
- // SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
534
- // adopter told to expect 403 goes hunting for a permissions problem that
535
- // does not exist.
536
- //
537
- // `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
538
- // would also be unsigned, for two reasons. It is the exact shape this
539
- // whole path was measured against end to end on live AWS resources (204,
540
- // object landed); and it is the only one of the two that can state the
541
- // CloudFront->S3 protocol, which for a request carrying a live upload
542
- // credential in its body should be pinned rather than inferred.
543
- // `withBucketDefaults()` emits `S3OriginConfig`, which has no
544
- // `OriginProtocolPolicy` field at all.
545
- // A dot in the bucket name breaks this origin specifically. S3's wildcard
546
- // certificate covers one label (`*.s3.<region>.amazonaws.com`), so
547
- // `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
548
- // CloudFront answers 502 on every upload. This is a CUSTOM origin doing
549
- // ordinary TLS validation; the read path uses an S3-type origin, which
550
- // CloudFront treats differently, so nothing else here would surface it -
551
- // and dotted names are most likely in exactly the BYO case ("a site's
552
- // existing content bucket") this behavior is written for. Only checkable
553
- // when the name is a real literal, which for an imported bucket it is.
554
- const bucketName = this.bucket.bucketName;
555
- if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
556
- throw new Error(`AssetSupport: uploadBehavior cannot use bucket "${bucketName}" - a dot in the bucket ` +
557
- `name puts an extra label in its regional domain name, which S3's wildcard ` +
558
- `certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
559
- `every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
560
- `dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
561
- }
562
- const uploadOrigin = new origins.HttpOrigin(this.bucket.bucketRegionalDomainName, {
563
- protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
564
- });
565
- // S3's POST Object is only valid at the bucket ROOT - without this rewrite
566
- // the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
567
- // behavior: rewriting unconditionally means no request arriving here can
568
- // address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
569
- // only the bucket-level operations at `/` (list, create, delete bucket).
570
- // A bucket this construct creates refuses all of them anonymously
571
- // (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
572
- // bucket policy to have got right, as it already is for the read path. The
573
- // only thing that can WRITE either way is a request carrying a valid
574
- // presigned policy, and only to the single key that policy names. Do not
575
- // make the rewrite conditional; that gives key addressability back.
576
- //
577
- // The same function also drops `Authorization`. A site behind HTTP basic
578
- // auth otherwise sends S3 a credential it cannot parse
579
- // (`400 InvalidArgument - Unsupported Authorization Type`), and the
580
- // presigned POST carries its own authority in the body, so nothing on this
581
- // route ever wants the header. It is dropped HERE, in the function, rather
582
- // than by naming it in the origin request policy's `allExcept` list below,
583
- // and that placement is deliberate: CloudFront rejects `Authorization` in
584
- // an origin request policy's header ALLOWLIST outright ("The parameter
585
- // Headers contains Authorization that is not allowed"), and whether the
586
- // same validation fires on an `allExcept` list is not something synth can
587
- // tell you - it would surface as a failed `cdk deploy` on the whole stack.
588
- // A viewer-request function has no such ambiguity: `method` is the only
589
- // read-only field on the request object, headers are freely mutable, and
590
- // `Authorization` is the header CloudFront's own basic-auth function
591
- // examples read. Dropping it at the viewer also means it is gone before
592
- // any policy runs, so the guarantee does not depend on the policy at all.
593
- // The same function ALSO answers the CORS preflight, and that is not
594
- // optional decoration - without it this route cannot work at all.
595
- //
596
- // The editor's upload is NOT a CORS simple request, though it looks like
597
- // one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
598
- // but it assigns `xhr.upload.onprogress` before `send()`, and registering
599
- // ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
600
- // from the simple-request rules, independent of method, headers and content
601
- // type. So every browser upload begins with `OPTIONS /`.
602
- //
603
- // Nothing else on this route would answer it. CloudFront does not
604
- // synthesize preflight responses - a response headers policy supplies
605
- // values for headers "in responses to CORS preflight requests", i.e. it
606
- // decorates a response that something else produced - and `ALLOW_ALL`
607
- // forwards the OPTIONS to S3, which with no CORS configuration answers
608
- // `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
609
- // is not a 2xx fails the browser's check whatever headers are attached, so
610
- // the POST is never sent and the upload dies as an opaque network error.
611
- //
612
- // This is why the end-to-end measurements did not catch it: they replayed
613
- // the POST as a script, which has no preflight. A scripted POST is not an
614
- // acceptance test for this route; a real browser upload is.
615
- //
616
- // Short-circuiting at the viewer also strengthens the containment argument
617
- // above rather than weakening it - an OPTIONS that never reaches the origin
618
- // can address nothing.
619
- const allowedOrigins = options.allowedOrigins ?? ['*'];
620
- const rewriteToBucketRoot = new cloudfront.Function(this, 'AssetUploadRewriteFunction', {
621
- code: cloudfront.FunctionCode.fromInline([
622
- `var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
623
- 'function handler(event) {',
624
- ' var request = event.request;',
625
- " if (request.method === 'OPTIONS') {",
626
- ' var headers = {',
627
- ` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
628
- " 'access-control-allow-headers': { value: '*' },",
629
- ` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
630
- ' };',
631
- // Echo the caller's own Origin when the list is narrowed: a preflight
632
- // may only be answered with `*` or the single requesting origin, so a
633
- // multi-entry list cannot be returned verbatim. No match means no
634
- // ACAO, and the browser's own check refuses the upload - which is the
635
- // correct outcome, and a clearer one than a 403 here would be.
636
- " var origin = request.headers.origin ? request.headers.origin.value : '';",
637
- " if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
638
- " headers['access-control-allow-origin'] = { value: '*' };",
639
- ' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
640
- " headers['access-control-allow-origin'] = { value: origin };",
641
- ' }',
642
- " return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
643
- ' }',
644
- " request.uri = '/';",
645
- ' delete request.headers.authorization;',
646
- ' return request;',
647
- '}',
648
- ].join('\n')),
649
- });
650
- // `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
651
- // dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
652
- // - the shape this path was measured against. It is NOT that policy
653
- // overall, and must not be replaced by it: the managed one is documented as
654
- // "Cookies: All, Query strings: All", so adopting it would forward the
655
- // editor session cookie to S3, which is one of the two hazards this whole
656
- // policy exists to remove. Only the header behavior is shared.
657
- //
658
- // `Authorization` is handled by the function above, not here.
659
- //
660
- // Cookies go through `CookiesConfig`, not the header list (a CloudFront
661
- // Function does not even see them in `request.headers`), so
662
- // `cookieBehavior.none()` is what strips them: an upload route mounted on
663
- // a site's own distribution would otherwise hand S3 - and S3's access logs
664
- // - the editor session cookie. On the dedicated distribution recommended
665
- // in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
666
- // none in the first place; this makes it true either way.
667
- const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, 'AssetUploadOriginRequestPolicy', {
668
- cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
669
- headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
670
- // A presigned POST carries everything in the multipart body.
671
- queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
672
- });
673
- // The edge supplies Access-Control-Allow-Origin, scoped to this behavior
674
- // only. This is the measurement that unblocked the whole design: a
675
- // response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
676
- // origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
677
- // object landed). Acceptance and advertisement are independent in S3 -
678
- // bucket CORS governs only whether S3 advertises - which is exactly the
679
- // property that lets this construct avoid writing a bucket CORS rule.
680
- //
681
- // `originOverride: true` so the header is ours deterministically even on a
682
- // bucket that does have its own CORS configuration.
683
- //
684
- // AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
685
- // and this path DOES trigger one - see the CloudFront Function above, which
686
- // answers it, and which exists because nothing else would. They are set
687
- // here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
688
- // two cannot tell a browser different things, and because a response
689
- // headers policy is documented to decorate responses without saying whether
690
- // a function-generated one is among them. If it is, `originOverride: true`
691
- // means the policy's values REPLACE the function's identical ones; if it is
692
- // not, the function's stand alone. Either way the viewer sees exactly one
693
- // of each, which is what makes it safe not to know.
694
- const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'AssetUploadResponseHeadersPolicy', {
695
- corsBehavior: {
696
- accessControlAllowOrigins: allowedOrigins,
697
- accessControlAllowCredentials: false,
698
- accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
699
- accessControlAllowHeaders: ['*'],
700
- accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
701
- originOverride: true,
702
- },
703
- });
704
- return {
705
- origin: uploadOrigin,
706
- // A POST is 405 without this. CloudFront offers no narrower set that
707
- // includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
708
- // The URI rewrite above is what makes that acceptable.
709
- allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
710
- // HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
711
- // redirects with 301, and a browser turns a 301'd POST into a GET - so
712
- // an `http://` upload URL would silently become a bodyless GET that this
713
- // behavior rewrites to `/` and S3 refuses, with the file never sent.
714
- // HTTPS_ONLY answers 403 instead: same refusal, visible.
715
- viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
716
- // Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
717
- // cache key is also what keeps the origin request policy above legal
718
- // (CloudFront requires it to be a superset of the cache key).
719
- cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
720
- originRequestPolicy,
721
- responseHeadersPolicy,
722
- functionAssociations: [
723
- {
724
- function: rewriteToBucketRoot,
725
- eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
726
- },
727
- ],
728
- };
729
- }
730
912
  /**
731
913
  * The two CloudFront behavior configs this system needs.
732
914
  *
733
- * If you use this rather than `attachTo` -- which now takes an `overrides`
734
- * parameter, so needing per-behavior options is no longer a reason to fall
735
- * back here -- you own the ordering, and you should assert it: read the
915
+ * If you use this rather than `attachTo` -- which takes an `overrides`
916
+ * parameter, so needing per-behavior options is not a reason to fall back
917
+ * here -- you own the ordering, and you should assert it: read the
736
918
  * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
737
919
  * object, since the property is about emitted order.
738
920
  *
@@ -788,6 +970,13 @@ export class AssetSupport extends Construct {
788
970
  * The upload route is the one part of this system with no per-environment
789
971
  * code in it - it depends on the bucket and nothing else - which is why it
790
972
  * is the part that can be split off this way.
973
+ *
974
+ * That same property is why `assetUploadBehavior` exists as a free function:
975
+ * it builds this behavior from a bucket alone, for a caller who wants the
976
+ * one-route distribution above and has no other use for an `AssetSupport`.
977
+ * Prefer this method when you already have one; both funnel into the same
978
+ * builder. See that function for what instantiating the construct purely to
979
+ * reach this method actually costs.
791
980
  */
792
981
  uploadBehavior() {
793
982
  if (!this.uploadOptions) {
@@ -798,7 +987,22 @@ export class AssetSupport extends Construct {
798
987
  }
799
988
  // Memoized: calling this twice must not attach two sets of policies, and
800
989
  // must not fail on duplicate construct ids.
801
- this.upload ??= this.buildUploadBehavior(this.uploadOptions);
990
+ //
991
+ // `scope` is `this`, which is what keeps the three child ids
992
+ // (`AssetUploadRewriteFunction` and the two policies) exactly where they
993
+ // were before the builder moved out of this class - the emitted template
994
+ // is unchanged, so no existing deployment sees a resource replacement.
995
+ //
996
+ // `validateUploadBehaviorOptions` is NOT re-run here: the constructor
997
+ // already ran it on these same options, and `this.uploadOptions` is its
998
+ // own snapshot, so there is nothing a second pass could catch.
999
+ this.upload ??= buildUploadBehavior(this, { ...this.uploadOptions, bucket: this.bucket }, 'AssetSupport: uploadBehavior');
1000
+ // The builder returns the origin it made; the attach guard needs a
1001
+ // reference to it (see `UploadOrigin`), and this is the only place that
1002
+ // knows the two belong to the same construct.
1003
+ if (this.upload.origin instanceof UploadOrigin) {
1004
+ this.uploadOrigin = this.upload.origin;
1005
+ }
802
1006
  return this.upload;
803
1007
  }
804
1008
  /**
@@ -838,14 +1042,11 @@ export class AssetSupport extends Construct {
838
1042
  * has left this method's remit and should use `assetBehaviors()` - and keep
839
1043
  * their own ordering assertion.
840
1044
  *
841
- * Typed `Partial<AddBehaviorOptions>` because that is exactly what
842
- * `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
843
- * POSITIONAL argument there, so `BehaviorOptions` (which is
844
- * `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
845
- * silently ignores. Measured, because the first version of this comment
846
- * claimed the opposite: widening the parameter and passing an `origin`
847
- * override changes nothing in the emitted template, so this narrowing
848
- * prevents a confusing no-op rather than a broken origin group.
1045
+ * Typed `Partial<AddBehaviorOptions>`, not `BehaviorOptions`, because
1046
+ * `addBehavior` takes `origin` positionally - so an `origin` key here would be
1047
+ * a silently ignored no-op. (Measured: passing one changes nothing in the
1048
+ * emitted template. It is a confusing no-op being prevented, not a broken
1049
+ * origin group.)
849
1050
  *
850
1051
  * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
851
1052
  * method on that class, not on `IDistribution` (what an imported/looked-up
@@ -864,14 +1065,14 @@ export class AssetSupport extends Construct {
864
1065
  // ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] and fails at
865
1066
  // deploy time when CloudFront rejects the duplicate patterns. Refuse it
866
1067
  // here instead, where the message can say which two routes collided.
867
- if (distributionsWithAssetBehaviors.has(distribution)) {
1068
+ if (distribution.node.tryFindChild(ATTACHED_MARKER_ID)) {
868
1069
  throw new Error(`AssetSupport: attachTo() was already called for this distribution. Each pattern ` +
869
1070
  `would be attached twice and CloudFront rejects duplicate path patterns at deploy ` +
870
1071
  `time. This usually means the distribution was given CanopyCmsDistribution's ` +
871
1072
  `\`assetSupport\` prop (which calls attachTo for you) as well as an explicit ` +
872
1073
  `attachTo() call -- keep one.`);
873
1074
  }
874
- distributionsWithAssetBehaviors.add(distribution);
1075
+ new Construct(distribution, ATTACHED_MARKER_ID);
875
1076
  // Drop explicitly-`undefined` keys before merging. A spread copies own
876
1077
  // enumerable keys INCLUDING ones whose value is undefined, so
877
1078
  // `{ ...transformRest, ...{ cachePolicy: undefined } }` deletes the
@@ -69,12 +69,13 @@ export interface CanopyCmsDistributionProps {
69
69
  * order matters. Calls `assetSupport.attachTo(distribution)` for you right
70
70
  * after construction, with nothing to forget.
71
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.
72
+ * If the asset behaviors need per-behavior options - a viewer-request
73
+ * function for tier auth being the motivating case, since without it
74
+ * `/assets/*` is anonymously readable on an authenticated tier - pass
75
+ * `assetBehaviorOverrides` alongside this prop. That requirement used to mean
76
+ * dropping this prop and hand-calling `attachTo` after construction, which
77
+ * sent exactly the adopter who most needs the ordering guarantee back to the
78
+ * manual path this prop exists to replace.
78
79
  *
79
80
  * Prefer this over passing `assetSupport.assetBehaviors()` through
80
81
  * `additionalBehaviors` by hand - that stays available as an escape hatch
@@ -89,6 +90,27 @@ export interface CanopyCmsDistributionProps {
89
90
  * @default - no asset behaviors are attached
90
91
  */
91
92
  assetSupport?: AssetSupport;
93
+ /**
94
+ * Per-behavior options merged into BOTH asset behaviors, forwarded verbatim
95
+ * to `AssetSupport.attachTo()`'s `overrides` parameter - see that method's
96
+ * doc comment for the merge semantics and for why the same set goes to both.
97
+ *
98
+ * The motivating case is a distribution running a viewer-request function on
99
+ * every behavior (tier basic-auth): without the same `functionAssociations`
100
+ * on the asset behaviors, `/assets/*` is anonymously readable on an
101
+ * authenticated tier.
102
+ *
103
+ * Useless without `assetSupport`, and silently so - there would be no
104
+ * behaviors to merge it into - so that combination throws from this
105
+ * construct's CONSTRUCTOR rather than being ignored. (A constructor throw,
106
+ * not an `addValidation`: nothing later can make the combination valid. An
107
+ * adopter sharing one props object across tiers, with `assetSupport` present
108
+ * on only some of them, should make the whole prop conditional rather than
109
+ * the overrides object.)
110
+ *
111
+ * @default - the asset behaviors are attached with no overrides
112
+ */
113
+ assetBehaviorOverrides?: Partial<cloudfront.AddBehaviorOptions>;
92
114
  }
93
115
  /**
94
116
  * Optional CDK construct for CanopyCMS CloudFront distribution.
@@ -152,6 +152,17 @@ function assertNoAssetBehaviorOrderingHazards(merged, attachingAssetSupport) {
152
152
  export class CanopyCmsDistribution extends Construct {
153
153
  constructor(scope, id, props) {
154
154
  super(scope, id);
155
+ // Overrides with nothing to override. They are merged into the asset
156
+ // behaviors and there are none, so the only outcome is that whatever the
157
+ // caller asked for - a tier-auth viewer function, most likely - silently
158
+ // does not happen.
159
+ if (props.assetBehaviorOverrides && !props.assetSupport) {
160
+ throw new Error('CanopyCmsDistribution: `assetBehaviorOverrides` was passed without `assetSupport`. ' +
161
+ 'The overrides are merged into the asset behaviors, so with no AssetSupport to attach ' +
162
+ 'there is nothing for them to apply to and they would be silently dropped - including ' +
163
+ 'a viewer-request function for tier auth. Pass `assetSupport` as well, or drop the ' +
164
+ 'overrides.');
165
+ }
155
166
  // ========================================================================
156
167
  // DNS — Hosted Zone lookup
157
168
  // ========================================================================
@@ -280,7 +291,11 @@ export class CanopyCmsDistribution extends Construct {
280
291
  // logic lives exactly once, inside attachTo() itself. Callers who instead
281
292
  // pass `assetSupport.assetBehaviors()` through `additionalBehaviors` by
282
293
  // hand are covered by `mergeBehaviors`'s synth-time guard above instead.
283
- props.assetSupport?.attachTo(this.distribution);
294
+ //
295
+ // `assetBehaviorOverrides` is forwarded so that needing per-behavior
296
+ // options is not a reason to leave this prop - and so the ordering
297
+ // guarantee survives the tier-auth case that most needs it.
298
+ props.assetSupport?.attachTo(this.distribution, props.assetBehaviorOverrides);
284
299
  // ========================================================================
285
300
  // DNS Records
286
301
  // ========================================================================
@@ -656,8 +656,12 @@ export class CanopyCmsService extends Construct {
656
656
  launchTemplate,
657
657
  minCapacity: 1,
658
658
  maxCapacity: 1,
659
- healthCheck: autoscaling.HealthCheck.ec2({
660
- grace: Duration.minutes(5),
659
+ // `healthChecks`, not the deprecated `healthCheck`/`HealthCheck.ec2({ grace })`:
660
+ // both synthesize the same HealthCheckType/HealthCheckGracePeriod, but the
661
+ // deprecated form prints a jsii warning on every synth -- in adopters'
662
+ // `cdk synth` output, not just ours -- and is slated for removal in v3.
663
+ healthChecks: autoscaling.HealthChecks.ec2({
664
+ gracePeriod: Duration.minutes(5),
661
665
  }),
662
666
  // Without an updatePolicy, CloudFormation's default behavior for an ASG
663
667
  // behind a changed launch template is to update the template resource
package/dist/index.d.ts CHANGED
@@ -4,5 +4,5 @@ export { CanopyCmsService } from './constructs/cms-service.js';
4
4
  export type { CanopyCmsServiceProps } from './constructs/cms-service.js';
5
5
  export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
6
6
  export type { CanopyCmsDistributionProps } from './constructs/cms-distribution.js';
7
- export { AssetSupport } from './constructs/asset-support.js';
8
- export type { AssetSupportProps, AssetCloudFrontBehaviors, AssetUploadBehaviorOptions, } from './constructs/asset-support.js';
7
+ export { AssetSupport, assetUploadBehavior } from './constructs/asset-support.js';
8
+ export type { AssetSupportProps, AssetCloudFrontBehaviors, AssetUploadBehaviorOptions, AssetUploadBehaviorRouteOptions, } from './constructs/asset-support.js';
package/dist/index.js CHANGED
@@ -3,4 +3,4 @@ export { CmsWorker } from './worker.js';
3
3
  // CDK Constructs
4
4
  export { CanopyCmsService } from './constructs/cms-service.js';
5
5
  export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
6
- export { AssetSupport } from './constructs/asset-support.js';
6
+ export { AssetSupport, assetUploadBehavior } from './constructs/asset-support.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-cdk",
3
- "version": "0.0.66-int.86",
3
+ "version": "0.0.67-int.88",
4
4
  "description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "peerDependencies": {
32
32
  "aws-cdk-lib": "^2.260.0",
33
33
  "constructs": "^10.0.0",
34
- "canopycms": "0.0.66-int.86"
34
+ "canopycms": "0.0.67-int.88"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "aws-cdk-lib": {
@@ -55,8 +55,8 @@
55
55
  "tsx": "^4.19.0",
56
56
  "typescript": "^5.6.3",
57
57
  "vitest": "^4.1.0",
58
- "canopycms": "0.0.66-int.86",
59
- "canopycms-auth-clerk": "0.0.66-int.86"
58
+ "canopycms": "0.0.67-int.88",
59
+ "canopycms-auth-clerk": "0.0.67-int.88"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",