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

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.
@@ -1,5 +1,38 @@
1
1
  import { Construct } from 'constructs';
2
2
  import { Duration, RemovalPolicy, aws_cloudfront as cloudfront, aws_iam as iam, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3 } from 'aws-cdk-lib';
3
+ /**
4
+ * Configuration for the presigned-upload CloudFront behavior. See
5
+ * `AssetSupportProps.uploadBehavior` to switch it on and
6
+ * `AssetSupport.uploadBehavior()` for the topology it belongs in.
7
+ */
8
+ export interface AssetUploadBehaviorOptions {
9
+ /**
10
+ * Origins the edge advertises in `Access-Control-Allow-Origin`, scoped to
11
+ * this behavior alone.
12
+ *
13
+ * `['*']` by default, and a wildcard is defensible HERE in a way a
14
+ * bucket-wide CORS rule is not, because **the edge authorises nothing**:
15
+ * measured, a presigned POST with a corrupted signature sent through this
16
+ * exact path returns 403 and nothing lands. Authority is entirely the
17
+ * presigned policy, which pins the bucket, the exact key, the content type,
18
+ * a size range and a 15-minute expiry. So the wildcard widens who may READ
19
+ * the response, not who may write - and the response is an empty 204.
20
+ *
21
+ * Narrow it to your editor origin(s) if you would rather; nothing else here
22
+ * depends on the wildcard.
23
+ *
24
+ * ORIGINS ARE MATCHED EXACTLY, and `'*'` is honoured only as the sole entry.
25
+ * CloudFront's response headers policy would accept a leftmost-subdomain
26
+ * pattern (`https://*.preview.example.com`), but the CORS preflight is
27
+ * answered at the edge by a CloudFront Function that compares strings, so a
28
+ * pattern would pass the POST response's policy and fail every preflight.
29
+ * Rather than half-honour it, the constructor refuses any entry containing
30
+ * `*` unless the list is exactly `['*']`.
31
+ *
32
+ * @default ['*']
33
+ */
34
+ readonly allowedOrigins?: string[];
35
+ }
3
36
  export interface AssetSupportProps {
4
37
  /**
5
38
  * Use an existing bucket (BYO mode - e.g. a site's existing content
@@ -18,8 +51,46 @@ export interface AssetSupportProps {
18
51
  * (the editor's own origin(s) - e.g. `http://localhost:3000` in dev, or
19
52
  * the deployed editor's domain). Only applied in standalone mode (see
20
53
  * `bucket`).
54
+ *
55
+ * This exists solely to write the bucket's CORS rule, which is only needed
56
+ * because the browser POSTs presigned uploads cross-origin. `uploadBehavior`
57
+ * is the OTHER way to satisfy that requirement: it supplies
58
+ * `Access-Control-Allow-Origin` from the edge instead, so no bucket rule is
59
+ * written and no exact origin has to be named anywhere.
60
+ *
61
+ * Optional since that edge route exists - it was required while the bucket
62
+ * rule was the only route. Standalone mode still refuses to synth with
63
+ * NEITHER, because the resulting failure is a genuinely misleading one (see
64
+ * the error's own text). An empty array counts as absent: CloudFormation
65
+ * rejects a CORS rule with no origins, so it can only ever have been a
66
+ * mistake.
67
+ *
68
+ * @default - no bucket CORS rule; standalone mode then requires `uploadBehavior`
21
69
  */
22
- readonly editorOrigins: string[];
70
+ readonly editorOrigins?: string[];
71
+ /**
72
+ * Build a CloudFront behavior that accepts the editor's presigned-POST
73
+ * uploads - the infrastructure half of `media.uploadUrl` (see the README's
74
+ * "Routing uploads through your own CDN"). Retrieve the result with
75
+ * `uploadBehavior()`, which also documents the distribution it belongs on.
76
+ *
77
+ * OFF unless set, and deliberately so: this is the only thing in this
78
+ * construct that puts a write-capable, OAC-UNSIGNED origin in front of the
79
+ * bucket, and that must never appear in a template by accident. Pass
80
+ * `uploadBehavior: {}` to take the defaults.
81
+ *
82
+ * BYO-BUCKET CAVEAT: this origin addresses the bucket by
83
+ * `bucketRegionalDomainName`, and for a bucket imported with
84
+ * `Bucket.fromBucketName()` that resolves to the STACK's region, not the
85
+ * bucket's. The read path tolerates a mismatch because CloudFront follows
86
+ * S3's region redirect on an S3-type origin; this one is a custom origin and
87
+ * does not, so S3's 301 reaches the browser, where a redirected cross-origin
88
+ * POST surfaces as an opaque network error. Import a cross-region bucket
89
+ * with `Bucket.fromBucketAttributes({ region })` so the domain name is right.
90
+ *
91
+ * @default - no upload behavior is built
92
+ */
93
+ readonly uploadBehavior?: AssetUploadBehaviorOptions;
23
94
  /**
24
95
  * Advisory upload-size cap in bytes. Not enforced by this construct -
25
96
  * the actual cap comes from the presigned POST's `content-length-range`
@@ -96,6 +167,32 @@ export interface AssetSupportProps {
96
167
  * never affected.
97
168
  */
98
169
  readonly transformOutputRetention?: Duration;
170
+ /**
171
+ * Execution role for the transform Lambda (default: CDK creates one).
172
+ *
173
+ * Set this when the role's ARN has to be computable WITHOUT a reference to
174
+ * this construct - the case that motivated the prop is an asset bucket in a
175
+ * different AWS account from the compute, where the resource-policy half of
176
+ * the cross-account grant must be written in the bucket's own stack and needs
177
+ * the principal as a plain string. Reading `transformFunction.role` across an
178
+ * account boundary does not give you that: CDK emits `Fn::GetStackOutput`, a
179
+ * CDK-CLI-only intrinsic resolved at deploy time, so the coupling is
180
+ * invisible to CloudFormation and unusable by any deploy path that is not
181
+ * `cdk deploy`. Create a deterministically NAMED role instead and both stacks
182
+ * can compute `arn:aws:iam::<account>:role/<name>` from literals, with
183
+ * nothing crossing between them.
184
+ *
185
+ * A named IAM role means the consuming stack needs `CAPABILITY_NAMED_IAM`,
186
+ * and cannot be replaced in place without a rename - that trade is yours to
187
+ * make here, which is the point of taking a role rather than a name.
188
+ *
189
+ * `iam.Role`, not `iam.IRole`, ON PURPOSE - an imported role silently
190
+ * discards the managed policies this construct has to re-attach. See
191
+ * `attachLambdaExecutionPolicies` (./lambda-execution-role) for what CDK
192
+ * drops when a role is passed, and why the narrower type is what makes the
193
+ * compensation reliable.
194
+ */
195
+ readonly transformRole?: iam.Role;
99
196
  /**
100
197
  * Name for the transform Lambda's CloudWatch log group (default:
101
198
  * `/canopycms/<stackName>/transform`). Deliberately NOT
@@ -127,6 +224,16 @@ export declare const ASSETS_TRANSFORM_PATH_PATTERN: string;
127
224
  * `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
128
225
  * instead. Exported so `cms-distribution.ts`'s synth-time guard can
129
226
  * recognize the mistake and name it in its error.
227
+ *
228
+ * `uploadBehavior()` deliberately adds NO key here, considered and rejected
229
+ * when it was added. It returns a bare `BehaviorOptions` rather than a named
230
+ * property of a returned object, so there is no spread to get wrong in the
231
+ * first place - and a speculative `'upload'` entry would actively misfire on
232
+ * an adopter whose distribution has a real upload route, since CloudFront
233
+ * treats a path pattern's leading slash as optional and `upload` is a legal
234
+ * spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
235
+ * Keep this list to property names that actually exist on
236
+ * `AssetCloudFrontBehaviors`.
130
237
  */
131
238
  export declare const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS: readonly ["assets", "assetsTransform"];
132
239
  /**
@@ -202,6 +309,9 @@ export interface AssetCloudFrontBehaviors {
202
309
  * for why the order is the whole point); `assetBehaviors()` is the escape
203
310
  * hatch for a distribution built entirely by hand (see
204
311
  * `AssetCloudFrontBehaviors`'s doc comment).
312
+ * - `uploadBehavior()`, opt-in, the write half: a CloudFront behavior that
313
+ * accepts the editor's presigned-POST uploads, for adopters who set
314
+ * `media.uploadUrl`. Belongs on its own distribution - see that method.
205
315
  * - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
206
316
  * principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
207
317
  */
@@ -217,8 +327,29 @@ export declare class AssetSupport extends Construct {
217
327
  /** Effective advisory upload-size cap (see `AssetSupportProps.maxUploadBytes`). */
218
328
  readonly maxUploadBytes: number;
219
329
  private readonly behaviors;
330
+ /** Set only when `AssetSupportProps.uploadBehavior` is set; validated in the constructor. */
331
+ private readonly uploadOptions?;
332
+ /**
333
+ * Memoized on the first `uploadBehavior()` call, NOT built in the
334
+ * constructor. Opting in creates three CloudFront resources (function,
335
+ * origin request policy, response headers policy) and response headers
336
+ * policies have a default account quota of 20 - so an adopter who sets the
337
+ * prop on a per-environment `AssetSupport` while building the single shared
338
+ * upload distribution the accessor recommends would otherwise mint a set per
339
+ * environment for one route. Deferring is invisible other than in what gets
340
+ * emitted, with one exception worth knowing rather than discovering: calling
341
+ * the accessor AFTER a synth has run modifies the construct tree, and CDK
342
+ * refuses that with `ConstructTreeModifiedAfterSynth` naming the constructs
343
+ * added. Loud, not silent.
344
+ */
345
+ private upload?;
220
346
  constructor(scope: Construct, id: string, props: AssetSupportProps);
221
347
  private buildBehaviors;
348
+ /**
349
+ * The behavior that accepts the editor's presigned-POST uploads. Every piece
350
+ * below is load-bearing; see `uploadBehavior()` for where the result goes.
351
+ */
352
+ private buildUploadBehavior;
222
353
  /**
223
354
  * The two CloudFront behavior configs this system needs.
224
355
  *
@@ -235,6 +366,51 @@ export declare class AssetSupport extends Construct {
235
366
  * doc comment for that shape and its ordering requirements.
236
367
  */
237
368
  assetBehaviors(): AssetCloudFrontBehaviors;
369
+ /**
370
+ * The CloudFront behavior that accepts the editor's presigned-POST uploads -
371
+ * the infrastructure half of `media.uploadUrl`. Requires
372
+ * `AssetSupportProps.uploadBehavior`.
373
+ *
374
+ * GIVE IT ITS OWN DISTRIBUTION, serving this route and nothing else:
375
+ *
376
+ * ```ts
377
+ * const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
378
+ * defaultBehavior: assetSupport.uploadBehavior(),
379
+ * })
380
+ * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
381
+ * ```
382
+ *
383
+ * No custom domain or certificate is needed - the `d111...cloudfront.net`
384
+ * name is a perfectly good `uploadUrl` - and as the default behavior of a
385
+ * one-route distribution there is no path pattern and so no ordering
386
+ * question of the kind `attachTo()` exists to settle.
387
+ *
388
+ * Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
389
+ * is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
390
+ *
391
+ * - NOT a behavior on the site's own distribution. `CustomErrorResponses`
392
+ * are distribution-wide, so a site that maps 403 to its own 404 page
393
+ * applies that to S3's upload errors too and the editor reports the
394
+ * substituted status. The site's cookies and any cached basic-auth
395
+ * credential are also live hazards there that have to be stripped by
396
+ * policy and function (`buildUploadBehavior` removes both - cookies via
397
+ * the origin request policy, `Authorization` in the viewer-request
398
+ * function) rather than simply never
399
+ * being sent. It works; it is just strictly worse.
400
+ * - NOT one shared distribution serving asset reads AND writes for every
401
+ * environment. One assets distribution means one `AssetSupport`, and
402
+ * `AssetSupport` owns the transform Lambda - so it would also mean one
403
+ * transform Lambda shared by every environment, and that Lambda ships
404
+ * inside this package. A `canopycms-cdk` bump would then move every
405
+ * environment's asset pipeline at once, which is a graduated rollout
406
+ * traded away for an upload path. Reads and transforms stay
407
+ * per-environment; only the upload route moves.
408
+ *
409
+ * The upload route is the one part of this system with no per-environment
410
+ * code in it - it depends on the bucket and nothing else - which is why it
411
+ * is the part that can be split off this way.
412
+ */
413
+ uploadBehavior(): cloudfront.BehaviorOptions;
238
414
  /**
239
415
  * Attach both asset behaviors to a concrete CloudFront distribution, in the
240
416
  * only safe order.
@@ -2,7 +2,8 @@ import { existsSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { Construct } from 'constructs';
5
- import { Duration, RemovalPolicy, Stack, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
5
+ import { Duration, RemovalPolicy, Stack, Token, aws_cloudfront as cloudfront, aws_cloudfront_origins as origins, aws_lambda as lambda, aws_logs as logs, aws_s3 as s3, } from 'aws-cdk-lib';
6
+ import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
6
7
  // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
7
8
  // output is real ESM - `__dirname` is not a global there. Derive it from
8
9
  // `import.meta.url` instead (mirrors ../../lambda/asset-transform/build.mjs).
@@ -50,8 +51,36 @@ const PREFIXES = {
50
51
  public: 'assets',
51
52
  transform: 'assets/t',
52
53
  };
53
- /** S3 CORS preflight cache duration for presigned-POST uploads from the editor. */
54
+ /**
55
+ * CORS preflight cache duration for presigned-POST uploads from the editor.
56
+ * Used for the bucket's own CORS rule and, when `uploadBehavior` is on, for
57
+ * both the edge's preflight response and its response headers policy.
58
+ */
54
59
  const CORS_MAX_AGE_SECONDS = 3000;
60
+ /**
61
+ * The only method the upload route exists to serve. Shared by the preflight
62
+ * response the CloudFront Function returns and the response headers policy's
63
+ * `Access-Control-Allow-Methods`, so a browser cannot be told two different
64
+ * things about what it may send. (The BEHAVIOR still allows all methods -
65
+ * CloudFront has no narrower set containing POST - which is a separate concern
66
+ * handled by the URI rewrite; see `buildUploadBehavior`.)
67
+ */
68
+ const UPLOAD_ALLOWED_METHOD = 'POST';
69
+ /**
70
+ * Render a string array as a JavaScript literal for embedding in CloudFront
71
+ * Function source.
72
+ *
73
+ * `JSON.stringify` already escapes quotes and backslashes, so an adopter's
74
+ * origin string cannot terminate the literal and inject code. It does NOT
75
+ * escape U+2028/U+2029, which are legal in JSON but were line terminators in
76
+ * JavaScript before ES2019 - and the CloudFront Functions runtime is not a
77
+ * runtime to find out about on. Escaping them costs one `replace`.
78
+ */
79
+ function toFunctionLiteral(values) {
80
+ return JSON.stringify(values)
81
+ .replace(/\u2028/g, '\\u2028')
82
+ .replace(/\u2029/g, '\\u2029');
83
+ }
55
84
  /** Matches packages/canopycms/src/assets/store-s3.ts's `DEFAULT_MAX_UPLOAD_BYTES`. */
56
85
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
57
86
  const TRANSFORM_LAMBDA_MEMORY_MB = 1536;
@@ -149,6 +178,16 @@ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
149
178
  * `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
150
179
  * instead. Exported so `cms-distribution.ts`'s synth-time guard can
151
180
  * recognize the mistake and name it in its error.
181
+ *
182
+ * `uploadBehavior()` deliberately adds NO key here, considered and rejected
183
+ * when it was added. It returns a bare `BehaviorOptions` rather than a named
184
+ * property of a returned object, so there is no spread to get wrong in the
185
+ * first place - and a speculative `'upload'` entry would actively misfire on
186
+ * an adopter whose distribution has a real upload route, since CloudFront
187
+ * treats a path pattern's leading slash as optional and `upload` is a legal
188
+ * spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
189
+ * Keep this list to property names that actually exist on
190
+ * `AssetCloudFrontBehaviors`.
152
191
  */
153
192
  export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
154
193
  /**
@@ -168,6 +207,9 @@ export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
168
207
  * for why the order is the whole point); `assetBehaviors()` is the escape
169
208
  * hatch for a distribution built entirely by hand (see
170
209
  * `AssetCloudFrontBehaviors`'s doc comment).
210
+ * - `uploadBehavior()`, opt-in, the write half: a CloudFront behavior that
211
+ * accepts the editor's presigned-POST uploads, for adopters who set
212
+ * `media.uploadUrl`. Belongs on its own distribution - see that method.
171
213
  * - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
172
214
  * principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
173
215
  */
@@ -194,6 +236,32 @@ export class AssetSupport extends Construct {
194
236
  ' pnpm --filter canopycms-cdk run build:lambda');
195
237
  }
196
238
  this.maxUploadBytes = props.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
239
+ // An empty array is treated as absent throughout: CloudFormation rejects a
240
+ // CORS rule whose AllowedOrigins is empty, so `editorOrigins: []` could
241
+ // only ever have been a mistake, and it is better caught by the check
242
+ // below than by a deploy-time template rejection.
243
+ const editorOrigins = props.editorOrigins ?? [];
244
+ // A browser presigned upload needs an Access-Control-Allow-Origin from
245
+ // SOMEWHERE - the bucket's own CORS rule (`editorOrigins`) or the edge
246
+ // (`uploadBehavior`). Standalone mode owns the bucket, so it can tell that
247
+ // neither is configured and say so here rather than let it be discovered
248
+ // in a browser. BYO-bucket mode cannot: the caller owns that bucket's CORS
249
+ // configuration and this construct has no way to read it (`IBucket` has no
250
+ // `addCorsRule`, which is the same reason `editorOrigins` is inert there).
251
+ if (!props.bucket && editorOrigins.length === 0 && !props.uploadBehavior) {
252
+ throw new Error('AssetSupport: standalone mode creates the bucket, and a browser presigned upload needs ' +
253
+ 'Access-Control-Allow-Origin from either the bucket or the edge - this construct was ' +
254
+ 'given neither. Pass `editorOrigins` (the editor origin(s) that POST uploads), or ' +
255
+ '`uploadBehavior` to route uploads through a CloudFront distribution that supplies the ' +
256
+ 'header at the edge. If you have wired an equivalent upload path yourself, outside ' +
257
+ 'this construct, pass `editorOrigins` anyway - a bucket CORS rule your own path makes ' +
258
+ 'redundant is harmless, and this check cannot see that path.\n' +
259
+ 'Worth knowing why this is worth failing on: S3 ACCEPTS a cross-origin presigned POST ' +
260
+ 'with no CORS rule at all and simply declines to ADVERTISE it (measured: 204, no ' +
261
+ 'ACAO header, object landed). So the object reaches asset-staging/ and the browser ' +
262
+ 'still reports the upload as a network error - a failure that looks like anything ' +
263
+ 'except missing CORS.');
264
+ }
197
265
  if (props.bucket) {
198
266
  this.bucket = props.bucket;
199
267
  }
@@ -229,15 +297,20 @@ export class AssetSupport extends Construct {
229
297
  expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
230
298
  },
231
299
  ],
232
- cors: [
233
- {
234
- id: 'editor-presigned-upload',
235
- allowedOrigins: props.editorOrigins,
236
- allowedMethods: [s3.HttpMethods.POST, s3.HttpMethods.PUT, s3.HttpMethods.GET],
237
- allowedHeaders: ['*'],
238
- maxAge: CORS_MAX_AGE_SECONDS,
239
- },
240
- ],
300
+ // Omitted entirely when `editorOrigins` is absent - the upload is then
301
+ // going through `uploadBehavior`, which supplies ACAO at the edge, and
302
+ // writing a rule with no origins would be an invalid template anyway.
303
+ cors: editorOrigins.length > 0
304
+ ? [
305
+ {
306
+ id: 'editor-presigned-upload',
307
+ allowedOrigins: editorOrigins,
308
+ allowedMethods: [s3.HttpMethods.POST, s3.HttpMethods.PUT, s3.HttpMethods.GET],
309
+ allowedHeaders: ['*'],
310
+ maxAge: CORS_MAX_AGE_SECONDS,
311
+ },
312
+ ]
313
+ : undefined,
241
314
  });
242
315
  }
243
316
  // Dedicated CloudWatch log group for the transform Lambda's stdout/
@@ -257,7 +330,18 @@ export class AssetSupport extends Construct {
257
330
  retention: props.transformLogRetention ?? logs.RetentionDays.THREE_MONTHS,
258
331
  removalPolicy: RemovalPolicy.DESTROY,
259
332
  });
333
+ // Re-attach what CDK silently drops for a caller-supplied role. MUST run
334
+ // for every passed role - see that function's doc comment. This function
335
+ // is not VPC-attached, so it takes basic execution only; the test suite
336
+ // pins the absence of the VPC-ENI policy here specifically, so that this
337
+ // call site and the CMS Lambda's cannot be collapsed into one.
338
+ if (props.transformRole) {
339
+ attachLambdaExecutionPolicies(props.transformRole, { vpc: false });
340
+ }
260
341
  this.transformFunction = new lambda.Function(this, 'TransformFunction', {
342
+ // Default (unset) leaves CDK to create the execution role, with its own
343
+ // managed policies intact. See `transformRole`'s doc comment.
344
+ role: props.transformRole,
261
345
  // Built by `pnpm run build:lambda` (lambda/asset-transform/build.mjs) -
262
346
  // esbuild bundle + a real linux/arm64 `npm install sharp` alongside it,
263
347
  // no Docker. `cdk synth`/`deploy` need that script run first; it is
@@ -313,6 +397,75 @@ export class AssetSupport extends Construct {
313
397
  authType: lambda.FunctionUrlAuthType.AWS_IAM,
314
398
  });
315
399
  this.behaviors = this.buildBehaviors();
400
+ // Validated eagerly, built lazily (see `upload`'s comment). An empty
401
+ // `allowedOrigins` is the same class of mistake as an empty
402
+ // `editorOrigins` and gets the same treatment: CloudFormation rejects
403
+ // `AccessControlAllowOrigins` with no items, so silently falling back to
404
+ // the `['*']` default would turn a forwarded-empty-env-var into either a
405
+ // failed deploy or, worse, a wildcard the caller did not ask for.
406
+ if (props.uploadBehavior?.allowedOrigins?.length === 0) {
407
+ throw new Error('AssetSupport: uploadBehavior.allowedOrigins is an empty array. CloudFront requires at ' +
408
+ 'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
409
+ "silently widen what you asked for. Omit the property to take the `['*']` default, " +
410
+ 'or list the origin(s) the editor is served from.');
411
+ }
412
+ // Two consumers read `allowedOrigins` and they do not share a matcher: the
413
+ // response headers policy (which decides ACAO on the POST response) accepts
414
+ // CloudFront's pattern grammar, including a leftmost-subdomain wildcard
415
+ // like `https://*.preview.example.com`; the preflight responder compiled
416
+ // into the CloudFront Function can only do exact-string comparison, because
417
+ // reimplementing that grammar in an edge function without being able to
418
+ // test it against CloudFront is how the two silently disagree.
419
+ //
420
+ // So a pattern is refused rather than half-honoured. Accepting one would
421
+ // deploy clean and then fail every upload from a matching origin at the
422
+ // PREFLIGHT, with a correct-looking policy sitting right next to it - the
423
+ // same invisible failure the preflight responder was added to remove.
424
+ const wildcardOrigins = (props.uploadBehavior?.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
425
+ const isBareWildcard = props.uploadBehavior?.allowedOrigins?.join() === '*';
426
+ if (wildcardOrigins.length > 0 && !isBareWildcard) {
427
+ throw new Error(`AssetSupport: uploadBehavior.allowedOrigins contains a wildcard pattern ` +
428
+ `(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
429
+ `headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
430
+ `CloudFront Function that compares origins exactly - so every upload from a matching ` +
431
+ `origin would fail its preflight while the policy looked correct. Use exactly ` +
432
+ `['*'] to allow any origin, or list each editor origin in full.`);
433
+ }
434
+ // Snapshotted, not aliased. The guard above runs now but `allowedOrigins`
435
+ // is not READ until the accessor builds the behavior, so holding the
436
+ // caller's array by reference would let `origins.length = 0` in between
437
+ // walk straight past the check just made.
438
+ this.uploadOptions = props.uploadBehavior && {
439
+ ...props.uploadBehavior,
440
+ allowedOrigins: props.uploadBehavior.allowedOrigins?.slice(),
441
+ };
442
+ // Setting `uploadBehavior` SATISFIES the standalone guard above, but only
443
+ // calling `uploadBehavior()` and attaching the result actually produces an
444
+ // Access-Control-Allow-Origin from anywhere. Opting in and stopping there -
445
+ // which is exactly what copying the README snippet and not finishing looks
446
+ // like - lands a standalone bucket with no CORS rule and no edge route: the
447
+ // silent failure the guard's own text calls misleading, reached through the
448
+ // guard rather than around it.
449
+ //
450
+ // A synth-time validation is the only place this can be caught, since the
451
+ // constructor cannot know what the caller will do next. It is the first
452
+ // `addValidation` in this package; the alternative (checking in the
453
+ // constructor) cannot express "and nothing used it".
454
+ this.node.addValidation({
455
+ validate: () => {
456
+ if (props.uploadBehavior && editorOrigins.length === 0 && !props.bucket && !this.upload) {
457
+ return [
458
+ 'AssetSupport: `uploadBehavior` was set but uploadBehavior() was never called, so ' +
459
+ 'no upload behavior was attached to any distribution and nothing supplies ' +
460
+ 'Access-Control-Allow-Origin. With no `editorOrigins` either, a browser upload ' +
461
+ 'will land the object in asset-staging/ and still report a network error. Pass ' +
462
+ "the behavior to a distribution (see uploadBehavior()'s doc comment), or pass " +
463
+ '`editorOrigins` instead.',
464
+ ];
465
+ }
466
+ return [];
467
+ },
468
+ });
316
469
  }
317
470
  buildBehaviors() {
318
471
  const s3Origin = origins.S3BucketOrigin.withOriginAccessControl(this.bucket);
@@ -359,6 +512,221 @@ export class AssetSupport extends Construct {
359
512
  };
360
513
  return { assets, assetsTransform };
361
514
  }
515
+ /**
516
+ * The behavior that accepts the editor's presigned-POST uploads. Every piece
517
+ * below is load-bearing; see `uploadBehavior()` for where the result goes.
518
+ */
519
+ buildUploadBehavior(options) {
520
+ // A DISTINCT origin for the bucket, with OAC signing OFF - the read
521
+ // behaviors' origin cannot be reused here no matter what this behavior
522
+ // sets, because OAC is a property of the ORIGIN, not the behavior. (On the
523
+ // dedicated distribution `uploadBehavior()` recommends this is the only
524
+ // origin present; the point is that it cannot be the read one.)
525
+ // CloudFront signs origin requests but never hashes
526
+ // the body, so an OAC-signed origin rejects every multipart POST whatever
527
+ // the viewer sent. Measured, that is `400 InvalidArgument`, the body
528
+ // naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
529
+ // ... or a valid sha256 value`. It fails CLOSED, but it is an
530
+ // argument-validation error, NOT the 403 that the Lambda Function URL
531
+ // origin produces in the same situation (docs/deploying-to-aws.md's
532
+ // "CloudFront OAC and request body signing"): there Lambda verifies a
533
+ // SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
534
+ // adopter told to expect 403 goes hunting for a permissions problem that
535
+ // does not exist.
536
+ //
537
+ // `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
538
+ // would also be unsigned, for two reasons. It is the exact shape this
539
+ // whole path was measured against end to end on live AWS resources (204,
540
+ // object landed); and it is the only one of the two that can state the
541
+ // CloudFront->S3 protocol, which for a request carrying a live upload
542
+ // credential in its body should be pinned rather than inferred.
543
+ // `withBucketDefaults()` emits `S3OriginConfig`, which has no
544
+ // `OriginProtocolPolicy` field at all.
545
+ // A dot in the bucket name breaks this origin specifically. S3's wildcard
546
+ // certificate covers one label (`*.s3.<region>.amazonaws.com`), so
547
+ // `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
548
+ // CloudFront answers 502 on every upload. This is a CUSTOM origin doing
549
+ // ordinary TLS validation; the read path uses an S3-type origin, which
550
+ // CloudFront treats differently, so nothing else here would surface it -
551
+ // and dotted names are most likely in exactly the BYO case ("a site's
552
+ // existing content bucket") this behavior is written for. Only checkable
553
+ // when the name is a real literal, which for an imported bucket it is.
554
+ const bucketName = this.bucket.bucketName;
555
+ if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
556
+ throw new Error(`AssetSupport: uploadBehavior cannot use bucket "${bucketName}" - a dot in the bucket ` +
557
+ `name puts an extra label in its regional domain name, which S3's wildcard ` +
558
+ `certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
559
+ `every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
560
+ `dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
561
+ }
562
+ const uploadOrigin = new origins.HttpOrigin(this.bucket.bucketRegionalDomainName, {
563
+ protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
564
+ });
565
+ // S3's POST Object is only valid at the bucket ROOT - without this rewrite
566
+ // the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
567
+ // behavior: rewriting unconditionally means no request arriving here can
568
+ // address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
569
+ // only the bucket-level operations at `/` (list, create, delete bucket).
570
+ // A bucket this construct creates refuses all of them anonymously
571
+ // (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
572
+ // bucket policy to have got right, as it already is for the read path. The
573
+ // only thing that can WRITE either way is a request carrying a valid
574
+ // presigned policy, and only to the single key that policy names. Do not
575
+ // make the rewrite conditional; that gives key addressability back.
576
+ //
577
+ // The same function also drops `Authorization`. A site behind HTTP basic
578
+ // auth otherwise sends S3 a credential it cannot parse
579
+ // (`400 InvalidArgument - Unsupported Authorization Type`), and the
580
+ // presigned POST carries its own authority in the body, so nothing on this
581
+ // route ever wants the header. It is dropped HERE, in the function, rather
582
+ // than by naming it in the origin request policy's `allExcept` list below,
583
+ // and that placement is deliberate: CloudFront rejects `Authorization` in
584
+ // an origin request policy's header ALLOWLIST outright ("The parameter
585
+ // Headers contains Authorization that is not allowed"), and whether the
586
+ // same validation fires on an `allExcept` list is not something synth can
587
+ // tell you - it would surface as a failed `cdk deploy` on the whole stack.
588
+ // A viewer-request function has no such ambiguity: `method` is the only
589
+ // read-only field on the request object, headers are freely mutable, and
590
+ // `Authorization` is the header CloudFront's own basic-auth function
591
+ // examples read. Dropping it at the viewer also means it is gone before
592
+ // any policy runs, so the guarantee does not depend on the policy at all.
593
+ // The same function ALSO answers the CORS preflight, and that is not
594
+ // optional decoration - without it this route cannot work at all.
595
+ //
596
+ // The editor's upload is NOT a CORS simple request, though it looks like
597
+ // one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
598
+ // but it assigns `xhr.upload.onprogress` before `send()`, and registering
599
+ // ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
600
+ // from the simple-request rules, independent of method, headers and content
601
+ // type. So every browser upload begins with `OPTIONS /`.
602
+ //
603
+ // Nothing else on this route would answer it. CloudFront does not
604
+ // synthesize preflight responses - a response headers policy supplies
605
+ // values for headers "in responses to CORS preflight requests", i.e. it
606
+ // decorates a response that something else produced - and `ALLOW_ALL`
607
+ // forwards the OPTIONS to S3, which with no CORS configuration answers
608
+ // `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
609
+ // is not a 2xx fails the browser's check whatever headers are attached, so
610
+ // the POST is never sent and the upload dies as an opaque network error.
611
+ //
612
+ // This is why the end-to-end measurements did not catch it: they replayed
613
+ // the POST as a script, which has no preflight. A scripted POST is not an
614
+ // acceptance test for this route; a real browser upload is.
615
+ //
616
+ // Short-circuiting at the viewer also strengthens the containment argument
617
+ // above rather than weakening it - an OPTIONS that never reaches the origin
618
+ // can address nothing.
619
+ const allowedOrigins = options.allowedOrigins ?? ['*'];
620
+ const rewriteToBucketRoot = new cloudfront.Function(this, 'AssetUploadRewriteFunction', {
621
+ code: cloudfront.FunctionCode.fromInline([
622
+ `var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
623
+ 'function handler(event) {',
624
+ ' var request = event.request;',
625
+ " if (request.method === 'OPTIONS') {",
626
+ ' var headers = {',
627
+ ` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
628
+ " 'access-control-allow-headers': { value: '*' },",
629
+ ` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
630
+ ' };',
631
+ // Echo the caller's own Origin when the list is narrowed: a preflight
632
+ // may only be answered with `*` or the single requesting origin, so a
633
+ // multi-entry list cannot be returned verbatim. No match means no
634
+ // ACAO, and the browser's own check refuses the upload - which is the
635
+ // correct outcome, and a clearer one than a 403 here would be.
636
+ " var origin = request.headers.origin ? request.headers.origin.value : '';",
637
+ " if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
638
+ " headers['access-control-allow-origin'] = { value: '*' };",
639
+ ' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
640
+ " headers['access-control-allow-origin'] = { value: origin };",
641
+ ' }',
642
+ " return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
643
+ ' }',
644
+ " request.uri = '/';",
645
+ ' delete request.headers.authorization;',
646
+ ' return request;',
647
+ '}',
648
+ ].join('\n')),
649
+ });
650
+ // `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
651
+ // dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
652
+ // - the shape this path was measured against. It is NOT that policy
653
+ // overall, and must not be replaced by it: the managed one is documented as
654
+ // "Cookies: All, Query strings: All", so adopting it would forward the
655
+ // editor session cookie to S3, which is one of the two hazards this whole
656
+ // policy exists to remove. Only the header behavior is shared.
657
+ //
658
+ // `Authorization` is handled by the function above, not here.
659
+ //
660
+ // Cookies go through `CookiesConfig`, not the header list (a CloudFront
661
+ // Function does not even see them in `request.headers`), so
662
+ // `cookieBehavior.none()` is what strips them: an upload route mounted on
663
+ // a site's own distribution would otherwise hand S3 - and S3's access logs
664
+ // - the editor session cookie. On the dedicated distribution recommended
665
+ // in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
666
+ // none in the first place; this makes it true either way.
667
+ const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, 'AssetUploadOriginRequestPolicy', {
668
+ cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
669
+ headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
670
+ // A presigned POST carries everything in the multipart body.
671
+ queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
672
+ });
673
+ // The edge supplies Access-Control-Allow-Origin, scoped to this behavior
674
+ // only. This is the measurement that unblocked the whole design: a
675
+ // response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
676
+ // origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
677
+ // object landed). Acceptance and advertisement are independent in S3 -
678
+ // bucket CORS governs only whether S3 advertises - which is exactly the
679
+ // property that lets this construct avoid writing a bucket CORS rule.
680
+ //
681
+ // `originOverride: true` so the header is ours deterministically even on a
682
+ // bucket that does have its own CORS configuration.
683
+ //
684
+ // AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
685
+ // and this path DOES trigger one - see the CloudFront Function above, which
686
+ // answers it, and which exists because nothing else would. They are set
687
+ // here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
688
+ // two cannot tell a browser different things, and because a response
689
+ // headers policy is documented to decorate responses without saying whether
690
+ // a function-generated one is among them. If it is, `originOverride: true`
691
+ // means the policy's values REPLACE the function's identical ones; if it is
692
+ // not, the function's stand alone. Either way the viewer sees exactly one
693
+ // of each, which is what makes it safe not to know.
694
+ const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'AssetUploadResponseHeadersPolicy', {
695
+ corsBehavior: {
696
+ accessControlAllowOrigins: allowedOrigins,
697
+ accessControlAllowCredentials: false,
698
+ accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
699
+ accessControlAllowHeaders: ['*'],
700
+ accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
701
+ originOverride: true,
702
+ },
703
+ });
704
+ return {
705
+ origin: uploadOrigin,
706
+ // A POST is 405 without this. CloudFront offers no narrower set that
707
+ // includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
708
+ // The URI rewrite above is what makes that acceptable.
709
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
710
+ // HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
711
+ // redirects with 301, and a browser turns a 301'd POST into a GET - so
712
+ // an `http://` upload URL would silently become a bodyless GET that this
713
+ // behavior rewrites to `/` and S3 refuses, with the file never sent.
714
+ // HTTPS_ONLY answers 403 instead: same refusal, visible.
715
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
716
+ // Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
717
+ // cache key is also what keeps the origin request policy above legal
718
+ // (CloudFront requires it to be a superset of the cache key).
719
+ cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
720
+ originRequestPolicy,
721
+ responseHeadersPolicy,
722
+ functionAssociations: [
723
+ {
724
+ function: rewriteToBucketRoot,
725
+ eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
726
+ },
727
+ ],
728
+ };
729
+ }
362
730
  /**
363
731
  * The two CloudFront behavior configs this system needs.
364
732
  *
@@ -377,6 +745,62 @@ export class AssetSupport extends Construct {
377
745
  assetBehaviors() {
378
746
  return this.behaviors;
379
747
  }
748
+ /**
749
+ * The CloudFront behavior that accepts the editor's presigned-POST uploads -
750
+ * the infrastructure half of `media.uploadUrl`. Requires
751
+ * `AssetSupportProps.uploadBehavior`.
752
+ *
753
+ * GIVE IT ITS OWN DISTRIBUTION, serving this route and nothing else:
754
+ *
755
+ * ```ts
756
+ * const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
757
+ * defaultBehavior: assetSupport.uploadBehavior(),
758
+ * })
759
+ * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
760
+ * ```
761
+ *
762
+ * No custom domain or certificate is needed - the `d111...cloudfront.net`
763
+ * name is a perfectly good `uploadUrl` - and as the default behavior of a
764
+ * one-route distribution there is no path pattern and so no ordering
765
+ * question of the kind `attachTo()` exists to settle.
766
+ *
767
+ * Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
768
+ * is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
769
+ *
770
+ * - NOT a behavior on the site's own distribution. `CustomErrorResponses`
771
+ * are distribution-wide, so a site that maps 403 to its own 404 page
772
+ * applies that to S3's upload errors too and the editor reports the
773
+ * substituted status. The site's cookies and any cached basic-auth
774
+ * credential are also live hazards there that have to be stripped by
775
+ * policy and function (`buildUploadBehavior` removes both - cookies via
776
+ * the origin request policy, `Authorization` in the viewer-request
777
+ * function) rather than simply never
778
+ * being sent. It works; it is just strictly worse.
779
+ * - NOT one shared distribution serving asset reads AND writes for every
780
+ * environment. One assets distribution means one `AssetSupport`, and
781
+ * `AssetSupport` owns the transform Lambda - so it would also mean one
782
+ * transform Lambda shared by every environment, and that Lambda ships
783
+ * inside this package. A `canopycms-cdk` bump would then move every
784
+ * environment's asset pipeline at once, which is a graduated rollout
785
+ * traded away for an upload path. Reads and transforms stay
786
+ * per-environment; only the upload route moves.
787
+ *
788
+ * The upload route is the one part of this system with no per-environment
789
+ * code in it - it depends on the bucket and nothing else - which is why it
790
+ * is the part that can be split off this way.
791
+ */
792
+ uploadBehavior() {
793
+ if (!this.uploadOptions) {
794
+ throw new Error('AssetSupport: uploadBehavior() needs the `uploadBehavior` prop, which is off by ' +
795
+ 'default because it is the only thing this construct builds that puts a ' +
796
+ 'write-capable, OAC-UNSIGNED origin in front of the bucket. Pass ' +
797
+ '`uploadBehavior: {}` to opt in and take the defaults.');
798
+ }
799
+ // Memoized: calling this twice must not attach two sets of policies, and
800
+ // must not fail on duplicate construct ids.
801
+ this.upload ??= this.buildUploadBehavior(this.uploadOptions);
802
+ return this.upload;
803
+ }
380
804
  /**
381
805
  * Attach both asset behaviors to a concrete CloudFront distribution, in the
382
806
  * only safe order.
@@ -1,5 +1,5 @@
1
1
  import { Construct } from 'constructs';
2
- import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_efs as efs, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs } from 'aws-cdk-lib';
2
+ import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_logs as logs } from 'aws-cdk-lib';
3
3
  import type { IBucket } from 'aws-cdk-lib/aws-s3';
4
4
  /**
5
5
  * Default CMS Lambda timeout.
@@ -168,6 +168,38 @@ export interface CanopyCmsServiceProps {
168
168
  * default name would collide).
169
169
  */
170
170
  workerLogGroupName?: string;
171
+ /**
172
+ * Execution role for the CMS Lambda (default: CDK creates one).
173
+ *
174
+ * Set this when the role's ARN has to be computable WITHOUT a reference to
175
+ * this construct - the motivating case is an asset bucket in a different AWS
176
+ * account from the compute, where the resource-policy half of the
177
+ * cross-account grant must be written in the bucket's own stack and needs the
178
+ * principal as a plain string. Reading `lambdaFunction.role` across an
179
+ * account boundary does not give you that: CDK emits `Fn::GetStackOutput`, a
180
+ * CDK-CLI-only intrinsic resolved at deploy time, so the coupling is
181
+ * invisible to CloudFormation and unusable by any deploy path that is not
182
+ * `cdk deploy`. Create a deterministically NAMED role instead and both stacks
183
+ * can compute `arn:aws:iam::<account>:role/<name>` from literals, with
184
+ * nothing crossing between them.
185
+ *
186
+ * A named IAM role means the consuming stack needs `CAPABILITY_NAMED_IAM`,
187
+ * and cannot be replaced in place without a rename - that trade is yours to
188
+ * make here, which is the point of taking a role rather than a name.
189
+ *
190
+ * This Lambda is VPC-attached, which makes the compensation in
191
+ * `attachLambdaExecutionPolicies` (./lambda-execution-role) load-bearing
192
+ * rather than cosmetic: CDK discards `AWSLambdaVPCAccessExecutionRole` for a
193
+ * passed role, and without it the function cannot create ENIs and so cannot
194
+ * start - after synthesizing and deploying clean. The construct re-attaches
195
+ * it, which is also why this is `iam.Role` and not `iam.IRole` (an imported
196
+ * role would drop it again, silently). Everything else survives a passed role
197
+ * untouched: the EFS access-point statements, the `cmsLogGroup` write grant
198
+ * and the `assetBucket` grants all land on it.
199
+ *
200
+ * The EC2 worker has its own instance role and is unaffected by this prop.
201
+ */
202
+ lambdaRole?: iam.Role;
171
203
  /**
172
204
  * Retention for the CMS Lambda's CloudWatch log group (default: three
173
205
  * months / 90 days).
@@ -2,6 +2,7 @@ import * as path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { Construct } from 'constructs';
4
4
  import { Duration, RemovalPolicy, Stack, aws_ec2 as ec2, aws_efs as efs, aws_iam as iam, aws_lambda as lambda, aws_autoscaling as autoscaling, aws_s3_assets as s3assets, aws_logs as logs, } from 'aws-cdk-lib';
5
+ import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
5
6
  // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
6
7
  // output is real ESM - `__dirname` is not a global there. Found while
7
8
  // fixing this construct's B1 deploy blocker below: the worker asset path a
@@ -404,8 +405,19 @@ export class CanopyCmsService extends Construct {
404
405
  removalPolicy: RemovalPolicy.DESTROY,
405
406
  });
406
407
  this.timeout = props.timeout ?? DEFAULT_CMS_LAMBDA_TIMEOUT;
408
+ // Re-attach what CDK silently drops for a caller-supplied role. MUST run
409
+ // for every passed role, and `vpc: true` here is the load-bearing part:
410
+ // this function is VPC-attached, so a role without
411
+ // AWSLambdaVPCAccessExecutionRole cannot create ENIs and the Lambda cannot
412
+ // start, having deployed clean. See that function's doc comment.
413
+ if (props.lambdaRole) {
414
+ attachLambdaExecutionPolicies(props.lambdaRole, { vpc: true });
415
+ }
407
416
  this.lambdaFunction = new lambda.DockerImageFunction(this, 'CmsFunction', {
408
417
  code: props.cmsDockerImage,
418
+ // Default (unset) leaves CDK to create the execution role, with its own
419
+ // managed policies intact. See `lambdaRole`'s doc comment.
420
+ role: props.lambdaRole,
409
421
  memorySize: props.memorySize ?? 2048,
410
422
  timeout: this.timeout,
411
423
  reservedConcurrentExecutions: props.reservedConcurrency ?? 10,
@@ -0,0 +1,60 @@
1
+ import { aws_iam as iam } from 'aws-cdk-lib';
2
+ export interface LambdaExecutionPolicyOptions {
3
+ /**
4
+ * Whether the function this role belongs to is VPC-attached (i.e. was given
5
+ * a `vpc` prop). Mirrors the `props.vpc &&` condition in the CDK snippet
6
+ * above rather than being decided here, so the two cannot drift.
7
+ *
8
+ * Required rather than defaulted: getting it wrong on a VPC function
9
+ * produces a Lambda that cannot start, and a default would let a new call
10
+ * site inherit the wrong answer by saying nothing.
11
+ */
12
+ readonly vpc: boolean;
13
+ }
14
+ /**
15
+ * Re-attach what CDK drops when a `lambda.Function` is handed a pre-built
16
+ * execution role. Call this for every caller-supplied role, alongside passing
17
+ * that role to the function.
18
+ *
19
+ * The contract this maintains is deliberately the strong one: **passing a role
20
+ * yields the same effective permissions as letting the construct create one.**
21
+ * The weaker alternative - attach only what each function demonstrably still
22
+ * needs - is a judgment call that goes stale silently. Concretely, neither
23
+ * Lambda in this package strictly needs `AWSLambdaBasicExecutionRole` today:
24
+ * both log to a custom-named group (`/canopycms/<stackName>/...`), that
25
+ * policy's `logs:CreateLogStream`/`logs:PutLogEvents` are scoped to
26
+ * `arn:aws:logs:*:*:log-group:/aws/lambda/*:*` and so grant nothing for such a
27
+ * group, and an explicit `logGroup.grantWrite()` is what actually enables
28
+ * their logging. Attaching it regardless costs one managed-policy ARN and
29
+ * means the first change to either function's logging cannot quietly leave
30
+ * passed-role deployments behind.
31
+ *
32
+ * WHY THE PARAMETER IS THE CONCRETE `iam.Role` AND NOT `iam.IRole`, which is
33
+ * the type a CDK prop would normally take: `addManagedPolicy` does nothing
34
+ * useful on an imported role, in two distinct ways, neither of which surfaces
35
+ * an error.
36
+ *
37
+ * - `ImmutableRole.addManagedPolicy(_policy) {}` - an empty method body. This
38
+ * is what `Role.fromRoleArn` returns for a CROSS-ACCOUNT role, or for any
39
+ * role imported with `mutable: false`.
40
+ * - `ImportedRole.addManagedPolicy` (the same-account, still-mutable case)
41
+ * attaches the policy only if it exposes `attachToRole`, which
42
+ * `ManagedPolicy.fromAwsManagedPolicyName` does not - so it takes the
43
+ * warning branch and emits nothing.
44
+ *
45
+ * Both confirmed on a synthesized template: no `AWS::IAM::Role` and no
46
+ * `AWS::IAM::Policy` resource appears in either case. So typed `IRole`, this
47
+ * function would silently no-op for every imported role and ship exactly the
48
+ * cannot-start Lambda it exists to prevent. There is no runtime guard
49
+ * available either - `ImmutableRole.addToPrincipalPolicy` returns
50
+ * `statementAdded: true` while emitting nothing, so the one signal CDK offers
51
+ * lies. Narrowing the type moves the failure to COMPILE time instead, which is
52
+ * the earliest it can be caught; `attachTo(distribution:
53
+ * cloudfront.Distribution)` in asset-support.ts narrows to a concrete class for
54
+ * the same kind of reason.
55
+ *
56
+ * Residual, documented rather than guarded because of its rarity: a role
57
+ * produced under `Role.customizeRoles` carries a precreated `ImportedRole` that
58
+ * `addManagedPolicy` delegates to, landing on the warning branch above.
59
+ */
60
+ export declare function attachLambdaExecutionPolicies(role: iam.Role, options: LambdaExecutionPolicyOptions): void;
@@ -0,0 +1,90 @@
1
+ import { aws_iam as iam } from 'aws-cdk-lib';
2
+ /**
3
+ * The two AWS managed policies CDK's `lambda.Function` attaches to an
4
+ * execution role IT creates, and silently DISCARDS when one is passed in.
5
+ *
6
+ * Verbatim from `aws-cdk-lib/aws-lambda/lib/function.js` (checked against
7
+ * 2.265.0 here, and independently against 2.267.0 by the adopter who filed
8
+ * this):
9
+ *
10
+ * ```js
11
+ * managedPolicies.push(ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'))
12
+ * props.vpc && managedPolicies.push(ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'))
13
+ * this.role = props.role || new iam.Role(this, 'ServiceRole', { assumedBy: ..., managedPolicies })
14
+ * ```
15
+ *
16
+ * The array is built unconditionally and then reaches ONLY the `||` fallback.
17
+ * Pass a role and both entries are dropped with nothing logged. Confirmed on a
18
+ * synthesized template, not inferred: a VPC-attached function given a bare role
19
+ * emits an `AWS::IAM::Role` whose `ManagedPolicyArns` is absent entirely.
20
+ *
21
+ * For the VPC-attached CMS Lambda that is not a degradation, it is fatal and
22
+ * invisible: without `AWSLambdaVPCAccessExecutionRole` the function cannot
23
+ * create ENIs, so it cannot start at all - while synthesizing and deploying
24
+ * perfectly clean and failing only at invoke, a long way from the cause. Which
25
+ * is why every `role` prop in this package routes through here.
26
+ *
27
+ * NOT affected by passing a role, so deliberately not re-applied below (all
28
+ * three verified in the same CDK source):
29
+ *
30
+ * - EFS access-point statements - `config.policies.forEach(p => this.role?.addToPrincipalPolicy(p))`.
31
+ * - `initialPolicy` - `this.role.addToPrincipalPolicy(statement)` per statement.
32
+ * - Every `grant*` call, because `this.grantPrincipal = this.role`. That covers
33
+ * `cmsLogGroup`/`transformLogGroup`'s `grantWrite` and all asset-bucket
34
+ * grants, which land on the passed role exactly as they would on a created
35
+ * one.
36
+ */
37
+ const BASIC_EXECUTION_POLICY = 'service-role/AWSLambdaBasicExecutionRole';
38
+ const VPC_ACCESS_POLICY = 'service-role/AWSLambdaVPCAccessExecutionRole';
39
+ /**
40
+ * Re-attach what CDK drops when a `lambda.Function` is handed a pre-built
41
+ * execution role. Call this for every caller-supplied role, alongside passing
42
+ * that role to the function.
43
+ *
44
+ * The contract this maintains is deliberately the strong one: **passing a role
45
+ * yields the same effective permissions as letting the construct create one.**
46
+ * The weaker alternative - attach only what each function demonstrably still
47
+ * needs - is a judgment call that goes stale silently. Concretely, neither
48
+ * Lambda in this package strictly needs `AWSLambdaBasicExecutionRole` today:
49
+ * both log to a custom-named group (`/canopycms/<stackName>/...`), that
50
+ * policy's `logs:CreateLogStream`/`logs:PutLogEvents` are scoped to
51
+ * `arn:aws:logs:*:*:log-group:/aws/lambda/*:*` and so grant nothing for such a
52
+ * group, and an explicit `logGroup.grantWrite()` is what actually enables
53
+ * their logging. Attaching it regardless costs one managed-policy ARN and
54
+ * means the first change to either function's logging cannot quietly leave
55
+ * passed-role deployments behind.
56
+ *
57
+ * WHY THE PARAMETER IS THE CONCRETE `iam.Role` AND NOT `iam.IRole`, which is
58
+ * the type a CDK prop would normally take: `addManagedPolicy` does nothing
59
+ * useful on an imported role, in two distinct ways, neither of which surfaces
60
+ * an error.
61
+ *
62
+ * - `ImmutableRole.addManagedPolicy(_policy) {}` - an empty method body. This
63
+ * is what `Role.fromRoleArn` returns for a CROSS-ACCOUNT role, or for any
64
+ * role imported with `mutable: false`.
65
+ * - `ImportedRole.addManagedPolicy` (the same-account, still-mutable case)
66
+ * attaches the policy only if it exposes `attachToRole`, which
67
+ * `ManagedPolicy.fromAwsManagedPolicyName` does not - so it takes the
68
+ * warning branch and emits nothing.
69
+ *
70
+ * Both confirmed on a synthesized template: no `AWS::IAM::Role` and no
71
+ * `AWS::IAM::Policy` resource appears in either case. So typed `IRole`, this
72
+ * function would silently no-op for every imported role and ship exactly the
73
+ * cannot-start Lambda it exists to prevent. There is no runtime guard
74
+ * available either - `ImmutableRole.addToPrincipalPolicy` returns
75
+ * `statementAdded: true` while emitting nothing, so the one signal CDK offers
76
+ * lies. Narrowing the type moves the failure to COMPILE time instead, which is
77
+ * the earliest it can be caught; `attachTo(distribution:
78
+ * cloudfront.Distribution)` in asset-support.ts narrows to a concrete class for
79
+ * the same kind of reason.
80
+ *
81
+ * Residual, documented rather than guarded because of its rarity: a role
82
+ * produced under `Role.customizeRoles` carries a precreated `ImportedRole` that
83
+ * `addManagedPolicy` delegates to, landing on the warning branch above.
84
+ */
85
+ export function attachLambdaExecutionPolicies(role, options) {
86
+ role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName(BASIC_EXECUTION_POLICY));
87
+ if (options.vpc) {
88
+ role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName(VPC_ACCESS_POLICY));
89
+ }
90
+ }
package/dist/index.d.ts CHANGED
@@ -5,4 +5,4 @@ export type { CanopyCmsServiceProps } from './constructs/cms-service.js';
5
5
  export { CanopyCmsDistribution } from './constructs/cms-distribution.js';
6
6
  export type { CanopyCmsDistributionProps } from './constructs/cms-distribution.js';
7
7
  export { AssetSupport } from './constructs/asset-support.js';
8
- export type { AssetSupportProps, AssetCloudFrontBehaviors } from './constructs/asset-support.js';
8
+ export type { AssetSupportProps, AssetCloudFrontBehaviors, AssetUploadBehaviorOptions, } from './constructs/asset-support.js';
@@ -4368,6 +4368,66 @@ var init_collection = __esm({
4368
4368
  }
4369
4369
  });
4370
4370
 
4371
+ // ../canopycms/src/utils/sanitize-href.ts
4372
+ function declaresScheme(url) {
4373
+ return /^[a-z][a-z0-9+.-]*:/i.test(url.trim());
4374
+ }
4375
+ function isImplicitlyOffOrigin(url) {
4376
+ if (declaresScheme(url)) return false;
4377
+ try {
4378
+ return new URL(url, SENTINEL_BASE).origin !== SENTINEL_BASE;
4379
+ } catch {
4380
+ return false;
4381
+ }
4382
+ }
4383
+ function isHttpUrlOrSameOriginPath(value, opts = {}) {
4384
+ if (value === "") return false;
4385
+ for (let index = 0; index < value.length; index++) {
4386
+ const code = value.charCodeAt(index);
4387
+ if (code <= 32 || code === 127) return false;
4388
+ }
4389
+ if (value.includes("?") || value.includes("#")) return false;
4390
+ if (value.includes("\\")) return false;
4391
+ if (DOT_SEGMENT.test(value)) return false;
4392
+ if (declaresScheme(value)) {
4393
+ if (!/^https?:\/\//i.test(value)) return false;
4394
+ try {
4395
+ new URL(value);
4396
+ return true;
4397
+ } catch {
4398
+ return false;
4399
+ }
4400
+ }
4401
+ if (isImplicitlyOffOrigin(value)) {
4402
+ return opts.allowProtocolRelative === true && value.startsWith("//") && value[2] !== "/";
4403
+ }
4404
+ return value.startsWith("/") && value[1] !== "/";
4405
+ }
4406
+ var SENTINEL_BASE, DOT_SEGMENT;
4407
+ var init_sanitize_href = __esm({
4408
+ "../canopycms/src/utils/sanitize-href.ts"() {
4409
+ "use strict";
4410
+ SENTINEL_BASE = "https://relative.invalid";
4411
+ DOT_SEGMENT = /(^|\/)(\.|%2e){1,2}(\/|$)/i;
4412
+ }
4413
+ });
4414
+
4415
+ // ../canopycms/src/config/schemas/url.ts
4416
+ var uploadTargetUrlSchema, assetMountUrlSchema;
4417
+ var init_url = __esm({
4418
+ "../canopycms/src/config/schemas/url.ts"() {
4419
+ "use strict";
4420
+ init_zod();
4421
+ init_sanitize_href();
4422
+ uploadTargetUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
4423
+ message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
4424
+ });
4425
+ assetMountUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
4426
+ message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
4427
+ });
4428
+ }
4429
+ });
4430
+
4371
4431
  // ../canopycms/src/config/schemas/media.ts
