canopycms-cdk 0.0.66-int.85 → 0.0.66

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,61 @@ 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 CAVEATS - three, and the first is about your bucket policy:
83
+ *
84
+ * 1. YOUR BUCKET POLICY IS THE ONLY GATE ON THIS ROUTE. The read behaviors
85
+ * reach the bucket through an OAC-SIGNED origin, so a permissive policy
86
+ * there is still gated by a signature CloudFront adds. This origin cannot
87
+ * be signed (CloudFront never hashes the body, so an OAC-signed origin
88
+ * rejects every multipart POST), so nothing gates it but the policy. The
89
+ * route is contained by rewriting every request to `/`, which leaves an
90
+ * anonymous caller only the bucket-level operations at the root - so what
91
+ * your policy grants anonymously AT THE BUCKET ROOT is what is reachable.
92
+ * A bucket this construct creates refuses all of them (BLOCK_ALL, no
93
+ * public policy); an existing bucket is yours to have got right, and "the
94
+ * read path already works" is not evidence that it is.
95
+ * 2. This origin addresses the bucket by `bucketRegionalDomainName`, and for
96
+ * a bucket imported with `Bucket.fromBucketName()` that resolves to the
97
+ * STACK's region, not the bucket's. The read path tolerates a mismatch
98
+ * because CloudFront follows S3's region redirect on an S3-type origin;
99
+ * this one is a custom origin and does not, so S3's 301 reaches the
100
+ * browser, where a redirected cross-origin POST surfaces as an opaque
101
+ * network error. Import a cross-region bucket with
102
+ * `Bucket.fromBucketAttributes({ region })` so the domain name is right.
103
+ * 3. A DOT in the bucket name breaks this origin specifically - refused at
104
+ * synth, with the reason, in `buildUploadBehavior`.
105
+ *
106
+ * @default - no upload behavior is built
107
+ */
108
+ readonly uploadBehavior?: AssetUploadBehaviorOptions;
23
109
  /**
24
110
  * Advisory upload-size cap in bytes. Not enforced by this construct -
25
111
  * the actual cap comes from the presigned POST's `content-length-range`
@@ -153,6 +239,16 @@ export declare const ASSETS_TRANSFORM_PATH_PATTERN: string;
153
239
  * `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
154
240
  * instead. Exported so `cms-distribution.ts`'s synth-time guard can
155
241
  * recognize the mistake and name it in its error.
242
+ *
243
+ * `uploadBehavior()` deliberately adds NO key here, considered and rejected
244
+ * when it was added. It returns a bare `BehaviorOptions` rather than a named
245
+ * property of a returned object, so there is no spread to get wrong in the
246
+ * first place - and a speculative `'upload'` entry would actively misfire on
247
+ * an adopter whose distribution has a real upload route, since CloudFront
248
+ * treats a path pattern's leading slash as optional and `upload` is a legal
249
+ * spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
250
+ * Keep this list to property names that actually exist on
251
+ * `AssetCloudFrontBehaviors`.
156
252
  */
157
253
  export declare const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS: readonly ["assets", "assetsTransform"];
158
254
  /**
@@ -228,6 +324,9 @@ export interface AssetCloudFrontBehaviors {
228
324
  * for why the order is the whole point); `assetBehaviors()` is the escape
229
325
  * hatch for a distribution built entirely by hand (see
230
326
  * `AssetCloudFrontBehaviors`'s doc comment).
327
+ * - `uploadBehavior()`, opt-in, the write half: a CloudFront behavior that
328
+ * accepts the editor's presigned-POST uploads, for adopters who set
329
+ * `media.uploadUrl`. Belongs on its own distribution - see that method.
231
330
  * - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
232
331
  * principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
233
332
  */
