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.
- package/dist/constructs/asset-support.d.ts +137 -269
- package/dist/constructs/asset-support.js +275 -455
- package/dist/constructs/cms-distribution.d.ts +23 -40
- package/dist/constructs/cms-distribution.js +61 -88
- package/dist/constructs/cms-service.d.ts +38 -60
- package/dist/constructs/cms-service.js +246 -390
- package/dist/constructs/lambda-execution-role.d.ts +23 -39
- package/dist/constructs/lambda-execution-role.js +37 -60
- package/dist/index.js +0 -2
- package/dist/worker.d.ts +0 -2
- package/dist/worker.js +0 -2
- package/lambda/asset-transform/dist/handler.js +33 -49
- package/lambda/asset-transform/dist/node_modules/.package-lock.json +38 -38
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/README.md +1 -1
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/lib/glib-2.0/include/glibconfig.h +2 -2
- 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
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/package.json +2 -2
- package/lambda/asset-transform/dist/node_modules/@img/sharp-libvips-linux-arm64/versions.json +11 -11
- package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/index.cjs +1 -1
- 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
- package/lambda/asset-transform/dist/node_modules/@img/sharp-linux-arm64/package.json +2 -2
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/index.cjs +1 -1
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.5.node.js +1 -0
- 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
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/package.json +1 -1
- package/lambda/asset-transform/dist/node_modules/@img/sharp-wasm32/versions.json +7 -7
- package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.cts +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/index.d.mts +3 -3
- package/lambda/asset-transform/dist/node_modules/sharp/dist/is.cjs +8 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/is.mjs +8 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.cjs +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/operation.mjs +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/dist/output.cjs +1 -0
- package/lambda/asset-transform/dist/node_modules/sharp/dist/output.mjs +1 -0
- package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.cjs +14 -14
- package/lambda/asset-transform/dist/node_modules/sharp/dist/resize.mjs +14 -14
- package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.cjs +3 -3
- package/lambda/asset-transform/dist/node_modules/sharp/dist/sharp.mjs +3 -3
- package/lambda/asset-transform/dist/node_modules/sharp/lib/index.d.ts +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/package.json +35 -35
- package/lambda/asset-transform/dist/node_modules/sharp/src/binding.gyp +1 -1
- package/lambda/asset-transform/dist/node_modules/sharp/src/common.cc +4 -4
- package/lambda/asset-transform/dist/node_modules/sharp/src/common.h +2 -2
- package/lambda/asset-transform/dist/node_modules/sharp/src/pipeline.cc +31 -6
- package/lambda/asset-transform/dist/node_modules/sharp/src/utilities.cc +1 -1
- package/package.json +4 -4
- package/worker/dist/index.js +251 -364
- 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.
|
|
13
|
-
* deployability guard and `Code.fromAsset()` below -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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`
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
115
|
-
*
|
|
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
167
|
-
*
|
|
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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
286
|
-
// behaviors' origin cannot be reused here
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
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.
|
|
332
|
-
// behavior:
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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
|
|
426
|
-
// dimension only
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
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
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
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
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
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
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
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
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
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
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
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.
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
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
|
-
//
|
|
723
|
-
// `/aws/lambda/<function name
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
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
|
-
//
|
|
741
|
-
//
|
|
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
|
-
//
|
|
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
|
|
758
|
-
//
|
|
759
|
-
//
|
|
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
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
// logs
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
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
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
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
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
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
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
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
|
|
838
|
-
// constructor cannot know what the caller will do next
|
|
839
|
-
//
|
|
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
|
-
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
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
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
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.
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
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
|
-
//
|
|
993
|
-
//
|
|
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
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
* pattern
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
* also
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
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
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
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
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
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.
|
|
1063
|
-
// `assetSupport` prop AND calling this yourself
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
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
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
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;
|