@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/amplify/auth/resource.js +16 -1
  2. package/amplify/auth/resource.ts +19 -1
  3. package/amplify/data/function-handler.js +20 -0
  4. package/amplify/data/function-handler.ts +40 -0
  5. package/amplify/data/resource.js +151 -61
  6. package/amplify/data/resource.ts +157 -61
  7. package/amplify/data/schema.js +2096 -1980
  8. package/amplify/data/schema.ts +198 -69
  9. package/amplify/functions/assignment-action/data-access.js +14 -15
  10. package/amplify/functions/assignment-action/data-access.ts +13 -14
  11. package/amplify/functions/assignment-action/handler.ts +1 -1
  12. package/amplify/functions/automation-view/handler.py +17 -0
  13. package/amplify/functions/automation-view/resource.js +24 -0
  14. package/amplify/functions/automation-view/resource.ts +34 -0
  15. package/amplify/functions/automation-view/schema.js +26 -0
  16. package/amplify/functions/automation-view/schema.ts +28 -0
  17. package/amplify/functions/automation-view/wire.js +56 -0
  18. package/amplify/functions/automation-view/wire.ts +76 -0
  19. package/amplify/functions/content-actions/handler.py +61 -2
  20. package/amplify/functions/content-actions/requirements.txt +1 -0
  21. package/amplify/functions/content-actions/resource.js +1 -1
  22. package/amplify/functions/content-actions/resource.ts +1 -1
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  25. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  26. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  27. package/amplify/functions/editorial-checks/schema.js +122 -0
  28. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  29. package/amplify/functions/editorial-checks/wire.js +53 -0
  30. package/amplify/functions/editorial-checks/wire.ts +61 -0
  31. package/amplify/functions/monitoring-view/handler.py +11 -0
  32. package/amplify/functions/monitoring-view/resource.js +24 -0
  33. package/amplify/functions/monitoring-view/resource.ts +34 -0
  34. package/amplify/functions/monitoring-view/schema.js +17 -0
  35. package/amplify/functions/monitoring-view/schema.ts +19 -0
  36. package/amplify/functions/monitoring-view/wire.js +37 -0
  37. package/amplify/functions/monitoring-view/wire.ts +47 -0
  38. package/amplify/functions/public-forms/core.js +247 -0
  39. package/amplify/functions/public-forms/core.ts +310 -0
  40. package/amplify/functions/public-forms/handler.ts +41 -0
  41. package/amplify/functions/public-forms/origins.js +35 -0
  42. package/amplify/functions/public-forms/origins.ts +43 -0
  43. package/amplify/functions/public-forms/resource.js +9 -0
  44. package/amplify/functions/public-forms/resource.ts +10 -0
  45. package/amplify/functions/public-forms/wire.js +25 -0
  46. package/amplify/functions/public-forms/wire.ts +32 -0
  47. package/amplify/functions/shared/editorial-context.ts +63 -4
  48. package/amplify/functions/shared/python-bundle.js +20 -14
  49. package/amplify/functions/shared/python-bundle.ts +25 -18
  50. package/amplify/plugins/newsletter/config.js +245 -0
  51. package/amplify/plugins/newsletter/config.ts +305 -0
  52. package/amplify/plugins/newsletter/function/handler.py +6 -0
  53. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  54. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  55. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  56. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  57. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  58. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  59. package/amplify/plugins/newsletter/schema.js +57 -0
  60. package/amplify/plugins/newsletter/schema.ts +62 -0
  61. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  62. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  63. package/amplify/plugins/newsletter/sending.js +140 -0
  64. package/amplify/plugins/newsletter/sending.ts +186 -0
  65. package/amplify/plugins/reader-steering/config.js +182 -0
  66. package/amplify/plugins/reader-steering/config.ts +290 -0
  67. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  68. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  69. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  70. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  71. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  72. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  73. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  74. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  76. package/amplify/plugins/reader-steering/schema.js +205 -0
  77. package/amplify/plugins/reader-steering/schema.ts +207 -0
  78. package/amplify/plugins/registry.js +4 -2
  79. package/amplify/plugins/registry.ts +4 -2
  80. package/amplify/plugins/signup/schema.js +2 -1
  81. package/amplify/plugins/signup/schema.ts +2 -1
  82. package/amplify/plugins/types.ts +5 -0
  83. package/amplify/plugins/wire.js +1 -0
  84. package/amplify/plugins/wire.ts +1 -0
  85. package/amplify/publication/projection-lease.js +30 -0
  86. package/amplify/publication/projection-lease.ts +39 -0
  87. package/amplify/publication/redirect-and-index.js +105 -0
  88. package/amplify/publication/redirect-rules.js +73 -0
  89. package/amplify/publication/redirect-rules.ts +93 -0
  90. package/amplify/publication/s3-projection.js +7 -1
  91. package/amplify/publication/s3-projection.ts +9 -1
  92. package/amplify/site-backend-config.ts +18 -1
  93. package/amplify/site-backend-names.js +4 -2
  94. package/amplify/site-backend-names.ts +4 -2
  95. package/amplify/site-backend.js +45 -3
  96. package/amplify/site-backend.ts +51 -3
  97. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  98. package/app/api/newsletter/confirm/route.ts +13 -0
  99. package/app/api/newsletter/preferences/route.ts +13 -0
  100. package/app/api/newsletter/state/route.ts +9 -0
  101. package/app/api/newsletter/subscribe/route.ts +9 -0
  102. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  103. package/app/api/newsroom/curation/context/route.ts +9 -0
  104. package/app/api/newsroom/curation/priming/route.ts +9 -0
  105. package/app/api/newsroom/curation/publication/route.ts +9 -0
  106. package/app/api/reader/comment/delete/route.ts +9 -0
  107. package/app/api/reader/comment/edit/route.ts +9 -0
  108. package/app/api/reader/comment/route.ts +9 -0
  109. package/app/api/reader/flag/route.ts +9 -0
  110. package/app/api/reader/handle/route.ts +9 -0
  111. package/app/api/reader/me/route.ts +9 -0
  112. package/app/api/reader/rating/route.ts +9 -0
  113. package/app/api/reader/report/route.ts +9 -0
  114. package/app/api/reader/retired/route.ts +9 -0
  115. package/app/api/reader/submit/route.ts +9 -0
  116. package/app/api/reader/vote/route.ts +9 -0
  117. package/bin/papyrus-infra.mjs +20 -1
  118. package/components/automation-panel.tsx +49 -0
  119. package/components/automation-run-now.tsx +97 -0
  120. package/components/automation-view.tsx +192 -0
  121. package/components/editorial-checks-admin-panel.tsx +36 -11
  122. package/components/moderation-panel.tsx +248 -0
  123. package/components/monitoring-overview-view.tsx +241 -0
  124. package/components/monitoring-panel.tsx +40 -0
  125. package/components/news-desk-client-provider.tsx +18 -18
  126. package/components/news-desk-taxonomy-client.ts +28 -8
  127. package/components/newsletter-panel.tsx +474 -0
  128. package/components/newsletter-sections.tsx +163 -0
  129. package/components/newsroom-access-gate.tsx +15 -1
  130. package/components/newsroom-article-editor.tsx +6 -6
  131. package/components/newsroom-articles-view.tsx +6 -6
  132. package/components/newsroom-board-view.tsx +8 -3
  133. package/components/newsroom-curation-bootstrap.tsx +45 -0
  134. package/components/newsroom-ops-overview.tsx +22 -92
  135. package/components/newsroom-ops-shell.tsx +2 -2
  136. package/components/newsroom-organizer.css +33 -0
  137. package/components/newsroom-overview-skeleton.tsx +25 -0
  138. package/components/newsroom-references-view.tsx +192 -49
  139. package/components/notification-center.tsx +273 -0
  140. package/components/publication-cyclotron-panel.tsx +15 -10
  141. package/components/reader-auth-state.ts +1 -1
  142. package/components/reference-add-entry.tsx +75 -0
  143. package/components/reference-curation-controls.tsx +31 -22
  144. package/components/reference-cyclotron-priming.tsx +58 -19
  145. package/components/reference-cyclotron-suggestion.tsx +10 -6
  146. package/components/reference-priming.tsx +14 -10
  147. package/components/reference-relevance-review.tsx +3 -3
  148. package/components/reference-section-suggestion.tsx +1 -1
  149. package/components/topic-steering-workspace.tsx +223 -144
  150. package/components/use-editorial-checks.ts +12 -10
  151. package/infra/amplify-app-shell.js +94 -16
  152. package/infra/amplify-app-shell.ts +106 -18
  153. package/infra/cloud-email-config.js +32 -0
  154. package/infra/cloud-email-config.ts +39 -0
  155. package/infra/cloud-email.js +57 -0
  156. package/infra/cloud-email.test.js +69 -0
  157. package/infra/cloud-email.test.ts +66 -0
  158. package/infra/cloud-email.ts +60 -0
  159. package/infra/index.js +1 -0
  160. package/infra/secure-string-parameter-handler.js +41 -0
  161. package/infra/secure-string-parameter-handler.ts +41 -0
  162. package/infra/secure-string-parameter.js +79 -0
  163. package/infra/secure-string-parameter.ts +91 -0
  164. package/infra/site-config.js +88 -6
  165. package/infra/site-config.ts +134 -6
  166. package/lib/automation-client.ts +54 -0
  167. package/lib/automation-fixture.ts +93 -0
  168. package/lib/automation-overview.ts +232 -0
  169. package/lib/category-repository.ts +11 -9
  170. package/lib/curation-routes.ts +345 -0
  171. package/lib/cyclotron-suggestions.ts +4 -4
  172. package/lib/editorial-checks-backend.ts +1 -1
  173. package/lib/editorial-checks-client.ts +19 -1
  174. package/lib/editorial-checks-demo.ts +2 -2
  175. package/lib/editorial-checks-settings.ts +3 -0
  176. package/lib/editorial-checks.ts +8 -11
  177. package/lib/editorial-preview.ts +5 -5
  178. package/lib/monitoring-client.ts +29 -0
  179. package/lib/monitoring-overview-fixture.ts +51 -0
  180. package/lib/monitoring-overview.ts +145 -0
  181. package/lib/news-desk-session.ts +12 -21
  182. package/lib/newsletter-api.ts +418 -0
  183. package/lib/newsletter-client.ts +46 -0
  184. package/lib/newsletter-fixture.ts +184 -0
  185. package/lib/newsletter-model.ts +202 -0
  186. package/lib/newsroom-organizer-nav.ts +2 -1
  187. package/lib/prospectus-session.ts +15 -14
  188. package/lib/prospectus-sign-in-routes.ts +84 -19
  189. package/lib/prospectus-site-identity.ts +24 -1
  190. package/lib/reader-api.ts +145 -0
  191. package/lib/reader-moderation.ts +220 -0
  192. package/lib/reader-retirement-route.ts +89 -0
  193. package/lib/reader-subject.md +29 -0
  194. package/lib/reader-subject.ts +53 -0
  195. package/lib/reference-review-details.ts +71 -0
  196. package/lib/reference-review-request.ts +14 -0
  197. package/lib/relevance-decisions.ts +42 -10
  198. package/lib/relevance-status.ts +2 -2
  199. package/lib/review-client.ts +99 -0
  200. package/lib/review-fixture.ts +45 -0
  201. package/lib/review-requests.ts +238 -0
  202. package/lib/semantic-graph.ts +2 -0
  203. package/lib/site-brand-tenant.ts +11 -6
  204. package/lib/site-brand.ts +6 -3
  205. package/lib/site-origin.ts +67 -0
  206. package/lib/use-reference-review-data.ts +44 -0
  207. package/middleware.ts +14 -3
  208. package/package.json +56 -3
  209. package/routes.manifest.json +215 -2
  210. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  211. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  212. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  213. 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 * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
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 prospectusSessionSealingSecret = prospectusAccountsApiStageArn
102
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
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 && prospectusSessionSealingSecret
115
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
108
116
  ? {
109
117
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
110
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
118
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
111
119
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
112
120
  }
