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/cdn-media.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
53
|
+
IMAGE_SIGNING_MASTER_SECRET: imageSigningMasterSecret.value,
|
|
54
|
+
IMAGE_SIGNING_MASTER_SECRET_OLD: imageSigningMasterSecretOld.value,
|
|
40
55
|
},
|
|
41
|
-
link: [
|
|
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
|
-
|
|
75
|
+
IMAGE_SIGNING_MASTER_SECRET: imageSigningMasterSecret.value,
|
|
76
|
+
IMAGE_SIGNING_MASTER_SECRET_OLD: imageSigningMasterSecretOld.value,
|
|
57
77
|
},
|
|
58
|
-
link: [
|
|
78
|
+
link: [
|
|
79
|
+
args.contentBucket,
|
|
80
|
+
imageSigningMasterSecret,
|
|
81
|
+
imageSigningMasterSecretOld,
|
|
82
|
+
],
|
|
59
83
|
});
|
|
60
84
|
}
|
|
61
85
|
|
|
62
|
-
return {
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
230
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
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 +
|
|
5
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
+
webhookWorker,
|
|
110
93
|
webhookDeliveryDLQ,
|
|
111
94
|
};
|
|
112
95
|
}
|