4372
4432
  var mediaSchema;
4373
4433
  var init_media = __esm({
@@ -4375,25 +4435,41 @@ var init_media = __esm({
4375
4435
  "use strict";
4376
4436
  init_zod();
4377
4437
  init_collection();
4438
+ init_url();
4378
4439
  mediaSchema = external_exports.discriminatedUnion("adapter", [
4379
4440
  external_exports.object({
4380
4441
  adapter: external_exports.literal("local"),
4381
- publicBaseUrl: external_exports.string().url().optional(),
4442
+ publicBaseUrl: assetMountUrlSchema.optional(),
4382
4443
  /** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
4383
4444
  directory: relativePathSchema.optional()
4384
- }),
4445
+ }).strict(),
4385
4446
  external_exports.object({
4386
4447
  adapter: external_exports.literal("s3"),
4387
4448
  bucket: external_exports.string().min(1),
4388
4449
  region: external_exports.string().min(1),
4389
- publicBaseUrl: external_exports.string().url().optional(),
4450
+ publicBaseUrl: assetMountUrlSchema.optional(),
4451
+ /**
4452
+ * Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
4453
+ * site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
4454
+ *
4455
+ * Set this to route uploads through your own CDN so they are same-origin with the
4456
+ * editor, which removes the need for a bucket CORS rule naming an exact origin. The
4457
+ * signature is unaffected — a presigned POST's string-to-sign is the base64 policy
4458
+ * alone, so the host never enters it (pinned by store-s3.test.ts).
4459
+ *
4460
+ * NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
4461
+ * the POST target outright and is passed through byte-for-byte. Do not route it through
4462
+ * `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
4463
+ * stop a CDN path pattern like `/asset-upload/*` from matching.
4464
+ */
4465
+ uploadUrl: uploadTargetUrlSchema.optional(),
4390
4466
  /** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
4391
4467
  maxUploadBytes: external_exports.number().int().positive().optional()
4392
- }),
4468
+ }).strict(),
4393
4469
  external_exports.object({
4394
4470
  adapter: external_exports.literal("lfs"),
4395
- publicBaseUrl: external_exports.string().url().optional()
4396
- })
4471
+ publicBaseUrl: assetMountUrlSchema.optional()
4472
+ }).strict()
4397
4473
  ]);
4398
4474
  }
4399
4475
  });
@@ -28367,6 +28443,9 @@ var BLOCK_STRUCTURAL_KEYS = new Set(BLOCK_DISCRIMINATOR_KEYS);
28367
28443
  init_error();
28368
28444
  init_logger();
28369
28445
 
28446
+ // ../canopycms/src/utils/url-prefix.ts
28447
+ init_sanitize_href();
28448
+
28370
28449
  // ../canopycms/src/static/seo.ts
28371
28450
  var DEFAULT_SEO_FIELD_NAMES = {
28372
28451
  title: "metaTitle",
@@ -30365,6 +30444,7 @@ var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
30365
30444
  // ../canopycms/src/assets/store-s3.ts
30366
30445
  var import_client_s3 = require("@aws-sdk/client-s3");
30367
30446
  var import_s3_presigned_post = require("@aws-sdk/s3-presigned-post");
30447
+ init_sanitize_href();
30368
30448
  var DEFAULT_MAX_UPLOAD_BYTES2 = 50 * 1024 * 1024;
30369
30449
  var PRESIGN_EXPIRY_SECONDS = 15 * 60;
30370
30450
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopycms-cdk",
3
- "version": "0.0.66-int.84",
3
+ "version": "0.0.66-int.86",
4
4
  "description": "AWS CDK constructs and EC2 worker for CanopyCMS deployment",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,8 +14,9 @@
14
14
  "types": "dist/index.d.ts",
15
15
  "exports": {
16
16
  ".": {
17
+ "types": "./dist/index.d.ts",
17
18
  "import": "./dist/index.js",
18
- "types": "./dist/index.d.ts"
19
+ "require": "./dist/index.js"
19
20
  }
20
21
  },
21
22
  "files": [
@@ -25,12 +26,12 @@
25
26
  "lambda/asset-transform/dist"
26
27
  ],
27
28
  "engines": {
28
- "node": ">=18"
29
+ "node": ">=22.12.0"
29
30
  },
30
31
  "peerDependencies": {
31
32
  "aws-cdk-lib": "^2.260.0",
32
33
  "constructs": "^10.0.0",
33
- "canopycms": "0.0.66-int.84"
34
+ "canopycms": "0.0.66-int.86"
34
35
  },
35
36
  "peerDependenciesMeta": {
36
37
  "aws-cdk-lib": {
@@ -54,16 +55,16 @@
54
55
  "tsx": "^4.19.0",
55
56
  "typescript": "^5.6.3",
56
57
  "vitest": "^4.1.0",
57
- "canopycms": "0.0.66-int.84",
58
- "canopycms-auth-clerk": "0.0.66-int.84"
58
+ "canopycms": "0.0.66-int.86",
59
+ "canopycms-auth-clerk": "0.0.66-int.86"
59
60
  },
60
61
  "scripts": {
61
62
  "build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
62
63
  "build:worker": "esbuild worker/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=worker/dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"",
63
64
  "build:lambda": "node lambda/asset-transform/build.mjs",
64
65
  "build:test-fixtures": "pnpm run build:worker && node lambda/asset-transform/build.mjs --skip-native",
65
- "lint": "eslint src/ 'lambda/**/*.ts' 'canary/**/*.ts' 'worker/**/*.ts'",
66
+ "lint": "eslint src/ 'lambda/**/*.ts' 'canary/**/*.ts' 'worker/**/*.ts' 'test-support/**/*.ts'",
66
67
  "test": "pnpm run build:test-fixtures && vitest run",
67
- "typecheck": "tsc --noEmit && tsc --noEmit -p lambda/tsconfig.json && tsc --noEmit -p canary/tsconfig.json && tsc --noEmit -p worker/tsconfig.json"
68
+ "typecheck": "tsc --noEmit && tsc --noEmit -p lambda/tsconfig.json && tsc --noEmit -p canary/tsconfig.json && tsc --noEmit -p worker/tsconfig.json && tsc --noEmit -p test-support/tsconfig.json"
68
69
  }
69
70
  }
@@ -12366,6 +12366,66 @@ var init_collection = __esm({
12366
12366
  }
12367
12367
  });
12368
12368
 
12369
+ // ../canopycms/src/utils/sanitize-href.ts
12370
+ function declaresScheme(url) {
12371
+ return /^[a-z][a-z0-9+.-]*:/i.test(url.trim());
12372
+ }
12373
+ function isImplicitlyOffOrigin(url) {
12374
+ if (declaresScheme(url)) return false;
12375
+ try {
12376
+ return new URL(url, SENTINEL_BASE).origin !== SENTINEL_BASE;
12377
+ } catch {
12378
+ return false;
12379
+ }
12380
+ }
12381
+ function isHttpUrlOrSameOriginPath(value, opts = {}) {
12382
+ if (value === "") return false;
12383
+ for (let index = 0; index < value.length; index++) {
12384
+ const code = value.charCodeAt(index);
12385
+ if (code <= 32 || code === 127) return false;
12386
+ }
12387
+ if (value.includes("?") || value.includes("#")) return false;
12388
+ if (value.includes("\\")) return false;
12389
+ if (DOT_SEGMENT.test(value)) return false;
12390
+ if (declaresScheme(value)) {
12391
+ if (!/^https?:\/\//i.test(value)) return false;
12392
+ try {
12393
+ new URL(value);
12394
+ return true;
12395
+ } catch {
12396
+ return false;
12397
+ }
12398
+ }
12399
+ if (isImplicitlyOffOrigin(value)) {
12400
+ return opts.allowProtocolRelative === true && value.startsWith("//") && value[2] !== "/";
12401
+ }
12402
+ return value.startsWith("/") && value[1] !== "/";
12403
+ }
12404
+ var SENTINEL_BASE, DOT_SEGMENT;
12405
+ var init_sanitize_href = __esm({
12406
+ "../canopycms/src/utils/sanitize-href.ts"() {
12407
+ "use strict";
12408
+ SENTINEL_BASE = "https://relative.invalid";
12409
+ DOT_SEGMENT = /(^|\/)(\.|%2e){1,2}(\/|$)/i;
12410
+ }
12411
+ });
12412
+
12413
+ // ../canopycms/src/config/schemas/url.ts
12414
+ var uploadTargetUrlSchema, assetMountUrlSchema;
12415
+ var init_url = __esm({
12416
+ "../canopycms/src/config/schemas/url.ts"() {
12417
+ "use strict";
12418
+ init_zod();
12419
+ init_sanitize_href();
12420
+ uploadTargetUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
12421
+ message: 'must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
12422
+ });
12423
+ assetMountUrlSchema = external_exports.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
12424
+ message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
12425
+ });
12426
+ }
12427
+ });
12428
+
12369
12429
  // ../canopycms/src/config/schemas/media.ts
12370
12430
  var mediaSchema;
12371
12431
  var init_media = __esm({
@@ -12373,25 +12433,41 @@ var init_media = __esm({
12373
12433
  "use strict";
12374
12434
  init_zod();
12375
12435
  init_collection();
12436
+ init_url();
12376
12437
  mediaSchema = external_exports.discriminatedUnion("adapter", [
12377
12438
  external_exports.object({
12378
12439
  adapter: external_exports.literal("local"),
12379
- publicBaseUrl: external_exports.string().url().optional(),
12440
+ publicBaseUrl: assetMountUrlSchema.optional(),
12380
12441
  /** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
12381
12442
  directory: relativePathSchema.optional()
12382
- }),
12443
+ }).strict(),
12383
12444
  external_exports.object({
12384
12445
  adapter: external_exports.literal("s3"),
12385
12446
  bucket: external_exports.string().min(1),
12386
12447
  region: external_exports.string().min(1),
12387
- publicBaseUrl: external_exports.string().url().optional(),
12448
+ publicBaseUrl: assetMountUrlSchema.optional(),
12449
+ /**
12450
+ * Where the browser POSTs a presigned direct upload. Absolute http(s) URL or a
12451
+ * site-relative path; defaults to the S3 REST endpoint the AWS SDK returns.
12452
+ *
12453
+ * Set this to route uploads through your own CDN so they are same-origin with the
12454
+ * editor, which removes the need for a bucket CORS rule naming an exact origin. The
12455
+ * signature is unaffected — a presigned POST's string-to-sign is the base64 policy
12456
+ * alone, so the host never enters it (pinned by store-s3.test.ts).
12457
+ *
12458
+ * NOT a prefix. Unlike `publicBaseUrl`, nothing is joined onto this value; it replaces
12459
+ * the POST target outright and is passed through byte-for-byte. Do not route it through
12460
+ * `joinUrlPrefix` "for consistency" — that would strip the trailing slash and silently
12461
+ * stop a CDN path pattern like `/asset-upload/*` from matching.
12462
+ */
12463
+ uploadUrl: uploadTargetUrlSchema.optional(),
12388
12464
  /** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
12389
12465
  maxUploadBytes: external_exports.number().int().positive().optional()
12390
- }),
12466
+ }).strict(),
12391
12467
  external_exports.object({
12392
12468
  adapter: external_exports.literal("lfs"),
12393
- publicBaseUrl: external_exports.string().url().optional()
12394
- })
12469
+ publicBaseUrl: assetMountUrlSchema.optional()
12470
+ }).strict()
12395
12471
  ]);
12396
12472
  }
12397
12473
  });