@anthusai/papyrus 2.0.0-next.11 → 2.0.0-next.110

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 (227) 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 +152 -62
  6. package/amplify/data/resource.ts +158 -62
  7. package/amplify/data/schema.js +2101 -1987
  8. package/amplify/data/schema.ts +197 -70
  9. package/amplify/functions/assignment-action/data-access.js +2 -0
  10. package/amplify/functions/assignment-action/data-access.ts +2 -0
  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 +64 -3
  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/functions/editorial-checks/handler.py +2 -2
  24. package/amplify/functions/editorial-checks/requirements.txt +1 -1
  25. package/amplify/functions/editorial-checks/resource.js +3 -3
  26. package/amplify/functions/editorial-checks/resource.ts +3 -3
  27. package/amplify/functions/editorial-checks/schema.js +38 -8
  28. package/amplify/functions/editorial-checks/schema.ts +38 -8
  29. package/amplify/functions/editorial-checks/wire.js +4 -0
  30. package/amplify/functions/editorial-checks/wire.ts +4 -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 +29 -0
  35. package/amplify/functions/monitoring-view/schema.ts +31 -0
  36. package/amplify/functions/monitoring-view/wire.js +64 -0
  37. package/amplify/functions/monitoring-view/wire.ts +81 -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 +1589 -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 +487 -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 +187 -0
  75. package/amplify/plugins/reader-steering/papyrus.plugin.ts +213 -0
  76. package/amplify/plugins/reader-steering/schema.js +252 -0
  77. package/amplify/plugins/reader-steering/schema.ts +254 -0
  78. package/amplify/plugins/registry.js +4 -0
  79. package/amplify/plugins/registry.ts +4 -0
  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.js +19 -0
  93. package/amplify/site-backend-config.ts +45 -1
  94. package/amplify/site-backend-names.js +4 -2
  95. package/amplify/site-backend-names.ts +4 -2
  96. package/amplify/site-backend.js +42 -4
  97. package/amplify/site-backend.ts +45 -4
  98. package/app/.well-known/papyrus-cli.json/route.ts +9 -0
  99. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  100. package/app/api/newsletter/confirm/route.ts +13 -0
  101. package/app/api/newsletter/preferences/route.ts +13 -0
  102. package/app/api/newsletter/state/route.ts +9 -0
  103. package/app/api/newsletter/subscribe/route.ts +9 -0
  104. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  105. package/app/api/newsroom/chat/[...path]/route.ts +17 -0
  106. package/app/api/newsroom/curation/context/route.ts +9 -0
  107. package/app/api/newsroom/curation/priming/route.ts +9 -0
  108. package/app/api/newsroom/curation/publication/route.ts +9 -0
  109. package/app/api/reader/comment/delete/route.ts +9 -0
  110. package/app/api/reader/comment/edit/route.ts +9 -0
  111. package/app/api/reader/comment/route.ts +9 -0
  112. package/app/api/reader/flag/route.ts +9 -0
  113. package/app/api/reader/handle/route.ts +9 -0
  114. package/app/api/reader/me/route.ts +9 -0
  115. package/app/api/reader/rating/route.ts +9 -0
  116. package/app/api/reader/report/route.ts +9 -0
  117. package/app/api/reader/retired/route.ts +9 -0
  118. package/app/api/reader/submit/route.ts +9 -0
  119. package/app/api/reader/vote/route.ts +9 -0
  120. package/app/layout.tsx +5 -1
  121. package/bin/papyrus-infra.mjs +20 -1
  122. package/components/automation-panel.tsx +49 -0
  123. package/components/automation-run-now.tsx +97 -0
  124. package/components/automation-view.tsx +195 -0
  125. package/components/editorial-checks-admin-panel.tsx +32 -17
  126. package/components/moderation-panel.tsx +248 -0
  127. package/components/monitoring-overview-view.tsx +422 -0
  128. package/components/monitoring-panel.tsx +60 -0
  129. package/components/news-desk-client-provider.tsx +18 -18
  130. package/components/news-desk-taxonomy-client.ts +28 -8
  131. package/components/newsletter-panel.tsx +474 -0
  132. package/components/newsletter-sections.tsx +163 -0
  133. package/components/newsroom-access-gate.tsx +16 -2
  134. package/components/newsroom-article-editor.tsx +9 -7
  135. package/components/newsroom-articles-view.tsx +6 -6
  136. package/components/newsroom-assignment-detail.tsx +6 -6
  137. package/components/newsroom-board-view.tsx +8 -3
  138. package/components/newsroom-curation-bootstrap.tsx +45 -0
  139. package/components/newsroom-ops-overview.tsx +26 -96
  140. package/components/newsroom-ops-shell.tsx +2 -2
  141. package/components/newsroom-organizer.css +33 -0
  142. package/components/newsroom-overview-skeleton.tsx +25 -0
  143. package/components/newsroom-references-view.tsx +192 -49
  144. package/components/notification-center.tsx +281 -0
  145. package/components/papyrus-console-conversation.css +14 -0
  146. package/components/papyrus-console-conversation.tsx +55 -0
  147. package/components/papyrus-console-shell.tsx +62 -420
  148. package/components/papyrus-private-chat-panel.tsx +134 -0
  149. package/components/publication-cyclotron-panel.tsx +15 -10
  150. package/components/reader-auth-state.ts +1 -1
  151. package/components/reference-add-entry.tsx +75 -0
  152. package/components/reference-curation-controls.tsx +31 -22
  153. package/components/reference-cyclotron-priming.tsx +58 -19
  154. package/components/reference-cyclotron-suggestion.tsx +10 -6
  155. package/components/reference-priming.tsx +14 -10
  156. package/components/reference-relevance-review.tsx +3 -3
  157. package/components/reference-section-suggestion.tsx +1 -1
  158. package/components/topic-steering-workspace.tsx +230 -144
  159. package/infra/amplify-app-shell.js +96 -16
  160. package/infra/amplify-app-shell.ts +108 -18
  161. package/infra/cloud-email-config.js +32 -0
  162. package/infra/cloud-email-config.ts +39 -0
  163. package/infra/cloud-email.js +57 -0
  164. package/infra/cloud-email.test.js +69 -0
  165. package/infra/cloud-email.test.ts +66 -0
  166. package/infra/cloud-email.ts +60 -0
  167. package/infra/index.js +1 -0
  168. package/infra/secure-string-parameter-handler.js +41 -0
  169. package/infra/secure-string-parameter-handler.ts +41 -0
  170. package/infra/secure-string-parameter.js +79 -0
  171. package/infra/secure-string-parameter.ts +91 -0
  172. package/infra/site-config.js +92 -6
  173. package/infra/site-config.ts +138 -6
  174. package/lib/automation-client.ts +54 -0
  175. package/lib/automation-fixture.ts +93 -0
  176. package/lib/automation-overview.ts +252 -0
  177. package/lib/category-repository.ts +14 -12
  178. package/lib/cli-login-discovery.ts +78 -0
  179. package/lib/console-web-context.ts +1 -1
  180. package/lib/curation-routes.ts +345 -0
  181. package/lib/cyclotron-suggestions.ts +4 -4
  182. package/lib/editorial-checks-client.ts +18 -0
  183. package/lib/editorial-checks-demo.ts +1 -1
  184. package/lib/editorial-checks.ts +8 -4
  185. package/lib/editorial-preview.ts +5 -5
  186. package/lib/monitoring-client.ts +83 -0
  187. package/lib/monitoring-overview-fixture.ts +51 -0
  188. package/lib/monitoring-overview.ts +191 -0
  189. package/lib/news-desk-session.ts +12 -21
  190. package/lib/newsletter-api.ts +418 -0
  191. package/lib/newsletter-client.ts +46 -0
  192. package/lib/newsletter-fixture.ts +184 -0
  193. package/lib/newsletter-model.ts +202 -0
  194. package/lib/newsroom-demo-dashboard.ts +1 -1
  195. package/lib/newsroom-nav.ts +1 -1
  196. package/lib/newsroom-organizer-nav.ts +2 -1
  197. package/lib/papyrus-chat-admission.ts +65 -0
  198. package/lib/papyrus-hosted-chat.ts +217 -0
  199. package/lib/papyrus-private-chat-client.ts +81 -0
  200. package/lib/prospectus-session.ts +30 -14
  201. package/lib/prospectus-sign-in-routes.ts +106 -31
  202. package/lib/prospectus-site-identity.ts +46 -6
  203. package/lib/reader-api.ts +145 -0
  204. package/lib/reader-moderation.ts +220 -0
  205. package/lib/reader-retirement-route.ts +89 -0
  206. package/lib/reader-subject.md +29 -0
  207. package/lib/reader-subject.ts +53 -0
  208. package/lib/reference-review-details.ts +71 -0
  209. package/lib/reference-review-request.ts +14 -0
  210. package/lib/relevance-decisions.ts +42 -10
  211. package/lib/relevance-status.ts +2 -2
  212. package/lib/review-client.ts +120 -0
  213. package/lib/review-fixture.ts +45 -0
  214. package/lib/review-requests.ts +246 -0
  215. package/lib/semantic-graph.ts +2 -0
  216. package/lib/shared-chat-admission.ts +182 -0
  217. package/lib/site-brand-tenant.ts +11 -6
  218. package/lib/site-brand.ts +6 -3
  219. package/lib/site-origin-value.ts +50 -0
  220. package/lib/site-origin.ts +8 -0
  221. package/lib/use-reference-review-data.ts +44 -0
  222. package/middleware.ts +14 -3
  223. package/package.json +67 -3
  224. package/routes.manifest.json +238 -2
  225. package/src/sync.mjs +11 -2
  226. package/src/with-papyrus.d.mts +1 -0
  227. package/src/with-papyrus.mjs +17 -1
