@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.41

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 (144) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2030 -1967
  4. package/amplify/data/schema.ts +73 -2
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +36 -0
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/functions/editorial-checks/handler.py +14 -0
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/functions/editorial-checks/resource.js +43 -0
  15. package/amplify/functions/editorial-checks/resource.ts +60 -0
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/functions/editorial-checks/schema.ts +123 -0
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +9 -3
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/reader-steering/config.js +110 -0
  40. package/amplify/plugins/reader-steering/config.ts +153 -0
  41. package/amplify/plugins/reader-steering/function/handler.py +544 -0
  42. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  43. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  44. package/amplify/plugins/reader-steering/papyrus.plugin.js +103 -0
  45. package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
  46. package/amplify/plugins/reader-steering/schema.js +40 -0
  47. package/amplify/plugins/reader-steering/schema.ts +42 -0
  48. package/amplify/plugins/registry.js +2 -0
  49. package/amplify/plugins/registry.ts +2 -0
  50. package/amplify/plugins/types.ts +3 -0
  51. package/amplify/plugins/wire.js +1 -0
  52. package/amplify/plugins/wire.ts +1 -0
  53. package/amplify/publication/redirect-and-index.js +105 -0
  54. package/amplify/publication/redirect-rules.js +73 -0
  55. package/amplify/publication/redirect-rules.ts +93 -0
  56. package/amplify/publication/s3-projection.js +7 -1
  57. package/amplify/publication/s3-projection.ts +9 -1
  58. package/amplify/site-backend-config.ts +17 -0
  59. package/amplify/site-backend.js +36 -1
  60. package/amplify/site-backend.ts +44 -1
  61. package/app/api/newsroom/curation/context/route.ts +9 -0
  62. package/app/api/newsroom/curation/priming/route.ts +9 -0
  63. package/app/api/newsroom/curation/publication/route.ts +9 -0
  64. package/app/api/reader/handle/route.ts +9 -0
  65. package/app/api/reader/me/route.ts +9 -0
  66. package/app/api/reader/rating/route.ts +9 -0
  67. package/app/api/reader/retired/route.ts +9 -0
  68. package/app/api/reader/vote/route.ts +9 -0
  69. package/bin/papyrus-infra.mjs +20 -1
  70. package/components/editorial-checks-admin-panel.tsx +362 -0
  71. package/components/editorial-finding-card.tsx +162 -0
  72. package/components/editorial-findings-panel.tsx +91 -0
  73. package/components/editorial-preview-popover.tsx +103 -0
  74. package/components/markus-body-editor.tsx +213 -0
  75. package/components/markus-ir-preview.tsx +147 -6
  76. package/components/monitoring-overview-view.tsx +241 -0
  77. package/components/monitoring-panel.tsx +40 -0
  78. package/components/newsroom-article-editor.tsx +136 -11
  79. package/components/newsroom-board-view.tsx +6 -7
  80. package/components/newsroom-curation-bootstrap.tsx +45 -0
  81. package/components/newsroom-ops-shell.tsx +7 -3
  82. package/components/newsroom-organizer.css +74 -61
  83. package/components/newsroom-references-view.tsx +14 -14
  84. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  85. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  86. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  87. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  88. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  89. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  90. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  91. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  92. package/components/newsroom-theme.css +39 -0
  93. package/components/newsroom-theme.tsx +33 -0
  94. package/components/publication-cyclotron-panel.tsx +15 -10
  95. package/components/reference-add-entry.tsx +75 -0
  96. package/components/reference-curation-controls.tsx +31 -22
  97. package/components/reference-cyclotron-priming.tsx +58 -19
  98. package/components/reference-cyclotron-suggestion.tsx +1 -1
  99. package/components/reference-priming.tsx +14 -10
  100. package/components/reference-section-suggestion.tsx +1 -1
  101. package/components/topic-steering-workspace.tsx +27 -2
  102. package/components/use-editorial-checks.ts +133 -0
  103. package/infra/amplify-app-shell.js +90 -16
  104. package/infra/amplify-app-shell.ts +102 -18
  105. package/infra/index.js +1 -0
  106. package/infra/secure-string-parameter-handler.js +41 -0
  107. package/infra/secure-string-parameter-handler.ts +41 -0
  108. package/infra/secure-string-parameter.js +79 -0
  109. package/infra/secure-string-parameter.ts +91 -0
  110. package/infra/site-config.js +85 -6
  111. package/infra/site-config.ts +123 -6
  112. package/lib/category-repository.ts +11 -9
  113. package/lib/curation-routes.ts +345 -0
  114. package/lib/editorial-checks-backend.ts +16 -0
  115. package/lib/editorial-checks-client.ts +212 -0
  116. package/lib/editorial-checks-demo.ts +147 -0
  117. package/lib/editorial-checks-settings.ts +27 -0
  118. package/lib/editorial-checks.ts +173 -0
  119. package/lib/editorial-preview.ts +228 -0
  120. package/lib/monitoring-client.ts +29 -0
  121. package/lib/monitoring-overview-fixture.ts +51 -0
  122. package/lib/monitoring-overview.ts +145 -0
  123. package/lib/newsroom-board.ts +27 -2
  124. package/lib/prospectus-session.ts +15 -14
  125. package/lib/prospectus-sign-in-routes.ts +54 -13
  126. package/lib/prospectus-site-identity.ts +24 -1
  127. package/lib/reader-api.ts +143 -0
  128. package/lib/reader-retirement-route.ts +89 -0
  129. package/lib/reader-subject.md +29 -0
  130. package/lib/reader-subject.ts +53 -0
  131. package/lib/relevance-status.ts +2 -2
  132. package/lib/site-brand-tenant.ts +11 -6
  133. package/lib/site-brand.ts +23 -3
  134. package/lib/site-origin.ts +67 -0
  135. package/middleware.ts +14 -3
  136. package/package.json +56 -4
  137. package/routes.manifest.json +89 -1
  138. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  139. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  140. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  141. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  142. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  143. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  144. package/components/newsroom-newsprint-board.css +0 -7