@@ -243,14 +342,41 @@ export declare class AssetSupport extends Construct {
243
342
  /** Effective advisory upload-size cap (see `AssetSupportProps.maxUploadBytes`). */
244
343
  readonly maxUploadBytes: number;
245
344
  private readonly behaviors;
345
+ /** Set only when `AssetSupportProps.uploadBehavior` is set; validated in the constructor. */
346
+ private readonly uploadOptions?;
347
+ /**
348
+ * Memoized on the first `uploadBehavior()` call, NOT built in the
349
+ * constructor. Opting in creates three CloudFront resources (function,
350
+ * origin request policy, response headers policy) and response headers
351
+ * policies have a default account quota of 20 - so an adopter who sets the
352
+ * prop on a per-environment `AssetSupport` while building the single shared
353
+ * upload distribution the accessor recommends would otherwise mint a set per
354
+ * environment for one route. Deferring is invisible other than in what gets
355
+ * emitted, with one exception worth knowing rather than discovering: calling
356
+ * the accessor AFTER a synth has run modifies the construct tree, and CDK
357
+ * refuses that with `ConstructTreeModifiedAfterSynth` naming the constructs
358
+ * added. Loud, not silent.
359
+ */
360
+ private upload?;
361
+ /**
362
+ * The origin inside `upload`, kept so the validation below can ask whether
363
+ * that behavior was ever attached to a distribution - see `UploadOrigin`.
364
+ * Set by `buildUploadBehavior`, i.e. at the same moment as `upload`.
365
+ */
366
+ private uploadOrigin?;
246
367
  constructor(scope: Construct, id: string, props: AssetSupportProps);
247
368
  private buildBehaviors;
369
+ /**
370
+ * The behavior that accepts the editor's presigned-POST uploads. Every piece
371
+ * below is load-bearing; see `uploadBehavior()` for where the result goes.
372
+ */
373
+ private buildUploadBehavior;
248
374
  /**
249
375
  * The two CloudFront behavior configs this system needs.
250
376
  *
251
- * If you use this rather than `attachTo` -- which now takes an `overrides`
252
- * parameter, so needing per-behavior options is no longer a reason to fall
253
- * back here -- you own the ordering, and you should assert it: read the
377
+ * If you use this rather than `attachTo` -- which takes an `overrides`
378
+ * parameter, so needing per-behavior options is not a reason to fall back
379
+ * here -- you own the ordering, and you should assert it: read the
254
380
  * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
255
381
  * object, since the property is about emitted order.
256
382
  *
@@ -261,6 +387,51 @@ export declare class AssetSupport extends Construct {
261
387
  * doc comment for that shape and its ordering requirements.
262
388
  */
263
389
  assetBehaviors(): AssetCloudFrontBehaviors;
390
+ /**
391
+ * The CloudFront behavior that accepts the editor's presigned-POST uploads -
392
+ * the infrastructure half of `media.uploadUrl`. Requires
393
+ * `AssetSupportProps.uploadBehavior`.
394
+ *
395
+ * GIVE IT ITS OWN DISTRIBUTION, serving this route and nothing else:
396
+ *
397
+ * ```ts
398
+ * const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
399
+ * defaultBehavior: assetSupport.uploadBehavior(),
400
+ * })
401
+ * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
402
+ * ```
403
+ *
404
+ * No custom domain or certificate is needed - the `d111...cloudfront.net`
405
+ * name is a perfectly good `uploadUrl` - and as the default behavior of a
406
+ * one-route distribution there is no path pattern and so no ordering
407
+ * question of the kind `attachTo()` exists to settle.
408
+ *
409
+ * Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
410
+ * is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
411
+ *
412
+ * - NOT a behavior on the site's own distribution. `CustomErrorResponses`
413
+ * are distribution-wide, so a site that maps 403 to its own 404 page
414
+ * applies that to S3's upload errors too and the editor reports the
415
+ * substituted status. The site's cookies and any cached basic-auth
416
+ * credential are also live hazards there that have to be stripped by
417
+ * policy and function (`buildUploadBehavior` removes both - cookies via
418
+ * the origin request policy, `Authorization` in the viewer-request
419
+ * function) rather than simply never
420
+ * being sent. It works; it is just strictly worse.
421
+ * - NOT one shared distribution serving asset reads AND writes for every
422
+ * environment. One assets distribution means one `AssetSupport`, and
423
+ * `AssetSupport` owns the transform Lambda - so it would also mean one
424
+ * transform Lambda shared by every environment, and that Lambda ships
425
+ * inside this package. A `canopycms-cdk` bump would then move every
426
+ * environment's asset pipeline at once, which is a graduated rollout
427
+ * traded away for an upload path. Reads and transforms stay
428
+ * per-environment; only the upload route moves.
429
+ *
430
+ * The upload route is the one part of this system with no per-environment
431
+ * code in it - it depends on the bucket and nothing else - which is why it
432
+ * is the part that can be split off this way.
433
+ */
434
+ uploadBehavior(): cloudfront.BehaviorOptions;
264
435
  /**
265
436
  * Attach both asset behaviors to a concrete CloudFront distribution, in the
266
437
  * only safe order.
@@ -298,14 +469,11 @@ export declare class AssetSupport extends Construct {
298
469
  * has left this method's remit and should use `assetBehaviors()` - and keep
299
470
  * their own ordering assertion.
300
471
  *
301
- * Typed `Partial<AddBehaviorOptions>` because that is exactly what
302
- * `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
303
- * POSITIONAL argument there, so `BehaviorOptions` (which is
304
- * `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
305
- * silently ignores. Measured, because the first version of this comment
306
- * claimed the opposite: widening the parameter and passing an `origin`
307
- * override changes nothing in the emitted template, so this narrowing
308
- * prevents a confusing no-op rather than a broken origin group.
472
+ * Typed `Partial<AddBehaviorOptions>`, not `BehaviorOptions`, because
473
+ * `addBehavior` takes `origin` positionally - so an `origin` key here would be
474
+ * a silently ignored no-op. (Measured: passing one changes nothing in the
475
+ * emitted template. It is a confusing no-op being prevented, not a broken
476
+ * origin group.)
309
477
  *
310
478
  * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
311
479
  * method on that class, not on `IDistribution` (what an imported/looked-up
@@ -2,7 +2,7 @@ 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
6
  import { attachLambdaExecutionPolicies } from './lambda-execution-role.js';
7
7
  // This package (`canopycms-cdk`) is `"type": "module"`, so its compiled
8
8
  // output is real ESM - `__dirname` is not a global there. Derive it from
@@ -32,17 +32,11 @@ const transformAssetDir = path.join(__dirname, '..', '..', 'lambda', 'asset-tran
32
32
  */
33
33
  const DEPLOYABLE_MARKER = '.deployable';
34
34
  /**
35
- * The four S3 key prefixes the asset system uses, mirrored from
36
- * `packages/canopycms/src/assets/asset-prefixes.ts` (the source of truth).
37
- * Duplicated as plain string literals - not imported from `canopycms` -
38
- * because this construct must synth cleanly for ANY consumer's CDK app
39
- * (e.g. a site's own `infrastructure/` package), which has no reason to
40
- * have `canopycms` itself resolvable from wherever its CDK code runs. The
41
- * transform Lambda (../../lambda/asset-transform/handler.ts), by contrast,
42
- * is bundled at build time from WITHIN this package (where `canopycms` is a
43
- * real workspace devDependency), so it imports the canonical constants
44
- * directly from `canopycms/server` instead of duplicating them - see that
45
- * file's doc comment.
35
+ * The four S3 key prefixes the asset system uses. Source of truth is
36
+ * `packages/canopycms/src/assets/asset-prefixes.ts`; these are copied as
37
+ * literals rather than imported so this construct synths in a consumer CDK app
38
+ * that has no `canopycms` resolvable. (The transform Lambda does import them -
39
+ * it is bundled from inside this package; see its handler's doc comment.)
46
40
  */
47
41
  const PREFIXES = {
48
42
  originals: 'asset-originals',
@@ -51,8 +45,36 @@ const PREFIXES = {
51
45
  public: 'assets',
52
46
  transform: 'assets/t',
53
47
  };
54
- /** S3 CORS preflight cache duration for presigned-POST uploads from the editor. */
48
+ /**
49
+ * CORS preflight cache duration for presigned-POST uploads from the editor.
50
+ * Used for the bucket's own CORS rule and, when `uploadBehavior` is on, for
51
+ * both the edge's preflight response and its response headers policy.
52
+ */
55
53
  const CORS_MAX_AGE_SECONDS = 3000;
54
+ /**
55
+ * The only method the upload route exists to serve. Shared by the preflight
56
+ * response the CloudFront Function returns and the response headers policy's
57
+ * `Access-Control-Allow-Methods`, so a browser cannot be told two different
58
+ * things about what it may send. (The BEHAVIOR still allows all methods -
59
+ * CloudFront has no narrower set containing POST - which is a separate concern
60
+ * handled by the URI rewrite; see `buildUploadBehavior`.)
61
+ */
62
+ const UPLOAD_ALLOWED_METHOD = 'POST';
63
+ /**
64
+ * Render a string array as a JavaScript literal for embedding in CloudFront
65
+ * Function source.
66
+ *
67
+ * `JSON.stringify` already escapes quotes and backslashes, so an adopter's
68
+ * origin string cannot terminate the literal and inject code. It does NOT
69
+ * escape U+2028/U+2029, which are legal in JSON but were line terminators in
70
+ * JavaScript before ES2019 - and the CloudFront Functions runtime is not a
71
+ * runtime to find out about on. Escaping them costs one `replace`.
72
+ */
73
+ function toFunctionLiteral(values) {
74
+ return JSON.stringify(values)
75
+ .replace(/\u2028/g, '\\u2028')
76
+ .replace(/\u2029/g, '\\u2029');
77
+ }
56
78
  /** Matches packages/canopycms/src/assets/store-s3.ts's `DEFAULT_MAX_UPLOAD_BYTES`. */
57
79
  const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
58
80
  const TRANSFORM_LAMBDA_MEMORY_MB = 1536;
@@ -113,23 +135,64 @@ const TRANSFORM_CACHE_MIN_TTL = Duration.seconds(0);
113
135
  const TRANSFORM_CACHE_DEFAULT_TTL = Duration.days(1);
114
136
  const TRANSFORM_CACHE_MAX_TTL = Duration.days(365);
115
137
  /**
116
- * Distributions that already carry the asset behaviors, tracked at MODULE level
117
- * rather than per instance.
138
+ * Id of the marker construct `attachTo` adds to a distribution to record that
139
+ * the asset behaviors are already on it.
118
140
  *
119
- * Per-instance (the first version of this) only caught the same `AssetSupport`
120
- * attaching twice. Two `AssetSupport` constructs attaching to one distribution
121
- * slipped through and synthesized
122
- * ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] - the identical
123
- * duplicate-path-pattern deploy failure the guard exists to convert into a
124
- * synth error. There is no legitimate form of that: both instances attach the
125
- * same two patterns, so the second is always wrong regardless of which
126
- * construct owns it.
141
+ * The fact being recorded is "THIS distribution already carries these two
142
+ * patterns", so it is stored ON that distribution rather than in a module-level
143
+ * registry. Per-INSTANCE state was the first version and was wrong: two
144
+ * different `AssetSupport` constructs attaching to one distribution slipped
145
+ * through and synthesized ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'],
146
+ * the duplicate-path-pattern deploy failure the guard exists to convert into a
147
+ * synth error. There is no legitimate form of that - both instances attach the
148
+ * same two patterns, so the second is always wrong regardless of which construct
149
+ * owns it - so the check has to be keyed on the distribution, not the attacher.
127
150
  *
128
- * A `WeakSet` keyed on the distribution object, so it holds no reference that
129
- * would outlive the construct tree and cannot leak across CDK apps in a test
130
- * process.
151
+ * A module-level `WeakSet` keyed on the distribution object did that correctly
152
+ * (a `cloudfront.Distribution` instance belongs to exactly one construct tree,
153
+ * and `attachTo` takes a concrete instance, so one could never be claimed by a
154
+ * second CDK app). A child construct is preferred anyway because the scoping is
155
+ * then a property of the tree rather than something the reader has to reason
156
+ * about process lifetime to trust - and it is visible in `node.children`, so a
157
+ * test can assert the guard's state instead of only its error.
158
+ *
159
+ * The marker is a bare `Construct`, which emits nothing into the template.
131
160
  */
132
- const distributionsWithAssetBehaviors = new WeakSet();
161
+ const ATTACHED_MARKER_ID = 'CanopyAssetBehaviorsAttached';
162
+ /**
163
+ * The upload behavior's origin, which records having been bound to a
164
+ * distribution.
165
+ *
166
+ * This exists so the synth-time validation below can tell "`uploadBehavior()`
167
+ * was CALLED" from "its result actually reached a distribution" - two states a
168
+ * memoized accessor cannot distinguish on its own, and the second is the one
169
+ * that determines whether anything supplies Access-Control-Allow-Origin.
170
+ *
171
+ * CDK calls `bind()` exactly when a `Distribution` takes a behavior: from the
172
+ * constructor for `defaultBehavior` (the topology `uploadBehavior()`
173
+ * recommends), and from `addBehavior` for an additional one. Both shapes, and a
174
+ * distribution in a different stack, are pinned as PASSING cases in
175
+ * asset-support.test.ts - a mechanism that only sees one of them would fail the
176
+ * other two at synth. All three bind eagerly at construction, before any
177
+ * validation runs. A behavior that is built and then dropped never binds.
178
+ *
179
+ * Observing the origin rather than searching the tree for the emitted behavior
180
+ * is what makes the cross-stack case work. Measured: a consuming stack renders
181
+ * the reference as `Fn::ImportValue`, not as the producing stack's resolved ARN,
182
+ * so a tree search would report "not attached" for a perfectly correct
183
+ * cross-stack distribution - a false synth failure, which is worse than the gap
184
+ * it closes.
185
+ */
186
+ class UploadOrigin extends origins.HttpOrigin {
187
+ constructor() {
188
+ super(...arguments);
189
+ this.attachedToDistribution = false;
190
+ }
191
+ bind(scope, options) {
192
+ this.attachedToDistribution = true;
193
+ return super.bind(scope, options);
194
+ }
195
+ }
133
196
  /**
134
197
  * The two CloudFront path patterns the asset system's behaviors are keyed
135
198
  * under, derived from `PREFIXES` above (the single source of truth for these
@@ -150,6 +213,16 @@ export const ASSETS_TRANSFORM_PATH_PATTERN = `/${PREFIXES.transform}/*`;
150
213
  * `attachTo()` (below) or `CanopyCmsDistribution`'s `assetSupport` prop
151
214
  * instead. Exported so `cms-distribution.ts`'s synth-time guard can
152
215
  * recognize the mistake and name it in its error.
216
+ *
217
+ * `uploadBehavior()` deliberately adds NO key here, considered and rejected
218
+ * when it was added. It returns a bare `BehaviorOptions` rather than a named
219
+ * property of a returned object, so there is no spread to get wrong in the
220
+ * first place - and a speculative `'upload'` entry would actively misfire on
221
+ * an adopter whose distribution has a real upload route, since CloudFront
222
+ * treats a path pattern's leading slash as optional and `upload` is a legal
223
+ * spelling of `/upload` (see `normalizePathPattern` in cms-distribution.ts).
224
+ * Keep this list to property names that actually exist on
225
+ * `AssetCloudFrontBehaviors`.
153
226
  */
154
227
  export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
155
228
  /**
@@ -169,6 +242,9 @@ export const ASSET_BEHAVIOR_SPREAD_MISTAKE_KEYS = ['assets', 'assetsTransform'];
169
242
  * for why the order is the whole point); `assetBehaviors()` is the escape
170
243
  * hatch for a distribution built entirely by hand (see
171
244
  * `AssetCloudFrontBehaviors`'s doc comment).
245
+ * - `uploadBehavior()`, opt-in, the write half: a CloudFront behavior that
246
+ * accepts the editor's presigned-POST uploads, for adopters who set
247
+ * `media.uploadUrl`. Belongs on its own distribution - see that method.
172
248
  * - `grantUploadAccess()`, the exact prefix-scoped grants the CMS/editor
173
249
  * principal needs to run `S3AssetStore` (packages/canopycms/src/assets/store-s3.ts).
174
250
  */
@@ -195,6 +271,32 @@ export class AssetSupport extends Construct {
195
271
  ' pnpm --filter canopycms-cdk run build:lambda');
196
272
  }
197
273
  this.maxUploadBytes = props.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
274
+ // An empty array is treated as absent throughout: CloudFormation rejects a
275
+ // CORS rule whose AllowedOrigins is empty, so `editorOrigins: []` could
276
+ // only ever have been a mistake, and it is better caught by the check
277
+ // below than by a deploy-time template rejection.
278
+ const editorOrigins = props.editorOrigins ?? [];
279
+ // A browser presigned upload needs an Access-Control-Allow-Origin from
280
+ // SOMEWHERE - the bucket's own CORS rule (`editorOrigins`) or the edge
281
+ // (`uploadBehavior`). Standalone mode owns the bucket, so it can tell that
282
+ // neither is configured and say so here rather than let it be discovered
283
+ // in a browser. BYO-bucket mode cannot: the caller owns that bucket's CORS
284
+ // configuration and this construct has no way to read it (`IBucket` has no
285
+ // `addCorsRule`, which is the same reason `editorOrigins` is inert there).
286
+ if (!props.bucket && editorOrigins.length === 0 && !props.uploadBehavior) {
287
+ throw new Error('AssetSupport: standalone mode creates the bucket, and a browser presigned upload needs ' +
288
+ 'Access-Control-Allow-Origin from either the bucket or the edge - this construct was ' +
289
+ 'given neither. Pass `editorOrigins` (the editor origin(s) that POST uploads), or ' +
290
+ '`uploadBehavior` to route uploads through a CloudFront distribution that supplies the ' +
291
+ 'header at the edge. If you have wired an equivalent upload path yourself, outside ' +
292
+ 'this construct, pass `editorOrigins` anyway - a bucket CORS rule your own path makes ' +
293
+ 'redundant is harmless, and this check cannot see that path.\n' +
294
+ 'Worth knowing why this is worth failing on: S3 ACCEPTS a cross-origin presigned POST ' +
295
+ 'with no CORS rule at all and simply declines to ADVERTISE it (measured: 204, no ' +
296
+ 'ACAO header, object landed). So the object reaches asset-staging/ and the browser ' +
297
+ 'still reports the upload as a network error - a failure that looks like anything ' +
298
+ 'except missing CORS.');
299
+ }
198
300
  if (props.bucket) {
199
301
  this.bucket = props.bucket;
200
302
  }
@@ -230,15 +332,20 @@ export class AssetSupport extends Construct {
230
332
  expiration: props.transformOutputRetention ?? TRANSFORM_OUTPUT_RETENTION,
231
333
  },
232
334
  ],
