canopycms-cdk 0.0.67-int.90 → 0.0.67

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.
Files changed (48) hide show
  1. package/dist/constructs/asset-support.d.ts +137 -269
  2. package/dist/constructs/asset-support.js +275 -455
  3. package/dist/constructs/cms-distribution.d.ts +23 -40
  4. package/dist/constructs/cms-distribution.js +61 -88
  5. package/dist/constructs/cms-service.d.ts +38 -60
  6. package/dist/constructs/cms-service.js +246 -390
  7. package/dist/constructs/lambda-execution-role.d.ts +23 -39
  8. package/dist/constructs/lambda-execution-role.js +37 -60
  9. package/dist/index.js +0 -2
  10. package/dist/worker.d.ts +0 -2
  11. package/dist/worker.js +0 -2
  12. package/lambda/asset-transform/dist/handler.js +33 -49
  13. package/lambda/asset-transform/dist/node_modules/.package-lock.json +38 -38
  14. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/README.md +1 -1
  15. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/lib/glib-2.0/include/glibconfig.h +2 -2
  16. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/lib/{libvips-cpp.so.8.18.6 → libvips-cpp.so.8.18.7} +0 -0
  17. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/package.json +2 -2
  18. package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/versions.json +11 -11
  19. package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/index.cjs +1 -1
  20. package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/lib/{sharp-linux-arm64-0.35.4.node → sharp-linux-arm64-0.35.5.node} +0 -0
  21. package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/package.json +2 -2
  22. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/index.cjs +1 -1
  23. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.5.node.js +1 -0
  24. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/{sharp-wasm32-0.35.4.node.wasm → sharp-wasm32-0.35.5.node.wasm} +0 -0
  25. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/package.json +1 -1
  26. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/versions.json +7 -7
  27. package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.cts +2 -2
  28. package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.mts +3 -3
  29. package/lambda/asset-transform/dist/node_modules/sharp/dist/is.cjs +8 -2
  30. package/lambda/asset-transform/dist/node_modules/sharp/dist/is.mjs +8 -2
  31. package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.cjs +2 -2
  32. package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.mjs +2 -2
  33. package/lambda/asset-transform/dist/node_modules/sharp/dist/output.cjs +1 -0
  34. package/lambda/asset-transform/dist/node_modules/sharp/dist/output.mjs +1 -0
  35. package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.cjs +14 -14
  36. package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.mjs +14 -14
  37. package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.cjs +3 -3
  38. package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.mjs +3 -3
  39. package/lambda/asset-transform/dist/node_modules/sharp/lib/index.d.ts +2 -2
  40. package/lambda/asset-transform/dist/node_modules/sharp/package.json +35 -35
  41. package/lambda/asset-transform/dist/node_modules/sharp/src/binding.gyp +1 -1
  42. package/lambda/asset-transform/dist/node_modules/sharp/src/common.cc +4 -4
  43. package/lambda/asset-transform/dist/node_modules/sharp/src/common.h +2 -2
  44. package/lambda/asset-transform/dist/node_modules/sharp/src/pipeline.cc +31 -6
  45. package/lambda/asset-transform/dist/node_modules/sharp/src/utilities.cc +1 -1
  46. package/package.json +4 -4
  47. package/worker/dist/index.js +251 -364
  48. package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.4.node.js +0 -1
@@ -9,26 +9,23 @@ import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
9
9
  // `import.meta.url` instead (mirrors ../../lambda/asset-transform/build.mjs).
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  /**
12
- * The transform Lambda's built code asset. Computed ONCE and shared by the
13
- * deployability guard and `Code.fromAsset()` below - if the check and the
14
- * bundle ever read two independently-computed paths, the guard can silently
15
- * start statting a directory that is never the one being deployed, and
16
- * nothing about either call site would look wrong.
12
+ * The transform Lambda's built code asset. ONE constant shared by the
13
+ * deployability guard and `Code.fromAsset()` below: two independently-computed
14
+ * paths would let the guard stat a directory that is never the one deployed,
15
+ * with nothing wrong-looking at either call site.
17
16
  *
18
- * The `'..', '..'` walk is correct from both `<pkg>/src/constructs/` (local
19
- * source) and `<pkg>/dist/constructs/` (published package) only because those
20
- * sit at the same depth - see the `__dirname` note above.
17
+ * The `'..', '..'` walk holds from both `<pkg>/src/constructs/` (local source)
18
+ * and `<pkg>/dist/constructs/` (published package) only because those sit at
19
+ * the same depth - see the `__dirname` note above.
21
20
  */
22
21
  const transformAssetDir = path.join(__dirname, '..', '..', 'lambda', 'asset-transform', 'dist');
23
22
  /**
24
23
  * Written by `build:lambda` as the last act of a successful FULL build, once
25
- * the linux/arm64 sharp binary is verified present. See that script's header
26
- * for why the marker is positive rather than negative: a partial build (a
27
- * thrown `npm install sharp`, a failed platform check) leaves a sharp-less
28
- * `dist/` on disk WITHOUT reaching the `--skip-native` branch, so a
29
- * "is it marked bad?" test would wave exactly that bundle through to a
30
- * deploy. Requiring proof-of-good instead fails closed on every unexpected
31
- * path, including the producer's and consumer's paths drifting apart.
24
+ * the linux/arm64 sharp binary is verified present. The marker is positive
25
+ * ("this bundle was verified") rather than negative, because a partial build
26
+ * leaves a sharp-less `dist/` on disk WITHOUT reaching the `--skip-native`
27
+ * branch: a "is it marked bad?" test would wave exactly that bundle through to
28
+ * a deploy. See that script's header.
32
29
  */
33
30
  const DEPLOYABLE_MARKER = '.deployable';
34
31
  /**
@@ -64,11 +61,10 @@ const UPLOAD_ALLOWED_METHOD = 'POST';
64
61
  * Render a string array as a JavaScript literal for embedding in CloudFront
65
62
  * Function source.
66
63
  *
67
- * `JSON.stringify` already escapes quotes and backslashes, so an adopter's
68
- * origin string cannot terminate the literal and inject code. It does NOT
69
- * escape U+2028/U+2029, which are legal in JSON but were line terminators in
70
- * JavaScript before ES2019 - and the CloudFront Functions runtime is not a
71
- * runtime to find out about on. Escaping them costs one `replace`.
64
+ * `JSON.stringify` escapes quotes and backslashes, so an adopter's origin
65
+ * string cannot terminate the literal and inject code. It does NOT escape
66
+ * U+2028/U+2029, legal in JSON but line terminators in JavaScript before
67
+ * ES2019, and the CloudFront Functions runtime is not one to find out about on.
72
68
  */