@@ -1,10 +1,10 @@
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
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
6
6
  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";
7
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
8
8
  function environmentVariables(variables) {
9
9
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
10
10
  }
@@ -68,6 +68,9 @@ export class AmplifyAppShellStack extends Stack {
68
68
  if (!config.reader)
69
69
  throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
70
70
  this.readerAppId = this.addReaderApp(config, config.reader);
71
+ if (config.autoDeploy) {
72
+ this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
73
+ }
71
74
  return;
72
75
  }
73
76
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
@@ -89,33 +92,49 @@ export class AmplifyAppShellStack extends Stack {
89
92
  });
90
93
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
91
94
  if (revalidateSecretParameterName) {
95
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
96
+ parameterName: revalidateSecretParameterName,
97
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
98
+ });
92
99
  computeRole.addToPolicy(new iam.PolicyStatement({
93
100
  sid: "ReadRevalidateSecret",
94
101
  actions: ["ssm:GetParameter"],
95
- resources: [
96
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
97
- ],
102
+ resources: [revalidateSecret.parameterArn],
98
103
  }));
99
104
  }
100
105
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
101
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
102
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
106
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
107
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
108
+ parameterName: resolveProspectusSessionSealingParameterName(config),
103
109
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
104
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
105
110
  })
106
111
  : undefined;
107
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
112
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
108
113
  ? {
109
114
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
110
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
115
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
111
116
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
112
117
  }
113
118
  : {};
114
- if (prospectusSessionSealingSecret) {
119
+ if (prospectusSessionSealing) {
115
120
  computeRole.addToPolicy(new iam.PolicyStatement({
116
121
  sid: "ReadProspectusSessionSealingSecret",
117
- actions: ["secretsmanager:GetSecretValue"],
118
- resources: [prospectusSessionSealingSecret.secretArn],
122
+ actions: ["ssm:GetParameter"],
123
+ resources: [prospectusSessionSealing.parameterArn],
124
+ }));
125
+ }
126
+ if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
127
+ // Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
128
+ // route through the CMS route /api/reader/retired, which it authenticates with this secret.
129
+ const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
130
+ parameterName: resolveReaderPollSecretParameterName(config),
131
+ description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
132
+ });
133
+ prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
134
+ computeRole.addToPolicy(new iam.PolicyStatement({
135
+ sid: "ReadReaderPollSecret",
136
+ actions: ["ssm:GetParameter"],
137
+ resources: [readerPollSecret.parameterArn],
119
138
  }));
