headroom-cms 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +11 -6
  2. package/admin/assets/{AdminsPage-Bt_ekZen.js → AdminsPage-BnzH9TL3.js} +1 -1
  3. package/admin/assets/AllContentPage-BtObN6oy.js +1 -0
  4. package/admin/assets/{ApiKeysPage-BfWCxGhC.js → ApiKeysPage-DEAa8eyC.js} +1 -1
  5. package/admin/assets/AuditPage-BN9yNsxh.js +1 -0
  6. package/admin/assets/BlockEditor-3wnisTOZ.js +176 -0
  7. package/admin/assets/BlockEditor-CQpF8tYb.css +1 -0
  8. package/admin/assets/BlockTypeEditPage-C2evAESK.js +1 -0
  9. package/admin/assets/BlockTypesPage-Dhkho6T_.js +1 -0
  10. package/admin/assets/{BulkActionBar-TRiXXLQd.js → BulkActionBar-BxdfUSrN.js} +1 -1
  11. package/admin/assets/CollectionEditPage-lOb4hEZy.js +1 -0
  12. package/admin/assets/{CollectionsPage-ClplrxNn.js → CollectionsPage-CgtOloa1.js} +1 -1
  13. package/admin/assets/{ContentCreatePage-DfYcEH1u.js → ContentCreatePage-LeQjahp_.js} +1 -1
  14. package/admin/assets/ContentEditPage-xczr4d_h.js +1 -0
  15. package/admin/assets/ContentField-pilCbdnA.js +1 -0
  16. package/admin/assets/ContentListPage-BAKDn1Xy.js +1 -0
  17. package/admin/assets/CustomBlockPreview-DNnTFM0z.js +479 -0
  18. package/admin/assets/FieldRenderer-DiOKvkWV.js +2 -0
  19. package/admin/assets/FilterBar-BZoa63zh.js +1 -0
  20. package/admin/assets/FloatingComposerController-D4uLQfUX-BMIvFCoE.js +1 -0
  21. package/admin/assets/IconPicker-CpIgiQTC.js +3 -0
  22. package/admin/assets/{LoginPage-DutieANA.js → LoginPage-D9ZsGLIi.js} +1 -1
  23. package/admin/assets/MediaField-CxccCFGQ.js +1 -0
  24. package/admin/assets/MediaPage-QvMaH2YJ.js +1 -0
  25. package/admin/assets/Pagination-Df9nQ7Z0.js +1 -0
  26. package/admin/assets/RelationshipPicker-B3Ftmqxp.js +1 -0
  27. package/admin/assets/{SiteSettingsPage-BtCC3RKc.js → SiteSettingsPage-6NvH7CiQ.js} +1 -1
  28. package/admin/assets/{SiteUserEditPage-ClHmp0T-.js → SiteUserEditPage-D5VaQ1Xq.js} +1 -1
  29. package/admin/assets/SiteUsersPage-BYVduiqs.js +1 -0
  30. package/admin/assets/{SitesPage-Bw_WBN6v.js → SitesPage-rfWWE0yK.js} +1 -1
  31. package/admin/assets/{SubmissionDetailPage-DS08LGxd.js → SubmissionDetailPage-BSUR685F.js} +1 -1
  32. package/admin/assets/SubmissionEditPage-DjLXHjWU.js +1 -0
  33. package/admin/assets/SubmissionListPage-DBxNEvde.js +1 -0
  34. package/admin/assets/{TagInput-BILCaC9b.js → TagInput-57c4DG1w.js} +1 -1
  35. package/admin/assets/{TagsPage-DdeZokow.js → TagsPage-BEO5AwCv.js} +1 -1
  36. package/admin/assets/{UsersPage-B0vLxjrg.js → UsersPage-BpIRorJ1.js} +1 -1
  37. package/admin/assets/{WebhookEditPage-SlJE4d3z.js → WebhookEditPage-D5xgi56h.js} +1 -1
  38. package/admin/assets/{WebhooksPage-C6lGZLpr.js → WebhooksPage-BY7AaiGr.js} +1 -1
  39. package/admin/assets/{card-hXVtlM0q.js → card-C9hfyHXf.js} +1 -1
  40. package/admin/assets/checkbox-DVJcwUt1.js +1 -0
  41. package/admin/assets/{collapsible-B414SspL.js → collapsible-D3d29uJp.js} +1 -1
  42. package/admin/assets/{command-fvBFHye4.js → command-Bfmj0MEL.js} +1 -1
  43. package/admin/assets/contentStatus-CkPi9Dh6.js +1 -0
  44. package/admin/assets/{core.esm-B_kcYf6n.js → core.esm-DdQHdRkd.js} +2 -2
  45. package/admin/assets/index-BB9Syqw2.css +1 -0
  46. package/admin/assets/index-Ce5pmRMj.js +18 -0
  47. package/admin/assets/media-url-DdCoIedP.js +1 -0
  48. package/admin/assets/popover-CzaQYEEP.js +1 -0
  49. package/admin/assets/radix-C5ZmWuuL.js +51 -0
  50. package/admin/assets/select-CrRhFGIi.js +1 -0
  51. package/admin/assets/serializeToText-2VrsuRUh.js +2 -0
  52. package/admin/assets/{sortable.esm-QyXA6fio.js → sortable.esm-qVEMoaTg.js} +1 -1
  53. package/admin/assets/{table-DLoIbCQ5.js → table-_3bMY0_z.js} +1 -1
  54. package/admin/assets/{textarea-vSXNxwTe.js → textarea-6fq0R6VV.js} +1 -1
  55. package/admin/assets/useAdminResolver-BJNPz3OG.js +1 -0
  56. package/admin/assets/useContent-Bs7nel7C.js +1 -0
  57. package/admin/assets/useContentSearch-B3aTjuCu.js +1 -0
  58. package/admin/assets/{useMedia-e3sqWm_t.js → useMedia-ae3s_ajC.js} +1 -1
  59. package/admin/assets/usePageTitle-C1r1-C00.js +1 -0
  60. package/admin/assets/useSiteUsers-DIaqgNSp.js +1 -0
  61. package/admin/assets/{useTags-f7AVSLuj.js → useTags-B-HgMVwo.js} +1 -1
  62. package/admin/assets/{useWebhooks-BH_r8-Mo.js → useWebhooks-BvZjUJkJ.js} +1 -1
  63. package/admin/assets/yjs-tXBm_srz.js +5 -0
  64. package/admin/favicon-16x16.png +0 -0
  65. package/admin/favicon-32x32.png +0 -0
  66. package/admin/icons/icon-180x180.png +0 -0
  67. package/admin/icons/icon-192x192.png +0 -0
  68. package/admin/icons/icon-512x512.png +0 -0
  69. package/admin/icons/maskable-icon-512x512.png +0 -0
  70. package/admin/index.html +3 -3
  71. package/admin/sw.js +1 -1
  72. package/dist/admin-site.d.ts +16 -2
  73. package/dist/admin-site.d.ts.map +1 -1
  74. package/dist/admin-site.js +10 -3
  75. package/dist/admin-site.js.map +1 -1
  76. package/dist/api.d.ts +7 -0
  77. package/dist/api.d.ts.map +1 -1
  78. package/dist/api.js +8 -1
  79. package/dist/api.js.map +1 -1
  80. package/dist/cdn-api.d.ts +25 -0
  81. package/dist/cdn-api.d.ts.map +1 -0
  82. package/dist/{cdn.js → cdn-api.js} +7 -139
  83. package/dist/cdn-api.js.map +1 -0
  84. package/dist/cdn-media.d.ts +26 -0
  85. package/dist/cdn-media.d.ts.map +1 -0
  86. package/dist/cdn-media.js +202 -0
  87. package/dist/cdn-media.js.map +1 -0
  88. package/dist/collaboration.d.ts +55 -0
  89. package/dist/collaboration.d.ts.map +1 -0
  90. package/dist/collaboration.js +141 -0
  91. package/dist/collaboration.js.map +1 -0
  92. package/dist/index.d.ts +27 -3
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +47 -12
  95. package/dist/index.js.map +1 -1
  96. package/dist/storage.d.ts +2 -0
  97. package/dist/storage.d.ts.map +1 -1
  98. package/dist/storage.js +33 -0
  99. package/dist/storage.js.map +1 -1
  100. package/lambda/api/bootstrap +0 -0
  101. package/lambda/image-lambda/node_modules/.package-lock.json +3 -3
  102. package/lambda/image-lambda/node_modules/semver/README.md +19 -4
  103. package/lambda/image-lambda/node_modules/semver/bin/semver.js +14 -10
  104. package/lambda/image-lambda/node_modules/semver/functions/truncate.js +48 -0
  105. package/lambda/image-lambda/node_modules/semver/index.js +2 -0
  106. package/lambda/image-lambda/node_modules/semver/internal/re.js +1 -1
  107. package/lambda/image-lambda/node_modules/semver/package.json +3 -3
  108. package/lambda/image-lambda/node_modules/semver/range.bnf +5 -4
  109. package/lambda/webhook-worker/bootstrap +0 -0
  110. package/package.json +1 -1
  111. package/src/admin-site.ts +26 -5
  112. package/src/api.ts +15 -1
  113. package/src/{cdn.ts → cdn-api.ts} +8 -161
  114. package/src/cdn-media.ts +250 -0
  115. package/src/collaboration.ts +187 -0
  116. package/src/index.ts +77 -14
  117. package/src/sst-env.d.ts +28 -0
  118. package/src/storage.ts +35 -0
  119. package/admin/assets/AllContentPage-CFqEMAl9.js +0 -1
  120. package/admin/assets/AuditPage-BE0XIUl2.js +0 -1
  121. package/admin/assets/BlockEditor-6wqsThJ7.js +0 -179
  122. package/admin/assets/BlockEditor-Cp_wZ2xN.css +0 -1
  123. package/admin/assets/BlockTypeEditPage-CuNJfZw0.js +0 -1
  124. package/admin/assets/BlockTypesPage-BIMBVxBs.js +0 -1
  125. package/admin/assets/CollectionEditPage-BqX_0cC2.js +0 -1
  126. package/admin/assets/ContentEditPage-D3Rvlktk.js +0 -2
  127. package/admin/assets/ContentListPage-zmO8Is4d.js +0 -1
  128. package/admin/assets/CustomBlockPreview-C6HqS4xv.js +0 -479
  129. package/admin/assets/FieldBuilder-36tfpSyM.js +0 -3
  130. package/admin/assets/FilterBar-DhRwTqFv.js +0 -1
  131. package/admin/assets/MediaField-J2TLG_fu.js +0 -1
  132. package/admin/assets/MediaPage-DZZKMGF4.js +0 -1
  133. package/admin/assets/RelationshipPicker-CDFs4TMW.js +0 -1
  134. package/admin/assets/SiteUsersPage-AyJvcVM7.js +0 -1
  135. package/admin/assets/SubmissionEditPage-Brf-DK2X.js +0 -1
  136. package/admin/assets/SubmissionListPage-DNMzQZHS.js +0 -1
  137. package/admin/assets/checkbox-WGrS3sUr.js +0 -1
  138. package/admin/assets/contentStatus-BmaiYVOm.js +0 -1
  139. package/admin/assets/index-Cir9tY_P.js +0 -18
  140. package/admin/assets/index-DACBYsKM.css +0 -1
  141. package/admin/assets/media-url-DIg_vSyf.js +0 -1
  142. package/admin/assets/popover-D5_HjjUC.js +0 -1
  143. package/admin/assets/radix-C1kb_NqW.js +0 -51
  144. package/admin/assets/select-_uJYxzeZ.js +0 -1
  145. package/admin/assets/serializeToText-DR_WnxiI.js +0 -2
  146. package/admin/assets/useAdminResolver-D-LlmquD.js +0 -1
  147. package/admin/assets/useContent-e8beBIuq.js +0 -1
  148. package/admin/assets/useContentSearch-DOjveB9t.js +0 -1
  149. package/admin/assets/useDebouncedValue-C-cQUcLG.js +0 -1
  150. package/admin/assets/usePageTitle-BNSba9_L.js +0 -1
  151. package/admin/assets/useSiteUsers-BdnvuM2E.js +0 -1
  152. package/dist/cdn.d.ts +0 -27
  153. package/dist/cdn.d.ts.map +0 -1
  154. package/dist/cdn.js.map +0 -1
