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.
- package/README.md +11 -6
- package/admin/.well-known/headroom.json +9 -0
- package/admin/assets/{AdminsPage-BIWASote.js → AdminsPage-DUMTsCEp.js} +1 -1
- package/admin/assets/{AllContentPage-1gXe2OC7.js → AllContentPage-D5ey5AOV.js} +1 -1
- package/admin/assets/{ApiKeysPage-BBW4ATBx.js → ApiKeysPage-CzUOSoz_.js} +1 -1
- package/admin/assets/{AuditPage-B5GGFWGG.js → AuditPage-CYAg4dbI.js} +1 -1
- package/admin/assets/BackupsPage-04_oMy3v.js +1 -0
- package/admin/assets/{BlockEditor-ClskiZoX.js → BlockEditor-s0CRZsjy.js} +3 -3
- package/admin/assets/BlockTypeEditPage-D1OFIlJZ.js +1 -0
- package/admin/assets/{BlockTypesPage-D8Me6OeX.js → BlockTypesPage-cJNR25fN.js} +1 -1
- package/admin/assets/{BulkActionBar--35xjnOP.js → BulkActionBar-BWysX7Wo.js} +1 -1
- package/admin/assets/CollectionEditPage-DRmCA_73.js +1 -0
- package/admin/assets/{CollectionsPage-BQmGXpvW.js → CollectionsPage-CeQB5e9u.js} +1 -1
- package/admin/assets/{ContentCreatePage-DlgxamOe.js → ContentCreatePage-Cq8Pi8EF.js} +1 -1
- package/admin/assets/ContentEditPage-CEJ7I3WH.js +1 -0
- package/admin/assets/{ContentField-D04Uo1Ov.js → ContentField-BZT4OUfI.js} +1 -1
- package/admin/assets/ContentListPage-BCEQrYVs.js +1 -0
- package/admin/assets/{CustomBlockPreview-Cs9bFDh4.js → CustomBlockPreview-Kc6bb3oq.js} +1 -1
- package/admin/assets/FieldRenderer-CT-DgCbC.js +2 -0
- package/admin/assets/FileTypeIcon-CNHtffHC.js +1 -0
- package/admin/assets/FloatingComposerController-D4uLQfUX-0_Y8mkGU.js +1 -0
- package/admin/assets/IconPicker-BpPlHJO0.js +3 -0
- package/admin/assets/{LoginPage-Bi7TBzK4.js → LoginPage-Dya8sF_P.js} +1 -1
- package/admin/assets/MediaField-C3qFf3g5.js +1 -0
- package/admin/assets/MediaPage-BNxc0wLq.js +1 -0
- package/admin/assets/{Pagination-CuHwUPHi.js → Pagination-Dx8h11Rn.js} +1 -1
- package/admin/assets/{RelationshipPicker-Dv7GaLcU.js → RelationshipPicker-C2MTxrhl.js} +1 -1
- package/admin/assets/{SiteSettingsPage-nBT7NzkA.js → SiteSettingsPage-BDZaUBmf.js} +1 -1
- package/admin/assets/{SiteUserEditPage-DroUTii9.js → SiteUserEditPage-MfzhPW7v.js} +1 -1
- package/admin/assets/{SiteUsersPage-iVXPCBPe.js → SiteUsersPage-CrYugXpx.js} +1 -1
- package/admin/assets/{SitesPage-BefZeWuJ.js → SitesPage-Cl8V3Hb7.js} +1 -1
- package/admin/assets/SubmissionDetailPage-BnVlsGb-.js +1 -0
- package/admin/assets/SubmissionEditPage-B0Kq52fb.js +1 -0
- package/admin/assets/SubmissionListPage-K665VwMp.js +1 -0
- package/admin/assets/{TagInput-d-Hw1fkL.js → TagInput-C6tcB5Xw.js} +1 -1
- package/admin/assets/{TagsPage-BZzDvcKa.js → TagsPage-BONR6bSu.js} +1 -1
- package/admin/assets/{UsersPage-CnQAOOGF.js → UsersPage-C2iCy0UR.js} +1 -1
- package/admin/assets/{WebhookEditPage-KeS8hmdW.js → WebhookEditPage-DjZFxT72.js} +1 -1
- package/admin/assets/{WebhooksPage-CASjmlPN.js → WebhooksPage-g_a224a4.js} +1 -1
- package/admin/assets/{card-CZTHR2Qa.js → card-DlfsF8lU.js} +1 -1
- package/admin/assets/{checkbox-DEgzM8H9.js → checkbox-BX8EcGFf.js} +1 -1
- package/admin/assets/{command-CdzYw11U.js → command-DaTsImUa.js} +1 -1
- package/admin/assets/{contentStatus-CkPi9Dh6.js → contentStatus-WXGfd7vX.js} +1 -1
- package/admin/assets/format-BRcflvs9.js +1 -0
- package/admin/assets/index-9sbb3-yI.css +1 -0
- package/admin/assets/{index-BA3y7HJs.js → index-DC1UyCW2.js} +10 -10
- package/admin/assets/listCellValue-CBqXAwce.js +1 -0
- package/admin/assets/media-url-DdCoIedP.js +1 -0
- package/admin/assets/{popover-BFw_h3j6.js → popover-BA-47SRI.js} +1 -1
- package/admin/assets/{select-dX9e6VDt.js → select-waaVyoQ5.js} +1 -1
- package/admin/assets/serializeToText-CjHhyvXp.js +2 -0
- package/admin/assets/{table-Dk7eeOt2.js → table-Br-QgtTL.js} +1 -1
- package/admin/assets/{textarea-CpDSUg2s.js → textarea-BILv1DQB.js} +1 -1
- package/admin/assets/useAdminResolver-CbDzGoDp.js +1 -0
- package/admin/assets/useContent-Bp4f9qe0.js +1 -0
- package/admin/assets/{useContentSearch-_bwacEth.js → useContentSearch-DbiA8aG-.js} +1 -1
- package/admin/assets/{usePageTitle-DYvuJQp6.js → usePageTitle-DOEFrHbj.js} +1 -1
- package/admin/assets/{useSiteUsers-CKtC_8Jc.js → useSiteUsers-BFYAbJNT.js} +1 -1
- package/admin/assets/{useTags-ybsMbCst.js → useTags-DJlXwDyc.js} +1 -1
- package/admin/assets/{useWebhooks-BAB-3sLa.js → useWebhooks-BkpJKNLN.js} +1 -1
- package/admin/favicon-16x16.png +0 -0
- package/admin/favicon-32x32.png +0 -0
- package/admin/icons/icon-180x180.png +0 -0
- package/admin/icons/icon-192x192.png +0 -0
- package/admin/icons/icon-512x512.png +0 -0
- package/admin/icons/maskable-icon-512x512.png +0 -0
- package/admin/index.html +2 -2
- package/admin/sw.js +1 -1
- package/admin/workbox-362996ec.js +1 -0
- package/dist/admin-site.d.ts +4 -2
- package/dist/admin-site.d.ts.map +1 -1
- package/dist/admin-site.js +49 -6
- package/dist/admin-site.js.map +1 -1
- package/dist/api.d.ts +2 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +57 -5
- package/dist/api.js.map +1 -1
- package/dist/backup.d.ts +29 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +95 -0
- package/dist/backup.js.map +1 -0
- package/dist/cdn-api.d.ts +25 -0
- package/dist/cdn-api.d.ts.map +1 -0
- package/dist/{cdn.js → cdn-api.js} +27 -158
- package/dist/cdn-api.js.map +1 -0
- package/dist/cdn-media.d.ts +26 -0
- package/dist/cdn-media.d.ts.map +1 -0
- package/dist/cdn-media.js +202 -0
- package/dist/cdn-media.js.map +1 -0
- package/dist/image.d.ts +8 -1
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js +26 -6
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +18 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -10
- package/dist/index.js.map +1 -1
- package/dist/storage.d.ts +1 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +21 -0
- package/dist/storage.js.map +1 -1
- package/dist/webhooks.d.ts +4 -3
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +22 -35
- package/dist/webhooks.js.map +1 -1
- package/lambda/api/bootstrap +0 -0
- package/lambda/backup-worker/bootstrap +0 -0
- package/lambda/image-lambda/index.mjs +30 -6
- package/lambda/image-lambda/node_modules/.package-lock.json +3 -3
- package/lambda/image-lambda/node_modules/semver/README.md +19 -4
- package/lambda/image-lambda/node_modules/semver/bin/semver.js +14 -10
- package/lambda/image-lambda/node_modules/semver/classes/range.js +7 -0
- package/lambda/image-lambda/node_modules/semver/functions/truncate.js +48 -0
- package/lambda/image-lambda/node_modules/semver/index.js +2 -0
- package/lambda/image-lambda/node_modules/semver/internal/re.js +1 -1
- package/lambda/image-lambda/node_modules/semver/package.json +3 -3
- package/lambda/image-lambda/node_modules/semver/range.bnf +5 -4
- package/lambda/image-lambda/node_modules/semver/ranges/subset.js +2 -2
- package/lambda/webhook-worker/bootstrap +0 -0
- package/package.json +1 -1
- package/src/admin-site.ts +53 -8
- package/src/api.ts +58 -5
- package/src/backup.ts +114 -0
- package/src/{cdn.ts → cdn-api.ts} +28 -183
- package/src/cdn-media.ts +250 -0
- package/src/image.ts +30 -6
- package/src/index.ts +71 -12
- package/src/sst-env.d.ts +4 -0
- package/src/storage.ts +22 -0
- package/src/webhooks.ts +22 -39
- package/admin/assets/BlockTypeEditPage-CY0gCPei.js +0 -1
- package/admin/assets/CollectionEditPage-y8t0ZO89.js +0 -1
- package/admin/assets/ContentEditPage-WkSbCnnG.js +0 -1
- package/admin/assets/ContentListPage-BDMx7pWb.js +0 -1
- package/admin/assets/FieldRenderer-wE-mtqZB.js +0 -2
- package/admin/assets/FilterBar-kFcOLffg.js +0 -1
- package/admin/assets/FloatingComposerController-D4uLQfUX-C0Lhbmda.js +0 -1
- package/admin/assets/IconPicker-BrgSAsa_.js +0 -3
- package/admin/assets/MediaField-B-Cz8TlK.js +0 -1
- package/admin/assets/MediaPage-C84p9d1U.js +0 -1
- package/admin/assets/SubmissionDetailPage-ktmzzOE1.js +0 -1
- package/admin/assets/SubmissionEditPage-C-ykTI2t.js +0 -1
- package/admin/assets/SubmissionListPage-DA-8deUy.js +0 -1
- package/admin/assets/format-C88SDH8g.js +0 -1
- package/admin/assets/index-c7UygSvP.css +0 -1
- package/admin/assets/media-url-DIg_vSyf.js +0 -1
- package/admin/assets/serializeToText-Zin3gYPm.js +0 -2
- package/admin/assets/useAdminResolver-Bljb4XGQ.js +0 -1
- package/admin/assets/useContent-CW0tm0FY.js +0 -1
- package/admin/assets/useMedia-Cu5N4rY8.js +0 -1
- package/admin/workbox-7d58179f.js +0 -1
- package/dist/cdn.d.ts +0 -27
- package/dist/cdn.d.ts.map +0 -1
- package/dist/cdn.js.map +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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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>;
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CDN Infrastructure
|
|
2
|
+
* API CDN Infrastructure
|
|
3
3
|
*
|
|
4
|
-
* CloudFront distribution
|
|
5
|
-
*
|
|
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
|
|
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
|
|
26
|
-
const { api,
|
|
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,64 +143,14 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
175
143
|
},
|
|
176
144
|
);
|
|
177
145
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
const versionCachePolicy = new aws.cloudfront.CachePolicy(
|
|
220
|
-
`${name}VersionCachePolicy`,
|
|
221
|
-
{
|
|
222
|
-
name: $interpolate`${$app.name}-${$app.stage}-version-cache`,
|
|
223
|
-
comment: "Short-TTL cache for /version to absorb client polling",
|
|
224
|
-
minTtl: 0,
|
|
225
|
-
defaultTtl: 2,
|
|
226
|
-
maxTtl: 2,
|
|
227
|
-
parametersInCacheKeyAndForwardedToOrigin: {
|
|
228
|
-
cookiesConfig: { cookieBehavior: "none" },
|
|
229
|
-
headersConfig: { headerBehavior: "none" },
|
|
230
|
-
queryStringsConfig: { queryStringBehavior: "none" },
|
|
231
|
-
enableAcceptEncodingBrotli: true,
|
|
232
|
-
enableAcceptEncodingGzip: true,
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
);
|
|
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.
|
|
236
154
|
|
|
237
155
|
// =========================================================================
|
|
238
156
|
// Origin Request Policy
|
|
@@ -275,26 +193,6 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
275
193
|
// authenticated behaviors below.
|
|
276
194
|
const cachingDisabledPolicyId = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
|
|
277
195
|
|
|
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
196
|
// =========================================================================
|
|
299
197
|
// CloudFront Distribution
|
|
300
198
|
// =========================================================================
|
|
@@ -304,13 +202,6 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
304
202
|
return parsed.hostname;
|
|
305
203
|
});
|
|
306
204
|
|
|
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
205
|
const priceClass = args.priceClass ?? "PriceClass_100";
|
|
315
206
|
|
|
316
207
|
// Build aliases and certificate config for custom domain
|
|
@@ -345,25 +236,6 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
345
236
|
originSslProtocols: ["TLSv1.2"],
|
|
346
237
|
},
|
|
347
238
|
},
|
|
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
239
|
],
|
|
368
240
|
|
|
369
241
|
defaultCacheBehavior: {
|
|
@@ -440,10 +312,17 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
440
312
|
},
|
|
441
313
|
],
|
|
442
314
|
},
|
|
443
|
-
// Version endpoint:
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
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.
|
|
447
326
|
//
|
|
448
327
|
// NOTE: pathPattern "/v1/*/version" matches exact "/version" only.
|
|
449
328
|
// CloudFront's `*` wildcard does not span path segments, so any
|
|
@@ -455,7 +334,7 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
455
334
|
allowedMethods: ["GET", "HEAD", "OPTIONS"],
|
|
456
335
|
cachedMethods: ["GET", "HEAD", "OPTIONS"],
|
|
457
336
|
compress: true,
|
|
458
|
-
cachePolicyId:
|
|
337
|
+
cachePolicyId: cachingDisabledPolicyId,
|
|
459
338
|
originRequestPolicyId: originRequestPolicy.id,
|
|
460
339
|
functionAssociations: [
|
|
461
340
|
{
|
|
@@ -549,32 +428,6 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
549
428
|
},
|
|
550
429
|
],
|
|
551
430
|
},
|
|
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
431
|
// Health endpoint: no caching, no auth
|
|
579
432
|
{
|
|
580
433
|
pathPattern: "/health",
|
|
@@ -596,14 +449,6 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
596
449
|
},
|
|
597
450
|
);
|
|
598
451
|
|
|
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
452
|
const url = args.domain
|
|
608
453
|
? $interpolate`https://${args.domain.name}`
|
|
609
454
|
: $interpolate`https://${distribution.domainName}`;
|
|
@@ -611,4 +456,4 @@ export function createCdn(name: string, args: CdnArgs) {
|
|
|
611
456
|
return { distribution, url };
|
|
612
457
|
}
|
|
613
458
|
|
|
614
|
-
export type
|
|
459
|
+
export type ApiCdnResources = ReturnType<typeof createApiCdn>;
|