120
139
  }
121
140
  if (prospectusAccountsApiStageArn) {
@@ -153,9 +172,15 @@ export class AmplifyAppShellStack extends Stack {
153
172
  const stagingOrigin = stagingDomainName
154
173
  ? `https://${stagingDomainName}/`
155
174
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
175
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
176
+ // A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
177
+ const siteOrigin = resolveSiteOrigin(config);
178
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
179
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
156
180
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
157
181
  const defaultOriginsToAllow = [
158
182
  ...(config.cms.domainName ? [] : [productionOrigin]),
183
+ ...(siteOrigin ? [`${siteOrigin}/`] : []),
159
184
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
160
185
  ];
161
186
  const oauthRedirectUrls = defaultOriginsToAllow.length === 0
@@ -165,7 +190,8 @@ export class AmplifyAppShellStack extends Stack {
165
190
  appId: cmsApp.attrAppId,
166
191
  branchName: "main",
167
192
  stage: "PRODUCTION",
168
- enableAutoBuild: true,
193
+ // Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
194
+ enableAutoBuild: !config.autoDeploy,
169
195
  enablePerformanceMode: false,
170
196
  environmentVariables: environmentVariables({
171
197
  ...config.cms.environment,
@@ -174,6 +200,7 @@ export class AmplifyAppShellStack extends Stack {
174
200
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
175
201
  PAPYRUS_SITE_BRAND: config.brand,
176
202
  SITE_ENV: "production",
203
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
177
204
  PAPYRUS_CONTENT_SOURCE: "published",
178
205
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
179
206
  ...prospectusEnvironment,
@@ -195,6 +222,7 @@ export class AmplifyAppShellStack extends Stack {
195
222
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
196
223
  PAPYRUS_SITE_BRAND: config.brand,
197
224
  SITE_ENV: "staging",
225
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
198
226
  ...stagingMode,
199
227
  ...prospectusEnvironment,
200
228
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -247,6 +275,12 @@ export class AmplifyAppShellStack extends Stack {
247
275
  }
248
276
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
249
277
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
278
+ if (config.autoDeploy) {
279
+ this.addGithubDeployRole(config, [
280
+ { appId: cmsApp.attrAppId, branch: "main" },
281
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
282
+ ]);
283
+ }
250
284
  if (config.github) {
251
285
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
252
286
  }
@@ -294,6 +328,46 @@ export class AmplifyAppShellStack extends Stack {
294
328
  description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
295
329
  });
296
330
  }
331
+ /**
332
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
333
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
334
+ * production branch of the site's apps.
335
+ */
336
+ addGithubDeployRole(config, targets) {
337
+ const { owner, repo } = resolveGithubRepository(config);
338
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubDeployOidcProvider", this.formatArn({
339
+ service: "iam",
340
+ region: "",
341
+ resource: "oidc-provider",
342
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
343
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
344
+ }));
345
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
346
+ roleName: `${config.siteId}-github-deploy`,
347
+ 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`,
348
+ maxSessionDuration: Duration.hours(1),
349
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
350
+ StringEquals: {
351
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
352
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
353
+ },
354
+ }),
355
+ });
356
+ deployRole.addToPolicy(new iam.PolicyStatement({
357
+ sid: "StartAndReadProductionJobs",
358
+ actions: ["amplify:StartJob", "amplify:GetJob"],
359
+ resources: targets.map((target) => this.formatArn({
360
+ service: "amplify",
361
+ resource: "apps",
362
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
363
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
364
+ })),
365
+ }));
366
+ new CfnOutput(this, "GithubDeployRoleArn", {
367
+ value: deployRole.roleArn,
368
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
369
+ });
370
+ }
297
371
  addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
298
372
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
299
373
  service: "iam",
@@ -309,7 +383,7 @@ export class AmplifyAppShellStack extends Stack {
309
383
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
310
384
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
311
385
  StringLike: {
312
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map((branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`),
386
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
313
387
  },
314
388
  }),
