headroom-cms 0.1.11 → 0.2.1

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 (135) hide show
  1. package/admin/.well-known/headroom.json +9 -0
  2. package/admin/assets/{AdminsPage-BnzH9TL3.js → AdminsPage-lGd1MFW3.js} +1 -1
  3. package/admin/assets/AllContentPage-YykTt4xH.js +1 -0
  4. package/admin/assets/{ApiKeysPage-DEAa8eyC.js → ApiKeysPage-BCpNEKxF.js} +1 -1
  5. package/admin/assets/{AuditPage-BN9yNsxh.js → AuditPage-DBKiujoI.js} +1 -1
  6. package/admin/assets/BackupsPage-DCrmYnQc.js +1 -0
  7. package/admin/assets/{BlockEditor-3wnisTOZ.js → BlockEditor-BQVXN_Yn.js} +3 -3
  8. package/admin/assets/BlockTypeEditPage-BZmvEezy.js +1 -0
  9. package/admin/assets/{BlockTypesPage-Dhkho6T_.js → BlockTypesPage-CAr2JDj9.js} +1 -1
  10. package/admin/assets/BulkActionBar-CgHMxSkm.js +1 -0
  11. package/admin/assets/{CollectionEditPage-lOb4hEZy.js → CollectionEditPage-BQoXWAB-.js} +1 -1
  12. package/admin/assets/{CollectionsPage-CgtOloa1.js → CollectionsPage-Dakk_NyE.js} +1 -1
  13. package/admin/assets/{ContentCreatePage-LeQjahp_.js → ContentCreatePage-d-xxhoxO.js} +1 -1
  14. package/admin/assets/ContentEditPage-CUi_mFRC.js +1 -0
  15. package/admin/assets/{ContentField-pilCbdnA.js → ContentField-D6UO6W9G.js} +1 -1
  16. package/admin/assets/ContentListPage-8riubzdw.js +1 -0
  17. package/admin/assets/{CustomBlockPreview-DNnTFM0z.js → CustomBlockPreview-BuRfUKgt.js} +1 -1
  18. package/admin/assets/FieldRenderer-CUVay-yx.js +2 -0
  19. package/admin/assets/FileTypeIcon-DRD08pW_.js +1 -0
  20. package/admin/assets/FloatingComposerController-D4uLQfUX-Dc1_NZ4l.js +1 -0
  21. package/admin/assets/{IconPicker-CpIgiQTC.js → IconPicker-DR-6b0GI.js} +2 -2
  22. package/admin/assets/{LoginPage-D9ZsGLIi.js → LoginPage-DbHhSxgY.js} +1 -1
  23. package/admin/assets/MediaField-eRyEALYH.js +1 -0
  24. package/admin/assets/MediaPage-DW85F2UV.js +1 -0
  25. package/admin/assets/{Pagination-Df9nQ7Z0.js → Pagination-DTboMLK6.js} +1 -1
  26. package/admin/assets/{RelationshipPicker-B3Ftmqxp.js → RelationshipPicker-VVZN4dlP.js} +1 -1
  27. package/admin/assets/{SiteSettingsPage-6NvH7CiQ.js → SiteSettingsPage-5-HzcJLC.js} +1 -1
  28. package/admin/assets/{SiteUserEditPage-D5VaQ1Xq.js → SiteUserEditPage-C_PPvEye.js} +1 -1
  29. package/admin/assets/{SiteUsersPage-BYVduiqs.js → SiteUsersPage-CIgMMi-T.js} +1 -1
  30. package/admin/assets/{SitesPage-rfWWE0yK.js → SitesPage-DkG0gyHT.js} +1 -1
  31. package/admin/assets/SubmissionDetailPage-BwFWjkVu.js +1 -0
  32. package/admin/assets/SubmissionEditPage-C6Og25NH.js +1 -0
  33. package/admin/assets/SubmissionListPage-spPh8dwQ.js +1 -0
  34. package/admin/assets/{TagInput-57c4DG1w.js → TagInput-DxktEo07.js} +1 -1
  35. package/admin/assets/{TagsPage-BEO5AwCv.js → TagsPage-Dcc1IkF7.js} +1 -1
  36. package/admin/assets/TrashPage-8ybk22ZM.js +1 -0
  37. package/admin/assets/{UsersPage-BpIRorJ1.js → UsersPage-D5uXgLV4.js} +1 -1
  38. package/admin/assets/{WebhookEditPage-D5xgi56h.js → WebhookEditPage-T5KsZGqe.js} +1 -1
  39. package/admin/assets/{WebhooksPage-BY7AaiGr.js → WebhooksPage-DEIu5cX0.js} +1 -1
  40. package/admin/assets/{card-C9hfyHXf.js → card-CVAiqxnT.js} +1 -1
  41. package/admin/assets/{checkbox-DVJcwUt1.js → checkbox-BO-Fusdb.js} +1 -1
  42. package/admin/assets/{command-Bfmj0MEL.js → command-D0ojin3H.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-BCa3VYjL.css +1 -0
  46. package/admin/assets/{index-Ce5pmRMj.js → index-CbEa9yyd.js} +10 -10
  47. package/admin/assets/listCellValue-BUdbRyCz.js +1 -0
  48. package/admin/assets/{popover-CzaQYEEP.js → popover-B9WNjj2t.js} +1 -1
  49. package/admin/assets/{select-CrRhFGIi.js → select-B2yTIHJT.js} +1 -1
  50. package/admin/assets/{serializeToText-2VrsuRUh.js → serializeToText-Tv-7pIdy.js} +1 -1
  51. package/admin/assets/{table-_3bMY0_z.js → table-C3EQVw1z.js} +1 -1
  52. package/admin/assets/{textarea-6fq0R6VV.js → textarea-DIHLWabG.js} +1 -1
  53. package/admin/assets/{useAdminResolver-BJNPz3OG.js → useAdminResolver-_OHcUYwq.js} +1 -1
  54. package/admin/assets/useContent-DLo6FUYZ.js +1 -0
  55. package/admin/assets/{useContentSearch-B3aTjuCu.js → useContentSearch-D-D0veFh.js} +1 -1
  56. package/admin/assets/{usePageTitle-C1r1-C00.js → usePageTitle-BJ2ARyuc.js} +1 -1
  57. package/admin/assets/{useSiteUsers-DIaqgNSp.js → useSiteUsers-Bn3GiEYB.js} +1 -1
  58. package/admin/assets/{useTags-B-HgMVwo.js → useTags-CyGo0zXa.js} +1 -1
  59. package/admin/assets/useTrash-BFJMIP8N.js +1 -0
  60. package/admin/assets/{useWebhooks-BvZjUJkJ.js → useWebhooks-BgJL2-Ek.js} +1 -1
  61. package/admin/index.html +2 -2
  62. package/admin/sw.js +1 -1
  63. package/admin/workbox-362996ec.js +1 -0
  64. package/dist/admin-site.d.ts.map +1 -1
  65. package/dist/admin-site.js +46 -3
  66. package/dist/admin-site.js.map +1 -1
  67. package/dist/api.d.ts +2 -0
  68. package/dist/api.d.ts.map +1 -1
  69. package/dist/api.js +57 -5
  70. package/dist/api.js.map +1 -1
  71. package/dist/backup.d.ts +29 -0
  72. package/dist/backup.d.ts.map +1 -0
  73. package/dist/backup.js +95 -0
  74. package/dist/backup.js.map +1 -0
  75. package/dist/cdn-api.d.ts.map +1 -1
  76. package/dist/cdn-api.js +20 -19
  77. package/dist/cdn-api.js.map +1 -1
  78. package/dist/cron.d.ts +42 -0
  79. package/dist/cron.d.ts.map +1 -0
  80. package/dist/cron.js +128 -0
  81. package/dist/cron.js.map +1 -0
  82. package/dist/image.d.ts +8 -1
  83. package/dist/image.d.ts.map +1 -1
  84. package/dist/image.js +26 -6
  85. package/dist/image.js.map +1 -1
  86. package/dist/index.d.ts +6 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +50 -1
  89. package/dist/index.js.map +1 -1
  90. package/dist/storage.d.ts +1 -0
  91. package/dist/storage.d.ts.map +1 -1
  92. package/dist/storage.js +21 -0
  93. package/dist/storage.js.map +1 -1
  94. package/dist/webhooks.d.ts +4 -3
  95. package/dist/webhooks.d.ts.map +1 -1
  96. package/dist/webhooks.js +22 -35
  97. package/dist/webhooks.js.map +1 -1
  98. package/lambda/api/bootstrap +0 -0
  99. package/lambda/backup-worker/bootstrap +0 -0
  100. package/lambda/image-lambda/index.mjs +30 -6
  101. package/lambda/image-lambda/node_modules/.package-lock.json +3 -3
  102. package/lambda/image-lambda/node_modules/semver/classes/range.js +7 -0
  103. package/lambda/image-lambda/node_modules/semver/package.json +1 -1
  104. package/lambda/image-lambda/node_modules/semver/ranges/subset.js +2 -2
  105. package/lambda/trash-sweeper/bootstrap +0 -0
  106. package/lambda/webhook-worker/bootstrap +0 -0
  107. package/package.json +1 -1
  108. package/src/admin-site.ts +46 -3
  109. package/src/api.ts +58 -5
  110. package/src/backup.ts +114 -0
  111. package/src/cdn-api.ts +20 -22
  112. package/src/cron.ts +153 -0
  113. package/src/image.ts +30 -6
  114. package/src/index.ts +58 -1
  115. package/src/sst-env.d.ts +21 -0
  116. package/src/storage.ts +22 -0
  117. package/src/webhooks.ts +22 -39
  118. package/admin/assets/AllContentPage-BtObN6oy.js +0 -1
  119. package/admin/assets/BlockTypeEditPage-C2evAESK.js +0 -1
  120. package/admin/assets/BulkActionBar-BxdfUSrN.js +0 -1
  121. package/admin/assets/ContentEditPage-xczr4d_h.js +0 -1
  122. package/admin/assets/ContentListPage-BAKDn1Xy.js +0 -1
  123. package/admin/assets/FieldRenderer-DiOKvkWV.js +0 -2
  124. package/admin/assets/FilterBar-BZoa63zh.js +0 -1
  125. package/admin/assets/FloatingComposerController-D4uLQfUX-BMIvFCoE.js +0 -1
  126. package/admin/assets/MediaField-CxccCFGQ.js +0 -1
  127. package/admin/assets/MediaPage-QvMaH2YJ.js +0 -1
  128. package/admin/assets/SubmissionDetailPage-BSUR685F.js +0 -1
  129. package/admin/assets/SubmissionEditPage-DjLXHjWU.js +0 -1
  130. package/admin/assets/SubmissionListPage-DBxNEvde.js +0 -1
  131. package/admin/assets/format-C88SDH8g.js +0 -1
  132. package/admin/assets/index-BB9Syqw2.css +0 -1
  133. package/admin/assets/useContent-Bs7nel7C.js +0 -1
  134. package/admin/assets/useMedia-ae3s_ajC.js +0 -1
  135. package/admin/workbox-7d58179f.js +0 -1
package/src/api.ts CHANGED
@@ -6,16 +6,40 @@
6
6
  */
7
7
 
8
8
  import path from "path";
9
+ import { execSync } from "child_process";
9
10
  import type { StorageResources } from "./storage.js";
10
11
  import type { AuthResources } from "./auth.js";
11
12
  import type { WebhookResources } from "./webhooks.js";
13
+ import type { BackupResources } from "./backup.js";
12
14
  import type { ImageResources } from "./image.js";
13
15
  import type { CollabTableResources } from "./collaboration.js";
14
16
 
17
+ /**
18
+ * Capture the deploying commit SHA so the API can self-report which build is
19
+ * running. Read at infra-eval time (once per `sst deploy` / `sst dev`) and
20
+ * baked into the Lambda's env. Falls back to "unknown" outside a git repo —
21
+ * never throws, since failing the deploy over a missing SHA would be hostile.
22
+ */
23
+ function captureGitSha(): string {
24
+ // Allow CI to inject a specific SHA — useful when builds happen on a
25
+ // detached checkout where `git rev-parse HEAD` returns the wrong value.
26
+ const fromEnv = process.env.HEADROOM_GIT_SHA;
27
+ if (fromEnv) return fromEnv.trim();
28
+ try {
29
+ return execSync("git rev-parse HEAD", {
30
+ encoding: "utf-8",
31
+ stdio: ["ignore", "pipe", "ignore"],
32
+ }).trim();
33
+ } catch {
34
+ return "unknown";
35
+ }
36
+ }
37
+
15
38
  export interface ApiArgs {
16
39
  storage: StorageResources;
17
40
  auth: AuthResources;
18
41
  webhooks: WebhookResources;
42
+ backup: BackupResources;
19
43
  image: ImageResources;
20
44
  /**
21
45
  * Just the collab DynamoDB table — the API Lambda only links the table
@@ -32,7 +56,7 @@ export interface ApiArgs {
32
56
  }
33
57
 
34
58
  export function createApi(name: string, args: ApiArgs) {
35
- const { storage, auth, webhooks, image, collab } = args;
59
+ const { storage, auth, webhooks, backup, image, collab } = args;
36
60
 
37
61
  const handlerConfig = args.dev
38
62
  ? {
@@ -68,8 +92,17 @@ export function createApi(name: string, args: ApiArgs) {
68
92
  AWS_REGION_NAME: aws.getRegionOutput().name,
69
93
  WEBHOOKS_TABLE: webhooks.webhooks.name,
70
94
  WEBHOOK_DELIVERIES_TABLE: webhooks.webhookDeliveries.name,
71
- WEBHOOK_QUEUE_URL: webhooks.webhookDeliveryQueue.url,
72
- IMAGE_SIGNING_SECRET: image.imageSigningSecret.value,
95
+ WEBHOOK_WORKER_FUNCTION_NAME: webhooks.webhookWorker.name,
96
+ // Backup admin endpoints (Phases 3+4): the API Lambda async-invokes
97
+ // the backup worker and serves list/presign/delete from the backup
98
+ // bucket directly. Both env vars must be present for /v1/admin/...
99
+ // backup endpoints to function; unset = 503 from the service layer.
100
+ BACKUP_BUCKET: storage.backupBucket.name,
101
+ BACKUP_WORKER_FUNCTION_NAME: backup.backupWorker.name,
102
+ // The Go API only signs with the primary master and derives the
103
+ // per-site key per request. It never receives the OLD master —
104
+ // only the Sharp Lambda's verifier needs it.
105
+ IMAGE_SIGNING_MASTER_SECRET: image.imageSigningMasterSecret.value,
73
106
  IMAGE_LAMBDA_NAME: image.imageLambda.name,
74
107
  RELATIONSHIPS_TABLE: storage.relationships.name,
75
108
  SITE_USERS_TABLE: storage.siteUsers.name,
@@ -79,6 +112,9 @@ export function createApi(name: string, args: ApiArgs) {
79
112
  // `X-Headroom-Internal` against this on internal service calls
80
113
  // (collab → draft snapshot). See packages/api/internal/middleware/jwt.go.
81
114
  INTERNAL_SECRET: storage.internalSecret.value,
115
+ // Commit SHA of the source tree at infra-eval time. Surfaced on
116
+ // /health so operators can map deploys → commits without tagging.
117
+ GIT_SHA: captureGitSha(),
82
118
  },
83
119
  link: [
84
120
  storage.sites,
@@ -92,12 +128,16 @@ export function createApi(name: string, args: ApiArgs) {
92
128
  storage.contentBucket,
93
129
  webhooks.webhooks,
94
130
  webhooks.webhookDeliveries,
95
- webhooks.webhookDeliveryQueue,
96
- image.imageSigningSecret,
131
+ image.imageSigningMasterSecret,
97
132
  storage.relationships,
98
133
  storage.siteUsers,
99
134
  collab.collabTable,
100
135
  storage.internalSecret,
136
+ // Backup bucket — admin endpoints list/presign-get/delete here. The
137
+ // worker writes here (already linked via backup.ts); the API only
138
+ // needs read + delete + presign-get + list-bucket, which are all
139
+ // covered by the SST `link` IAM grant for the bucket.
140
+ storage.backupBucket,
101
141
  ],
102
142
  permissions: [
103
143
  {
@@ -123,6 +163,19 @@ export function createApi(name: string, args: ApiArgs) {
123
163
  actions: ["lambda:InvokeFunction"],
124
164
  resources: [image.imageLambda.arn],
125
165
  },
166
+ {
167
+ // Async invoke of the webhook worker. Replaces the old SQS
168
+ // SendMessage permission that was previously auto-derived from
169
+ // linking the queue.
170
+ actions: ["lambda:InvokeFunction"],
171
+ resources: [webhooks.webhookWorker.arn],
172
+ },
173
+ {
174
+ // Async invoke of the backup worker — admin endpoints for trigger
175
+ // backup / restore call lambda:Invoke with InvocationType=Event.
176
+ actions: ["lambda:InvokeFunction"],
177
+ resources: [backup.backupWorker.arn],
178
+ },
126
179
  {
127
180
  actions: ["ses:SendEmail", "ses:SendRawEmail"],
128
181
  resources: ["*"],
package/src/backup.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Backup Infrastructure
3
+ *
4
+ * A single Go Lambda that handles both backup export and restore import.
5
+ * The API service invokes it synchronously via lambda:InvokeFunction for the
6
+ * "scheduled" entry point this will gain a daily EventBridge cron (Phase 4).
7
+ *
8
+ * The Lambda links every site-scoped table plus the content + backup buckets.
9
+ * Deliberately NOT linked: Collab, collabStateBucket, SchedulerLock,
10
+ * WebhookDeliveries — these are transient / operational and excluded from
11
+ * backup payloads (see steering doc "What's in scope vs. out of scope").
12
+ */
13
+
14
+ import path from "path";
15
+ import type { StorageResources } from "./storage.js";
16
+ import type { WebhookResources } from "./webhooks.js";
17
+
18
+ export interface BackupArgs {
19
+ storage: StorageResources;
20
+ webhooks: WebhookResources;
21
+ pkgRoot: string;
22
+ dev?: {
23
+ /** Go source path, e.g. "packages/backup-worker" */
24
+ handler: string;
25
+ };
26
+ }
27
+
28
+ export function createBackup(name: string, args: BackupArgs) {
29
+ const { storage, webhooks } = args;
30
+
31
+ const workerConfig = args.dev
32
+ ? {
33
+ handler: args.dev.handler,
34
+ runtime: "go" as const,
35
+ }
36
+ : {
37
+ bundle: path.join(args.pkgRoot, "lambda/backup-worker"),
38
+ handler: "bootstrap",
39
+ runtime: "provided.al2023" as const,
40
+ architecture: "arm64" as const,
41
+ };
42
+
43
+ const backupWorker = new sst.aws.Function(`${name}BackupWorker`, {
44
+ ...workerConfig,
45
+ // 15-minute Lambda budget. Large sites with many media files may bump up
46
+ // against this; see Deferred "Step Functions for very large sites" for
47
+ // the escape hatch.
48
+ timeout: "15 minutes",
49
+ // Higher than default — the worker holds tar/gzip pipes, an in-memory
50
+ // 25-row BatchWriteItem buffer, and parallel S3 GetObject buffers.
51
+ memory: "1024 MB",
52
+ environment: {
53
+ SITES_TABLE: storage.sites.name,
54
+ CONTENT_TABLE: storage.content.name,
55
+ DRAFT_CONTENT_TABLE: storage.draftContent.name,
56
+ BLOCKS_TABLE: storage.blocks.name,
57
+ MEDIA_TABLE: storage.media.name,
58
+ COLLECTIONS_TABLE: storage.collections.name,
59
+ BLOCK_TYPES_TABLE: storage.blockTypes.name,
60
+ ADMIN_AUDIT_TABLE: storage.adminAudit.name,
61
+ RELATIONSHIPS_TABLE: storage.relationships.name,
62
+ SITE_USERS_TABLE: storage.siteUsers.name,
63
+ WEBHOOKS_TABLE: webhooks.webhooks.name,
64
+ CONTENT_BUCKET: storage.contentBucket.name,
65
+ BACKUP_BUCKET: storage.backupBucket.name,
66
+ KVS_ARN: storage.kvs.arn,
67
+ AWS_REGION_NAME: aws.getRegionOutput().name,
68
+ },
69
+ link: [
70
+ storage.sites,
71
+ storage.content,
72
+ storage.draftContent,
73
+ storage.blocks,
74
+ storage.media,
75
+ storage.collections,
76
+ storage.blockTypes,
77
+ storage.adminAudit,
78
+ storage.relationships,
79
+ storage.siteUsers,
80
+ webhooks.webhooks,
81
+ storage.contentBucket,
82
+ storage.backupBucket,
83
+ ],
84
+ permissions: [
85
+ {
86
+ // CloudFront KVS resync on restore: re-publish API key hashes so the
87
+ // edge function recognizes them. Fail-loud per steering §2.1 step 18.
88
+ actions: [
89
+ "cloudfront-keyvaluestore:DescribeKeyValueStore",
90
+ "cloudfront-keyvaluestore:PutKey",
91
+ "cloudfront-keyvaluestore:DeleteKey",
92
+ "cloudfront-keyvaluestore:GetKey",
93
+ ],
94
+ resources: [storage.kvs.arn],
95
+ },
96
+ ],
97
+ });
98
+
99
+ // Phase 4 — daily EventBridge cron that invokes the worker with a
100
+ // "scheduled" event. The handler scans the Sites table, runs backup +
101
+ // retention cleanup for every site with backupSchedule.enabled. 02:00
102
+ // UTC is a low-traffic window for most public sites; tune in the
103
+ // steering doc if needed. Cost is one Lambda invocation per day per
104
+ // stack, regardless of site count.
105
+ const backupCron = new sst.aws.Cron(`${name}BackupSchedule`, {
106
+ schedule: "cron(0 2 * * ? *)",
107
+ function: backupWorker.arn,
108
+ event: { type: "scheduled" },
109
+ });
110
+
111
+ return { backupWorker, backupCron };
112
+ }
113
+
114
+ export type BackupResources = ReturnType<typeof createBackup>;
package/src/cdn-api.ts CHANGED
@@ -143,23 +143,14 @@ export function createApiCdn(name: string, args: ApiCdnArgs) {
143
143
  },
144
144
  );
145
145
 
146
- const versionCachePolicy = new aws.cloudfront.CachePolicy(
147
- `${name}VersionCachePolicy`,
148
- {
149
- name: $interpolate`${$app.name}-${$app.stage}-version-cache`,
150
- comment: "Short-TTL cache for /version to absorb client polling",
151
- minTtl: 0,
152
- defaultTtl: 2,
153
- maxTtl: 2,
154
- parametersInCacheKeyAndForwardedToOrigin: {
155
- cookiesConfig: { cookieBehavior: "none" },
156
- headersConfig: { headerBehavior: "none" },
157
- queryStringsConfig: { queryStringBehavior: "none" },
158
- enableAcceptEncodingBrotli: true,
159
- enableAcceptEncodingGzip: true,
160
- },
161
- },
162
- );
146
+ // NOTE: the old `versionCachePolicy` (custom 2-second TTL) was deleted
147
+ // when /version began returning the per-site image signing secret.
148
+ // CloudFront custom cache policies do not honor `Cache-Control: private`
149
+ // from the origin — the explicit TTLs would win and the response (with
150
+ // the secret) would be edge-cached for 2s. We now use the AWS-managed
151
+ // `CachingDisabled` policy below for /v1/*/version, so the origin's
152
+ // `Cache-Control: private, max-age=2` is what reaches the browser
153
+ // (short-window in-browser cache) while the edge bypasses caching.
163
154
 
164
155
  // =========================================================================
165
156
  // Origin Request Policy
@@ -321,10 +312,17 @@ export function createApiCdn(name: string, args: ApiCdnArgs) {
321
312
  },
322
313
  ],
