headroom-cms 0.1.10 → 0.2.0

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/.well-known/headroom.json +9 -0
  3. package/admin/assets/{AdminsPage-BIWASote.js → AdminsPage-DUMTsCEp.js} +1 -1
  4. package/admin/assets/{AllContentPage-1gXe2OC7.js → AllContentPage-D5ey5AOV.js} +1 -1
  5. package/admin/assets/{ApiKeysPage-BBW4ATBx.js → ApiKeysPage-CzUOSoz_.js} +1 -1
  6. package/admin/assets/{AuditPage-B5GGFWGG.js → AuditPage-CYAg4dbI.js} +1 -1
  7. package/admin/assets/BackupsPage-04_oMy3v.js +1 -0
  8. package/admin/assets/{BlockEditor-ClskiZoX.js → BlockEditor-s0CRZsjy.js} +3 -3
  9. package/admin/assets/BlockTypeEditPage-D1OFIlJZ.js +1 -0
  10. package/admin/assets/{BlockTypesPage-D8Me6OeX.js → BlockTypesPage-cJNR25fN.js} +1 -1
  11. package/admin/assets/{BulkActionBar--35xjnOP.js → BulkActionBar-BWysX7Wo.js} +1 -1
  12. package/admin/assets/CollectionEditPage-DRmCA_73.js +1 -0
  13. package/admin/assets/{CollectionsPage-BQmGXpvW.js → CollectionsPage-CeQB5e9u.js} +1 -1
  14. package/admin/assets/{ContentCreatePage-DlgxamOe.js → ContentCreatePage-Cq8Pi8EF.js} +1 -1
  15. package/admin/assets/ContentEditPage-CEJ7I3WH.js +1 -0
  16. package/admin/assets/{ContentField-D04Uo1Ov.js → ContentField-BZT4OUfI.js} +1 -1
  17. package/admin/assets/ContentListPage-BCEQrYVs.js +1 -0
  18. package/admin/assets/{CustomBlockPreview-Cs9bFDh4.js → CustomBlockPreview-Kc6bb3oq.js} +1 -1
  19. package/admin/assets/FieldRenderer-CT-DgCbC.js +2 -0
  20. package/admin/assets/FileTypeIcon-CNHtffHC.js +1 -0
  21. package/admin/assets/FloatingComposerController-D4uLQfUX-0_Y8mkGU.js +1 -0
  22. package/admin/assets/IconPicker-BpPlHJO0.js +3 -0
  23. package/admin/assets/{LoginPage-Bi7TBzK4.js → LoginPage-Dya8sF_P.js} +1 -1
  24. package/admin/assets/MediaField-C3qFf3g5.js +1 -0
  25. package/admin/assets/MediaPage-BNxc0wLq.js +1 -0
  26. package/admin/assets/{Pagination-CuHwUPHi.js → Pagination-Dx8h11Rn.js} +1 -1
  27. package/admin/assets/{RelationshipPicker-Dv7GaLcU.js → RelationshipPicker-C2MTxrhl.js} +1 -1
  28. package/admin/assets/{SiteSettingsPage-nBT7NzkA.js → SiteSettingsPage-BDZaUBmf.js} +1 -1
  29. package/admin/assets/{SiteUserEditPage-DroUTii9.js → SiteUserEditPage-MfzhPW7v.js} +1 -1
  30. package/admin/assets/{SiteUsersPage-iVXPCBPe.js → SiteUsersPage-CrYugXpx.js} +1 -1
  31. package/admin/assets/{SitesPage-BefZeWuJ.js → SitesPage-Cl8V3Hb7.js} +1 -1
  32. package/admin/assets/SubmissionDetailPage-BnVlsGb-.js +1 -0
  33. package/admin/assets/SubmissionEditPage-B0Kq52fb.js +1 -0
  34. package/admin/assets/SubmissionListPage-K665VwMp.js +1 -0
  35. package/admin/assets/{TagInput-d-Hw1fkL.js → TagInput-C6tcB5Xw.js} +1 -1
  36. package/admin/assets/{TagsPage-BZzDvcKa.js → TagsPage-BONR6bSu.js} +1 -1
  37. package/admin/assets/{UsersPage-CnQAOOGF.js → UsersPage-C2iCy0UR.js} +1 -1
  38. package/admin/assets/{WebhookEditPage-KeS8hmdW.js → WebhookEditPage-DjZFxT72.js} +1 -1
  39. package/admin/assets/{WebhooksPage-CASjmlPN.js → WebhooksPage-g_a224a4.js} +1 -1
  40. package/admin/assets/{card-CZTHR2Qa.js → card-DlfsF8lU.js} +1 -1
  41. package/admin/assets/{checkbox-DEgzM8H9.js → checkbox-BX8EcGFf.js} +1 -1
  42. package/admin/assets/{command-CdzYw11U.js → command-DaTsImUa.js} +1 -1
  43. package/admin/assets/{contentStatus-CkPi9Dh6.js → contentStatus-WXGfd7vX.js} +1 -1
  44. package/admin/assets/format-BRcflvs9.js +1 -0
  45. package/admin/assets/index-9sbb3-yI.css +1 -0
  46. package/admin/assets/{index-BA3y7HJs.js → index-DC1UyCW2.js} +10 -10
  47. package/admin/assets/listCellValue-CBqXAwce.js +1 -0
  48. package/admin/assets/media-url-DdCoIedP.js +1 -0
  49. package/admin/assets/{popover-BFw_h3j6.js → popover-BA-47SRI.js} +1 -1
  50. package/admin/assets/{select-dX9e6VDt.js → select-waaVyoQ5.js} +1 -1
  51. package/admin/assets/serializeToText-CjHhyvXp.js +2 -0
  52. package/admin/assets/{table-Dk7eeOt2.js → table-Br-QgtTL.js} +1 -1
  53. package/admin/assets/{textarea-CpDSUg2s.js → textarea-BILv1DQB.js} +1 -1
  54. package/admin/assets/useAdminResolver-CbDzGoDp.js +1 -0
  55. package/admin/assets/useContent-Bp4f9qe0.js +1 -0
  56. package/admin/assets/{useContentSearch-_bwacEth.js → useContentSearch-DbiA8aG-.js} +1 -1
  57. package/admin/assets/{usePageTitle-DYvuJQp6.js → usePageTitle-DOEFrHbj.js} +1 -1
  58. package/admin/assets/{useSiteUsers-CKtC_8Jc.js → useSiteUsers-BFYAbJNT.js} +1 -1
  59. package/admin/assets/{useTags-ybsMbCst.js → useTags-DJlXwDyc.js} +1 -1
  60. package/admin/assets/{useWebhooks-BAB-3sLa.js → useWebhooks-BkpJKNLN.js} +1 -1
  61. package/admin/favicon-16x16.png +0 -0
  62. package/admin/favicon-32x32.png +0 -0
  63. package/admin/icons/icon-180x180.png +0 -0
  64. package/admin/icons/icon-192x192.png +0 -0
  65. package/admin/icons/icon-512x512.png +0 -0
  66. package/admin/icons/maskable-icon-512x512.png +0 -0
  67. package/admin/index.html +2 -2
  68. package/admin/sw.js +1 -1
  69. package/admin/workbox-362996ec.js +1 -0
  70. package/dist/admin-site.d.ts +4 -2
  71. package/dist/admin-site.d.ts.map +1 -1
  72. package/dist/admin-site.js +49 -6
  73. package/dist/admin-site.js.map +1 -1
  74. package/dist/api.d.ts +2 -0
  75. package/dist/api.d.ts.map +1 -1
  76. package/dist/api.js +57 -5
  77. package/dist/api.js.map +1 -1
  78. package/dist/backup.d.ts +29 -0
  79. package/dist/backup.d.ts.map +1 -0
  80. package/dist/backup.js +95 -0
  81. package/dist/backup.js.map +1 -0
  82. package/dist/cdn-api.d.ts +25 -0
  83. package/dist/cdn-api.d.ts.map +1 -0
  84. package/dist/{cdn.js → cdn-api.js} +27 -158
  85. package/dist/cdn-api.js.map +1 -0
  86. package/dist/cdn-media.d.ts +26 -0
  87. package/dist/cdn-media.d.ts.map +1 -0
  88. package/dist/cdn-media.js +202 -0
  89. package/dist/cdn-media.js.map +1 -0
  90. package/dist/image.d.ts +8 -1
  91. package/dist/image.d.ts.map +1 -1
  92. package/dist/image.js +26 -6
  93. package/dist/image.js.map +1 -1
  94. package/dist/index.d.ts +18 -3
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +52 -10
  97. package/dist/index.js.map +1 -1
  98. package/dist/storage.d.ts +1 -0
  99. package/dist/storage.d.ts.map +1 -1
  100. package/dist/storage.js +21 -0
  101. package/dist/storage.js.map +1 -1
  102. package/dist/webhooks.d.ts +4 -3
  103. package/dist/webhooks.d.ts.map +1 -1
  104. package/dist/webhooks.js +22 -35
  105. package/dist/webhooks.js.map +1 -1
  106. package/lambda/api/bootstrap +0 -0
  107. package/lambda/backup-worker/bootstrap +0 -0
  108. package/lambda/image-lambda/index.mjs +30 -6
  109. package/lambda/image-lambda/node_modules/.package-lock.json +3 -3
  110. package/lambda/image-lambda/node_modules/semver/README.md +19 -4
  111. package/lambda/image-lambda/node_modules/semver/bin/semver.js +14 -10
  112. package/lambda/image-lambda/node_modules/semver/classes/range.js +7 -0
  113. package/lambda/image-lambda/node_modules/semver/functions/truncate.js +48 -0
  114. package/lambda/image-lambda/node_modules/semver/index.js +2 -0
  115. package/lambda/image-lambda/node_modules/semver/internal/re.js +1 -1
  116. package/lambda/image-lambda/node_modules/semver/package.json +3 -3
  117. package/lambda/image-lambda/node_modules/semver/range.bnf +5 -4
  118. package/lambda/image-lambda/node_modules/semver/ranges/subset.js +2 -2
  119. package/lambda/webhook-worker/bootstrap +0 -0
  120. package/package.json +1 -1
  121. package/src/admin-site.ts +53 -8
  122. package/src/api.ts +58 -5
  123. package/src/backup.ts +114 -0
  124. package/src/{cdn.ts → cdn-api.ts} +28 -183
  125. package/src/cdn-media.ts +250 -0
  126. package/src/image.ts +30 -6
  127. package/src/index.ts +71 -12
  128. package/src/sst-env.d.ts +4 -0
  129. package/src/storage.ts +22 -0
  130. package/src/webhooks.ts +22 -39
  131. package/admin/assets/BlockTypeEditPage-CY0gCPei.js +0 -1
  132. package/admin/assets/CollectionEditPage-y8t0ZO89.js +0 -1
  133. package/admin/assets/ContentEditPage-WkSbCnnG.js +0 -1
  134. package/admin/assets/ContentListPage-BDMx7pWb.js +0 -1
  135. package/admin/assets/FieldRenderer-wE-mtqZB.js +0 -2
  136. package/admin/assets/FilterBar-kFcOLffg.js +0 -1
  137. package/admin/assets/FloatingComposerController-D4uLQfUX-C0Lhbmda.js +0 -1
  138. package/admin/assets/IconPicker-BrgSAsa_.js +0 -3
  139. package/admin/assets/MediaField-B-Cz8TlK.js +0 -1
  140. package/admin/assets/MediaPage-C84p9d1U.js +0 -1
  141. package/admin/assets/SubmissionDetailPage-ktmzzOE1.js +0 -1
  142. package/admin/assets/SubmissionEditPage-C-ykTI2t.js +0 -1
  143. package/admin/assets/SubmissionListPage-DA-8deUy.js +0 -1
  144. package/admin/assets/format-C88SDH8g.js +0 -1
  145. package/admin/assets/index-c7UygSvP.css +0 -1
  146. package/admin/assets/media-url-DIg_vSyf.js +0 -1
  147. package/admin/assets/serializeToText-Zin3gYPm.js +0 -2
  148. package/admin/assets/useAdminResolver-Bljb4XGQ.js +0 -1
  149. package/admin/assets/useContent-CW0tm0FY.js +0 -1
  150. package/admin/assets/useMedia-Cu5N4rY8.js +0 -1
  151. package/admin/workbox-7d58179f.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