315
389
  });
@@ -361,7 +435,7 @@ export class AmplifyAppShellStack extends Stack {
361
435
  appId: readerApp.attrAppId,
362
436
  branchName: readerBranchName,
363
437
  stage: "PRODUCTION",
364
- enableAutoBuild: true,
438
+ enableAutoBuild: !config.autoDeploy,
365
439
  environmentVariables: environmentVariables({
366
440
  ...(reader.environment ?? {}),
367
441
  PAPYRUS_SITE_BRAND: config.brand,
@@ -2,10 +2,10 @@ 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
6
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
7
7
  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";
8
+ 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
9
 
10
10
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
@@ -72,6 +72,9 @@ export class AmplifyAppShellStack extends Stack {
72
72
  if (!hasCmsApp(config)) {
73
73
  if (!config.reader) throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
74
74
  this.readerAppId = this.addReaderApp(config, config.reader);
75
+ if (config.autoDeploy) {
76
+ this.addGithubDeployRole(config, [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }]);
77
+ }
75
78
  return;
76
79
  }
77
80
 
@@ -97,37 +100,55 @@ export class AmplifyAppShellStack extends Stack {
97
100
 
98
101
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
99
102
  if (revalidateSecretParameterName) {
103
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
104
+ parameterName: revalidateSecretParameterName,
105
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
106
+ });
100
107
  computeRole.addToPolicy(
101
108
  new iam.PolicyStatement({
102
109
  sid: "ReadRevalidateSecret",
103
110
  actions: ["ssm:GetParameter"],
104
- resources: [
105
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
106
- ],
111
+ resources: [revalidateSecret.parameterArn],
107
112
  }),
108
113
  );
109
114
  }
110
115
 
111
116
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
112
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
113
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
117
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
118
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
119
+ parameterName: resolveProspectusSessionSealingParameterName(config),
114
120
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
115
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
116
121
  })
117
122
  : undefined;
118
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
123
+ const prospectusEnvironment: Record<string, string> = prospectusAccountsApiStageArn && prospectusSessionSealing
119
124
  ? {
120
125
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
121
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
126
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
122
127
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
123
128
  }
124
129
  : {};