233
- cors: [
234
- {
235
- id: 'editor-presigned-upload',
236
- allowedOrigins: props.editorOrigins,
237
- allowedMethods: [s3.HttpMethods.POST, s3.HttpMethods.PUT, s3.HttpMethods.GET],
238
- allowedHeaders: ['*'],
239
- maxAge: CORS_MAX_AGE_SECONDS,
240
- },
241
- ],
335
+ // Omitted entirely when `editorOrigins` is absent - the upload is then
336
+ // going through `uploadBehavior`, which supplies ACAO at the edge, and
337
+ // writing a rule with no origins would be an invalid template anyway.
338
+ cors: editorOrigins.length > 0
339
+ ? [
340
+ {
341
+ id: 'editor-presigned-upload',
342
+ allowedOrigins: editorOrigins,
343
+ allowedMethods: [s3.HttpMethods.POST, s3.HttpMethods.PUT, s3.HttpMethods.GET],
344
+ allowedHeaders: ['*'],
345
+ maxAge: CORS_MAX_AGE_SECONDS,
346
+ },
347
+ ]
348
+ : undefined,
242
349
  });
243
350
  }
244
351
  // Dedicated CloudWatch log group for the transform Lambda's stdout/
@@ -325,6 +432,89 @@ export class AssetSupport extends Construct {
325
432
  authType: lambda.FunctionUrlAuthType.AWS_IAM,
326
433
  });