@@ -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>;
package/src/image.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  *
4
4
  * Sharp-based image transformation Lambda with HMAC-signed URLs.
5
5
  * Supports dev mode (Node.js source) and package mode (pre-bundled handler).
6
+ *
7
+ * The signing key is per-site: the Lambda derives it from a master KDF
8
+ * input (HMAC-SHA256(master, site)) so leaking one site's key cannot be
9
+ * used to forge URLs for another site. The OLD master is an opt-in
10
+ * fallback for smooth rotation — the Sharp Lambda accepts either, the Go
11
+ * API only signs with the primary.
6
12
  */
7
13
 
8
14
  import path from "path";
@@ -18,7 +24,15 @@ export interface ImageArgs {
18
24
  }
19
25
 
20
26
  export function createImage(name: string, args: ImageArgs) {
21
- const imageSigningSecret = new sst.Secret(`${name}ImageSigningSecret`);
27
+ const imageSigningMasterSecret = new sst.Secret(
28
+ `${name}ImageSigningMasterSecret`,
29
+ );
30
+ // Optional fallback master used during rotation. SST treats unset
31
+ // secrets as empty strings, which the Lambda's deriveSiteSecret turns
32
+ // into a null per-site key — disabling the fallback path entirely.
33
+ const imageSigningMasterSecretOld = new sst.Secret(
34
+ `${name}ImageSigningMasterSecretOld`,
35
+ );
22
36
 
23
37
  let imageLambda: sst.aws.Function;
24
38
  if (args.dev) {
@@ -36,9 +50,14 @@ export function createImage(name: string, args: ImageArgs) {
36
50
  },
37
51
  environment: {
38
52
  CONTENT_BUCKET: args.contentBucket.name,
39
- IMAGE_SIGNING_SECRET: imageSigningSecret.value,
53
+ IMAGE_SIGNING_MASTER_SECRET: imageSigningMasterSecret.value,
54
+ IMAGE_SIGNING_MASTER_SECRET_OLD: imageSigningMasterSecretOld.value,
40
55
  },
41
- link: [args.contentBucket, imageSigningSecret],
56
+ link: [
57
+ args.contentBucket,
58
+ imageSigningMasterSecret,
59
+ imageSigningMasterSecretOld,
60
+ ],
42
61
  });