73
69
  function toFunctionLiteral(values) {
74
70
  return JSON.stringify(values)
@@ -90,14 +86,12 @@ const TRANSFORM_LAMBDA_TIMEOUT = Duration.seconds(30);
90
86
  * CloudFront+S3 misses, each a sharp transform on a 1536MB Lambda plus a
91
87
  * permanently stored S3 object.
92
88
  *
93
- * A reservation is the cheap half of bounding that: it is a CAP carved from the
94
- * account's concurrency pool, not pre-warmed capacity, so it costs nothing when
95
- * idle (that is `provisionedConcurrentExecutions`, which this is not).
96
- *
97
- * 10 mirrors the CMS Lambda's own reservation. Genuine demand is first-render
98
- * misses only — every already-generated derivative is served by the S3 primary
99
- * origin without invoking this function at all — so 10 concurrent transforms
100
- * covers a cold page of images comfortably while capping a flood.
89
+ * A reservation is a CAP carved from the account's concurrency pool, not
90
+ * pre-warmed capacity, so it costs nothing when idle (that is
91
+ * `provisionedConcurrentExecutions`, which this is not). 10 mirrors the CMS
92
+ * Lambda's own reservation: genuine demand is first-render misses only, since
93
+ * every already-generated derivative is served by the S3 primary origin without
94
+ * invoking this function at all.
101
95
  */
102
96
  const TRANSFORM_LAMBDA_RESERVED_CONCURRENCY = 10;
103
97
  /**
@@ -111,25 +105,21 @@ const TRANSFORM_LAMBDA_RESERVED_CONCURRENCY = 10;
111
105
  * S3 lifecycle is prefix+age based; there is no "expire if not recently read"
112
106
  * mode. Expiry is self-healing anyway: the next request for an expired
113
107
  * derivative misses S3, fails over to the transform Lambda, regenerates and
114
- * re-stores it — one invocation, then it is warm in both CloudFront and S3
115
- * again. 180 days is set so ordinary traffic never notices and only genuinely
116
- * cold or abusive objects age out.
108
+ * re-stores it. 180 days is long enough that only genuinely cold or abusive
109
+ * objects age out.
117
110
  */
118
111
  const TRANSFORM_OUTPUT_RETENTION = Duration.days(180);
119
112
  /**
120
- * `/assets/t/*`-specific cache TTLs. The managed `CachePolicy.CACHING_OPTIMIZED`
121
- * (used for the plain `/assets/*` static behavior) has a 1-second MIN TTL,
122
- * which is exactly the bug this policy exists to avoid: the transform
123
- * Lambda's oversized-output path (handler.ts) returns a `Cache-Control:
124
- * no-store` 302 redirect specifically so CloudFront never caches it - but a
125
- * managed policy with ANY nonzero min TTL still caches that response for at
126
- * least that long regardless of the origin's own `no-store`, so the redirect
127
- * (to the now-written canonical S3 key) gets cached and re-served, and
128
- * CloudFront's next hit for that same canonical key 404s/403s off S3 (or
129
- * hasn't propagated yet), falling back to the Lambda again - a self-sustaining
130
- * redirect loop. `minTtl: 0` lets an origin's own `Cache-Control` (including
131
- * `no-store`) be honored immediately; `maxTtl`/`defaultTtl` stay generous so
132
- * the normal case (an immutable 200 with a real `max-age`) still caches well.
113
+ * `/assets/t/*`-specific cache TTLs, custom rather than the managed
114
+ * `CACHING_OPTIMIZED` used for plain `/assets/*`, whose 1-second MIN TTL is the
115
+ * bug this policy exists to avoid. The transform Lambda's oversized-output path
116
+ * (handler.ts) returns a `Cache-Control: no-store` 302 to the canonical S3 key;
117
+ * ANY nonzero min TTL caches that redirect regardless of the origin's
118
+ * `no-store`, and re-serving it while CloudFront's hit for the canonical key
119
+ * still 403s/404s off S3 falls back to the Lambda again - a self-sustaining
120
+ * redirect loop. `minTtl: 0` honors the origin's own `Cache-Control`
121
+ * immediately; `maxTtl`/`defaultTtl` stay generous so the normal case (an
122
+ * immutable 200 with a real `max-age`) still caches well.
133
123
  */
134
124
  const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
135
125
  const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
@@ -138,50 +128,39 @@ const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
138
128
  * Id of the marker construct `attachTo` adds to a distribution to record that
139
129
  * the asset behaviors are already on it.
140
130
  *
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.
150
- *
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.
131
+ * The fact recorded is "THIS distribution already carries these two patterns",
132
+ * so the check is keyed on the DISTRIBUTION, not the attacher. Per-instance
133
+ * state lets two different `AssetSupport` constructs attach to one distribution
134
+ * and synthesize ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'], the
135
+ * duplicate-path-pattern deploy failure this guard converts into a synth error.
136
+ * There is no legitimate form of that: both instances attach the same two
137
+ * patterns, so the second is always wrong whichever construct owns it.
158
138
  *
159
- * The marker is a bare `Construct`, which emits nothing into the template.
139
+ * The marker is a bare `Construct`, which emits nothing into the template. A child construct
140
+ * rather than a module-level `WeakSet` keyed on the distribution: the scoping is then a property
141
+ * of the tree, not of process lifetime, and `node.children` lets a test assert the guard's state.
160
142
  */
161
143
  const ATTACHED_MARKER_ID = 'CanopyAssetBehaviorsAttached';
162
144
  /**
163
145
  * The upload behavior's origin, which records having been bound to a
164
146
  * distribution.
165
147
  *
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
148
+ * This lets the synth-time validation below tell "`uploadBehavior()` was
149
+ * CALLED" from "its result actually reached a distribution" - two states a
168
150
  * memoized accessor cannot distinguish on its own, and the second is the one
169
151
  * that determines whether anything supplies Access-Control-Allow-Origin.
170
152
  *
171
153
  * CDK calls `bind()` exactly when a `Distribution` takes a behavior: from the
172
154
  * 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.
155
+ * recommends), and from `addBehavior` for an additional one. Both bind eagerly
156
+ * at construction, before any validation runs, in a cross-stack distribution
157
+ * too. A behavior that is built and then dropped never binds.
178
158
  *
179
159
  * 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.
160
+ * is what makes the cross-stack case work: a consuming stack renders the
161
+ * reference as `Fn::ImportValue`, not as the producing stack's resolved ARN, so
162
+ * a tree search would report "not attached" for a perfectly correct cross-stack
163
+ * distribution.
185
164
  */
186
165
  class UploadOrigin extends origins.HttpOrigin {
187
166
  constructor() {
@@ -214,26 +193,24 @@ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
214
193
  * instead. Exported so `cms-distribution.ts`'s synth-time guard can
215
194
  * recognize the mistake and name it in its error.
216
195
  *
217
- * `uploadBehavior()` deliberately adds NO key here, considered and rejected
218
- * when it was added. It returns a bare `BehaviorOptions` rather than a named
219
- * property of a returned object, so there is no spread to get wrong in the
220
- * first place - and a speculative `'upload'` entry would actively misfire on
221
- * an adopter whose distribution has a real upload route, since CloudFront
222
- * treats a path pattern's leading slash as optional and `upload` is a legal
223
- * spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
224
- * Keep this list to property names that actually exist on
225
- * `AssetCloudFrontBehaviors`.
196
+ * `uploadBehavior()` deliberately adds NO key here: it returns a bare
197
+ * `BehaviorOptions` rather than a named property, so there is no spread to get
198
+ * wrong, and a speculative `'upload'` entry would misfire on an adopter whose
199
+ * distribution has a real upload route - CloudFront treats a path pattern's
200
+ * leading slash as optional, so `upload` is a legal spelling of `/upload` (see
201
+ * `normalizePathPattern` in cms-distribution.ts). Keep this list to property
202
+ * names that actually exist on `AssetCloudFrontBehaviors`.
226
203
  */
227
204
  export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
228
205
  /**
229
206
  * The `allowedOrigins` guards, shared by both entry points.
230
207
  *
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.
208
+ * Separate from `buildUploadBehavior` because `AssetSupport` runs them in its
209
+ * CONSTRUCTOR - eagerly, long before anything builds the behavior - and moving
210
+ * them into the builder would delay the throw to the first `uploadBehavior()`
211
+ * call. `assetUploadBehavior` runs them on entry instead. One implementation,
212
+ * two call sites, so the standalone form cannot quietly accept what the
213
+ * construct refuses.
237
214
  *
238
215
  * `label` is the options path to name in the message, so each caller's error
239
216
  * points at the property the caller actually wrote.
@@ -282,40 +259,34 @@ function validateUploadBehaviorOptions(options, label) {
282
259
  * entry point the caller used.
283
260
  */
284
261
  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.
262
+ // A DISTINCT origin for the bucket, with OAC signing OFF. The read
263
+ // behaviors' origin cannot be reused here whatever this behavior sets,
264
+ // because OAC is a property of the ORIGIN, not the behavior. CloudFront
265
+ // signs origin requests but never hashes the body, so an OAC-signed origin
266
+ // rejects every multipart POST whatever the viewer sent: S3 answers `400
267
+ // InvalidArgument` - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD, ... or
268
+ // a valid sha256 value`. It fails CLOSED, but as an
269
+ // argument-validation error, NOT the 403 a Lambda Function URL origin gives
270
+ // in the same situation (docs/deploying-to-aws.md, "CloudFront OAC and
271
+ // request body signing") - there Lambda verifies a SIGNATURE over that
272
+ // header, here S3 validates its VALUE FORMAT.
301
273
  //
302
274
  // `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
275
+ // would also be unsigned: it is the only one of the two that can state the
306
276
  // CloudFront->S3 protocol, which for a request carrying a live upload
307
- // credential in its body should be pinned rather than inferred.
277
+ // credential in its body is pinned rather than inferred.
308
278
  // `withBucketDefaults()` emits `S3OriginConfig`, which has no
309
279
  // `OriginProtocolPolicy` field at all.
310
- // A dot in the bucket name breaks this origin specifically. S3's wildcard
280
+ //
281
+ // A DOT in the bucket name breaks this origin specifically: S3's wildcard
311
282
  // certificate covers one label (`*.s3.<region>.amazonaws.com`), so
312
283
  // `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
313
284
  // 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.
285
+ // ordinary TLS validation; the read path's S3-type origin is treated
286
+ // differently, so nothing else here would surface it - and dotted names are
287
+ // likeliest in exactly the BYO case this behavior is written for. Only
288
+ // checkable when the name is a real literal, which for an imported bucket it
289
+ // is.
319
290
  const bucketName = options.bucket.bucketName;
320
291
  if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
321
292
  throw new Error(`${label} cannot use bucket "${bucketName}" - a dot in the bucket ` +
@@ -328,75 +299,42 @@ function buildUploadBehavior(scope, options, label) {
328
299
  protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
329
300
  });
330
301
  // 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.
302
+ // the upload gets 405 MethodNotAllowed. The rewrite is also what CONTAINS
303
+ // this behavior: unconditional, no request arriving here can address a key
304
+ // at all, so the `ALLOW_ALL` below buys an anonymous caller only the
305
+ // bucket-level operations at `/` (list, create, delete bucket). A bucket
306
+ // this construct creates refuses all of them anonymously (BLOCK_ALL, no
307
+ // public policy); in BYO-bucket mode that is the caller's bucket policy to
308
+ // have got right, and it is a WEAKER backstop here than on the read path -
309
+ // those behaviors go through an OAC-SIGNED origin, so a too-permissive
310
+ // policy is still gated by a signature CloudFront adds, while this origin is
311
+ // deliberately unsigned and the policy is the ONLY gate. Stated for a BYO
312
+ // adopter in `uploadBehavior()`'s caveat block too.
347
313
  //
348
314
  // The only thing that can WRITE either way is a request carrying a valid
349
315
  // presigned policy, and only to the single key that policy names. Do not
350
316
  // 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
317
  const allowedOrigins = options.allowedOrigins ?? ['*'];
395
318
  const rewriteToBucketRoot = new cloudfront.Function(scope, 'AssetUploadRewriteFunction', {
396
319
  code: cloudfront.FunctionCode.fromInline([
397
320
  `var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
398
321
  'function handler(event) {',
399
322
  ' var request = event.request;',
323
+ // The CORS preflight is answered HERE, and without that this route
324
+ // cannot work at all. The editor's upload is NOT a CORS simple request
325
+ // though it looks like one: `xhr-upload.ts` assigns
326
+ // `xhr.upload.onprogress` before `send()`, and registering ANY listener
327
+ // on the `XMLHttpRequestUpload` object disqualifies it independently of
328
+ // method, headers and content type - so every browser upload begins
329
+ // with `OPTIONS /`. Nothing else would answer it: CloudFront does not
330
+ // synthesize preflight responses (a response headers policy only
331
+ // decorates one something else produced), and `ALLOW_ALL` forwards the
332
+ // OPTIONS to S3, which with no CORS configuration answers `403
333
+ // CORSResponse: CORS is not enabled for this bucket`. A preflight that
334
+ // is not 2xx fails the browser's check whatever headers are attached,
335
+ // so the POST is never sent and the upload dies as an opaque network
336
+ // error. An OPTIONS short-circuited here never reaches the origin, so
337
+ // it can address nothing.
400
338
  " if (request.method === 'OPTIONS') {",
401
339
  ' var headers = {',
402
340
  ` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
@@ -417,28 +355,36 @@ function buildUploadBehavior(scope, options, label) {
417
355
  " return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
418
356
  ' }',
419
357
  " request.uri = '/';",
358
+ // A site behind HTTP basic auth otherwise sends S3 a credential it
359
+ // cannot parse (`400 InvalidArgument - Unsupported Authorization
360
+ // Type`), and the presigned POST carries its own authority in the body,
361
+ // so nothing on this route ever wants the header. Dropped HERE rather
362
+ // than named in the origin request policy's `allExcept` list below:
363
+ // CloudFront rejects `Authorization` in a header ALLOWLIST outright,
364
+ // and whether the same validation fires on an `allExcept` list is not
365
+ // something synth can tell you - it would surface as a failed `cdk
366
+ // deploy` on the whole stack. At the viewer the header is also gone
367
+ // before any policy runs, so the guarantee does not depend on one.
420
368
  ' delete request.headers.authorization;',
421
369
  ' return request;',
422
370
  '}',
423
371
  ].join('\n')),
424
372
  });
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.
373
+ // `denyList('host')` emits CloudFormation's `allExcept`, which in the HEADERS
374
+ // dimension only matches the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy.
375
+ // Do NOT replace this with that policy: it is documented as "Cookies: All,
376
+ // Query strings: All", so adopting it would forward the editor session cookie
377
+ // to S3 - one of the two hazards this policy exists to remove.
432
378
  //
433
379
  // `Authorization` is handled by the function above, not here.
434
380
  //
435
381
  // Cookies go through `CookiesConfig`, not the header list (a CloudFront
436
382
  // 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.
383
+ // `cookieBehavior.none()` is what strips them: an upload route mounted on a
384
+ // site's own distribution would otherwise hand S3 - and S3's access logs -
385
+ // the editor session cookie. On the dedicated distribution `uploadBehavior()`
386
+ // recommends, a cross-origin XHR without `withCredentials` sends none anyway;
387
+ // this makes it true either way.
442
388
  const originRequestPolicy = new cloudfront.OriginRequestPolicy(scope, 'AssetUploadOriginRequestPolicy', {
443
389
  cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
444
390
  headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
@@ -446,26 +392,22 @@ function buildUploadBehavior(scope, options, label) {
446
392
  queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
447
393
  });
448
394
  // 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.
395
+ // only: a response-headers policy DOES attach ACAO to a 2xx from an unsigned
396
+ // S3 origin on POST with NO bucket CORS configuration at all. Acceptance and
397
+ // advertisement are independent in S3 - bucket CORS governs only whether S3
398
+ // advertises - which is what lets this construct write no bucket CORS rule.
455
399
  //
456
400
  // `originOverride: true` so the header is ours deterministically even on a
457
401
  // bucket that does have its own CORS configuration.
458
402
  //
459
403
  // 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.
404
+ // which the CloudFront Function above answers. They are set here anyway,
405
+ // sharing `UPLOAD_ALLOWED_METHOD` with that function so the two cannot tell a
406
+ // browser different things, and because whether a response headers policy
407
+ // decorates a function-generated response is undocumented. If it does,
408
+ // `originOverride: true` means these values REPLACE the function's identical
409
+ // ones; if it does not, the function's stand alone. Either way the viewer
410
+ // sees exactly one of each, which is what makes it safe not to know.
469
411
  const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(scope, 'AssetUploadResponseHeadersPolicy', {
470
412
  corsBehavior: {
471
413
  accessControlAllowOrigins: allowedOrigins,
@@ -514,90 +456,41 @@ function buildUploadBehavior(scope, options, label) {
514
456
  * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
515
457
  * ```
516
458
  *
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.
459
+ * Separate from `AssetSupport.uploadBehavior()` because the upload route needs
460
+ * the bucket and nothing else, while that construct's CONSTRUCTOR always builds
461
+ * the transform Lambda, a log group, a Function URL, a role and prefix grants.
462
+ * Those grants never reach the BUCKET policy: `Grant.addToPrincipalOrResource`
463
+ * stops after the identity statement for a same-account grantee, and
464
+ * `addToResourcePolicy` on a bucket CDK does not own is a no-op - an owned
465
+ * bucket, one imported by name, and one imported by ARN in another account all
466
+ * emit zero `AWS::S3::BucketPolicy`, so a cross-account adopter writes the
467
+ * resource half on the bucket's own side. Prefer the method when you already
468
+ * have an `AssetSupport`; both funnel into the same builder and cannot drift.
557
469
  *
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.
470
+ * No bucket CORS rule is written and none is needed: the edge supplies
471
+ * `Access-Control-Allow-Origin`, and S3's ACCEPTANCE of a presigned POST is
472
+ * independent of its ADVERTISEMENT of CORS, which is all a bucket rule governs
473
+ * (`AssetSupportProps.editorOrigins` writes one, for the construct). No
474
+ * "built but never attached" guard either: a caller here brings their own
475
+ * bucket, so never reaches the construct's "something must supply ACAO" guard.
588
476
  */
589
477
  export function assetUploadBehavior(scope, options) {
590
478
  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()`.
479
+ // `scope` takes the CloudFront Function and the two policies as children
480
+ // under fixed ids, so this is ONE CALL PER SCOPE - a second throws CDK's
481
+ // duplicate-construct-id error. Moving an EXISTING deployment between this
482
+ // function and `AssetSupport.uploadBehavior()` changes their construct path
483
+ // and so their logical IDs, replacing all three CloudFront resources.
484
+ //
485
+ // `allowedOrigins` is snapshotted for the same reason `AssetSupport`
486
+ // snapshots it: the guard above has just read the array, so holding the
487
+ // caller's reference would let a later `origins.length = 0` walk past a check
488
+ // that already passed.
596
489
  return buildUploadBehavior(scope, { ...options, allowedOrigins: options.allowedOrigins?.slice() }, 'assetUploadBehavior');
597
490
  }
598
491
  /**
599
492
  * Per-site CDK construct for the asset/media delivery system (see
600
- * `.claude/future-tasks/assets-media-system.md` for the full design record).
493
+ * `.claude/future-tasks/resolved/assets-media-system.md` for the full design record).
601
494
  * Wires:
602
495
  *
603
496
  * - The bucket's asset-prefix lifecycle rule + CORS (standalone mode only).
@@ -623,14 +516,12 @@ export class AssetSupport extends Construct {
623
516
  super(scope, id);
624
517
  // Fail closed before anything else: refuse to build a stack around a
625
518
  // transform Lambda whose code asset was never verified to contain the
626
- // linux/arm64 sharp binary. Without this, `pnpm test` (whose
627
- // canopycms-cdk suite rebuilds that directory as a --skip-native
628
- // fixture) or any partially-failed build leaves a sharp-less bundle on
629
- // disk, and a later in-repo `cdk deploy` ships it - producing a Lambda
630
- // that throws at cold start on the first image request, a long way from
631
- // the cause. Guarding in the construct rather than at one deploy
632
- // entrypoint means future entrypoints inherit the protection instead of
633
- // having to remember it.
519
+ // linux/arm64 sharp binary. `pnpm test` (whose canopycms-cdk suite rebuilds
520
+ // that directory as a --skip-native fixture) and any partially-failed build
521
+ // leave a sharp-less bundle on disk, and a later in-repo `cdk deploy` ships
522
+ // it - a Lambda that throws at cold start on the first image request, a
523
+ // long way from the cause. In the construct rather than at one deploy
524
+ // entrypoint, so future entrypoints inherit the protection.
634
525
  if ((props.requireDeployableBundle ?? true) &&
635
526
  !existsSync(path.join(transformAssetDir, DEPLOYABLE_MARKER))) {
636
527
  throw new Error(`The transform Lambda code asset at ${transformAssetDir} carries no ${DEPLOYABLE_MARKER} ` +
@@ -681,13 +572,6 @@ export class AssetSupport extends Construct {
681
572
  // under `assets/t/` after `transformOutputRetention`. Originals, meta
682
573
  // and the public prefix are kept forever by design (content-addressed,
683
574
  // immutable - see the design record's "Storage" section).
684
- //
685
- // Derivatives were originally in that keep-forever set, on the same
686
- // "immutable" reasoning. That holds for their CONTENT but not for
687
- // their COUNT: `assets/t/` is the one prefix an anonymous caller can
688
- // mint unbounded distinct keys in (see
689
- // TRANSFORM_LAMBDA_RESERVED_CONCURRENCY), and unlike an original, a
690
- // derivative that is deleted can simply be recomputed.
691
575
  lifecycleRules: [
692
576
  {
693
577
  id: 'expire-asset-staging',
@@ -718,28 +602,23 @@ export class AssetSupport extends Construct {
718
602
  : undefined,
719
603
  });
720
604
  }
721
- // Dedicated CloudWatch log group for the transform Lambda's stdout/
722
- // stderr. Custom name (NOT the CloudFormation-implicit
723
- // `/aws/lambda/<function name>`), for two reasons: (1) CDK does not
724
- // manage that implicit group at all - infinite retention, and
725
- // `cdk destroy` leaves it behind; (2) Lambda auto-creates
726
- // `/aws/lambda/<function name>` on first invoke, OUTSIDE CloudFormation -
727
- // once that has happened (e.g. this construct was already deployed
728
- // before this log group existed), a CDK `LogGroup` construct using that
729
- // exact name would fail its `CreateLogGroup` call with "already exists"
730
- // and block every future `cdk deploy`. Mirrors `CanopyCmsService`'s
731
- // `workerLogGroup`/`cmsLogGroup` (cms-service.ts), which established this
732
- // convention.
605
+ // Dedicated CloudWatch log group for the transform Lambda's stdout/stderr,
606
+ // custom-named and NOT the CloudFormation-implicit
607
+ // `/aws/lambda/<function name>`: CDK does not manage that group at all
608
+ // (infinite retention, and `cdk destroy` leaves it behind), and Lambda
609
+ // auto-creates it on first invoke OUTSIDE CloudFormation, after which a CDK
610
+ // `LogGroup` using that exact name fails `CreateLogGroup` with "already
611
+ // exists" and blocks every future `cdk deploy`. Same convention as
612
+ // `CanopyCmsService`'s log groups (cms-service.ts).
733
613
  this.transformLogGroup = new logs.LogGroup(this, 'TransformFunctionLogs', {
734
614
  logGroupName: props.transformLogGroupName ?? `/canopycms/${Stack.of(this).stackName}/transform`,
735
615
  retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
736
616
  removalPolicy: RemovalPolicy.DESTROY,
737
617
  });
738
618
  // Re-attach what CDK silently drops for a caller-supplied role. MUST run
739
- // for every passed role - see that function's doc comment. This function
740
- // is not VPC-attached, so it takes basic execution only; the test suite
741
- // pins the absence of the VPC-ENI policy here specifically, so that this
742
- // call site and the CMS Lambda's cannot be collapsed into one.
619
+ // for every passed role - see that function's doc comment. This function is
620
+ // not VPC-attached, so it takes basic execution only and must NOT be
621
+ // collapsed into the CMS Lambda's `vpc: true` call site.
743
622
  if (props.transformRole) {
744
623
  attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
745
624
  }
@@ -749,14 +628,13 @@ export class AssetSupport extends Construct {
749
628
  role: props.transformRole,
750
629
  // Built by `pnpm run build:lambda` (lambda/asset-transform/build.mjs) -
751
630
  // esbuild bundle + a real linux/arm64 `npm install sharp` alongside it,
752
- // no Docker. `cdk synth`/`deploy` need that script run first; it is
753
- // NOT run automatically here (kept explicit rather than magic - see
754
- // this construct's class doc comment).
631
+ // no Docker. `cdk synth`/`deploy` need that script run first; it is NOT
632
+ // run automatically here.
755
633
  code: lambda.Code.fromAsset(transformAssetDir),
756
634
  handler: 'handler.handler',
757
- // nodejs20.x was deprecated 2026-04-30; CDK's CloudFormation validation
758
- // now fails synth on it. The esbuild bundle (lambda/asset-transform/
759
- // build.mjs) targets node22 to match; move the two together.
635
+ // nodejs20.x is deprecated and CDK's CloudFormation validation fails synth
636
+ // on it. The esbuild bundle (lambda/asset-transform/build.mjs) targets
637
+ // node22 to match; move the two together.
760
638
  runtime: lambda.Runtime.NODEJS_22_X,
761
639
  architecture: lambda.Architecture.ARM_64,
762
640
  memorySize: TRANSFORM_LAMBDA_MEMORY_MB,
@@ -774,21 +652,18 @@ export class AssetSupport extends Construct {
774
652
  },
775
653
  });
776
654
  // Explicit, scoped grant - NOT a reliance on the auto-created execution
777
- // role's AWSLambdaBasicExecutionRole managed policy (attached by CDK's
778
- // lambda.Function regardless of `logGroup`, and never adjusted for it -
779
- // passing `logGroup` only points the function's LoggingConfig at this
780
- // group, it grants no IAM permissions). That managed policy's
781
- // logs:CreateLogStream/logs:PutLogEvents statement is scoped to
782
- // `arn:aws:logs:*:*:log-group:/aws/lambda/*:*` only (its
783
- // logs:CreateLogGroup statement is the sole one that's unrestricted) -
784
- // it grants nothing for a custom-named group like this one. Without this
785
- // grantWrite, log delivery to this group would fail permission checks
786
- // with no error surfaced anywhere - logs would simply vanish.
655
+ // role's AWSLambdaBasicExecutionRole managed policy, which CDK attaches
656
+ // regardless of `logGroup` and never adjusts for it (passing `logGroup`
657
+ // only points the function's LoggingConfig at this group; it grants no
658
+ // IAM). That policy's logs:CreateLogStream/logs:PutLogEvents statement is
659
+ // scoped to `arn:aws:logs:*:*:log-group:/aws/lambda/*:*`, so it grants
660
+ // nothing for a custom-named group. Without this grantWrite, log delivery
661
+ // fails its permission check with no error surfaced anywhere - logs simply
662
+ // vanish.
787
663
  this.transformLogGroup.grantWrite(this.transformFunction);
788
- // Read access to originals (what it transforms) - deviation from the PR
789
- // spec's literal "read asset-originals/, write assets/" grant list: the
790
- // handler must also read `asset-meta/{hash32}.json` to look up kind/ext
791
- // before it can transform anything, so meta read access is granted too.
664
+ // Originals are what it transforms; `asset-meta/{hash32}.json` carries the
665
+ // kind/ext it must look up before it can transform anything, so meta is
666
+ // read too.
792
667
  this.bucket.grantRead(this.transformFunction, `${PREFIXES.originals}/*`);
793
668
  this.bucket.grantRead(this.transformFunction, `${PREFIXES.meta}/*`);
794
669
  // Write access to `assets/*` covers both the public prefix and
@@ -821,23 +696,19 @@ export class AssetSupport extends Construct {
821
696
  // Setting `uploadBehavior` SATISFIES the standalone guard above, but only
822
697
  // ATTACHING the behavior to a distribution actually produces an
823
698
  // Access-Control-Allow-Origin from anywhere. Opting in and stopping there -
824
- // which is exactly what copying the README snippet and not finishing looks
825
- // like - lands a standalone bucket with no CORS rule and no edge route: the
826
- // silent failure the guard's own text calls misleading, reached through the
827
- // guard rather than around it.
699
+ // a half-finished copy of the README snippet - lands a standalone bucket
700
+ // with no CORS rule and no edge route: the silent failure that guard's own
701
+ // text calls misleading, reached through it rather than around it.
828
702
  //
829
703
  // 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.
704
+ // The accessor memoizes, so a caller who invokes it and loses the value in
705
+ // a refactor sets every flag an accessor can set and still lands in exactly
706
+ // that state. `UploadOrigin` (above) is what makes the difference
707
+ // observable from here.
836
708
  //
837
- // A synth-time validation is the only place this can be caught, since the
838
- // constructor cannot know what the caller will do next. It is the first
839
- // `addValidation` in this package; the alternative (checking in the
840
- // constructor) cannot express "and nothing used it".
709
+ // A synth-time validation is the only place this can be caught: the
710
+ // constructor cannot know what the caller will do next, so it cannot
711
+ // express "and nothing used it".
841
712
  this.node.addValidation({
842
713
  validate: () => {
843
714
  if (props.uploadBehavior &&
@@ -901,7 +772,7 @@ export class AssetSupport extends Construct {
901
772
  // never reveals object existence) - CloudFront's own S3 origin
902
773
  // handling can still surface 404 in some paths, so both are
903
774
  // configured. Confirmed working end-to-end by the sandbox spike
904
- // (.claude/future-tasks/assets-media-system.md's SPIKE RESULT).
775
+ // (.claude/future-tasks/resolved/assets-media-system.md's SPIKE RESULT).
905
776
  fallbackStatusCodes: [403, 404],
906
777
  }),
907
778
  viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
@@ -912,17 +783,13 @@ export class AssetSupport extends Construct {
912
783
  /**
913
784
  * The two CloudFront behavior configs this system needs.
914
785
  *
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
918
- * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
919
- * object, since the property is about emitted order.
920
- *
921
- * Prefer `attachTo(distribution)` or `CanopyCmsDistribution`'s
922
- * `assetSupport` prop, which attach these to a distribution in the only
923
- * safe order automatically. This method exists as the escape hatch for a
924
- * distribution assembled entirely by hand - see `AssetCloudFrontBehaviors`'s
925
- * doc comment for that shape and its ordering requirements.
786
+ * Prefer `attachTo(distribution)` or `CanopyCmsDistribution`'s `assetSupport`
787
+ * prop, which attach these in the only safe order automatically; `attachTo`
788
+ * takes an `overrides` parameter, so needing per-behavior options is not a
789
+ * reason to fall back here. Use this only for a distribution assembled
790
+ * entirely by hand - you then own the ordering, and should assert it against
791
+ * the SYNTHESIZED template's `CacheBehaviors` array index rather than your own
792
+ * source object, since the property is about emitted order.
926
793
  */
927
794
  assetBehaviors() {
928
795
  return this.behaviors;
@@ -940,43 +807,21 @@ export class AssetSupport extends Construct {
940
807
  * })
941
808
  * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
942
809
  * ```
810
+ * No custom domain or certificate is needed, and as the default behavior of a
811
+ * one-route distribution there is no path pattern, so no ordering question of
812
+ * the kind `attachTo()` settles. Two shapes this deliberately is NOT:
943
813
  *
944
- * No custom domain or certificate is needed - the `d111...cloudfront.net`
945
- * name is a perfectly good `uploadUrl` - and as the default behavior of a
946
- * one-route distribution there is no path pattern and so no ordering
947
- * question of the kind `attachTo()` exists to settle.
948
- *
949
- * Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
950
- * is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
951
- *
952
- * - NOT a behavior on the site's own distribution. `CustomErrorResponses`
953
- * are distribution-wide, so a site that maps 403 to its own 404 page
954
- * applies that to S3's upload errors too and the editor reports the
955
- * substituted status. The site's cookies and any cached basic-auth
956
- * credential are also live hazards there that have to be stripped by
957
- * policy and function (`buildUploadBehavior` removes both - cookies via
958
- * the origin request policy, `Authorization` in the viewer-request
959
- * function) rather than simply never
960
- * being sent. It works; it is just strictly worse.
814
+ * - NOT a behavior on the site's own distribution. `CustomErrorResponses` are
815
+ * distribution-wide, so a site mapping 403 to its own 404 page applies that
816
+ * to S3's upload errors and the editor reports the substituted status; the
817
+ * site's cookies and any cached basic-auth credential are live hazards
818
+ * there, stripped by policy and function rather than never sent at all.
961
819
  * - NOT one shared distribution serving asset reads AND writes for every
962
- * environment. One assets distribution means one `AssetSupport`, and
963
- * `AssetSupport` owns the transform Lambda - so it would also mean one
964
- * transform Lambda shared by every environment, and that Lambda ships
965
- * inside this package. A `canopycms-cdk` bump would then move every
966
- * environment's asset pipeline at once, which is a graduated rollout
967
- * traded away for an upload path. Reads and transforms stay
968
- * per-environment; only the upload route moves.
969
- *
970
- * The upload route is the one part of this system with no per-environment
971
- * code in it - it depends on the bucket and nothing else - which is why it
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.
820
+ * environment. That means one `AssetSupport`, which owns the transform
821
+ * Lambda shipping inside this package, so a `canopycms-cdk` bump would move
822
+ * every environment's asset pipeline at once. Only the upload route moves;
823
+ * it depends on the bucket and nothing else, the same property that lets
824
+ * `assetUploadBehavior` build it from a bucket alone.
980
825
  */
981
826
  uploadBehavior() {
982
827
  if (!this.uploadOptions) {
@@ -988,10 +833,9 @@ export class AssetSupport extends Construct {
988
833
  // Memoized: calling this twice must not attach two sets of policies, and
989
834
  // must not fail on duplicate construct ids.
990
835
  //
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.
836
+ // `scope` is `this`, which is what keeps the three child ids where the
837
+ // emitted template already has them; moving them replaces deployed
838
+ // CloudFront resources.
995
839
  //
996
840
  // `validateUploadBehaviorOptions` is NOT re-run here: the constructor
997
841
  // already ran it on these same options, and `this.uploadOptions` is its
@@ -1009,62 +853,40 @@ export class AssetSupport extends Construct {
1009
853
  * Attach both asset behaviors to a concrete CloudFront distribution, in the
1010
854
  * only safe order.
1011
855
  *
1012
- * THIS METHOD EXISTS BECAUSE THE ORDER IS THE WHOLE POINT.
1013
- * `assetBehaviors()` returns `{ assets, assetsTransform }` with no path
1014
- * pattern attached at all (see that method's and `AssetCloudFrontBehaviors`'s
1015
- * doc comments) - so nothing stops a caller from attaching the two in
1016
- * either order. CloudFront matches path patterns in the order given and
1017
- * stops at the first match. `/assets/*` is a broader, S3-only pattern that
1018
- * also matches every `/assets/t/*` request; `/assets/t/*` is an origin
1019
- * group that fails over to the transform Lambda on a miss. Attach
1020
- * `/assets/*` first and every never-yet-computed transform gets a
1021
- * permanent 403 (an OAC-signed S3 miss reports 403) while already-computed
1022
- * transforms keep working - silent, launch-delayed, and permanent.
1023
- * `'/assets/*'` also sorts BEFORE `'/assets/t/*'` lexicographically (`*` =
1024
- * 0x2A, `t` = 0x74), so alphabetizing the keys reproduces exactly this
1025
- * failure, with no synth or deploy error to catch it.
856
+ * THE ORDER IS THE WHOLE POINT. CloudFront matches path patterns in the order
857
+ * given and stops at the first match. `/assets/*` is a broader, S3-only
858
+ * pattern that also matches every `/assets/t/*` request; `/assets/t/*` is an
859
+ * origin group that fails over to the transform Lambda on a miss. Attach
860
+ * `/assets/*` first and every never-yet-computed transform gets a permanent
861
+ * 403 (an OAC-signed S3 miss reports 403) while already-computed transforms
862
+ * keep working. `'/assets/*'` also sorts BEFORE `'/assets/t/*'`
863
+ * lexicographically (`*` = 0x2A, `t` = 0x74), so alphabetizing the keys
864
+ * reproduces exactly this failure with no synth or deploy error to catch it -
865
+ * and `assetBehaviors()` attaches no path pattern, so nothing there stops a
866
+ * caller getting it wrong.
1026
867
  *
1027
- * `overrides` is merged into BOTH behaviors, and exists because without it
1028
- * this method is unusable by exactly the adopters who most need the ordering
1029
- * guarantee. A distribution that runs a viewer-request function on every
1030
- * behavior - tier basic-auth, most commonly - needs the asset behaviors to
1031
- * carry that same `functionAssociations`, or `/assets/*` is anonymously
1032
- * readable on an authenticated tier. Before this parameter existed such an
1033
- * adopter had to fall back to `assetBehaviors()` plus two hand-ordered
1034
- * `addBehavior` calls: the precise shape this method was added to eliminate,
1035
- * re-entered while believing ordering was handled upstream, which is worse
1036
- * than never having had the method. (`responseHeadersPolicy` is the same
1037
- * story for a repo with a shared security-headers policy.)
868
+ * `overrides` is merged into BOTH behaviors. The motivating case is a
869
+ * distribution running a viewer-request function on every behavior - tier
870
+ * basic-auth - where without the same `functionAssociations` `/assets/*` is
871
+ * anonymously readable on an authenticated tier (`responseHeadersPolicy` is
872
+ * the same story for a shared security-headers policy). One set for both is
873
+ * what keeps ordering the only thing this method decides; a caller who needs
874
+ * the two to differ should use `assetBehaviors()` and assert their own order.
1038
875
  *
1039
- * Merged into both rather than per-behavior on purpose: applying one set to
1040
- * both is what preserves the ordering guarantee as the only thing this
1041
- * method decides. A caller who genuinely needs the two behaviors to differ
1042
- * has left this method's remit and should use `assetBehaviors()` - and keep
1043
- * their own ordering assertion.
1044
- *
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.)
1050
- *
1051
- * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
1052
- * method on that class, not on `IDistribution` (what an imported/looked-up
1053
- * distribution reference gives you). For a distribution built entirely
1054
- * inline (its `additionalBehaviors` fixed at construction, with no
1055
- * distribution yet to call `addBehavior` on), call `assetBehaviors()`
1056
- * directly instead and list `/assets/t/*` before `/assets/*` yourself - see
1057
- * `AssetCloudFrontBehaviors`'s doc comment.
876
+ * Typed `Partial<AddBehaviorOptions>`, not `BehaviorOptions`: `addBehavior`
877
+ * takes `origin` positionally, so an `origin` key here is a silent no-op. It
878
+ * needs a concrete `cloudfront.Distribution` too - `addBehavior` is an
879
+ * instance method on that class, not on `IDistribution`. For a distribution
880
+ * built entirely inline, call `assetBehaviors()` and order it yourself.
1058
881
  */
1059
882
  attachTo(distribution, overrides) {
1060
883
  // `addBehavior` does not dedupe, and these calls bypass
1061
884
  // `CanopyCmsDistribution`'s own synth-time guard because they run after
1062
- // that distribution is constructed. So attaching twice -- passing the
1063
- // `assetSupport` prop AND calling this yourself, most likely, since the
1064
- // prop's doc comment describes them as equivalent -- synthesizes
1065
- // ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] and fails at
1066
- // deploy time when CloudFront rejects the duplicate patterns. Refuse it
1067
- // here instead, where the message can say which two routes collided.
885
+ // that distribution is constructed. Attaching twice - passing the
886
+ // `assetSupport` prop AND calling this yourself - synthesizes
887
+ // ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] and fails at deploy
888
+ // time when CloudFront rejects the duplicate patterns. Refuse it here
889
+ // instead, where the message can say which two routes collided.
1068
890
  if (distribution.node.tryFindChild(ATTACHED_MARKER_ID)) {
1069
891
  throw new Error(`AssetSupport: attachTo() was already called for this distribution. Each pattern ` +
1070
892
  `would be attached twice and CloudFront rejects duplicate path patterns at deploy ` +
@@ -1076,18 +898,16 @@ export class AssetSupport extends Construct {
1076
898
  // Drop explicitly-`undefined` keys before merging. A spread copies own
1077
899
  // enumerable keys INCLUDING ones whose value is undefined, so
1078
900
  // `{ ...transformRest, ...{ cachePolicy: undefined } }` deletes the
1079
- // construct's choice and lets CDK substitute its own default - which is a
1080
- // DIFFERENT default. Measured: `cachePolicy: undefined` swaps this
1081
- // behavior's custom policy (TRANSFORM_CACHE_MIN_TTL = 0, which exists
1082
- // solely to stop the oversized-output redirect loop documented above) for
1083
- // the managed CACHING_OPTIMIZED and its 1-second min TTL; and
1084
- // `viewerProtocolPolicy: undefined` downgrades BOTH behaviors from
1085
- // redirect-to-https to allow-all, serving assets over plain HTTP.
1086
- //
1087
- // This is not a contrived input - it is what forwarding optional props
1088
- // produces: `attachTo(dist, { cachePolicy: props.maybePolicy })` with the
1089
- // prop unset. It typechecks (every AddBehaviorOptions field is already
1090
- // optional), synthesizes and deploys clean.
901
+ // construct's choice and lets CDK substitute a DIFFERENT default:
902
+ // `cachePolicy: undefined` swaps this behavior's custom policy
903
+ // (TRANSFORM_CACHE_MIN_TTL = 0, which exists solely to stop the
904
+ // oversized-output redirect loop) for the managed CACHING_OPTIMIZED and its
905
+ // 1-second min TTL, and `viewerProtocolPolicy: undefined` downgrades BOTH
906
+ // behaviors from redirect-to-https to allow-all, serving assets over plain
907
+ // HTTP. Not a contrived input: `attachTo(dist, { cachePolicy:
908
+ // props.maybePolicy })` with the prop unset typechecks (every
909
+ // AddBehaviorOptions field is already optional), synthesizes and deploys
910
+ // clean.
1091
911
  const definedOverrides = Object.fromEntries(Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined));
1092
912
  const { origin: transformOrigin, ...transformRest } = this.behaviors.assetsTransform;
1093
913
  const { origin: assetsOrigin, ...assetsRest } = this.behaviors.assets;