327
434
  this.behaviors = this.buildBehaviors();
435
+ // Validated eagerly, built lazily (see `upload`'s comment). An empty
436
+ // `allowedOrigins` is the same class of mistake as an empty
437
+ // `editorOrigins` and gets the same treatment: CloudFormation rejects
438
+ // `AccessControlAllowOrigins` with no items, so silently falling back to
439
+ // the `['*']` default would turn a forwarded-empty-env-var into either a
440
+ // failed deploy or, worse, a wildcard the caller did not ask for.
441
+ if (props.uploadBehavior?.allowedOrigins?.length === 0) {
442
+ throw new Error('AssetSupport: uploadBehavior.allowedOrigins is an empty array. CloudFront requires at ' +
443
+ 'least one origin, and defaulting an explicitly-empty list to the wildcard would ' +
444
+ "silently widen what you asked for. Omit the property to take the `['*']` default, " +
445
+ 'or list the origin(s) the editor is served from.');
446
+ }
447
+ // Two consumers read `allowedOrigins` and they do not share a matcher: the
448
+ // response headers policy (which decides ACAO on the POST response) accepts
449
+ // CloudFront's pattern grammar, including a leftmost-subdomain wildcard
450
+ // like `https://*.preview.example.com`; the preflight responder compiled
451
+ // into the CloudFront Function can only do exact-string comparison, because
452
+ // reimplementing that grammar in an edge function without being able to
453
+ // test it against CloudFront is how the two silently disagree.
454
+ //
455
+ // So a pattern is refused rather than half-honoured. Accepting one would
456
+ // deploy clean and then fail every upload from a matching origin at the
457
+ // PREFLIGHT, with a correct-looking policy sitting right next to it - the
458
+ // same invisible failure the preflight responder was added to remove.
459
+ const wildcardOrigins = (props.uploadBehavior?.allowedOrigins ?? []).filter((origin) => origin.includes('*'));
460
+ const isBareWildcard = props.uploadBehavior?.allowedOrigins?.join() === '*';
461
+ if (wildcardOrigins.length > 0 && !isBareWildcard) {
462
+ throw new Error(`AssetSupport: uploadBehavior.allowedOrigins contains a wildcard pattern ` +
463
+ `(${wildcardOrigins.map((o) => JSON.stringify(o)).join(', ')}). CloudFront's response ` +
464
+ `headers policy would accept it, but the CORS preflight is answered at the edge by a ` +
465
+ `CloudFront Function that compares origins exactly - so every upload from a matching ` +
466
+ `origin would fail its preflight while the policy looked correct. Use exactly ` +
467
+ `['*'] to allow any origin, or list each editor origin in full.`);
468
+ }
469
+ // Snapshotted, not aliased. The guard above runs now but `allowedOrigins`
470
+ // is not READ until the accessor builds the behavior, so holding the
471
+ // caller's array by reference would let `origins.length = 0` in between
472
+ // walk straight past the check just made.
473
+ this.uploadOptions = props.uploadBehavior && {
474
+ ...props.uploadBehavior,
475
+ allowedOrigins: props.uploadBehavior.allowedOrigins?.slice(),
476
+ };
477
+ // Setting `uploadBehavior` SATISFIES the standalone guard above, but only
478
+ // ATTACHING the behavior to a distribution actually produces an
479
+ // Access-Control-Allow-Origin from anywhere. Opting in and stopping there -
480
+ // which is exactly what copying the README snippet and not finishing looks
481
+ // like - lands a standalone bucket with no CORS rule and no edge route: the
482
+ // silent failure the guard's own text calls misleading, reached through the
483
+ // guard rather than around it.
484
+ //
485
+ // The condition is ATTACHMENT, not `uploadBehavior()` having been called.
486
+ // Those look equivalent and are not: the accessor memoizes, so a caller who
487
+ // invokes it and loses the value in a refactor sets every flag an accessor
488
+ // can set and still lands in exactly the state above. That shape is if
489
+ // anything likelier than never calling it at all, since it is what a
490
+ // half-finished copy of the README snippet leaves behind. `UploadOrigin`
491
+ // (above) is what makes the difference observable from here.
492
+ //
493
+ // A synth-time validation is the only place this can be caught, since the
494
+ // constructor cannot know what the caller will do next. It is the first
495
+ // `addValidation` in this package; the alternative (checking in the
496
+ // constructor) cannot express "and nothing used it".
497
+ this.node.addValidation({
498
+ validate: () => {
499
+ if (props.uploadBehavior &&
500
+ editorOrigins.length === 0 &&
501
+ !props.bucket &&
502
+ !this.uploadOrigin?.attachedToDistribution) {
503
+ return [
504
+ 'AssetSupport: `uploadBehavior` was set but the upload behavior was never attached ' +
505
+ 'to a distribution' +
506
+ (this.upload
507
+ ? ' - uploadBehavior() was called, but its return value was not passed to one'
508
+ : ' - uploadBehavior() was never called') +
509
+ ', so nothing supplies Access-Control-Allow-Origin. With no `editorOrigins` ' +
510
+ 'either, a browser upload will land the object in asset-staging/ and still ' +
511
+ 'report a network error. Pass the behavior to a distribution (see ' +
512
+ "uploadBehavior()'s doc comment), or pass `editorOrigins` instead.",
513
+ ];
514
+ }
515
+ return [];
516
+ },
517
+ });
328
518
  }