@@ -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,19 @@ 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,
202
+ PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
172
203
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
173
204
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
174
205
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
175
206
  PAPYRUS_SITE_BRAND: config.brand,
176
207
  SITE_ENV: "production",
208
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
177
209
  PAPYRUS_CONTENT_SOURCE: "published",
178
210
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
179
211
  ...prospectusEnvironment,
@@ -190,11 +222,13 @@ export class AmplifyAppShellStack extends Stack {
190
222
  buildSpec: cmsStagingBuildSpec(config),
191
223
  environmentVariables: environmentVariables({
192
224
  ...config.cms.environment,
225
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "false",
193
226
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
194
227
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
195
228
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
196
229
  PAPYRUS_SITE_BRAND: config.brand,
197
230
  SITE_ENV: "staging",
231
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
198
232
  ...stagingMode,
199
233
  ...prospectusEnvironment,
200
234
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -247,6 +281,12 @@ export class AmplifyAppShellStack extends Stack {
247
281
  }
248
282
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
249
283
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
284
+ if (config.autoDeploy) {
285
+ this.addGithubDeployRole(config, [
286
+ { appId: cmsApp.attrAppId, branch: "main" },
287
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
288
+ ]);
289
+ }
250
290
  if (config.github) {
251
291
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
252
292
  }
@@ -294,6 +334,46 @@ export class AmplifyAppShellStack extends Stack {
294
334
  description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
295
335
  });
296
336
  }
337
+ /**
338
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
339
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
340
+ * production branch of the site's apps.
341
+ */
342
+ addGithubDeployRole(config, targets) {
343
+ const { owner, repo } = resolveGithubRepository(config);
344
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
345
+ service: "iam",
346
+ region: "",
347
+ resource: "oidc-provider",
348
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
349
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
350
+ }));
351
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
352
+ roleName: `${config.siteId}-github-deploy`,
353
+ 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`,
354
+ maxSessionDuration: Duration.hours(1),
355
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
356
+ StringEquals: {
357
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
358
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
359
+ },
360
+ }),
361
+ });
362
+ deployRole.addToPolicy(new iam.PolicyStatement({
363
+ sid: "StartAndReadProductionJobs",
364
+ actions: ["amplify:StartJob", "amplify:GetJob"],
365
+ resources: targets.map((target) => this.formatArn({
366
+ service: "amplify",
367
+ resource: "apps",
368
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
369
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
370
+ })),
371
+ }));
372
+ new CfnOutput(this, "GithubDeployRoleArn", {
373
+ value: deployRole.roleArn,
374
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
375
+ });
376
+ }
297
377
  addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
298
378
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
299
379
  service: "iam",
@@ -309,7 +389,7 @@ export class AmplifyAppShellStack extends Stack {
309
389
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
310
390
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
311
391
  StringLike: {
312
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
392
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
313
393
  },
314
394
  }),
315
395
  });
@@ -361,7 +441,7 @@ export class AmplifyAppShellStack extends Stack {
361
441
  appId: readerApp.attrAppId,
362
442
  branchName: readerBranchName,
363
443
  stage: "PRODUCTION",
364
- enableAutoBuild: true,
444
+ enableAutoBuild: !config.autoDeploy,
365
445
  environmentVariables: environmentVariables({
366
446
  ...(reader.environment ?? {}),
367
447
  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,19 @@ 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,
224
+ PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
192
225
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
193
226
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
194
227
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
195
228
  PAPYRUS_SITE_BRAND: config.brand,
196
229
  SITE_ENV: "production",
230
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
197
231
  PAPYRUS_CONTENT_SOURCE: "published",
198
232
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
199
233
  ...prospectusEnvironment,
@@ -211,11 +245,13 @@ export class AmplifyAppShellStack extends Stack {
211
245
  buildSpec: cmsStagingBuildSpec(config),
212
246
  environmentVariables: environmentVariables({
213
247
  ...config.cms.environment,
248
+ PAPYRUS_PRIVATE_CHAT_ENABLED: "false",
214
249
  PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
215
250
  ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
216
251
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
217
252
  PAPYRUS_SITE_BRAND: config.brand,
218
253
  SITE_ENV: "staging",
254
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
219
255
  ...stagingMode,
220
256
  ...prospectusEnvironment,
221
257
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -274,6 +310,13 @@ export class AmplifyAppShellStack extends Stack {
274
310
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
275
311
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
276
312
 
313
+ if (config.autoDeploy) {
314
+ this.addGithubDeployRole(config, [
315
+ { appId: cmsApp.attrAppId, branch: "main" },
316
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
317
+ ]);
318
+ }
319
+
277
320
  if (config.github) {
278
321
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
279
322
  }
@@ -326,6 +369,55 @@ export class AmplifyAppShellStack extends Stack {
326
369
  });
327
370
  }
328
371
 
372
+ /**
373
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
374
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
375
+ * production branch of the site's apps.
376
+ */
377
+ private addGithubDeployRole(config: AmplifyAppShellSiteConfig, targets: { appId: string; branch: string }[]): void {
378
+ const { owner, repo } = resolveGithubRepository(config);
379
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
380
+ this,
381
+ "GithubDeployOidcProvider",
382
+ this.formatArn({
383
+ service: "iam",
384
+ region: "",
385
+ resource: "oidc-provider",
386
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
387
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
388
+ }),
389
+ );
390
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
391
+ roleName: `${config.siteId}-github-deploy`,
392
+ 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`,
393
+ maxSessionDuration: Duration.hours(1),
394
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
395
+ StringEquals: {
396
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
397
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
398
+ },
399
+ }),
400
+ });
401
+ deployRole.addToPolicy(
402
+ new iam.PolicyStatement({
403
+ sid: "StartAndReadProductionJobs",
404
+ actions: ["amplify:StartJob", "amplify:GetJob"],
405
+ resources: targets.map((target) =>
406
+ this.formatArn({
407
+ service: "amplify",
408
+ resource: "apps",
409
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
410
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
411
+ }),
412
+ ),
413
+ }),
414
+ );
415
+ new CfnOutput(this, "GithubDeployRoleArn", {
416
+ value: deployRole.roleArn,
417
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
418
+ });
419
+ }
420
+
329
421
  private addGithubCiRole(
330
422
  config: AmplifyAppShellSiteConfig,
331
423
  github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
@@ -351,9 +443,7 @@ export class AmplifyAppShellStack extends Stack {
351
443
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
352
444
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
353
445
  StringLike: {
354
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
355
- (branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
356
- ),
446
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
357
447
  },
358
448
  }),
359
449
  });
@@ -420,7 +510,7 @@ export class AmplifyAppShellStack extends Stack {
420
510
  appId: readerApp.attrAppId,
421
511
  branchName: readerBranchName,
422
512
  stage: "PRODUCTION",
423
- enableAutoBuild: true,
513
+ enableAutoBuild: !config.autoDeploy,
424
514
  environmentVariables: environmentVariables({
425
515
  ...(reader.environment ?? {}),
426
516
  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
+ }