package/src/api.ts CHANGED
@@ -10,12 +10,19 @@ import type { StorageResources } from "./storage.js";
10
10
  import type { AuthResources } from "./auth.js";
11
11
  import type { WebhookResources } from "./webhooks.js";
12
12
  import type { ImageResources } from "./image.js";
13
+ import type { CollabTableResources } from "./collaboration.js";
13
14
 
14
15
  export interface ApiArgs {
15
16
  storage: StorageResources;
16
17
  auth: AuthResources;
17
18
  webhooks: WebhookResources;
18
19
  image: ImageResources;
20
+ /**
21
+ * Just the collab DynamoDB table — the API Lambda only links the table
22
+ * for ticket reads/writes, it does NOT depend on the WebSocket handler.
23
+ * (The WS handler is created AFTER the API because its env needs api.url.)
24
+ */
25
+ collab: CollabTableResources;
19
26
  senderEmail: string;
20
27
  pkgRoot: string;
21
28
  dev?: {
@@ -25,7 +32,7 @@ export interface ApiArgs {
25
32
  }
26
33
 
27
34
  export function createApi(name: string, args: ApiArgs) {
28
- const { storage, auth, webhooks, image } = args;
35
+ const { storage, auth, webhooks, image, collab } = args;
29
36
 
30
37
  const handlerConfig = args.dev
31
38
  ? {
@@ -67,6 +74,11 @@ export function createApi(name: string, args: ApiArgs) {
67
74
  RELATIONSHIPS_TABLE: storage.relationships.name,
68
75
  SITE_USERS_TABLE: storage.siteUsers.name,
69
76
  SES_FROM_EMAIL: args.senderEmail,
77
+ COLLAB_TABLE: collab.collabTable.name,
78
+ // Shared with the collab Lambda. The Go JWT middleware verifies
79
+ // `X-Headroom-Internal` against this on internal service calls
80
+ // (collab → draft snapshot). See packages/api/internal/middleware/jwt.go.
81
+ INTERNAL_SECRET: storage.internalSecret.value,
70
82
  },
71
83
  link: [
72
84
  storage.sites,
@@ -84,6 +96,8 @@ export function createApi(name: string, args: ApiArgs) {
84
96
  image.imageSigningSecret,
85
97
  storage.relationships,
86
98
  storage.siteUsers,
99
+ collab.collabTable,
100
+ storage.internalSecret,
87
101
  ],
88
102
  permissions: [
89
103
  {
@@ -1,18 +1,16 @@
1
1
  /**
2
- * CDN Infrastructure
2
+ * API CDN Infrastructure
3
3
  *
4
- * CloudFront distribution with edge authentication, version-based caching,
5
- * direct S3 media serving, and Sharp Lambda image transforms.
4
+ * CloudFront distribution for the Headroom API only — `/v1/*` and `/health`.
5
+ * Edge-auth function + KVS association live here. No `/media/*` or `/img/*`
6
+ * behaviors; those are served by the separate media CDN (see cdn-media.ts).
6
7
  */
7
8
 
8
9
  import type { StorageResources } from "./storage.js";
9
10
  import type { ApiResources } from "./api.js";
10
- import type { ImageResources } from "./image.js";
11
11
 
12
- export interface CdnArgs {
12
+ export interface ApiCdnArgs {
13
13
  api: ApiResources;
14
- image: ImageResources;
15
- contentBucket: StorageResources["contentBucket"];
16
14
  kvs: StorageResources["kvs"];
17
15
  priceClass?: "PriceClass_100" | "PriceClass_200" | "PriceClass_All";
18
16
  apiCacheTtl?: number;
@@ -22,8 +20,8 @@ export interface CdnArgs {
22
20
  };
23
21
  }
24
22
 
25
- export function createCdn(name: string, args: CdnArgs) {
26
- const { api, image, contentBucket, kvs } = args;
23
+ export function createApiCdn(name: string, args: ApiCdnArgs) {
24
+ const { api, kvs } = args;
27
25
  const apiCacheTtl = args.apiCacheTtl ?? 3600;
28
26
 
29
27
  // Extract KVS UUID from ARN (cf.kvs() needs the UUID, not the name)
@@ -109,36 +107,6 @@ export function createCdn(name: string, args: CdnArgs) {
109
107
  `,
110
108
  });
111
109
 
112
- // =========================================================================
113
- // CloudFront Function: Media Rewrite
114
- // =========================================================================
115
- const mediaRewriteFunction = new aws.cloudfront.Function(
116
- `${name}MediaRewrite`,
117
- {
118
- name: $interpolate`${$app.name}-${$app.stage}-media-rewrite`,
119
- runtime: "cloudfront-js-2.0",
120
- publish: true,
121
- code: `
122
- function handler(event) {
123
- var request = event.request;
124
- var uri = request.uri;
125
-
126
- // Rewrite /media/{site}/{mediaId}/{file} → /sites/{site}/media/{mediaId}/{file}
127
- var parts = uri.split('/');
128
- // parts: ['', 'media', '{site}', '{mediaId}', '{file}']
129
- if (parts.length >= 5 && parts[1] === 'media') {
130
- var site = parts[2];
131
- var mediaId = parts[3];
132
- var rest = parts.slice(4).join('/');
133
- request.uri = '/sites/' + site + '/media/' + mediaId + '/' + rest;
134
- }
135
-
136
- return request;
137
- }
138
- `,
139
- },
140
- );
141
-
142
110
  // =========================================================================
143
111
  // Cache Policies
144
112
  // =========================================================================
@@ -175,47 +143,6 @@ export function createCdn(name: string, args: CdnArgs) {
175
143
  },
176
144
  );
177
145
 
178
- const imageCachePolicy = new aws.cloudfront.CachePolicy(
179
- `${name}ImageCachePolicy`,
180
- {
181
- name: $interpolate`${$app.name}-${$app.stage}-image-cache`,
182
- comment: "Cache policy for transformed images (immutable)",
183
- defaultTtl: 31536000,
184
- maxTtl: 31536000,
185
- minTtl: 31536000,
186
- parametersInCacheKeyAndForwardedToOrigin: {
187
- cookiesConfig: { cookieBehavior: "none" },
188
- headersConfig: { headerBehavior: "none" },
189
- queryStringsConfig: {
190
- queryStringBehavior: "whitelist",
191
- queryStrings: {
192
- items: ["w", "h", "fit", "format", "q", "sig"],
193
- },
194
- },
195
- enableAcceptEncodingBrotli: true,
196
- enableAcceptEncodingGzip: true,
197
- },
198
- },
199
- );
200
-
201
- const mediaCachePolicy = new aws.cloudfront.CachePolicy(
202
- `${name}MediaCachePolicy`,
203
- {
204
- name: $interpolate`${$app.name}-${$app.stage}-media-cache`,
205
- comment: "Cache policy for original media files (immutable)",
206
- defaultTtl: 31536000,
207
- maxTtl: 31536000,
208
- minTtl: 31536000,
209
- parametersInCacheKeyAndForwardedToOrigin: {
210
- cookiesConfig: { cookieBehavior: "none" },
211
- headersConfig: { headerBehavior: "none" },
212
- queryStringsConfig: { queryStringBehavior: "none" },
213
- enableAcceptEncodingBrotli: true,
214
- enableAcceptEncodingGzip: true,
215
- },
216
- },
217
- );
218
-
219
146
  const versionCachePolicy = new aws.cloudfront.CachePolicy(
220
147
  `${name}VersionCachePolicy`,
221
148
  {
@@ -275,26 +202,6 @@ export function createCdn(name: string, args: CdnArgs) {
275
202
  // authenticated behaviors below.
276
203
  const cachingDisabledPolicyId = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
277
204
 
278
- // =========================================================================
279
- // Origin Access Controls (OAC)
280
- // =========================================================================
281
-
282
- const mediaOAC = new aws.cloudfront.OriginAccessControl(`${name}MediaOAC`, {
283
- name: $interpolate`${$app.name}-${$app.stage}-media-oac`,
284
- description: "OAC for S3 media origin",
285
- originAccessControlOriginType: "s3",
286
- signingBehavior: "always",
287
- signingProtocol: "sigv4",
288
- });
289
-
290
- const imageOAC = new aws.cloudfront.OriginAccessControl(`${name}ImageOAC`, {
291
- name: $interpolate`${$app.name}-${$app.stage}-image-oac`,
292
- description: "OAC for image transform Lambda origin",
293
- originAccessControlOriginType: "lambda",
294
- signingBehavior: "always",
295
- signingProtocol: "sigv4",
296
- });
297
-
298
205
  // =========================================================================
299
206
  // CloudFront Distribution
300
207
  // =========================================================================
@@ -304,13 +211,6 @@ export function createCdn(name: string, args: CdnArgs) {
304
211
  return parsed.hostname;
305
212
  });
306
213
 
307
- const imageLambdaDomain = image.imageLambda.url.apply((url: string) => {
308
- const parsed = new URL(url);
309
- return parsed.hostname;
310
- });
311
-
312
- const s3RegionalDomain = $interpolate`${contentBucket.name}.s3.${aws.getRegionOutput().name}.amazonaws.com`;
313
-
314
214
  const priceClass = args.priceClass ?? "PriceClass_100";
315
215
 
316
216
  // Build aliases and certificate config for custom domain
@@ -345,25 +245,6 @@ export function createCdn(name: string, args: CdnArgs) {
345
245
  originSslProtocols: ["TLSv1.2"],
346
246
  },
347
247
  },
348
- {
349
- originId: "media-s3",
350
- domainName: s3RegionalDomain,
351
- originAccessControlId: mediaOAC.id,
352
- s3OriginConfig: {
353
- originAccessIdentity: "",
354
- },
355
- },
356
- {
357
- originId: "image-lambda",
358
- domainName: imageLambdaDomain,
359
- originAccessControlId: imageOAC.id,
360
- customOriginConfig: {
361
- httpPort: 80,
362
- httpsPort: 443,
363
- originProtocolPolicy: "https-only",
364
- originSslProtocols: ["TLSv1.2"],
365
- },
366
- },
367
248
  ],
368
249
 
369
250
  defaultCacheBehavior: {
@@ -549,32 +430,6 @@ export function createCdn(name: string, args: CdnArgs) {
549
430
  },
550
431
  ],
551
432
  },
552
- // Media originals: served directly from S3 via OAC
553
- {
554
- pathPattern: "/media/*",
555
- targetOriginId: "media-s3",
556
- viewerProtocolPolicy: "redirect-to-https",
557
- allowedMethods: ["GET", "HEAD", "OPTIONS"],
558
- cachedMethods: ["GET", "HEAD", "OPTIONS"],
559
- compress: true,
560
- cachePolicyId: mediaCachePolicy.id,
561
- functionAssociations: [
562
- {
563
- eventType: "viewer-request",
564
- functionArn: mediaRewriteFunction.arn,
565
- },
566
- ],
567
- },
568
- // Image transforms: served via Sharp Lambda
569
- {
570
- pathPattern: "/img/*",
571
- targetOriginId: "image-lambda",
572
- viewerProtocolPolicy: "redirect-to-https",
573
- allowedMethods: ["GET", "HEAD", "OPTIONS"],
574
- cachedMethods: ["GET", "HEAD", "OPTIONS"],
575
- compress: true,
576
- cachePolicyId: imageCachePolicy.id,
577
- },
578
433
  // Health endpoint: no caching, no auth
579
434
  {
580
435
  pathPattern: "/health",
@@ -596,14 +451,6 @@ export function createCdn(name: string, args: CdnArgs) {
596
451
  },
597
452
  );
598
453
 
599
- // Allow CloudFront to invoke the image Lambda via OAC
600
- new aws.lambda.Permission(`${name}ImageLambdaCFPermission`, {
601
- action: "lambda:InvokeFunctionUrl",
602
- function: image.imageLambda.name,
603
- principal: "cloudfront.amazonaws.com",
604
- sourceArn: distribution.arn,
605
- });
606
-
607
454
  const url = args.domain
608
455
  ? $interpolate`https://${args.domain.name}`
609
456
  : $interpolate`https://${distribution.domainName}`;
@@ -611,4 +458,4 @@ export function createCdn(name: string, args: CdnArgs) {
611
458
  return { distribution, url };
612
459
  }
613
460
 
614
- export type CdnResources = ReturnType<typeof createCdn>;
461
+ export type ApiCdnResources = ReturnType<typeof createApiCdn>;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Media CDN Infrastructure
3
+ *
4
+ * CloudFront distribution for the public media surface — `/media/*` and
5
+ * `/img/*` only. No edge auth (`/media/*` is OAC-public, `/img/*` is HMAC
6
+ * signed at the Sharp Lambda). The default behavior targets the S3 origin
7
+ * with no rewrite, so unmatched paths return AccessDenied from S3 — defense
8
+ * in depth.
9
+ */
10
+
11
+ import type { StorageResources } from "./storage.js";
12
+ import type { ImageResources } from "./image.js";
13
+
14
+ export interface MediaCdnArgs {
15
+ image: ImageResources;
16
+ contentBucket: StorageResources["contentBucket"];
17
+ priceClass?: "PriceClass_100" | "PriceClass_200" | "PriceClass_All";
18
+ domain?: {
19
+ name: string;
20
+ certificateArn: string;
21
+ };
22
+ }
23
+
24
+ export function createMediaCdn(name: string, args: MediaCdnArgs) {
25
+ const { image, contentBucket } = args;
26
+
27
+ // =========================================================================
28
+ // CloudFront Function: Media Rewrite
29
+ // =========================================================================
30
+ const mediaRewriteFunction = new aws.cloudfront.Function(
31
+ `${name}MediaRewrite`,
32
+ {
33
+ name: $interpolate`${$app.name}-${$app.stage}-media-rewrite`,
34
+ runtime: "cloudfront-js-2.0",
35
+ publish: true,
36
+ code: `
37
+ function handler(event) {
38
+ var request = event.request;
39
+ var uri = request.uri;
40
+
41
+ // Rewrite /media/{site}/{mediaId}/{file} → /sites/{site}/media/{mediaId}/{file}
42
+ var parts = uri.split('/');
43
+ // parts: ['', 'media', '{site}', '{mediaId}', '{file}']
44
+ if (parts.length >= 5 && parts[1] === 'media') {
45
+ var site = parts[2];
46
+ var mediaId = parts[3];
47
+ var rest = parts.slice(4).join('/');
48
+ request.uri = '/sites/' + site + '/media/' + mediaId + '/' + rest;
49
+ }
50
+
51
+ return request;
52
+ }
53
+ `,
54
+ },
55
+ );
56
+
57
+ // =========================================================================
58
+ // Cache Policies
59
+ // =========================================================================
60
+
61
+ const imageCachePolicy = new aws.cloudfront.CachePolicy(
62
+ `${name}ImageCachePolicy`,
63
+ {
64
+ name: $interpolate`${$app.name}-${$app.stage}-image-cache`,
65
+ comment: "Cache policy for transformed images (immutable)",
66
+ defaultTtl: 31536000,
67
+ maxTtl: 31536000,
68
+ minTtl: 31536000,
69
+ parametersInCacheKeyAndForwardedToOrigin: {
70
+ cookiesConfig: { cookieBehavior: "none" },
71
+ headersConfig: { headerBehavior: "none" },
72
+ queryStringsConfig: {
73
+ queryStringBehavior: "whitelist",
74
+ queryStrings: {
75
+ items: ["w", "h", "fit", "format", "q", "sig"],
76
+ },
77
+ },
78
+ enableAcceptEncodingBrotli: true,
79
+ enableAcceptEncodingGzip: true,
80
+ },
81
+ },
82
+ );
83
+
84
+ const mediaCachePolicy = new aws.cloudfront.CachePolicy(
85
+ `${name}MediaCachePolicy`,
86
+ {
87
+ name: $interpolate`${$app.name}-${$app.stage}-media-cache`,
88
+ comment: "Cache policy for original media files (immutable)",
89
+ defaultTtl: 31536000,
90
+ maxTtl: 31536000,
91
+ minTtl: 31536000,
92
+ parametersInCacheKeyAndForwardedToOrigin: {
93
+ cookiesConfig: { cookieBehavior: "none" },
94
+ headersConfig: { headerBehavior: "none" },
95
+ queryStringsConfig: { queryStringBehavior: "none" },
96
+ enableAcceptEncodingBrotli: true,
97
+ enableAcceptEncodingGzip: true,
98
+ },
99
+ },
100
+ );
101
+
102
+ // =========================================================================
103
+ // Origin Access Controls (OAC)
104
+ // =========================================================================
105
+
106
+ const mediaOAC = new aws.cloudfront.OriginAccessControl(`${name}MediaOAC`, {
107
+ name: $interpolate`${$app.name}-${$app.stage}-media-oac`,
108
+ description: "OAC for S3 media origin",
109
+ originAccessControlOriginType: "s3",
110
+ signingBehavior: "always",
111
+ signingProtocol: "sigv4",
112
+ });
113
+
114
+ const imageOAC = new aws.cloudfront.OriginAccessControl(`${name}ImageOAC`, {
115
+ name: $interpolate`${$app.name}-${$app.stage}-image-oac`,
116
+ description: "OAC for image transform Lambda origin",
117
+ originAccessControlOriginType: "lambda",
118
+ signingBehavior: "always",
119
+ signingProtocol: "sigv4",
120
+ });
121
+
122
+ // =========================================================================
123
+ // CloudFront Distribution
124
+ // =========================================================================
125
+
126
+ const imageLambdaDomain = image.imageLambda.url.apply((url: string) => {
127
+ const parsed = new URL(url);
128
+ return parsed.hostname;
129
+ });
130
+
131
+ const s3RegionalDomain = $interpolate`${contentBucket.name}.s3.${aws.getRegionOutput().name}.amazonaws.com`;
132
+
133
+ const priceClass = args.priceClass ?? "PriceClass_100";
134
+
135
+ // Build aliases and certificate config for custom domain
136
+ const aliases = args.domain ? [args.domain.name] : undefined;
137
+ const viewerCertificate = args.domain
138
+ ? {
139
+ acmCertificateArn: args.domain.certificateArn,
140
+ sslSupportMethod: "sni-only" as const,
141
+ minimumProtocolVersion: "TLSv1.2_2021" as const,
142
+ }
143
+ : {
144
+ cloudfrontDefaultCertificate: true,
145
+ };
146
+
147
+ // Fresh resource ID `${name}MediaDistribution` (parallel to
148
+ // `${name}ApiDistribution` in cdn-api.ts) — same blue/green rationale.
149
+ const distribution = new aws.cloudfront.Distribution(
150
+ `${name}MediaDistribution`,
151
+ {
152
+ enabled: true,
153
+ comment: $interpolate`Headroom CMS Media - ${$app.stage}`,
154
+ httpVersion: "http2and3",
155
+ priceClass,
156
+ aliases,
157
+
158
+ origins: [
159
+ {
160
+ originId: "media-s3",
161
+ domainName: s3RegionalDomain,
162
+ originAccessControlId: mediaOAC.id,
163
+ s3OriginConfig: {
164
+ originAccessIdentity: "",
165
+ },
166
+ },
167
+ {
168
+ originId: "image-lambda",
169
+ domainName: imageLambdaDomain,
170
+ originAccessControlId: imageOAC.id,
171
+ customOriginConfig: {
172
+ httpPort: 80,
173
+ httpsPort: 443,
174
+ originProtocolPolicy: "https-only",
175
+ originSslProtocols: ["TLSv1.2"],
176
+ },
177
+ },
178
+ ],
179
+
180
+ // Default behavior: target media-s3 with mediaCachePolicy and NO
181
+ // viewer-request rewrite. Anything not under /media/* or /img/* hits
182
+ // S3 with the original URI and returns 403 AccessDenied — defense in
183
+ // depth so a typo can't accidentally rewrite to a real S3 key.
184
+ defaultCacheBehavior: {
185
+ targetOriginId: "media-s3",
186
+ viewerProtocolPolicy: "redirect-to-https",
187
+ allowedMethods: ["GET", "HEAD", "OPTIONS"],
188
+ cachedMethods: ["GET", "HEAD", "OPTIONS"],
189
+ compress: true,
190
+ cachePolicyId: mediaCachePolicy.id,
191
+ },
192
+
193
+ orderedCacheBehaviors: [
194
+ // Media originals: served directly from S3 via OAC. Explicit (not
195
+ // implicit-via-default) so the rewrite never runs on unrelated paths.
196
+ {
197
+ pathPattern: "/media/*",
198
+ targetOriginId: "media-s3",
199
+ viewerProtocolPolicy: "redirect-to-https",
200
+ allowedMethods: ["GET", "HEAD", "OPTIONS"],
201
+ cachedMethods: ["GET", "HEAD", "OPTIONS"],
202
+ compress: true,
203
+ cachePolicyId: mediaCachePolicy.id,
204
+ functionAssociations: [
205
+ {
206
+ eventType: "viewer-request",
207
+ functionArn: mediaRewriteFunction.arn,
208
+ },
209
+ ],
210
+ },
211
+ // Image transforms: served via Sharp Lambda
212
+ {
213
+ pathPattern: "/img/*",
214
+ targetOriginId: "image-lambda",
215
+ viewerProtocolPolicy: "redirect-to-https",
216
+ allowedMethods: ["GET", "HEAD", "OPTIONS"],
217
+ cachedMethods: ["GET", "HEAD", "OPTIONS"],
218
+ compress: true,
219
+ cachePolicyId: imageCachePolicy.id,
220
+ },
221
+ ],
222
+
223
+ restrictions: {
224
+ geoRestriction: { restrictionType: "none" },
225
+ },
226
+
227
+ viewerCertificate,
228
+ },
229
+ );
230
+
231
+ // Allow CloudFront to invoke the image Lambda via OAC.
232
+ // CRITICAL: sourceArn must reference the MEDIA distribution's ARN. Pointing
233
+ // it at the API CDN ARN would silently break image transforms in a way
234
+ // that's hard to spot from logs. (Single most-important consistency point
235
+ // per the design doc.)
236
+ new aws.lambda.Permission(`${name}ImageLambdaCFPermission`, {
237
+ action: "lambda:InvokeFunctionUrl",
238
+ function: image.imageLambda.name,
239
+ principal: "cloudfront.amazonaws.com",
240
+ sourceArn: distribution.arn,
241
+ });
242
+
243
+ const url = args.domain
244
+ ? $interpolate`https://${args.domain.name}`
245
+ : $interpolate`https://${distribution.domainName}`;
246
+
247
+ return { distribution, url };
248
+ }
249
+
250
+ export type MediaCdnResources = ReturnType<typeof createMediaCdn>;