329
519
  buildBehaviors() {
330
520
  const s3Origin = origins.S3BucketOrigin.withOriginAccessControl(this.bucket);
@@ -371,12 +561,238 @@ export class AssetSupport extends Construct {
371
561
  };
372
562
  return { assets, assetsTransform };
373
563
  }
564
+ /**
565
+ * The behavior that accepts the editor's presigned-POST uploads. Every piece
566
+ * below is load-bearing; see `uploadBehavior()` for where the result goes.
567
+ */
568
+ buildUploadBehavior(options) {
569
+ // A DISTINCT origin for the bucket, with OAC signing OFF - the read
570
+ // behaviors' origin cannot be reused here no matter what this behavior
571
+ // sets, because OAC is a property of the ORIGIN, not the behavior. (On the
572
+ // dedicated distribution `uploadBehavior()` recommends this is the only
573
+ // origin present; the point is that it cannot be the read one.)
574
+ // CloudFront signs origin requests but never hashes
575
+ // the body, so an OAC-signed origin rejects every multipart POST whatever
576
+ // the viewer sent. Measured, that is `400 InvalidArgument`, the body
577
+ // naming the mechanism - `x-amz-content-sha256 must be UNSIGNED-PAYLOAD,
578
+ // ... or a valid sha256 value`. It fails CLOSED, but it is an
579
+ // argument-validation error, NOT the 403 that the Lambda Function URL
580
+ // origin produces in the same situation (docs/deploying-to-aws.md's
581
+ // "CloudFront OAC and request body signing"): there Lambda verifies a
582
+ // SIGNATURE over that header, here S3 validates its VALUE FORMAT. An
583
+ // adopter told to expect 403 goes hunting for a permissions problem that
584
+ // does not exist.
585
+ //
586
+ // `HttpOrigin` rather than `S3BucketOrigin.withBucketDefaults()`, which
587
+ // would also be unsigned, for two reasons. It is the exact shape this
588
+ // whole path was measured against end to end on live AWS resources (204,
589
+ // object landed); and it is the only one of the two that can state the
590
+ // CloudFront->S3 protocol, which for a request carrying a live upload
591
+ // credential in its body should be pinned rather than inferred.
592
+ // `withBucketDefaults()` emits `S3OriginConfig`, which has no
593
+ // `OriginProtocolPolicy` field at all.
594
+ // A dot in the bucket name breaks this origin specifically. S3's wildcard
595
+ // certificate covers one label (`*.s3.<region>.amazonaws.com`), so
596
+ // `my.docs.bucket.s3.<region>.amazonaws.com` fails TLS validation and
597
+ // CloudFront answers 502 on every upload. This is a CUSTOM origin doing
598
+ // ordinary TLS validation; the read path uses an S3-type origin, which
599
+ // CloudFront treats differently, so nothing else here would surface it -
600
+ // and dotted names are most likely in exactly the BYO case ("a site's
601
+ // existing content bucket") this behavior is written for. Only checkable
602
+ // when the name is a real literal, which for an imported bucket it is.
603
+ const bucketName = this.bucket.bucketName;
604
+ if (!Token.isUnresolved(bucketName) && bucketName.includes('.')) {
605
+ throw new Error(`AssetSupport: uploadBehavior cannot use bucket "${bucketName}" - a dot in the bucket ` +
606
+ `name puts an extra label in its regional domain name, which S3's wildcard ` +
607
+ `certificate does not cover, so CloudFront fails TLS to the origin and answers 502 on ` +
608
+ `every upload. The read behaviors are unaffected (they use an S3-type origin). Use a ` +
609
+ `dot-free bucket for uploads, or route uploads somewhere other than this construct.`);
610
+ }
611
+ const uploadOrigin = new UploadOrigin(this.bucket.bucketRegionalDomainName, {
612
+ protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
613
+ });
614
+ this.uploadOrigin = uploadOrigin;
615
+ // S3's POST Object is only valid at the bucket ROOT - without this rewrite
616
+ // the upload gets 405 MethodNotAllowed. It is also what CONTAINS this
617
+ // behavior: rewriting unconditionally means no request arriving here can
618
+ // address a key at all, so the `ALLOW_ALL` below buys an anonymous caller
619
+ // only the bucket-level operations at `/` (list, create, delete bucket).
620
+ // A bucket this construct creates refuses all of them anonymously
621
+ // (BLOCK_ALL, no public policy); in BYO-bucket mode that is the caller's
622
+ // bucket policy to have got right.
623
+ //
624
+ // Their bucket policy is a WEAKER backstop here than on the read path, and
625
+ // the two must not be read as equivalent: the read behaviors go through an
626
+ // OAC-SIGNED origin, so a too-permissive policy there is still gated by a
627
+ // signature CloudFront adds. This origin is deliberately unsigned (it has
628
+ // to be - see the OAC note at the top of this method), so on a BYO bucket
629
+ // the policy is the ONLY thing standing between an anonymous caller and
630
+ // whatever that policy allows at `/`. Stated where a BYO adopter reads it,
631
+ // in `uploadBehavior()`'s caveat block, not only here.
632
+ //
633
+ // The only thing that can WRITE either way is a request carrying a valid
634
+ // presigned policy, and only to the single key that policy names. Do not
635
+ // make the rewrite conditional; that gives key addressability back.
636
+ //
637
+ // The same function also drops `Authorization`. A site behind HTTP basic
638
+ // auth otherwise sends S3 a credential it cannot parse
639
+ // (`400 InvalidArgument - Unsupported Authorization Type`), and the
640
+ // presigned POST carries its own authority in the body, so nothing on this
641
+ // route ever wants the header. It is dropped HERE, in the function, rather
642
+ // than by naming it in the origin request policy's `allExcept` list below,
643
+ // and that placement is deliberate: CloudFront rejects `Authorization` in
644
+ // an origin request policy's header ALLOWLIST outright ("The parameter
645
+ // Headers contains Authorization that is not allowed"), and whether the
646
+ // same validation fires on an `allExcept` list is not something synth can
647
+ // tell you - it would surface as a failed `cdk deploy` on the whole stack.
648
+ // A viewer-request function has no such ambiguity: `method` is the only
649
+ // read-only field on the request object, headers are freely mutable, and
650
+ // `Authorization` is the header CloudFront's own basic-auth function
651
+ // examples read. Dropping it at the viewer also means it is gone before
652
+ // any policy runs, so the guarantee does not depend on the policy at all.
653
+ // The same function ALSO answers the CORS preflight, and that is not
654
+ // optional decoration - without it this route cannot work at all.
655
+ //
656
+ // The editor's upload is NOT a CORS simple request, though it looks like
657
+ // one: `xhr-upload.ts` sends multipart/form-data with no custom headers,
658
+ // but it assigns `xhr.upload.onprogress` before `send()`, and registering
659
+ // ANY listener on the `XMLHttpRequestUpload` object is its own disqualifier
660
+ // from the simple-request rules, independent of method, headers and content
661
+ // type. So every browser upload begins with `OPTIONS /`.
662
+ //
663
+ // Nothing else on this route would answer it. CloudFront does not
664
+ // synthesize preflight responses - a response headers policy supplies
665
+ // values for headers "in responses to CORS preflight requests", i.e. it
666
+ // decorates a response that something else produced - and `ALLOW_ALL`
667
+ // forwards the OPTIONS to S3, which with no CORS configuration answers
668
+ // `403 CORSResponse: CORS is not enabled for this bucket`. A preflight that
669
+ // is not a 2xx fails the browser's check whatever headers are attached, so
670
+ // the POST is never sent and the upload dies as an opaque network error.
671
+ //
672
+ // This is why the end-to-end measurements did not catch it: they replayed
673
+ // the POST as a script, which has no preflight. A scripted POST is not an
674
+ // acceptance test for this route; a real browser upload is.
675
+ //
676
+ // Short-circuiting at the viewer also strengthens the containment argument
677
+ // above rather than weakening it - an OPTIONS that never reaches the origin
678
+ // can address nothing.
679
+ const allowedOrigins = options.allowedOrigins ?? ['*'];
680
+ const rewriteToBucketRoot = new cloudfront.Function(this, 'AssetUploadRewriteFunction', {
681
+ code: cloudfront.FunctionCode.fromInline([
682
+ `var ALLOWED_ORIGINS = ${toFunctionLiteral(allowedOrigins)};`,
683
+ 'function handler(event) {',
684
+ ' var request = event.request;',
685
+ " if (request.method === 'OPTIONS') {",
686
+ ' var headers = {',
687
+ ` 'access-control-allow-methods': { value: '${UPLOAD_ALLOWED_METHOD}' },`,
688
+ " 'access-control-allow-headers': { value: '*' },",
689
+ ` 'access-control-max-age': { value: '${CORS_MAX_AGE_SECONDS}' }`,
690
+ ' };',
691
+ // Echo the caller's own Origin when the list is narrowed: a preflight
692
+ // may only be answered with `*` or the single requesting origin, so a
693
+ // multi-entry list cannot be returned verbatim. No match means no
694
+ // ACAO, and the browser's own check refuses the upload - which is the
695
+ // correct outcome, and a clearer one than a 403 here would be.
696
+ " var origin = request.headers.origin ? request.headers.origin.value : '';",
697
+ " if (ALLOWED_ORIGINS.length === 1 && ALLOWED_ORIGINS[0] === '*') {",
698
+ " headers['access-control-allow-origin'] = { value: '*' };",
699
+ ' } else if (ALLOWED_ORIGINS.indexOf(origin) !== -1) {',
700
+ " headers['access-control-allow-origin'] = { value: origin };",
701
+ ' }',
702
+ " return { statusCode: 204, statusDescription: 'No Content', headers: headers };",
703
+ ' }',
704
+ " request.uri = '/';",
705
+ ' delete request.headers.authorization;',
706
+ ' return request;',
707
+ '}',
708
+ ].join('\n')),
709
+ });
710
+ // `denyList('host')` emits CloudFormation's `allExcept`. In the HEADERS
711
+ // dimension only, that is the managed ALL_VIEWER_EXCEPT_HOST_HEADER policy
712
+ // - the shape this path was measured against. It is NOT that policy
713
+ // overall, and must not be replaced by it: the managed one is documented as
714
+ // "Cookies: All, Query strings: All", so adopting it would forward the
715
+ // editor session cookie to S3, which is one of the two hazards this whole
716
+ // policy exists to remove. Only the header behavior is shared.
717
+ //
718
+ // `Authorization` is handled by the function above, not here.
719
+ //
720
+ // Cookies go through `CookiesConfig`, not the header list (a CloudFront
721
+ // Function does not even see them in `request.headers`), so
722
+ // `cookieBehavior.none()` is what strips them: an upload route mounted on
723
+ // a site's own distribution would otherwise hand S3 - and S3's access logs
724
+ // - the editor session cookie. On the dedicated distribution recommended
725
+ // in `uploadBehavior()` a cross-origin XHR without `withCredentials` sends
726
+ // none in the first place; this makes it true either way.
727
+ const originRequestPolicy = new cloudfront.OriginRequestPolicy(this, 'AssetUploadOriginRequestPolicy', {
728
+ cookieBehavior: cloudfront.OriginRequestCookieBehavior.none(),
729
+ headerBehavior: cloudfront.OriginRequestHeaderBehavior.denyList('host'),
730
+ // A presigned POST carries everything in the multipart body.
731
+ queryStringBehavior: cloudfront.OriginRequestQueryStringBehavior.none(),
732
+ });
733
+ // The edge supplies Access-Control-Allow-Origin, scoped to this behavior
734
+ // only. This is the measurement that unblocked the whole design: a
735
+ // response-headers policy DOES attach ACAO to a 2xx from an unsigned S3
736
+ // origin on POST with NO bucket CORS configuration at all (204, `ACAO: *`,
737
+ // object landed). Acceptance and advertisement are independent in S3 -
738
+ // bucket CORS governs only whether S3 advertises - which is exactly the
739
+ // property that lets this construct avoid writing a bucket CORS rule.
740
+ //
741
+ // `originOverride: true` so the header is ours deterministically even on a
742
+ // bucket that does have its own CORS configuration.
743
+ //
744
+ // AllowMethods/AllowHeaders/MaxAge take effect only on a CORS PREFLIGHT,
745
+ // and this path DOES trigger one - see the CloudFront Function above, which
746
+ // answers it, and which exists because nothing else would. They are set
747
+ // here anyway, sharing `UPLOAD_ALLOWED_METHOD` with that function so the
748
+ // two cannot tell a browser different things, and because a response
749
+ // headers policy is documented to decorate responses without saying whether
750
+ // a function-generated one is among them. If it is, `originOverride: true`
751
+ // means the policy's values REPLACE the function's identical ones; if it is
752
+ // not, the function's stand alone. Either way the viewer sees exactly one
753
+ // of each, which is what makes it safe not to know.
754
+ const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, 'AssetUploadResponseHeadersPolicy', {
755
+ corsBehavior: {
756
+ accessControlAllowOrigins: allowedOrigins,
757
+ accessControlAllowCredentials: false,
758
+ accessControlAllowMethods: [UPLOAD_ALLOWED_METHOD],
759
+ accessControlAllowHeaders: ['*'],
760
+ accessControlMaxAge: Duration.seconds(CORS_MAX_AGE_SECONDS),
761
+ originOverride: true,
762
+ },
763
+ });
764
+ return {
765
+ origin: uploadOrigin,
766
+ // A POST is 405 without this. CloudFront offers no narrower set that
767
+ // includes POST - GET_HEAD, GET_HEAD_OPTIONS and ALL are the only three.
768
+ // The URI rewrite above is what makes that acceptable.
769
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
770
+ // HTTPS_ONLY, NOT the read behaviors' REDIRECT_TO_HTTPS. CloudFront
771
+ // redirects with 301, and a browser turns a 301'd POST into a GET - so
772
+ // an `http://` upload URL would silently become a bodyless GET that this
773
+ // behavior rewrites to `/` and S3 refuses, with the file never sent.
774
+ // HTTPS_ONLY answers 403 instead: same refusal, visible.
775
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
776
+ // Nothing on this route is cacheable, and CACHING_DISABLED's all-`none`
777
+ // cache key is also what keeps the origin request policy above legal
778
+ // (CloudFront requires it to be a superset of the cache key).
779
+ cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
780
+ originRequestPolicy,
781
+ responseHeadersPolicy,
782
+ functionAssociations: [
783
+ {
784
+ function: rewriteToBucketRoot,
785
+ eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
786
+ },
787
+ ],
788
+ };
789
+ }
374
790
  /**
375
791
  * The two CloudFront behavior configs this system needs.
376
792
  *
377
- * If you use this rather than `attachTo` -- which now takes an `overrides`
378
- * parameter, so needing per-behavior options is no longer a reason to fall
379
- * back here -- you own the ordering, and you should assert it: read the
793
+ * If you use this rather than `attachTo` -- which takes an `overrides`
794
+ * parameter, so needing per-behavior options is not a reason to fall back
795
+ * here -- you own the ordering, and you should assert it: read the
380
796
  * SYNTHESIZED template's `CacheBehaviors` array index, not your own source
381
797
  * object, since the property is about emitted order.
382
798
  *
@@ -389,6 +805,62 @@ export class AssetSupport extends Construct {
389
805
  assetBehaviors() {
390
806
  return this.behaviors;
391
807
  }
808
+ /**
809
+ * The CloudFront behavior that accepts the editor's presigned-POST uploads -
810
+ * the infrastructure half of `media.uploadUrl`. Requires
811
+ * `AssetSupportProps.uploadBehavior`.
812
+ *
813
+ * GIVE IT ITS OWN DISTRIBUTION, serving this route and nothing else:
814
+ *
815
+ * ```ts
816
+ * const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
817
+ * defaultBehavior: assetSupport.uploadBehavior(),
818
+ * })
819
+ * // media.uploadUrl = `https://${uploads.distributionDomainName}/`
820
+ * ```
821
+ *
822
+ * No custom domain or certificate is needed - the `d111...cloudfront.net`
823
+ * name is a perfectly good `uploadUrl` - and as the default behavior of a
824
+ * one-route distribution there is no path pattern and so no ordering
825
+ * question of the kind `attachTo()` exists to settle.
826
+ *
827
+ * Two shapes this deliberately is NOT, both settled 2026-09-11 (the record
828
+ * is `.claude/future-tasks/resolved/asset-support-upload-behavior.md`):
829
+ *
830
+ * - NOT a behavior on the site's own distribution. `CustomErrorResponses`
831
+ * are distribution-wide, so a site that maps 403 to its own 404 page
832
+ * applies that to S3's upload errors too and the editor reports the
833
+ * substituted status. The site's cookies and any cached basic-auth
834
+ * credential are also live hazards there that have to be stripped by
835
+ * policy and function (`buildUploadBehavior` removes both - cookies via
836
+ * the origin request policy, `Authorization` in the viewer-request
837
+ * function) rather than simply never
838
+ * being sent. It works; it is just strictly worse.
839
+ * - NOT one shared distribution serving asset reads AND writes for every
840
+ * environment. One assets distribution means one `AssetSupport`, and
841
+ * `AssetSupport` owns the transform Lambda - so it would also mean one
842
+ * transform Lambda shared by every environment, and that Lambda ships
843
+ * inside this package. A `canopycms-cdk` bump would then move every
844
+ * environment's asset pipeline at once, which is a graduated rollout
845
+ * traded away for an upload path. Reads and transforms stay
846
+ * per-environment; only the upload route moves.
847
+ *
848
+ * The upload route is the one part of this system with no per-environment
849
+ * code in it - it depends on the bucket and nothing else - which is why it
850
+ * is the part that can be split off this way.
851
+ */
852
+ uploadBehavior() {
853
+ if (!this.uploadOptions) {
854
+ throw new Error('AssetSupport: uploadBehavior() needs the `uploadBehavior` prop, which is off by ' +
855
+ 'default because it is the only thing this construct builds that puts a ' +
856
+ 'write-capable, OAC-UNSIGNED origin in front of the bucket. Pass ' +
857
+ '`uploadBehavior: {}` to opt in and take the defaults.');
858
+ }
859
+ // Memoized: calling this twice must not attach two sets of policies, and
860
+ // must not fail on duplicate construct ids.
861
+ this.upload ??= this.buildUploadBehavior(this.uploadOptions);
862
+ return this.upload;
863
+ }
392
864
  /**
393
865
  * Attach both asset behaviors to a concrete CloudFront distribution, in the
394
866
  * only safe order.
@@ -426,14 +898,11 @@ export class AssetSupport extends Construct {
426
898
  * has left this method's remit and should use `assetBehaviors()` - and keep
427
899
  * their own ordering assertion.
428
900
  *
429
- * Typed `Partial<AddBehaviorOptions>` because that is exactly what
430
- * `addBehavior(pattern, origin, behaviorOptions?)` accepts - `origin` is a
431
- * POSITIONAL argument there, so `BehaviorOptions` (which is
432
- * `AddBehaviorOptions` plus `origin`) would let a caller pass a key the call
433
- * silently ignores. Measured, because the first version of this comment
434
- * claimed the opposite: widening the parameter and passing an `origin`
435
- * override changes nothing in the emitted template, so this narrowing
436
- * prevents a confusing no-op rather than a broken origin group.
901
+ * Typed `Partial<AddBehaviorOptions>`, not `BehaviorOptions`, because
902
+ * `addBehavior` takes `origin` positionally - so an `origin` key here would be
903
+ * a silently ignored no-op. (Measured: passing one changes nothing in the
904
+ * emitted template. It is a confusing no-op being prevented, not a broken
905
+ * origin group.)
437
906
  *
438
907
  * Needs a concrete `cloudfront.Distribution` - `addBehavior` is an instance
439
908
  * method on that class, not on `IDistribution` (what an imported/looked-up
@@ -452,14 +921,14 @@ export class AssetSupport extends Construct {
452
921
  // ['/assets/t/*','/assets/*','/assets/t/*','/assets/*'] and fails at
453
922
  // deploy time when CloudFront rejects the duplicate patterns. Refuse it
454
923
  // here instead, where the message can say which two routes collided.
455
- if (distributionsWithAssetBehaviors.has(distribution)) {
924
+ if (distribution.node.tryFindChild(ATTACHED_MARKER_ID)) {
456
925
  throw new Error(`AssetSupport: attachTo() was already called for this distribution. Each pattern ` +
457
926
  `would be attached twice and CloudFront rejects duplicate path patterns at deploy ` +
458
927
  `time. This usually means the distribution was given CanopyCmsDistribution's ` +
459
928
  `\`assetSupport\` prop (which calls attachTo for you) as well as an explicit ` +
460
929
  `attachTo() call -- keep one.`);
461
930
  }
462
- distributionsWithAssetBehaviors.add(distribution);
931
+ new Construct(distribution, ATTACHED_MARKER_ID);
463
932
  // Drop explicitly-`undefined` keys before merging. A spread copies own
464
933
  // enumerable keys INCLUDING ones whose value is undefined, so
465
934
  // `{ ...transformRest, ...{ cachePolicy: undefined } }` deletes the
@@ -69,12 +69,13 @@ export interface CanopyCmsDistributionProps {
69
69
  * order matters. Calls `assetSupport.attachTo(distribution)` for you right
70
70
  * after construction, with nothing to forget.
71
71
  *
72
- * NOT fully equivalent to calling `attachTo` yourself: this path cannot pass
73
- * that method's `overrides` argument. If the asset behaviors need
74
- * per-behavior options - a viewer-request function for tier auth being the
75
- * motivating case, since without it `/assets/*` is anonymously readable on
76
- * an authenticated tier - drop this prop and call
77
- * `assetSupport.attachTo(this.distribution, { ... })` after construction.
72
+ * If the asset behaviors need per-behavior options - a viewer-request
73
+ * function for tier auth being the motivating case, since without it
74
+ * `/assets/*` is anonymously readable on an authenticated tier - pass
75
+ * `assetBehaviorOverrides` alongside this prop. That requirement used to mean
76
+ * dropping this prop and hand-calling `attachTo` after construction, which
77
+ * sent exactly the adopter who most needs the ordering guarantee back to the
78
+ * manual path this prop exists to replace.
78
79
  *
79
80
  * Prefer this over passing `assetSupport.assetBehaviors()` through
80
81
  * `additionalBehaviors` by hand - that stays available as an escape hatch
@@ -89,6 +90,27 @@ export interface CanopyCmsDistributionProps {
89
90
  * @default - no asset behaviors are attached
90
91
  */