43
62
  } else {
44
63
  imageLambda = new sst.aws.Function(`${name}ImageLambda`, {
@@ -53,13 +72,18 @@ export function createImage(name: string, args: ImageArgs) {
53
72
  },
54
73
  environment: {
55
74
  CONTENT_BUCKET: args.contentBucket.name,
56
- IMAGE_SIGNING_SECRET: imageSigningSecret.value,
75
+ IMAGE_SIGNING_MASTER_SECRET: imageSigningMasterSecret.value,
76
+ IMAGE_SIGNING_MASTER_SECRET_OLD: imageSigningMasterSecretOld.value,
57
77
  },
58
- link: [args.contentBucket, imageSigningSecret],
78
+ link: [
79
+ args.contentBucket,
80
+ imageSigningMasterSecret,
81
+ imageSigningMasterSecretOld,
82
+ ],
59
83
  });
60
84
  }
61
85
 
62
- return { imageSigningSecret, imageLambda };
86
+ return { imageSigningMasterSecret, imageSigningMasterSecretOld, imageLambda };
63
87
  }
64
88
 
65
89
  export type ImageResources = ReturnType<typeof createImage>;
package/src/index.ts CHANGED
@@ -10,9 +10,11 @@ import path from "path";
10
10
  import { createStorage } from "./storage.js";