125
- if (prospectusSessionSealingSecret) {
130
+ if (prospectusSessionSealing) {
126
131
  computeRole.addToPolicy(
127
132
  new iam.PolicyStatement({
128
133
  sid: "ReadProspectusSessionSealingSecret",
129
- actions: ["secretsmanager:GetSecretValue"],
130
- resources: [prospectusSessionSealingSecret.secretArn],
134
+ actions: ["ssm:GetParameter"],
135
+ resources: [prospectusSessionSealing.parameterArn],
136
+ }),
137
+ );
138
+ }
139
+ if (prospectusAccountsApiStageArn && config.cms.readerRetirementPoll) {
140
+ // Prospectus admits one caller per site, this compute role, so the scheduled snapshot run reaches the retired-subject
141
+ // route through the CMS route /api/reader/retired, which it authenticates with this secret.
142
+ const readerPollSecret = new SecureStringParameter(this, "ReaderPollSecretParameter", {
143
+ parameterName: resolveReaderPollSecretParameterName(config),
144
+ description: `Authorizes the scheduled reader snapshot run to call the retired-subject route of ${cmsAppName}`,
145
+ });
146
+ prospectusEnvironment.PAPYRUS_READER_POLL_SECRET_PARAMETER = readerPollSecret.parameterName;
147
+ computeRole.addToPolicy(
148
+ new iam.PolicyStatement({
149
+ sid: "ReadReaderPollSecret",
150
+ actions: ["ssm:GetParameter"],
151
+ resources: [readerPollSecret.parameterArn],
131
152
  }),
132
153
  );
133
154
  }
@@ -172,9 +193,15 @@ export class AmplifyAppShellStack extends Stack {
172
193
  const stagingOrigin = stagingDomainName
173
194
  ? `https://${stagingDomainName}/`
174
195
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
196
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
197
+ // A site served by its own edge (cms.siteOrigin) has no Amplify domain, so the edge host is its origin.
198
+ const siteOrigin = resolveSiteOrigin(config);
199
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : siteOrigin ?? Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
200
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
175
201
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
176
202
  const defaultOriginsToAllow = [
177
203
  ...(config.cms.domainName ? [] : [productionOrigin]),
204
+ ...(siteOrigin ? [`${siteOrigin}/`] : []),
178
205
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
179
206
  ];
180
207
  const oauthRedirectUrls = defaultOriginsToAllow.length === 0
@@ -185,7 +212,8 @@ export class AmplifyAppShellStack extends Stack {
185
212
  appId: cmsApp.attrAppId,
186
213
  branchName: "main",
187
214
  stage: "PRODUCTION",
188
- enableAutoBuild: true,
215
+ // Auto-build off when GitHub Actions starts the job on merge (autoDeploy): nothing may double-build.
216
+ enableAutoBuild: !config.autoDeploy,
189
217
  enablePerformanceMode: false,
190
218
  environmentVariables: environmentVariables({
191
219
  ...config.cms.environment,
@@ -194,6 +222,7 @@ export class AmplifyAppShellStack extends Stack {
194
222
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
195
223
  PAPYRUS_SITE_BRAND: config.brand,
196
224
  SITE_ENV: "production",
225
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
197
226
  PAPYRUS_CONTENT_SOURCE: "published",
198
227
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
199
228
  ...prospectusEnvironment,
@@ -216,6 +245,7 @@ export class AmplifyAppShellStack extends Stack {
216
245
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
217
246
  PAPYRUS_SITE_BRAND: config.brand,
218
247
  SITE_ENV: "staging",
248
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
219
249
  ...stagingMode,
220
250
  ...prospectusEnvironment,
221
251
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -274,6 +304,13 @@ export class AmplifyAppShellStack extends Stack {
274
304
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
275
305
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
276
306
 
307
+ if (config.autoDeploy) {
308
+ this.addGithubDeployRole(config, [
309
+ { appId: cmsApp.attrAppId, branch: "main" },
310
+ ...(this.readerAppId && config.reader ? [{ appId: this.readerAppId, branch: config.reader.branchName ?? "main" }] : []),
311
+ ]);
312
+ }
313
+
277
314
  if (config.github) {
278
315
  this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
279
316
  }
@@ -326,6 +363,55 @@ export class AmplifyAppShellStack extends Stack {
326
363
  });
327
364
  }
328
365
 
366
+ /**
367
+ * The merge-to-main deploy role: assumable only by a GitHub Actions run on `main` of the site's own
368
+ * repository (OIDC, no keys), and able to do exactly one thing: start and read Amplify jobs on the
369
+ * production branch of the site's apps.
370
+ */
371
+ private addGithubDeployRole(config: AmplifyAppShellSiteConfig, targets: { appId: string; branch: string }[]): void {
372
+ const { owner, repo } = resolveGithubRepository(config);
373
+ const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
374
+ this,
375
+ "GithubDeployOidcProvider",
376
+ this.formatArn({
377
+ service: "iam",
378
+ region: "",
379
+ resource: "oidc-provider",
380
+ resourceName: GITHUB_OIDC_PROVIDER_HOST,
381
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
382
+ }),
383
+ );
384
+ const deployRole = new iam.Role(this, "GithubDeployRole", {
385
+ roleName: `${config.siteId}-github-deploy`,
386
+ 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`,
387
+ maxSessionDuration: Duration.hours(1),
388
+ assumedBy: new iam.OpenIdConnectPrincipal(provider, {
389
+ StringEquals: {
390
+ [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com",
391
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: githubOidcSubjects(config, "main"),
392
+ },
393
+ }),
394
+ });
395
+ deployRole.addToPolicy(
396
+ new iam.PolicyStatement({
397
+ sid: "StartAndReadProductionJobs",
398
+ actions: ["amplify:StartJob", "amplify:GetJob"],
399
+ resources: targets.map((target) =>
400
+ this.formatArn({
401
+ service: "amplify",
402
+ resource: "apps",
403
+ resourceName: `${target.appId}/branches/${target.branch}/jobs/*`,
404
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
405
+ }),
406
+ ),
407
+ }),
408
+ );
409
+ new CfnOutput(this, "GithubDeployRoleArn", {
410
+ value: deployRole.roleArn,
411
+ description: `Role for the merge-to-main deploy workflow in ${owner}/${repo}`,
412
+ });
413
+ }
414
+
329
415
  private addGithubCiRole(
330
416
  config: AmplifyAppShellSiteConfig,
331
417
  github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
@@ -351,9 +437,7 @@ export class AmplifyAppShellStack extends Stack {
351
437
  assumedBy: new iam.OpenIdConnectPrincipal(provider, {
352
438
  StringEquals: { [`${GITHUB_OIDC_PROVIDER_HOST}:aud`]: "sts.amazonaws.com" },
353
439
  StringLike: {
354
- [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.map(
355
- (branch) => `repo:${github.owner}/${github.repo}:ref:refs/heads/${branch}`,
356
- ),
440
+ [`${GITHUB_OIDC_PROVIDER_HOST}:sub`]: github.branches.flatMap((branch) => githubOidcSubjects(config, branch)),
357
441
  },
358
442
  }),
359
443
  });
@@ -420,7 +504,7 @@ export class AmplifyAppShellStack extends Stack {
420
504
  appId: readerApp.attrAppId,
421
505
  branchName: readerBranchName,
422
506
  stage: "PRODUCTION",
423
- enableAutoBuild: true,
507
+ enableAutoBuild: !config.autoDeploy,
424
508
  environmentVariables: environmentVariables({
425
509
  ...(reader.environment ?? {}),
426
510
  PAPYRUS_SITE_BRAND: config.brand,
package/infra/index.js CHANGED
@@ -10,6 +10,7 @@ try {
10
10
  );
11
11
  }
12
12
  export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
13
+ export const { SecureStringParameter } = await import("./secure-string-parameter.js");
13
14
  export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
14
15
  export { parseSiteConfig } from "./site-config.js";
15
16
  export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Source of the Lambda behind SecureStringParameter, as a string so the staged
3
+ * package (which ships only .ts under infra/) can inline it and tests can run it
4
+ * against a fake SSM client. CommonJS, under the 4096-character inline limit.
5
+ *
6
+ * It creates the parameter when it is missing and does nothing else: it never
7
+ * overwrites an existing value, never reads one back, never logs the value, and
8
+ * returns no data (only the parameter name as the physical id).
9
+ */
10
+ export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
11
+ const { randomInt } = require("node:crypto");
12
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
13
+ function generate(length) {
14
+ let value = "";
15
+ for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
16
+ return value;
17
+ }
18
+ exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
19
+ const name = event.ResourceProperties.ParameterName;
20
+ if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
21
+ try {
22
+ await ssm.send(new PutParameterCommand({
23
+ Name: name,
24
+ Description: event.ResourceProperties.Description,
25
+ Type: "SecureString",
26
+ Tier: "Standard",
27
+ Overwrite: false,
28
+ Value: generate(Number(event.ResourceProperties.Length)),
29
+ }));
30
+ } catch (error) {
31
+ if (!error || error.name !== "ParameterAlreadyExists") {
32
+ throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
33
+ }
34
+ }
35
+ return { PhysicalResourceId: name };
36
+ };
37
+ exports.handler = async (event) => {
38
+ const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
39
+ return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
40
+ };
41
+ `;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Source of the Lambda behind SecureStringParameter, as a string so the staged
3
+ * package (which ships only .ts under infra/) can inline it and tests can run it
4
+ * against a fake SSM client. CommonJS, under the 4096-character inline limit.
5
+ *
6
+ * It creates the parameter when it is missing and does nothing else: it never
7
+ * overwrites an existing value, never reads one back, never logs the value, and
8
+ * returns no data (only the parameter name as the physical id).
9
+ */
10
+ export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
11
+ const { randomInt } = require("node:crypto");
12
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
13
+ function generate(length) {
14
+ let value = "";
15
+ for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
16
+ return value;
17
+ }
18
+ exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
19
+ const name = event.ResourceProperties.ParameterName;
20
+ if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
21
+ try {
22
+ await ssm.send(new PutParameterCommand({
23
+ Name: name,
24
+ Description: event.ResourceProperties.Description,
25
+ Type: "SecureString",
26
+ Tier: "Standard",
27
+ Overwrite: false,
28
+ Value: generate(Number(event.ResourceProperties.Length)),
29
+ }));
30
+ } catch (error) {
31
+ if (!error || error.name !== "ParameterAlreadyExists") {
32
+ throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
33
+ }
34
+ }
35
+ return { PhysicalResourceId: name };
36
+ };
37
+ exports.handler = async (event) => {
38
+ const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
39
+ return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
40
+ };
41
+ `;
@@ -0,0 +1,79 @@
1
+ import { Construct } from "constructs";
2
+ import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import * as customResources from "aws-cdk-lib/custom-resources";
6
+ import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler.js";
7
+ /** One Lambda and role per stack, shared by every SecureStringParameter in it. */
8
+ class SecureStringParameterProvider extends Construct {
9
+ static ID = "SecureStringParameterProvider";
10
+ role;
11
+ provider;
12
+ static of(stack) {
13
+ return stack.node.tryFindChild(SecureStringParameterProvider.ID)
14
+ ?? new SecureStringParameterProvider(stack);
15
+ }
16
+ constructor(stack) {
17
+ super(stack, SecureStringParameterProvider.ID);
18
+ this.role = new iam.Role(this, "Role", {
19
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
20
+ description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
21
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
22
+ });
23
+ const onEvent = new lambda.Function(this, "OnEvent", {
24
+ runtime: lambda.Runtime.NODEJS_22_X,
25
+ handler: "index.handler",
26
+ code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
27
+ role: this.role,
28
+ timeout: Duration.seconds(30),
29
+ description: "Creates a generated SecureString parameter once; never returns or logs the value",
30
+ });
31
+ this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
32
+ }
33
+ }
34
+ /**
35
+ * A generated secret in SSM Parameter Store as a standard SecureString (free,
36
+ * AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
37
+ *
38
+ * CloudFormation cannot create a SecureString, so a custom resource Lambda puts
39
+ * it. The Lambda generates a random value at create time and never returns or
40
+ * logs it, so the value is in no template, output or stack event. It is
41
+ * create-only: an update never regenerates it and never overwrites a parameter
42
+ * that already exists (a hand-made one is adopted as it is), and delete retains
43
+ * it, so a stack teardown cannot sign anyone out. Rotate it with
44
+ * `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
45
+ * `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
46
+ */
47
+ export class SecureStringParameter extends Construct {
48
+ parameterName;
49
+ parameterArn;
50
+ constructor(scope, id, props) {
51
+ super(scope, id);
52
+ if (!props.parameterName.startsWith("/"))
53
+ throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
54
+ const length = props.length ?? 64;
55
+ if (!Number.isInteger(length) || length < 32)
56
+ throw new Error("A generated secret must be at least 32 characters.");
57
+ const stack = Stack.of(this);
58
+ this.parameterName = props.parameterName;
59
+ this.parameterArn = stack.formatArn({
60
+ service: "ssm",
61
+ resource: "parameter",
62
+ resourceName: props.parameterName.slice(1),
63
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
64
+ });
65
+ const shared = SecureStringParameterProvider.of(stack);
66
+ shared.role.addToPolicy(new iam.PolicyStatement({
67
+ sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
68
+ actions: ["ssm:PutParameter"],
69
+ resources: [this.parameterArn],
70
+ }));
71
+ const resource = new CustomResource(this, "Resource", {
72
+ serviceToken: shared.provider.serviceToken,
73
+ resourceType: "Custom::SecureStringParameter",
74
+ properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
75
+ removalPolicy: RemovalPolicy.RETAIN,
76
+ });
77
+ resource.node.addDependency(shared.role);
78
+ }
79
+ }