113
121
  : {};
114
- if (prospectusSessionSealingSecret) {
122
+ if (prospectusSessionSealing) {
115
123
  computeRole.addToPolicy(new iam.PolicyStatement({
116
124
  sid: "ReadProspectusSessionSealingSecret",
117
- actions: ["secretsmanager:GetSecretValue"],
118
- resources: [prospectusSessionSealingSecret.secretArn],
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
- enableAutoBuild: true,
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.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
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: true,
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 * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
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 prospectusSessionSealingSecret = prospectusAccountsApiStageArn
113
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
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 && prospectusSessionSealingSecret
126
+ const prospectusEnvironment: Record<string, string> = prospectusAccountsApiStageArn && prospectusSessionSealing
119
127
  ? {
120
128
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
121
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
129
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
122
130
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
123
131
  }
124
132
  : {};
125
- if (prospectusSessionSealingSecret) {
133
+ if (prospectusSessionSealing) {
126
134
  computeRole.addToPolicy(
127
135
  new iam.PolicyStatement({
128
136
  sid: "ReadProspectusSessionSealingSecret",
129
- actions: ["secretsmanager:GetSecretValue"],
130
- resources: [prospectusSessionSealingSecret.secretArn],
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
- enableAutoBuild: true,
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.map(
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: true,
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]);