11
11
  import { createAuth } from "./auth.js";
12
12
  import { createWebhooks } from "./webhooks.js";
13
+ import { createBackup } from "./backup.js";
13
14
  import { createImage } from "./image.js";
14
15
  import { createApi } from "./api.js";
15
- import { createCdn } from "./cdn.js";
16
+ import { createApiCdn } from "./cdn-api.js";
17
+ import { createMediaCdn } from "./cdn-media.js";
16
18
  import { createScheduler } from "./scheduler.js";
17
19
  import { createCollabTable, createCollabHandler } from "./collaboration.js";
18
20
  import { createAdminSite } from "./admin-site.js";
@@ -47,9 +49,11 @@ function resolvePkgRoot(): string {
47
49
  export type { StorageResources } from "./storage.js";
48
50
  export type { AuthResources } from "./auth.js";
49
51
  export type { WebhookResources } from "./webhooks.js";
52
+ export type { BackupResources } from "./backup.js";
50
53
  export type { ImageResources } from "./image.js";
51
54
  export type { ApiResources } from "./api.js";
52
- export type { CdnResources } from "./cdn.js";
55
+ export type { ApiCdnResources } from "./cdn-api.js";
56
+ export type { MediaCdnResources } from "./cdn-media.js";
53
57
  export type { SchedulerResources } from "./scheduler.js";
54
58
  export type { CollaborationResources } from "./collaboration.js";
55
59
  export type { AdminSiteResources } from "./admin-site.js";
@@ -63,7 +67,7 @@ export interface HeadroomCMSArgs {
63
67
  senderEmail: string;
64
68
 
65
69
  /**
66
- * Custom domain for the CDN (API endpoint).
70
+ * Custom domain for the API CDN (`/v1/*` and `/health`).
67
71
  * If not provided, uses the default CloudFront domain.
68
72
  */
69
73
  domain?: {
@@ -72,6 +76,17 @@ export interface HeadroomCMSArgs {
72
76
  certificateArn: string;
73
77
  };
74
78
 
79
+ /**
80
+ * Custom domain for the media CDN (S3 + image-Lambda origin, serves
81
+ * `/media/*` and `/img/*`). If not provided, uses the default CloudFront
82
+ * domain. The certificate must be in us-east-1, same as `domain`.
83
+ */
84
+ mediaDomain?: {
85
+ name: string;
86
+ /** ACM certificate ARN (must be in us-east-1 for CloudFront) */
87
+ certificateArn: string;
88
+ };
89
+
75
90
  /**
76
91
  * Custom domain for the admin UI.
77
92
  * If not provided, uses the default CloudFront domain.
@@ -114,6 +129,8 @@ export interface HeadroomCMSArgs {
114
129
  apiHandler: string;
115
130
  /** Go source path for webhook worker, e.g. "packages/webhook-worker" */
116
131
  webhookWorkerHandler: string;
132
+ /** Go source path for backup worker, e.g. "packages/backup-worker" */
133
+ backupWorkerHandler: string;
117
134
  /** SST handler for custom message function, e.g. "packages/functions/custom-message.handler" */
118
135
  customMessageHandler: string;
119
136
  /** SST handler for image Lambda, e.g. "packages/image-lambda/index.handler" */
@@ -138,7 +155,8 @@ export interface HeadroomCMSArgs {
138
155
 
139
156
  export class HeadroomCMS {
140
157
  public readonly apiUrl: $util.Output<string>;
141
- public readonly cdnUrl: $util.Output<string>;
158
+ public readonly apiCdnUrl: $util.Output<string>;
159
+ public readonly mediaCdnUrl: $util.Output<string>;
142
160
  public readonly adminUrl: $util.Output<string>;
143
161
  public readonly userPoolId: $util.Output<string>;
144
162
  public readonly userPoolClientId: $util.Output<string>;
@@ -163,7 +181,7 @@ export class HeadroomCMS {
163
181
  : undefined,
164
182
  });
165
183
 
166
- // 3. Webhooks (DynamoDB tables, SQS queues, worker Lambda)
184
+ // 3. Webhooks (DynamoDB tables, DLQ, worker Lambda)
167
185
  const webhooks = createWebhooks(name, {
168
186
  sites: storage.sites,
169
187
  pkgRoot,
@@ -172,6 +190,18 @@ export class HeadroomCMS {
172
190
  : undefined,
173
191
  });
174
192
 
193
+ // 3b. Backup worker Lambda (export + restore). Needs access to every
194
+ // site-scoped table plus the content + backup buckets. Created after
195
+ // webhooks so it can link the webhooks table for backup payloads.
196
+ const backup = createBackup(name, {
197
+ storage,
198
+ webhooks,
199
+ pkgRoot,
200
+ dev: args.dev
201
+ ? { handler: args.dev.backupWorkerHandler }
202
+ : undefined,
203
+ });
204
+
175
205
  // 4. Image Lambda (Sharp transform with HMAC-signed URLs)
176
206
  const image = createImage(name, {
177
207
  contentBucket: storage.contentBucket,
@@ -190,6 +220,7 @@ export class HeadroomCMS {
190
220
  storage,
191
221
  auth,
192
222
  webhooks,
223
+ backup,
193
224
  image,
194
225
  collab: collabTable,
195
226
  senderEmail: args.senderEmail,
@@ -226,21 +257,28 @@ export class HeadroomCMS {
226
257
  : undefined,
227
258
  });
228
259
 
229
- // 8. CDN (CloudFront distribution + edge functions)
230
- const cdn = createCdn(name, {
260
+ // 8a. API CDN (CloudFront distribution + edge auth, /v1/* and /health)
261
+ const apiCdn = createApiCdn(name, {
231
262
  api,
232
- image,
233
- contentBucket: storage.contentBucket,
234
263
  kvs: storage.kvs,
235
264
  priceClass: args.priceClass,
236
265
  apiCacheTtl: args.apiCacheTtl,
237
266
  domain: args.domain,
238
267
  });
239
268
 
269
+ // 8b. Media CDN (CloudFront distribution, /media/* and /img/* only)
270
+ const mediaCdn = createMediaCdn(name, {
271
+ image,
272
+ contentBucket: storage.contentBucket,
273
+ priceClass: args.priceClass,
274
+ domain: args.mediaDomain,
275
+ });
276
+
240
277
  // 9. Admin UI (static site)
241
278
  const admin = createAdminSite(name, {
242
279
  api,
243
- cdn,
280
+ apiCdn,
281
+ mediaCdn,
244
282
  auth,
245
283
  collab,
246
284
  pkgRoot,
@@ -253,7 +291,8 @@ export class HeadroomCMS {
253
291
 
254
292
  // Expose outputs
255
293
  this.apiUrl = api.api.url;
256
- this.cdnUrl = cdn.url;
294
+ this.apiCdnUrl = apiCdn.url;
295
+ this.mediaCdnUrl = mediaCdn.url;
257
296
  this.adminUrl = admin.url;
258
297
  this.userPoolId = auth.userPool.id;
259
298
  this.userPoolClientId = auth.userPoolClient.id;
@@ -261,11 +300,31 @@ export class HeadroomCMS {
261
300
 
262
301
  this.outputs = {
263
302
  api: api.api.url,
264
- cdn: cdn.url,
303
+ apiCdn: apiCdn.url,
304
+ mediaCdn: mediaCdn.url,
265
305
  admin: admin.url,
266
306
  userPoolId: auth.userPool.id,
267
307
  userPoolClientId: auth.userPoolClient.id,
268
308
  collabWs: collab.wsUrl,
309
+ // DynamoDB table names (with Pulumi-generated random suffixes baked
310
+ // in). Exposed so consumers — notably the Phase 6 E2E test harness —
311
+ // can discover real table names rather than guess them from the
312
+ // `<app>-<stage>-<Name>` template (which omits the random suffix).
313
+ // Per CLAUDE.md, `.sst/outputs.json` IS the supported interface for
314
+ // post-deploy discovery.
315
+ sitesTable: storage.sites.name,
316
+ contentTable: storage.content.name,
317
+ draftContentTable: storage.draftContent.name,
318
+ blocksTable: storage.blocks.name,
319
+ mediaTable: storage.media.name,
320
+ collectionsTable: storage.collections.name,
321
+ blockTypesTable: storage.blockTypes.name,
322
+ adminAuditTable: storage.adminAudit.name,
323
+ relationshipsTable: storage.relationships.name,
324
+ siteUsersTable: storage.siteUsers.name,
325
+ webhooksTable: webhooks.webhooks.name,
326
+ contentBucket: storage.contentBucket.name,
327
+ backupBucket: storage.backupBucket.name,
269
328
  };
270
329
  }
271
330
  }
package/src/sst-env.d.ts CHANGED
@@ -154,6 +154,10 @@ declare namespace aws {
154
154
  class EventSourceMapping {
155
155
  constructor(name: string, args?: any, opts?: any);
156
156
  }
157
+
158
+ class FunctionEventInvokeConfig {
159
+ constructor(name: string, args?: any, opts?: any);
160
+ }
157
161
  }
158
162
  }
159
163
 
package/src/storage.ts CHANGED
@@ -39,15 +39,23 @@ export function createStorage(name: string) {
39
39
  fields: {
40
40
  pk: "string",
41
41
  sk: "string",
42
+ siteHost: "string",
42
43
  },
43
44
  primaryIndex: { hashKey: "pk", rangeKey: "sk" },
45
+ globalIndexes: {
46
+ bySite: { hashKey: "siteHost", rangeKey: "sk" },
47
+ },
44
48
  });
45
49
 
46
50
  const blocks = new sst.aws.Dynamo(`${name}Blocks`, {
47
51
  fields: {
48
52
  pk: "string",
53
+ siteHost: "string",
49
54
  },
50
55
  primaryIndex: { hashKey: "pk" },
56
+ globalIndexes: {
57
+ bySite: { hashKey: "siteHost", rangeKey: "pk" },
58
+ },
51
59
  });
52
60
 
53
61
  const media = new sst.aws.Dynamo(`${name}Media`, {
@@ -94,10 +102,12 @@ export function createStorage(name: string) {
94
102
  sk: "string",
95
103
  targetPk: "string",
96
104
  targetSk: "string",
105
+ siteHost: "string",
97
106
  },
98
107
  primaryIndex: { hashKey: "pk", rangeKey: "sk" },
99
108
  globalIndexes: {
100
109
  byTarget: { hashKey: "targetPk", rangeKey: "targetSk" },
110
+ bySite: { hashKey: "siteHost", rangeKey: "sk" },
101
111
  },
102
112
  });
103
113
 
@@ -106,14 +116,25 @@ export function createStorage(name: string) {
106
116
  pk: "string",
107
117
  sk: "string",
108
118
  userId: "string",
119
+ siteHost: "string",
109
120
  },
110
121
  primaryIndex: { hashKey: "pk", rangeKey: "sk" },
111
122
  globalIndexes: {
112
123
  byUserId: { hashKey: "userId", rangeKey: "sk" },
124
+ bySite: { hashKey: "siteHost", rangeKey: "sk" },
113
125
  },
114
126
  ttl: "expiresAt",
115
127
  });
116
128
 
129
+ // Backup bucket: archives of full site exports written by the backup worker.
130
+ // Layout: backups/{host}/{timestamp}.tar.gz + backups/{host}/latest.json.
131
+ // Versioning disabled — archives are immutable by convention (a new timestamp
132
+ // is a new object). No CloudFront origin — admin endpoints serve presigned
133
+ // URLs directly off S3.
134
+ const backupBucket = new sst.aws.Bucket(`${name}BackupBucket`, {
135
+ versioning: false,
136
+ });
137
+
117
138
  const contentBucket = new sst.aws.Bucket(`${name}ContentBucket`, {
118
139
  versioning: true,
119
140
  access: "cloudfront",
@@ -181,6 +202,7 @@ export function createStorage(name: string) {
181
202
  relationships,
182
203
  siteUsers,
183
204
  contentBucket,
205
+ backupBucket,
184
206
  collabStateBucket,
185
207
  kvs,
186
208
  internalSecret,
package/src/webhooks.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Webhook Infrastructure
3
3
  *
4
- * 2 DynamoDB tables + 2 SQS queues + webhook worker Lambda.
5
- * Supports dev mode (Go source) and package mode (pre-compiled binary).
4
+ * 2 DynamoDB tables + webhook worker Lambda (async-invoked by the API) + DLQ
5
+ * (Lambda async on-failure destination). The API directly invokes the worker
6
+ * via lambda:InvokeFunction; there is no SQS queue between them.
6
7
  */
7
8
 
8
9
  import path from "path";
@@ -35,6 +36,9 @@ export function createWebhooks(name: string, args: WebhookArgs) {
35
36
  ttl: "ttl",
36
37
  });
37
38
 
39
+ // DLQ retained as Lambda async OnFailure destination. Lambda writes a
40
+ // failure envelope (not the original payload verbatim) when retries are
41
+ // exhausted. No consumer polls this queue — it's a sink only.
38
42
  const webhookDeliveryDLQ = new sst.aws.Queue(`${name}WebhookDeliveryDLQ`, {
39
43
  transform: {
40
44
  queue: (queueArgs: any) => {
@@ -43,26 +47,6 @@ export function createWebhooks(name: string, args: WebhookArgs) {
43
47
  },
44
48
  });
45
49
 
46
- const webhookDeliveryQueue = new sst.aws.Queue(
47
- `${name}WebhookDeliveryQueue`,
48
- {
49
- dlq: {
50
- queue: webhookDeliveryDLQ.arn,
51
- retry: 5,
52
- },
53
- transform: {
54
- queue: (queueArgs: any) => {
55
- queueArgs.visibilityTimeoutSeconds = 60;
56
- queueArgs.messageRetentionSeconds = 4 * 24 * 60 * 60; // 4 days
57
- },
58
- },
59
- },
60
- );
61
-
62
- // Webhook worker Lambda: processes SQS messages and delivers webhooks.
63
- // Note: We use a manual Function + EventSourceMapping instead of
64
- // queue.subscribe() to avoid a duplicate LambdaEncryptionKey issue
65
- // caused by SST's dynamic import creating a separate Function class instance.
66
50
  const workerConfig = args.dev
67
51
  ? {
68
52
  handler: args.dev.handler,
@@ -81,32 +65,31 @@ export function createWebhooks(name: string, args: WebhookArgs) {
81
65
  timeout: "30 seconds",
82
66
  environment: {
83
67
  WEBHOOK_DELIVERIES_TABLE: webhookDeliveries.name,
84
- WEBHOOKS_TABLE: webhooks.name,
85
- SITES_TABLE: args.sites.name,
86
68
  },
87
- link: [webhookDeliveries, webhooks, args.sites],
88
- permissions: [
89
- {
90
- actions: [
91
- "sqs:ReceiveMessage",
92
- "sqs:DeleteMessage",
93
- "sqs:GetQueueAttributes",
94
- ],
95
- resources: [webhookDeliveryQueue.arn],
96
- },
97
- ],
69
+ // webhookDeliveryDLQ is linked so SST auto-grants sqs:SendMessage on the
70
+ // worker's execution role. Lambda async OnFailure delivery uses the
71
+ // function's own role to write the failure envelope to the DLQ — without
72
+ // this grant, AWS accepts the FunctionEventInvokeConfig at deploy time but
73
+ // silently drops failure envelopes at runtime.
74
+ link: [webhookDeliveries, webhookDeliveryDLQ],
98
75
  });
99
76
 
100
- new aws.lambda.EventSourceMapping(`${name}WebhookWorkerEventSource`, {
101
- eventSourceArn: webhookDeliveryQueue.arn,
77
+ // Lambda async retry + DLQ on terminal failure. MaximumRetryAttempts is
78
+ // 0–2 (industry standard for webhook delivery — Stripe/GitHub publish
79
+ // similar caps). Total attempts = initial + 2 retries = 3.
80
+ new aws.lambda.FunctionEventInvokeConfig(`${name}WebhookWorkerAsyncConfig`, {
102
81
  functionName: webhookWorker.name,
103
- batchSize: 1,
82
+ maximumRetryAttempts: 2,
83
+ maximumEventAgeInSeconds: 6 * 60 * 60, // 6 hours
84
+ destinationConfig: {
85
+ onFailure: { destination: webhookDeliveryDLQ.arn },
86
+ },
104
87
  });
105
88
 
106
89
  return {
107
90
  webhooks,
108
91
  webhookDeliveries,
109
- webhookDeliveryQueue,
92
+ webhookWorker,
110
93
  webhookDeliveryDLQ,
111
94
  };
112
95
  }