323
314
  },
324
- // Version endpoint: short-TTL cache (2s) shared across all consumers
325
- // for a given site. The edge function still runs for API key
326
- // validation. Placed before /v1/*/users/* and the default behavior
327
- // because CloudFront evaluates path patterns in order, first match wins.
315
+ // Version endpoint: caching DISABLED at the edge. /version now
316
+ // carries a per-site image signing secret in the body, so the
317
+ // shared edge cache must not hold it. The origin sets
318
+ // `Cache-Control: private, max-age=2` so browsers may still cache
319
+ // for the 2-second window — that absorbs single-flight contention
320
+ // from the same client without exposing the secret to any other
321
+ // viewer.
322
+ //
323
+ // Tradeoff: origin Lambda invocations on /version are now one per
324
+ // client poll instead of one per 2-second edge window. The SDK
325
+ // polls every 10s by default, so the impact is bounded.
328
326
  //
329
327
  // NOTE: pathPattern "/v1/*/version" matches exact "/version" only.
330
328
  // CloudFront's `*` wildcard does not span path segments, so any
@@ -336,7 +334,7 @@ export function createApiCdn(name: string, args: ApiCdnArgs) {
336
334
  allowedMethods: ["GET", "HEAD", "OPTIONS"],
337
335
  cachedMethods: ["GET", "HEAD", "OPTIONS"],
338
336
  compress: true,
339
- cachePolicyId: versionCachePolicy.id,
337
+ cachePolicyId: cachingDisabledPolicyId,
340
338
  originRequestPolicyId: originRequestPolicy.id,
341
339
  functionAssociations: [
342
340
  {
package/src/cron.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Cron Infrastructure
3
+ *
4
+ * EventBridge-driven scheduled Lambdas for periodic CMS maintenance. Today
5
+ * this is just the trash sweeper (Phase 2b of `steering/TRASH_CAN.md`); other
6
+ * cron-shaped workers (backup retention, etc.) live in their own modules.
7
+ *
8
+ * The trash sweeper Lambda walks every site, every collection, and purges
9
+ * content trashed more than `model.TrashRetentionDays` (30) days ago via the
10
+ * service-layer `Purge` — which fans out to drafts, slug lookups, tag counts,
11
+ * relationships, audit, and a `content.deleted` webhook per item. See
12
+ * `packages/api/cmd/trash_sweeper/main.go` for the entrypoint.
13
+ *
14
+ * Mass-delete protection is a per-run circuit breaker inside the sweeper
15
+ * (`TRASH_PURGE_RUN_CAP`), not a CloudWatch alarm: a run that would purge at
16
+ * least the cap aborts and logs at ERROR instead of completing. This keeps the
17
+ * feature at $0-at-rest (no custom CloudWatch metric, no alarm, no SNS topic)
18
+ * while actively *preventing* a runaway purge rather than alerting after the
19
+ * fact. The abort is visible in the sweeper's daily CloudWatch Logs line.
20
+ */
21
+
22
+ import path from "path";
23
+ import type { StorageResources } from "./storage.js";
24
+ import type { WebhookResources } from "./webhooks.js";
25
+
26
+ export interface CronArgs {
27
+ storage: StorageResources;
28
+ webhooks: WebhookResources;
29
+ pkgRoot: string;
30
+ /**
31
+ * Stage name (`$app.stage`). Used to tune per-stage alarm thresholds: prod
32
+ * is tight (1000 purges/day signals mass-delete), dev/PR stages are loose
33
+ * to absorb manual testing without paging.
34
+ */
35
+ stage: string;
36
+ dev?: {
37
+ /** Go source path for the trash sweeper, e.g. "packages/api/cmd/trash_sweeper" */
38
+ handler: string;
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Per-stage value for the sweeper's per-run purge circuit breaker
44
+ * (`TRASH_PURGE_RUN_CAP`). A run that would purge at least this many items
45
+ * aborts instead of completing — the cheap, $0 replacement for the old
46
+ * CloudWatch mass-delete alarm. Production is tight (1000/run signals a likely
47
+ * mass-delete); dev/PR stages are loose so manual testing doesn't trip it.
48
+ * Read by `packages/api/cmd/trash_sweeper/main.go`.
49
+ */
50
+ function trashPurgeRunCap(stage: string): number {
51
+ return stage === "production" ? 1000 : 10000;
52
+ }
53
+
54
+ export function createCron(name: string, args: CronArgs) {
55
+ const { storage, webhooks } = args;
56
+
57
+ const trashSweeperConfig = args.dev
58
+ ? {
59
+ handler: args.dev.handler,
60
+ runtime: "go" as const,
61
+ }
62
+ : {
63
+ bundle: path.join(args.pkgRoot, "lambda/trash-sweeper"),
64
+ handler: "bootstrap",
65
+ runtime: "provided.al2023" as const,
66
+ architecture: "arm64" as const,
67
+ };
68
+
69
+ // Daily at 03:00 UTC. Cron syntax: minute hour day-of-month month day-of-week year.
70
+ // EventBridge requires either day-of-month or day-of-week to be `?`.
71
+ const trashSweeperCron = new sst.aws.Cron(`${name}TrashSweeper`, {
72
+ schedule: "cron(0 3 * * ? *)",
73
+ job: {
74
+ ...trashSweeperConfig,
75
+ // The sweeper pages through every site × every collection. 5 minutes
76
+ // is generous; a single site rarely has > 100 trashed items per
77
+ // collection per day. If we ever hit the timeout, the next run will
78
+ // pick up where we left off (items remain in the trash partition).
79
+ timeout: "5 minutes",
80
+ memory: "512 MB",
81
+ environment: {
82
+ // DynamoDB tables. NOTE: The sweeper's only entry point is
83
+ // AdminContentService.Purge, which touches content + drafts + audit
84
+ // + relationships + (indirectly via tagsRepo) ContentTable's
85
+ // tag-count items. blocksRepo and mediaRepo are passed to
86
+ // NewAdminContentService for symmetry with the API, but Purge's
87
+ // call graph never reaches them — so BLOCKS_TABLE / MEDIA_TABLE
88
+ // are deliberately omitted. If the sweeper ever grows a second
89
+ // entry point (Trash / Restore / Publish), revisit.
90
+ SITES_TABLE: storage.sites.name,
91
+ CONTENT_TABLE: storage.content.name,
92
+ DRAFT_CONTENT_TABLE: storage.draftContent.name,
93
+ COLLECTIONS_TABLE: storage.collections.name,
94
+ BLOCK_TYPES_TABLE: storage.blockTypes.name,
95
+ ADMIN_AUDIT_TABLE: storage.adminAudit.name,
96
+ RELATIONSHIPS_TABLE: storage.relationships.name,
97
+ WEBHOOKS_TABLE: webhooks.webhooks.name,
98
+ WEBHOOK_DELIVERIES_TABLE: webhooks.webhookDeliveries.name,
99
+ // Purge bumps the CDN site-version on published items, so the
100
+ // sweeper needs to write into the KVS just like the API Lambda.
101
+ KVS_ARN: storage.kvs.arn,
102
+ // Webhook fan-out async-invokes this Lambda per subscribed webhook.
103
+ WEBHOOK_WORKER_FUNCTION_NAME: webhooks.webhookWorker.name,
104
+ AWS_REGION_NAME: aws.getRegionOutput().name,
105
+ // Per-run purge circuit breaker. A sweep that would purge at least
106
+ // this many items aborts (and logs at ERROR) instead of completing —
107
+ // a $0 guard against a mass-delete / runaway, replacing the old
108
+ // CloudWatch mass-delete alarm. See trashPurgeRunCap above.
109
+ TRASH_PURGE_RUN_CAP: String(trashPurgeRunCap(args.stage)),
110
+ },
111
+ link: [
112
+ // Read/write — Purge mutates content (incl. tag-count items and
113
+ // slug-lookup rows) + drafts + audit + relationships, and emits
114
+ // per-item webhook delivery rows. Sites/collections/blockTypes are
115
+ // read-only for the sweep loop (ListAll + GetCollections).
116
+ // storage.blocks and storage.media are deliberately NOT linked —
117
+ // see env comment above.
118
+ storage.sites,
119
+ storage.content,
120
+ storage.draftContent,
121
+ storage.collections,
122
+ storage.blockTypes,
123
+ storage.adminAudit,
124
+ storage.relationships,
125
+ webhooks.webhooks,
126
+ webhooks.webhookDeliveries,
127
+ ],
128
+ permissions: [
129
+ {
130
+ // CloudFront KVS bump on Purge of a published item — mirrors the
131
+ // API Lambda's KVS permission set.
132
+ actions: [
133
+ "cloudfront-keyvaluestore:DescribeKeyValueStore",
134
+ "cloudfront-keyvaluestore:PutKey",
135
+ "cloudfront-keyvaluestore:DeleteKey",
136
+ "cloudfront-keyvaluestore:GetKey",
137
+ ],
138
+ resources: [storage.kvs.arn],
139
+ },
140
+ {
141
+ // Async invoke of the webhook worker. Same grant pattern as the
142
+ // API Lambda (`webhooks.webhookWorker.arn` in `api.ts`).
143
+ actions: ["lambda:InvokeFunction"],
144
+ resources: [webhooks.webhookWorker.arn],
145
+ },
146
+ ],
147
+ },
148
+ });
149
+
150
+ return { trashSweeperCron };
151
+ }
152
+
153
+ export type CronResources = ReturnType<typeof createCron>;
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,6 +10,7 @@ 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
16
  import { createApiCdn } from "./cdn-api.js";
@@ -17,6 +18,7 @@ import { createMediaCdn } from "./cdn-media.js";
17
18
  import { createScheduler } from "./scheduler.js";
18
19
  import { createCollabTable, createCollabHandler } from "./collaboration.js";
19
20
  import { createAdminSite } from "./admin-site.js";
21
+ import { createCron } from "./cron.js";
20
22
 
21
23
  /**
22
24
  * Resolve the headroom-cms package root directory.
@@ -48,6 +50,7 @@ function resolvePkgRoot(): string {
48
50
  export type { StorageResources } from "./storage.js";
49
51
  export type { AuthResources } from "./auth.js";
50
52
  export type { WebhookResources } from "./webhooks.js";
53
+ export type { BackupResources } from "./backup.js";
51
54
  export type { ImageResources } from "./image.js";
52
55
  export type { ApiResources } from "./api.js";
53
56
  export type { ApiCdnResources } from "./cdn-api.js";
@@ -55,6 +58,7 @@ export type { MediaCdnResources } from "./cdn-media.js";
55
58
  export type { SchedulerResources } from "./scheduler.js";
56
59
  export type { CollaborationResources } from "./collaboration.js";
57
60
  export type { AdminSiteResources } from "./admin-site.js";
61
+ export type { CronResources } from "./cron.js";
58
62
 
59
63
  export interface HeadroomCMSArgs {
60
64
  /**
@@ -127,6 +131,8 @@ export interface HeadroomCMSArgs {
127
131
  apiHandler: string;
128
132
  /** Go source path for webhook worker, e.g. "packages/webhook-worker" */
129
133
  webhookWorkerHandler: string;
134
+ /** Go source path for backup worker, e.g. "packages/backup-worker" */
135
+ backupWorkerHandler: string;
130
136
  /** SST handler for custom message function, e.g. "packages/functions/custom-message.handler" */
131
137
  customMessageHandler: string;
132
138
  /** SST handler for image Lambda, e.g. "packages/image-lambda/index.handler" */
@@ -137,6 +143,8 @@ export interface HeadroomCMSArgs {
137
143
  schedulerHandler?: string;
138
144
  /** SST handler for collab Lambda, e.g. "packages/collab/src/index.handler" */
139
145
  collabHandler?: string;
146
+ /** Go source path for trash sweeper Lambda, e.g. "packages/api/cmd/trash_sweeper" */
147
+ trashSweeperHandler?: string;
140
148
  };
141
149
 
142
150
  /**
@@ -177,7 +185,7 @@ export class HeadroomCMS {
177
185
  : undefined,
178
186
  });
179
187
 
180
- // 3. Webhooks (DynamoDB tables, SQS queues, worker Lambda)
188
+ // 3. Webhooks (DynamoDB tables, DLQ, worker Lambda)
181
189
  const webhooks = createWebhooks(name, {
182
190
  sites: storage.sites,
183
191
  pkgRoot,
@@ -186,6 +194,18 @@ export class HeadroomCMS {
186
194
  : undefined,
187
195
  });
188
196
 
197
+ // 3b. Backup worker Lambda (export + restore). Needs access to every
198
+ // site-scoped table plus the content + backup buckets. Created after
199
+ // webhooks so it can link the webhooks table for backup payloads.
200
+ const backup = createBackup(name, {
201
+ storage,
202
+ webhooks,
203
+ pkgRoot,
204
+ dev: args.dev
205
+ ? { handler: args.dev.backupWorkerHandler }
206
+ : undefined,
207
+ });
208
+
189
209
  // 4. Image Lambda (Sharp transform with HMAC-signed URLs)
190
210
  const image = createImage(name, {
191
211
  contentBucket: storage.contentBucket,
@@ -204,6 +224,7 @@ export class HeadroomCMS {
204
224
  storage,
205
225
  auth,
206
226
  webhooks,
227
+ backup,
207
228
  image,
208
229
  collab: collabTable,
209
230
  senderEmail: args.senderEmail,
@@ -240,6 +261,19 @@ export class HeadroomCMS {
240
261
  : undefined,
241
262
  });
242
263
 
264
+ // 7b. Cron — daily trash sweeper Lambda (with an in-sweeper per-run purge
265
+ // circuit breaker; no CloudWatch metric/alarm). See `steering/TRASH_CAN.md`
266
+ // Phase 2b.
267
+ const cron = createCron(name, {
268
+ storage,
269
+ webhooks,
270
+ pkgRoot,
271
+ stage: $app.stage,
272
+ dev: args.dev?.trashSweeperHandler
273
+ ? { handler: args.dev.trashSweeperHandler }
274
+ : undefined,
275
+ });
276
+
243
277
  // 8a. API CDN (CloudFront distribution + edge auth, /v1/* and /health)
244
278
  const apiCdn = createApiCdn(name, {
245
279
  api,
@@ -289,6 +323,29 @@ export class HeadroomCMS {
289
323
  userPoolId: auth.userPool.id,
290
324
  userPoolClientId: auth.userPoolClient.id,
291
325
  collabWs: collab.wsUrl,
326
+ // DynamoDB table names (with Pulumi-generated random suffixes baked
327
+ // in). Exposed so consumers — notably the Phase 6 E2E test harness —
328
+ // can discover real table names rather than guess them from the
329
+ // `<app>-<stage>-<Name>` template (which omits the random suffix).
330
+ // Per CLAUDE.md, `.sst/outputs.json` IS the supported interface for
331
+ // post-deploy discovery.
332
+ sitesTable: storage.sites.name,
333
+ contentTable: storage.content.name,
334
+ draftContentTable: storage.draftContent.name,
335
+ blocksTable: storage.blocks.name,
336
+ mediaTable: storage.media.name,
337
+ collectionsTable: storage.collections.name,
338
+ blockTypesTable: storage.blockTypes.name,
339
+ adminAuditTable: storage.adminAudit.name,
340
+ relationshipsTable: storage.relationships.name,
341
+ siteUsersTable: storage.siteUsers.name,
342
+ webhooksTable: webhooks.webhooks.name,
343
+ contentBucket: storage.contentBucket.name,
344
+ backupBucket: storage.backupBucket.name,
345
+ // Trash-sweeper Lambda function name (Phase 2b). Surfaced so ops/test
346
+ // scripts can target the sweeper by deterministic output instead of
347
+ // `aws lambda list-functions | grep`.
348
+ trashSweeperFunctionName: cron.trashSweeperCron.nodes.function.name,
292
349
  };
293
350
  }
294
351
  }