91
92
  assetSupport?: AssetSupport;
93
+ /**
94
+ * Per-behavior options merged into BOTH asset behaviors, forwarded verbatim
95
+ * to `AssetSupport.attachTo()`'s `overrides` parameter - see that method's
96
+ * doc comment for the merge semantics and for why the same set goes to both.
97
+ *
98
+ * The motivating case is a distribution running a viewer-request function on
99
+ * every behavior (tier basic-auth): without the same `functionAssociations`
100
+ * on the asset behaviors, `/assets/*` is anonymously readable on an
101
+ * authenticated tier.
102
+ *
103
+ * Useless without `assetSupport`, and silently so - there would be no
104
+ * behaviors to merge it into - so that combination throws from this
105
+ * construct's CONSTRUCTOR rather than being ignored. (A constructor throw,
106
+ * not an `addValidation`: nothing later can make the combination valid. An
107
+ * adopter sharing one props object across tiers, with `assetSupport` present
108
+ * on only some of them, should make the whole prop conditional rather than
109
+ * the overrides object.)
110
+ *
111
+ * @default - the asset behaviors are attached with no overrides
112
+ */
113
+ assetBehaviorOverrides?: Partial<cloudfront.AddBehaviorOptions>;
92
114
  }
93
115
  /**
94
116
  * Optional CDK construct for CanopyCMS CloudFront distribution.
@@ -152,6 +152,17 @@ function assertNoAssetBehaviorOrderingHazards(merged, attachingAssetSupport) {
152
152
  export class CanopyCmsDistribution extends Construct {
153
153
  constructor(scope, id, props) {
154
154
  super(scope, id);
155
+ // Overrides with nothing to override. They are merged into the asset
156
+ // behaviors and there are none, so the only outcome is that whatever the
157
+ // caller asked for - a tier-auth viewer function, most likely - silently
158
+ // does not happen.
159
+ if (props.assetBehaviorOverrides && !props.assetSupport) {
160
+ throw new Error('CanopyCmsDistribution: `assetBehaviorOverrides` was passed without `assetSupport`. ' +
161
+ 'The overrides are merged into the asset behaviors, so with no AssetSupport to attach ' +
162
+ 'there is nothing for them to apply to and they would be silently dropped - including ' +
163
+ 'a viewer-request function for tier auth. Pass `assetSupport` as well, or drop the ' +
164
+ 'overrides.');
165
+ }
155
166
  // ========================================================================
156
167
  // DNS — Hosted Zone lookup
157
168
  // ========================================================================
@@ -280,7 +291,11 @@ export class CanopyCmsDistribution extends Construct {
280
291
  // logic lives exactly once, inside attachTo() itself. Callers who instead
281
292
  // pass `assetSupport.assetBehaviors()` through `additionalBehaviors` by
282
293
  // hand are covered by `mergeBehaviors`'s synth-time guard above instead.
283
- props.assetSupport?.attachTo(this.distribution);
294
+ //
295
+ // `assetBehaviorOverrides` is forwarded so that needing per-behavior
296
+ // options is not a reason to leave this prop - and so the ordering
297
+ // guarantee survives the tier-auth case that most needs it.
298
+ props.assetSupport?.attachTo(this.distribution, props.assetBehaviorOverrides);
284
299
  // ========================================================================
285
300
  // DNS Records
286
301
  // ========================================================================
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.85",
3
+ "version": "0.0.66",
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.85"
34
+ "canopycms": "0.0.66"
34
35
  },
35
36
  "peerDependenciesMeta": {
36
37
  "aws-cdk-lib": {
@@ -54,8 +55,8 @@
54
55
  "tsx": "^4.19.0",
55
56
  "typescript": "^5.6.3",
56
57
  "vitest": "^4.1.0",
57
- "canopycms-auth-clerk": "0.0.66-int.85",
58
- "canopycms": "0.0.66-int.85"
58
+ "canopycms": "0.0.66",
59
+ "canopycms-auth-clerk": "0.0.66"
59
60
  },
60
61
  "scripts": {
61
62
  "build": "rm -rf dist && tsc && node ../../scripts/add-js-extensions.mjs dist",
@@ -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
  });