@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80
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/amplify/data/function-handler.js +20 -0
- package/amplify/data/function-handler.ts +40 -0
- package/amplify/data/resource.js +151 -61
- package/amplify/data/resource.ts +157 -61
- package/amplify/data/schema.js +2096 -1980
- package/amplify/data/schema.ts +198 -69
- package/amplify/functions/assignment-action/data-access.js +14 -15
- package/amplify/functions/assignment-action/data-access.ts +13 -14
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/automation-view/handler.py +17 -0
- package/amplify/functions/automation-view/resource.js +24 -0
- package/amplify/functions/automation-view/resource.ts +34 -0
- package/amplify/functions/automation-view/schema.js +26 -0
- package/amplify/functions/automation-view/schema.ts +28 -0
- package/amplify/functions/automation-view/wire.js +56 -0
- package/amplify/functions/automation-view/wire.ts +76 -0
- package/amplify/functions/content-actions/handler.py +61 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +122 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +17 -0
- package/amplify/functions/monitoring-view/schema.ts +19 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +57 -0
- package/amplify/plugins/newsletter/schema.ts +62 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +182 -0
- package/amplify/plugins/reader-steering/config.ts +290 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
- package/amplify/plugins/reader-steering/schema.js +205 -0
- package/amplify/plugins/reader-steering/schema.ts +207 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/signup/schema.js +2 -1
- package/amplify/plugins/signup/schema.ts +2 -1
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +18 -1
- package/amplify/site-backend-names.js +4 -2
- package/amplify/site-backend-names.ts +4 -2
- package/amplify/site-backend.js +41 -2
- package/amplify/site-backend.ts +46 -2
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/automation-panel.tsx +49 -0
- package/components/automation-run-now.tsx +97 -0
- package/components/automation-view.tsx +192 -0
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/news-desk-client-provider.tsx +18 -18
- package/components/news-desk-taxonomy-client.ts +28 -8
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-access-gate.tsx +15 -1
- package/components/newsroom-article-editor.tsx +6 -6
- package/components/newsroom-articles-view.tsx +6 -6
- package/components/newsroom-board-view.tsx +8 -3
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-overview.tsx +22 -92
- package/components/newsroom-ops-shell.tsx +2 -2
- package/components/newsroom-organizer.css +33 -0
- package/components/newsroom-overview-skeleton.tsx +25 -0
- package/components/newsroom-references-view.tsx +192 -49
- package/components/notification-center.tsx +273 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reader-auth-state.ts +1 -1
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +10 -6
- package/components/reference-priming.tsx +14 -10
- package/components/reference-relevance-review.tsx +3 -3
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +223 -144
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +94 -16
- package/infra/amplify-app-shell.ts +106 -18
- package/infra/cloud-email-config.js +32 -0
- package/infra/cloud-email-config.ts +39 -0
- package/infra/cloud-email.js +57 -0
- package/infra/cloud-email.test.js +69 -0
- package/infra/cloud-email.test.ts +66 -0
- package/infra/cloud-email.ts +60 -0
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +88 -6
- package/infra/site-config.ts +134 -6
- package/lib/automation-client.ts +54 -0
- package/lib/automation-fixture.ts +93 -0
- package/lib/automation-overview.ts +232 -0
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/cyclotron-suggestions.ts +4 -4
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/news-desk-session.ts +12 -21
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/reference-review-details.ts +71 -0
- package/lib/reference-review-request.ts +14 -0
- package/lib/relevance-decisions.ts +42 -10
- package/lib/relevance-status.ts +2 -2
- package/lib/review-client.ts +99 -0
- package/lib/review-fixture.ts +45 -0
- package/lib/review-requests.ts +238 -0
- package/lib/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/lib/use-reference-review-data.ts +44 -0
- package/middleware.ts +14 -3
- package/package.json +56 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
|
-
import
|
|
4
|
+
import { SecureStringParameter } from "./secure-string-parameter.js";
|
|
5
|
+
import { addCloudEmail } from "./cloud-email.js";
|
|
6
|
+
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
5
7
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
6
8
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
7
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
9
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
8
10
|
function environmentVariables(variables) {
|
|
9
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
10
12
|
}
|
|
@@ -68,9 +70,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
68
70
|
if (!config.reader)
|
|
69
71
|
throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
|
|
70
72
|
this.readerAppId = this.addReaderApp(config, config.reader);
|
|
73
|
+
if (config.autoDeploy) {
|
|
74
|
+
this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
|
|
75
|
+
}
|
|
71
76
|
return;
|
|
72
77
|
}
|
|
73
78
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
79
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId));
|
|
74
80
|
const stagingEnabled = isStagingEnabled(config);
|
|
75
81
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
76
82
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -89,33 +95,49 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
89
95
|
});
|
|
90
96
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
91
97
|
if (revalidateSecretParameterName) {
|
|
98
|
+
const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
|
|
99
|
+
parameterName: revalidateSecretParameterName,
|
|
100
|
+
description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
|
|
101
|
+
});
|
|
92
102
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
93
103
|
sid: "ReadRevalidateSecret",
|
|
94
104
|
actions: ["ssm:GetParameter"],
|
|
95
|
-
resources: [
|
|
96
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
97
|
-
],
|
|
105
|
+
resources: [revalidateSecret.parameterArn],
|
|
98
106
|
}));
|
|
99
107
|
}
|
|
100
108
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
101
|
-
const
|
|
102
|
-
? new
|
|
109
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
110
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
111
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
103
112
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
104
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
105
113
|
})
|
|
106
114
|
: undefined;
|
|
107
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
115
|
+
const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
108
116
|
? {
|
|
109
117
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
110
|
-
|
|
118
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
111
119
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
112
120
|
}
|
|
113
121
|
: {};
|
|
114
|
-
if (
|
|
122
|
+
if (prospectusSessionSealing) {
|
|
115
123
|
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
116
124
|
sid: "ReadProspectusSessionSealingSecret",
|
|
117
|
-
actions: ["
|
|
118
|
-
resources: [
|
|
125
|
+
actions: ["ssm:GetParameter"],
|
|
126
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
130
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
131
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
132
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
133
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
134
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
135
|
+
});
|
|
136
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
137
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
138
|
+
sid: "ReadReaderPollSecret",
|
|
139
|
+
actions: ["ssm:GetParameter"],
|
|
140
|
+
resources: [readerPollSecret.parameterArn],
|
|
119
141
|
}));
|
|
120
142
|
}
|
|
121
143
|
if (prospectusAccountsApiStageArn) {
|
|
@@ -153,9 +175,15 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
153
175
|
const stagingOrigin = stagingDomainName
|
|
154
176
|
? `https://${stagingDomainName}/`
|
|
155
177
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
|
|
178
|
+
// The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
|
|
179
|
+
// A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
|
|
180
|
+
const siteOrigin = resolveSiteOrigin(config);
|
|
181
|
+
const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
|
|
182
|
+
const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
|
|
156
183
|
const sharedRootDomain = config.cms.domainPrefix !== undefined;
|
|
157
184
|
const defaultOriginsToAllow = [
|
|
158
185
|
...(config.cms.domainName ? [] : [productionOrigin]),
|
|
186
|
+
...(siteOrigin ? [`${siteOrigin}/`] : []),
|
|
159
187
|
...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
|
|
160
188
|
];
|
|
161
189
|
const oauthRedirectUrls = defaultOriginsToAllow.length === 0
|
|
@@ -165,15 +193,18 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
165
193
|
appId: cmsApp.attrAppId,
|
|
166
194
|
branchName: "main",
|
|
167
195
|
stage: "PRODUCTION",
|
|
168
|
-
|
|
196
|
+
// Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
|
|
197
|
+
enableAutoBuild: !config.autoDeploy,
|
|
169
198
|
enablePerformanceMode: false,
|
|
170
199
|
environmentVariables: environmentVariables({
|
|
171
200
|
...config.cms.environment,
|
|
201
|
+
...emailEnvironment,
|
|
172
202
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
173
203
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
174
204
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
175
205
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
176
206
|
SITE_ENV: "production",
|
|
207
|
+
PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
|
|
177
208
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
178
209
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
179
210
|
...prospectusEnvironment,
|
|
@@ -195,6 +226,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
195
226
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
196
227
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
197
228
|
SITE_ENV: "staging",
|
|
229
|
+
PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
|
|
198
230
|
...stagingMode,
|
|
199
231
|
...prospectusEnvironment,
|
|
200
232
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
@@ -247,6 +279,12 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
247
279
|
}
|
|
248
280
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
249
281
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
282
|
+
if (config.autoDeploy) {
|
|
283
|
+
this.addGithubDeployRole(config, [
|
|
284
|
+
{ appId: cmsApp.attrAppId, branch: "main" },
|
|
285
|
+
...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
250
288
|
if (config.github) {
|
|
251
289
|
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
252
290
|
}
|
|
@@ -294,6 +332,46 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
294
332
|
description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
|
|
295
333
|
});
|
|
296
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
|
|
337
|
+
* repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
|
|
338
|
+
* production branch of the site's apps.
|
|
339
|
+
*/
|
|
340
|
+
addGithubDeployRole(config, targets) {
|
|
341
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
342
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
|
|
343
|
+
service: "iam",
|
|
344
|
+
region: "",
|
|
345
|
+
resource: "oidc-provider",
|
|
346
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
347
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
348
|
+
}));
|
|
349
|
+
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
350
|
+
roleName: `${config.siteId}-github-deploy`,
|
|
351
|
+
description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
|
|
352
|
+
maxSessionDuration: Duration.hours(1),
|
|
353
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
354
|
+
StringEquals: {
|
|
355
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
|
|
356
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
|
|
357
|
+
},
|
|
358
|
+
}),
|
|
359
|
+
});
|
|
360
|
+
deployRole.addToPolicy(new iam.PolicyStatement({
|
|
361
|
+
sid: "StartAndReadProductionJobs",
|
|
362
|
+
actions: ["amplify:StartJob", "amplify:GetJob"],
|
|
363
|
+
resources: targets.map((target) => this.formatArn({
|
|
364
|
+
service: "amplify",
|
|
365
|
+
resource: "apps",
|
|
366
|
+
resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
|
|
367
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
368
|
+
})),
|
|
369
|
+
}));
|
|
370
|
+
new CfnOutput(this, "GithubDeployRoleArn", {
|
|
371
|
+
value: deployRole.roleArn,
|
|
372
|
+
description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
297
375
|
addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
|
|
298
376
|
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
|
|
299
377
|
service: "iam",
|
|
@@ -309,7 +387,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
309
387
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
310
388
|
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
311
389
|
StringLike: {
|
|
312
|
-
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.
|
|
390
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
|
|
313
391
|
},
|
|
314
392
|
}),
|
|
315
393
|
});
|
|
@@ -361,7 +439,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
361
439
|
appId: readerApp.attrAppId,
|
|
362
440
|
branchName: readerBranchName,
|
|
363
441
|
stage: "PRODUCTION",
|
|
364
|
-
enableAutoBuild:
|
|
442
|
+
enableAutoBuild: !config.autoDeploy,
|
|
365
443
|
environmentVariables: environmentVariables({
|
|
366
444
|
...(reader.environment ?? {}),
|
|
367
445
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
@@ -2,10 +2,12 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-lib";
|
|
3
3
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
5
|
-
import
|
|
5
|
+
import { SecureStringParameter } from "./secure-string-parameter";
|
|
6
|
+
import { addCloudEmail } from "./cloud-email";
|
|
7
|
+
import { parseCloudEmail } from "./cloud-email-config";
|
|
6
8
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
7
9
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
8
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
10
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
9
11
|
|
|
10
12
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
11
13
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
@@ -72,10 +74,14 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
72
74
|
if (!hasCmsApp(config)) {
|
|
73
75
|
if (!config.reader) throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
|
|
74
76
|
this.readerAppId = this.addReaderApp(config, config.reader);
|
|
77
|
+
if (config.autoDeploy) {
|
|
78
|
+
this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
|
|
79
|
+
}
|
|
75
80
|
return;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
84
|
+
const emailEnvironment = addCloudEmail(this, parseCloudEmail(config.email, true, config.siteId)!);
|
|
79
85
|
const stagingEnabled = isStagingEnabled(config);
|
|
80
86
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
81
87
|
const cmsHostName = resolveCmsHostName(config);
|
|
@@ -97,37 +103,55 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
97
103
|
|
|
98
104
|
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
99
105
|
if (revalidateSecretParameterName) {
|
|
106
|
+
const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
|
|
107
|
+
parameterName: revalidateSecretParameterName,
|
|
108
|
+
description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
|
|
109
|
+
});
|
|
100
110
|
computeRole.addToPolicy(
|
|
101
111
|
new iam.PolicyStatement({
|
|
102
112
|
sid: "ReadRevalidateSecret",
|
|
103
113
|
actions: ["ssm:GetParameter"],
|
|
104
|
-
resources: [
|
|
105
|
-
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
106
|
-
],
|
|
114
|
+
resources: [revalidateSecret.parameterArn],
|
|
107
115
|
}),
|
|
108
116
|
);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
|
|
112
|
-
const
|
|
113
|
-
? new
|
|
120
|
+
const prospectusSessionSealing = prospectusAccountsApiStageArn
|
|
121
|
+
? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
|
|
122
|
+
parameterName: resolveProspectusSessionSealingParameterName(config),
|
|
114
123
|
description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
|
|
115
|
-
generateSecretString: { passwordLength: 64, excludePunctuation: true },
|
|
116
124
|
})
|
|
117
125
|
: undefined;
|
|
118
|
-
const prospectusEnvironment = prospectusAccountsApiStageArn &&
|
|
126
|
+
const prospectusEnvironment: Record<string, string> = prospectusAccountsApiStageArn && prospectusSessionSealing
|
|
119
127
|
? {
|
|
120
128
|
PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
|
|
121
|
-
|
|
129
|
+
PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
|
|
122
130
|
PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
|
|
123
131
|
}
|
|
124
132
|
: {};
|
|
125
|
-
if (
|
|
133
|
+
if (prospectusSessionSealing) {
|
|
126
134
|
computeRole.addToPolicy(
|
|
127
135
|
new iam.PolicyStatement({
|
|
128
136
|
sid: "ReadProspectusSessionSealingSecret",
|
|
129
|
-
actions: ["
|
|
130
|
-
resources: [
|
|
137
|
+
actions: ["ssm:GetParameter"],
|
|
138
|
+
resources: [prospectusSessionSealing.parameterArn],
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
|
|
143
|
+
// Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
|
|
144
|
+
// route through the CMS route /api/reader/retired, which it authenticates with this secret.
|
|
145
|
+
const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
|
|
146
|
+
parameterName: resolveReaderPollSecretParameterName(config),
|
|
147
|
+
description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
|
|
148
|
+
});
|
|
149
|
+
prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
|
|
150
|
+
computeRole.addToPolicy(
|
|
151
|
+
new iam.PolicyStatement({
|
|
152
|
+
sid: "ReadReaderPollSecret",
|
|
153
|
+
actions: ["ssm:GetParameter"],
|
|
154
|
+
resources: [readerPollSecret.parameterArn],
|
|
131
155
|
}),
|
|
132
156
|
);
|
|
133
157
|
}
|
|
@@ -172,9 +196,15 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
172
196
|
const stagingOrigin = stagingDomainName
|
|
173
197
|
? `https://${stagingDomainName}/`
|
|
174
198
|
: Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
|
|
199
|
+
// The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
|
|
200
|
+
// A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
|
|
201
|
+
const siteOrigin = resolveSiteOrigin(config);
|
|
202
|
+
const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
|
|
203
|
+
const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
|
|
175
204
|
const sharedRootDomain = config.cms.domainPrefix !== undefined;
|
|
176
205
|
const defaultOriginsToAllow = [
|
|
177
206
|
...(config.cms.domainName ? [] : [productionOrigin]),
|
|
207
|
+
...(siteOrigin ? [`${siteOrigin}/`] : []),
|
|
178
208
|
...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
|
|
179
209
|
];
|
|
180
210
|
const oauthRedirectUrls = defaultOriginsToAllow.length === 0
|
|
@@ -185,15 +215,18 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
185
215
|
appId: cmsApp.attrAppId,
|
|
186
216
|
branchName: "main",
|
|
187
217
|
stage: "PRODUCTION",
|
|
188
|
-
|
|
218
|
+
// Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
|
|
219
|
+
enableAutoBuild: !config.autoDeploy,
|
|
189
220
|
enablePerformanceMode: false,
|
|
190
221
|
environmentVariables: environmentVariables({
|
|
191
222
|
...config.cms.environment,
|
|
223
|
+
...emailEnvironment,
|
|
192
224
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
193
225
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
194
226
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
195
227
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
196
228
|
SITE_ENV: "production",
|
|
229
|
+
PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
|
|
197
230
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
198
231
|
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
199
232
|
...prospectusEnvironment,
|
|
@@ -216,6 +249,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
216
249
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
217
250
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
218
251
|
SITE_ENV: "staging",
|
|
252
|
+
PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
|
|
219
253
|
...stagingMode,
|
|
220
254
|
...prospectusEnvironment,
|
|
221
255
|
NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
|
|
@@ -274,6 +308,13 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
274
308
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
275
309
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
276
310
|
|
|
311
|
+
if (config.autoDeploy) {
|
|
312
|
+
this.addGithubDeployRole(config, [
|
|
313
|
+
{ appId: cmsApp.attrAppId, branch: "main" },
|
|
314
|
+
...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
|
|
315
|
+
]);
|
|
316
|
+
}
|
|
317
|
+
|
|
277
318
|
if (config.github) {
|
|
278
319
|
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
279
320
|
}
|
|
@@ -326,6 +367,55 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
326
367
|
});
|
|
327
368
|
}
|
|
328
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
|
|
372
|
+
* repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
|
|
373
|
+
* production branch of the site's apps.
|
|
374
|
+
*/
|
|
375
|
+
private addGithubDeployRole(config: AmplifyAppShellSiteConfig, targets: { appId: string; branch: string }[]): void {
|
|
376
|
+
const { owner, repo } = resolveGithubRepository(config);
|
|
377
|
+
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
378
|
+
this,
|
|
379
|
+
"GithubDeployOidcProvider",
|
|
380
|
+
this.formatArn({
|
|
381
|
+
service: "iam",
|
|
382
|
+
region: "",
|
|
383
|
+
resource: "oidc-provider",
|
|
384
|
+
resourceName: GITHUB_OIDC_PROVIDER_HOST,
|
|
385
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
389
|
+
roleName: `${config.siteId}-github-deploy`,
|
|
390
|
+
description: `Merge-to-main deploy for ${owner}/${repo}: GitHub OIDC on refs/heads/main only; amplify:StartJob and GetJob on the production branch of the site's apps only`,
|
|
391
|
+
maxSessionDuration: Duration.hours(1),
|
|
392
|
+
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
393
|
+
StringEquals: {
|
|
394
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
|
|
395
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
|
|
396
|
+
},
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
deployRole.addToPolicy(
|
|
400
|
+
new iam.PolicyStatement({
|
|
401
|
+
sid: "StartAndReadProductionJobs",
|
|
402
|
+
actions: ["amplify:StartJob", "amplify:GetJob"],
|
|
403
|
+
resources: targets.map((target) =>
|
|
404
|
+
this.formatArn({
|
|
405
|
+
service: "amplify",
|
|
406
|
+
resource: "apps",
|
|
407
|
+
resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
|
|
408
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
409
|
+
}),
|
|
410
|
+
),
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
new CfnOutput(this, "GithubDeployRoleArn", {
|
|
414
|
+
value: deployRole.roleArn,
|
|
415
|
+
description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
329
419
|
private addGithubCiRole(
|
|
330
420
|
config: AmplifyAppShellSiteConfig,
|
|
331
421
|
github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
|
|
@@ -351,9 +441,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
351
441
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, {
|
|
352
442
|
StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
|
|
353
443
|
StringLike: {
|
|
354
|
-
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.
|
|
355
|
-
(branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
|
|
356
|
-
),
|
|
444
|
+
[`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
|
|
357
445
|
},
|
|
358
446
|
}),
|
|
359
447
|
});
|
|
@@ -420,7 +508,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
420
508
|
appId: readerApp.attrAppId,
|
|
421
509
|
branchName: readerBranchName,
|
|
422
510
|
stage: "PRODUCTION",
|
|
423
|
-
enableAutoBuild:
|
|
511
|
+
enableAutoBuild: !config.autoDeploy,
|
|
424
512
|
environmentVariables: environmentVariables({
|
|
425
513
|
...(reader.environment ?? {}),
|
|
426
514
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function parseCloudEmail(raw, cloud, siteId) {
|
|
2
|
+
if (!cloud) {
|
|
3
|
+
if (raw !== undefined)
|
|
4
|
+
throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
8
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
9
|
+
}
|
|
10
|
+
const value = raw;
|
|
11
|
+
for (const key of Object.keys(value)) {
|
|
12
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
13
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (value.mode !== "managed" && value.mode !== "existing")
|
|
17
|
+
throw new Error("site.json: email.mode must be managed or existing");
|
|
18
|
+
const string = (key, pattern, fallback) => {
|
|
19
|
+
const result = value[key] ?? fallback;
|
|
20
|
+
if (typeof result !== "string" || !pattern.test(result))
|
|
21
|
+
throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
mode: value.mode,
|
|
26
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
27
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
28
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
29
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
30
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Email belongs to a cloud site, independently of any newsletter plugin. */
|
|
2
|
+
export type CloudEmailConfig = {
|
|
3
|
+
mode: "managed" | "existing";
|
|
4
|
+
sendingDomain: string;
|
|
5
|
+
senderLocalPart: string;
|
|
6
|
+
configurationSet: string;
|
|
7
|
+
eventTopicName: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseCloudEmail(raw: unknown, cloud: boolean, siteId: string): CloudEmailConfig | undefined {
|
|
11
|
+
if (!cloud) {
|
|
12
|
+
if (raw !== undefined) throw new Error("site.json: email requires a cloud cms; static-only sites do not need email resources");
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
16
|
+
throw new Error("site.json: email is required for cloud sites; configure mode managed or existing and an explicit sendingDomain (see docs/email-reference-intake.md)");
|
|
17
|
+
}
|
|
18
|
+
const value = raw as Record<string, unknown>;
|
|
19
|
+
for (const key of Object.keys(value)) {
|
|
20
|
+
if (!["mode", "sendingDomain", "senderLocalPart", "configurationSet", "eventTopicName"].includes(key)) {
|
|
21
|
+
throw new Error(`site.json: email.${key} is not a known field`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (value.mode !== "managed" && value.mode !== "existing") throw new Error("site.json: email.mode must be managed or existing");
|
|
25
|
+
const string = (key: string, pattern: RegExp, fallback?: string) => {
|
|
26
|
+
const result = value[key] ?? fallback;
|
|
27
|
+
if (typeof result !== "string" || !pattern.test(result)) throw new Error(`site.json: email.${key} is required and must be valid`);
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
mode: value.mode,
|
|
32
|
+
sendingDomain: string("sendingDomain", /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/),
|
|
33
|
+
senderLocalPart: string("senderLocalPart", /^[a-z0-9][a-z0-9._+-]{0,63}$/, "notifications"),
|
|
34
|
+
configurationSet: string("configurationSet", /^[A-Za-z0-9_-]{1,64}$/, value.mode === "managed" ? `papyrus-${siteId}` : undefined),
|
|
35
|
+
// Preserve the newsletter consumer's historical default; the topic now also serves notifications.
|
|
36
|
+
eventTopicName: string("eventTopicName", /^[A-Za-z0-9_-]{1,256}$/, value.mode === "managed" ? `papyrus-newsletter-events-${siteId}` : undefined),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ArnFormat, CfnOutput, RemovalPolicy } from "aws-cdk-lib";
|
|
2
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import * as ses from "aws-cdk-lib/aws-ses";
|
|
4
|
+
import * as sns from "aws-cdk-lib/aws-sns";
|
|
5
|
+
/** No DNS mutation, sending grant or receipt activation. Existing identities remain with their owner. */
|
|
6
|
+
export function addCloudEmail(stack, config) {
|
|
7
|
+
if (config.mode === "managed") {
|
|
8
|
+
const configurationSet = new ses.CfnConfigurationSet(stack, "CloudEmailConfigurationSet", {
|
|
9
|
+
name: config.configurationSet,
|
|
10
|
+
reputationOptions: { reputationMetricsEnabled: true },
|
|
11
|
+
suppressionOptions: { suppressedReasons: ["BOUNCE", "COMPLAINT"] },
|
|
12
|
+
sendingOptions: { sendingEnabled: true },
|
|
13
|
+
});
|
|
14
|
+
configurationSet.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
15
|
+
const identity = new ses.CfnEmailIdentity(stack, "CloudEmailIdentity", {
|
|
16
|
+
emailIdentity: config.sendingDomain,
|
|
17
|
+
dkimSigningAttributes: { nextSigningKeyLength: "RSA_2048_BIT" },
|
|
18
|
+
configurationSetAttributes: { configurationSetName: configurationSet.ref },
|
|
19
|
+
});
|
|
20
|
+
identity.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
21
|
+
const topic = new sns.Topic(stack, "CloudEmailEvents", { topicName: config.eventTopicName });
|
|
22
|
+
topic.applyRemovalPolicy(RemovalPolicy.RETAIN);
|
|
23
|
+
topic.addToResourcePolicy(new iam.PolicyStatement({
|
|
24
|
+
principals: [new iam.ServicePrincipal("ses.amazonaws.com")], actions: ["sns:Publish"],
|
|
25
|
+
resources: [topic.topicArn], conditions: { StringEquals: { "aws:SourceAccount": stack.account } },
|
|
26
|
+
}));
|
|
27
|
+
const events = new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailEventDestination", {
|
|
28
|
+
configurationSetName: configurationSet.ref,
|
|
29
|
+
eventDestination: { name: "delivery-events", enabled: true,
|
|
30
|
+
matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT", "REJECT", "RENDERING_FAILURE", "DELIVERY_DELAY"],
|
|
31
|
+
snsDestination: { topicArn: topic.topicArn } },
|
|
32
|
+
});
|
|
33
|
+
events.node.addDependency(topic);
|
|
34
|
+
// These dimensions also feed the newsletter consumer's existing bounce/complaint alarms.
|
|
35
|
+
new ses.CfnConfigurationSetEventDestination(stack, "CloudEmailMetrics", {
|
|
36
|
+
configurationSetName: configurationSet.ref,
|
|
37
|
+
eventDestination: { name: "cloudwatch-metrics", enabled: true,
|
|
38
|
+
matchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
39
|
+
cloudWatchDestination: { dimensionConfigurations: [{ dimensionName: "ses:configuration-set",
|
|
40
|
+
dimensionValueSource: "MESSAGE_TAG", defaultDimensionValue: config.configurationSet }] } },
|
|
41
|
+
});
|
|
42
|
+
const records = [[identity.attrDkimDnsTokenName1, identity.attrDkimDnsTokenValue1],
|
|
43
|
+
[identity.attrDkimDnsTokenName2, identity.attrDkimDnsTokenValue2],
|
|
44
|
+
[identity.attrDkimDnsTokenName3, identity.attrDkimDnsTokenValue3]];
|
|
45
|
+
records.forEach(([name, value], index) => {
|
|
46
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Name`, { value: name, description: "CNAME name; DNS change requires separate approval" });
|
|
47
|
+
new CfnOutput(stack, `EmailDkimRecord${index + 1}Value`, { value });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const identityArn = stack.formatArn({ service: "ses", resource: "identity", resourceName: config.sendingDomain, arnFormat: ArnFormat.SLASH_RESOURCE_NAME });
|
|
51
|
+
new CfnOutput(stack, "EmailIdentityArn", { value: identityArn });
|
|
52
|
+
new CfnOutput(stack, "EmailConfigurationSetName", { value: config.configurationSet });
|
|
53
|
+
new CfnOutput(stack, "EmailEventTopicName", { value: config.eventTopicName });
|
|
54
|
+
return { PAPYRUS_EMAIL_FROM_ADDRESS: `${config.senderLocalPart}@${config.sendingDomain}`,
|
|
55
|
+
PAPYRUS_EMAIL_IDENTITY_ARN: identityArn, PAPYRUS_EMAIL_CONFIGURATION_SET: config.configurationSet,
|
|
56
|
+
PAPYRUS_EMAIL_EVENT_TOPIC_NAME: config.eventTopicName };
|
|
57
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { App } from "aws-cdk-lib";
|
|
3
|
+
import { Template } from "aws-cdk-lib/assertions";
|
|
4
|
+
import { AmplifyAppShellStack } from "./amplify-app-shell.js";
|
|
5
|
+
import { parseSiteConfig } from "./site-config.js";
|
|
6
|
+
const base = () => ({
|
|
7
|
+
siteId: "example", repository: "https://github.com/example/publication", brand: "example",
|
|
8
|
+
frontend: "markus-static", papyrusVersion: "5.32.0", autoDeploy: false,
|
|
9
|
+
cms: { cognitoDomainPrefix: "example-cms", staging: false,
|
|
10
|
+
environment: { PAPYRUS_OAUTH_REDIRECT_URLS: "http://localhost:3001/" } },
|
|
11
|
+
});
|
|
12
|
+
function synth(raw) {
|
|
13
|
+
const app = new App();
|
|
14
|
+
return Template.fromStack(new AmplifyAppShellStack(app, "EmailTest", parseSiteConfig(raw), {
|
|
15
|
+
env: { account: "123456789012", region: "us-east-1" },
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
const checks = {
|
|
19
|
+
managed() {
|
|
20
|
+
const template = synth({ ...base(), email: { mode: "managed", sendingDomain: "news.example.test" } });
|
|
21
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 1);
|
|
22
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 1);
|
|
23
|
+
template.resourceCountIs("AWS::Route53::RecordSet", 0);
|
|
24
|
+
template.resourceCountIs("AWS::SES::ReceiptRuleSet", 0);
|
|
25
|
+
template.hasResourceProperties("AWS::SES::EmailIdentity", { EmailIdentity: "news.example.test" });
|
|
26
|
+
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "papyrus-newsletter-events-example" });
|
|
27
|
+
template.hasResourceProperties("AWS::SES::ConfigurationSetEventDestination", {
|
|
28
|
+
EventDestination: { Name: "cloudwatch-metrics", MatchingEventTypes: ["SEND", "BOUNCE", "COMPLAINT"],
|
|
29
|
+
CloudWatchDestination: { DimensionConfigurations: [{ DimensionName: "ses:configuration-set",
|
|
30
|
+
DimensionValueSource: "MESSAGE_TAG", DefaultDimensionValue: "papyrus-example" }] } },
|
|
31
|
+
});
|
|
32
|
+
const text = JSON.stringify(template.toJSON());
|
|
33
|
+
assert.ok(text.includes("PAPYRUS_EMAIL_FROM_ADDRESS"));
|
|
34
|
+
assert.ok(text.includes("notifications@news.example.test"));
|
|
35
|
+
assert.ok(text.includes("EmailDkimRecord1Name"));
|
|
36
|
+
assert.ok(!text.includes("ses:SendEmail"), "identity setup does not grant unsolicited sending authority");
|
|
37
|
+
},
|
|
38
|
+
existing() {
|
|
39
|
+
const template = synth({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test",
|
|
40
|
+
configurationSet: "already-owned", eventTopicName: "already-owned-events" } });
|
|
41
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
42
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
43
|
+
template.resourceCountIs("AWS::SNS::Topic", 0);
|
|
44
|
+
assert.ok(JSON.stringify(template.toJSON()).includes("already-owned"));
|
|
45
|
+
},
|
|
46
|
+
static() {
|
|
47
|
+
const raw = { ...base(), reader: { buildCommand: "make site", baseDirectory: "dist" } };
|
|
48
|
+
delete raw.cms;
|
|
49
|
+
const template = synth(raw);
|
|
50
|
+
template.resourceCountIs("AWS::SES::EmailIdentity", 0);
|
|
51
|
+
template.resourceCountIs("AWS::SES::ConfigurationSet", 0);
|
|
52
|
+
template.resourceCountIs("AWS::SES::ReceiptRule", 0);
|
|
53
|
+
},
|
|
54
|
+
missing() {
|
|
55
|
+
assert.throws(() => parseSiteConfig(base()), /email.*required.*cloud/i);
|
|
56
|
+
assert.throws(() => parseSiteConfig({ ...base(), email: { mode: "existing", sendingDomain: "news.example.test" } }), /email.configurationSet/);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export function runCloudEmailTests(selected) {
|
|
60
|
+
if (selected) {
|
|
61
|
+
assert.ok(checks[selected], selected);
|
|
62
|
+
checks[selected]();
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
for (const check of Object.values(checks))
|
|
66
|
+
check();
|
|
67
|
+
console.log("cloud email infrastructure: ok");
|
|
68
|
+
}
|
|
69
|
+
runCloudEmailTests(process.argv[2]);
|