@anthusai/papyrus 1.0.0-next.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +42 -80
  6. package/amplify/data/resource.ts +42 -80
  7. package/amplify/data/schema.js +45 -60
  8. package/amplify/data/schema.ts +47 -60
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +143 -1
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  76. package/app/api/media/[...path]/route.ts +5 -16
  77. package/app/api/revalidate/route.ts +15 -7
  78. package/app/archive/page.tsx +1 -1
  79. package/app/articles/[slug]/page.tsx +7 -5
  80. package/app/blog-rhythm.css +278 -0
  81. package/app/cyclotron/page.tsx +13 -0
  82. package/app/globals.css +2 -0
  83. package/app/layout.tsx +16 -0
  84. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  85. package/app/newsroom/[section]/page.tsx +6 -3
  86. package/app/newsroom/layout.tsx +4 -9
  87. package/app/newsroom/settings/page.tsx +6 -0
  88. package/app/newsroom/work/page.tsx +10 -0
  89. package/app/page.tsx +22 -8
  90. package/app/robots.ts +2 -5
  91. package/app/tailwind.css +1 -0
  92. package/components/amplify-client-provider.tsx +5 -22
  93. package/components/archive-shell.tsx +2 -22
  94. package/components/article-video.tsx +152 -5
  95. package/components/blog-page-background.tsx +23 -6
  96. package/components/news-desk-page.tsx +4 -15
  97. package/components/news-desk-taxonomy-client.ts +90 -3
  98. package/components/newspaper.tsx +6 -2
  99. package/components/newsroom-access-gate.tsx +2 -1
  100. package/components/newsroom-board-view.tsx +99 -0
  101. package/components/newsroom-client-shell.tsx +12 -0
  102. package/components/newsroom-curation-provider.tsx +17 -0
  103. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  104. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  106. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  109. package/components/newsroom-newsprint-board.css +48 -0
  110. package/components/newsroom-ops-overview.tsx +28 -11
  111. package/components/newsroom-ops-shell.tsx +7 -1
  112. package/components/newsroom-organizer-shell.tsx +130 -0
  113. package/components/newsroom-organizer.css +63 -0
  114. package/components/newsroom-references-view.tsx +92 -8
  115. package/components/papyrus-console-shell.tsx +4 -2
  116. package/components/pictogram-figure.tsx +9 -2
  117. package/components/presentation-header.tsx +135 -0
  118. package/components/presentation-rhythm-hrule.tsx +17 -0
  119. package/components/priming-proof-page.tsx +92 -0
  120. package/components/publication-cyclotron-panel.tsx +57 -0
  121. package/components/reader-auth-control.tsx +8 -5
  122. package/components/reference-curation-controls.tsx +77 -0
  123. package/components/reference-cyclotron-status.tsx +109 -0
  124. package/components/reference-priming.tsx +18 -0
  125. package/components/reference-relevance-review.tsx +75 -0
  126. package/components/settings-page.tsx +2 -1
  127. package/components/signup-entries-panel.tsx +94 -0
  128. package/components/topic-steering-workspace.tsx +78 -6
  129. package/components/use-blog-header-obstacles.ts +81 -0
  130. package/components/use-brand-fonts-ready.ts +28 -0
  131. package/components/use-near-viewport.ts +30 -0
  132. package/components/use-rhythm-overlay.ts +29 -0
  133. package/infra/amplify-app-shell.js +148 -27
  134. package/infra/amplify-app-shell.ts +160 -31
  135. package/infra/build-specs.js +23 -7
  136. package/infra/build-specs.ts +27 -10
  137. package/infra/site-config.js +248 -36
  138. package/infra/site-config.ts +313 -51
  139. package/lib/amplify-outputs-path.ts +5 -1
  140. package/lib/amplify-server-runtime.ts +36 -1
  141. package/lib/approved-priming-proof-reports.ts +6 -0
  142. package/lib/blog-feature-solver.ts +459 -0
  143. package/lib/blog-rhythm.ts +134 -0
  144. package/lib/brand-fonts-watch.ts +43 -0
  145. package/lib/brand-fonts.ts +112 -0
  146. package/lib/cached-content-repository.ts +12 -0
  147. package/lib/category-repository.ts +43 -93
  148. package/lib/content-item-lifecycle.ts +55 -0
  149. package/lib/content-repository.ts +3 -0
  150. package/lib/content-types.ts +4 -0
  151. package/lib/curation-client.ts +61 -0
  152. package/lib/edition-routes.ts +31 -29
  153. package/lib/graphql-content-repository.ts +56 -22
  154. package/lib/media-route-response.ts +45 -0
  155. package/lib/newspaper-layout.ts +3 -2
  156. package/lib/newsroom-base-path.ts +12 -3
  157. package/lib/newsroom-board.ts +99 -0
  158. package/lib/newsroom-category-drilldown.ts +3 -2
  159. package/lib/newsroom-forum-routes.ts +6 -4
  160. package/lib/newsroom-index-filters.ts +14 -10
  161. package/lib/newsroom-nav.ts +3 -1
  162. package/lib/newsroom-organizer-nav.ts +37 -0
  163. package/lib/newsroom-section-routing.ts +20 -0
  164. package/lib/oauth-redirect-priority.ts +40 -0
  165. package/lib/papyrus-web-locations.ts +7 -6
  166. package/lib/plugin-settings.ts +11 -0
  167. package/lib/presentation-footer.ts +3 -2
  168. package/lib/pretext-layout.ts +12 -3
  169. package/lib/priming-proof-report.ts +89 -0
  170. package/lib/prospectus-site-identity.ts +303 -0
  171. package/lib/publication-cyclotron.ts +74 -0
  172. package/lib/rate-limit-retry.ts +55 -0
  173. package/lib/reader-base-path.ts +24 -0
  174. package/lib/reader-cache-lifetime.ts +5 -0
  175. package/lib/reader-static-params.ts +85 -4
  176. package/lib/reader-storage-url.ts +9 -12
  177. package/lib/reference-policy.ts +5 -0
  178. package/lib/relevance-decisions.ts +103 -0
  179. package/lib/relevance-status.ts +63 -0
  180. package/lib/renderer.ts +7 -0
  181. package/lib/revalidate-prewarm.ts +53 -0
  182. package/lib/revalidate-secret.ts +59 -0
  183. package/lib/robots-policy.ts +14 -0
  184. package/lib/root-route-redirect.ts +16 -0
  185. package/lib/semantic-graph.ts +7 -0
  186. package/lib/shared-credentials-provider.ts +61 -0
  187. package/lib/signup-csv.ts +24 -0
  188. package/lib/site-brand.ts +59 -0
  189. package/lib/staging-gated-path.ts +23 -0
  190. package/lib/staging-preview-object.ts +21 -5
  191. package/lib/test-editor-override.ts +4 -0
  192. package/lib/video-loading.ts +22 -0
  193. package/lib/video-script.ts +14 -0
  194. package/middleware.ts +11 -19
  195. package/package.json +64 -1
  196. package/renderers/pretext/article-page.tsx +39 -3
  197. package/renderers/pretext/presentation-shared.tsx +113 -0
  198. package/renderers/pretext/presentation-shell.tsx +15 -102
  199. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  200. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  201. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  202. package/routes.manifest.json +68 -12
  203. package/src/sync.mjs +30 -6
  204. package/src/with-papyrus.d.mts +9 -0
  205. package/src/with-papyrus.mjs +48 -3
  206. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  207. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  208. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -3,22 +3,45 @@ import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
4
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
5
5
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
6
- import { isStagingEnabled, resolveStackName, resolveStagingDomainName } from "./site-config.js";
6
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
7
7
  function environmentVariables(variables) {
8
8
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
9
9
  }
10
+ function readerCustomHeaders(rules) {
11
+ if (!rules)
12
+ return undefined;
13
+ const lines = ["customHeaders:"];
14
+ for (const rule of rules) {
15
+ lines.push(` - pattern: ${JSON.stringify(rule.pattern)}`, " headers:");
16
+ for (const [key, value] of Object.entries(rule.headers)) {
17
+ lines.push(` - key: ${JSON.stringify(key)}`, ` value: ${JSON.stringify(value)}`);
18
+ }
19
+ }
20
+ return lines.join("\n") + "\n";
21
+ }
10
22
  /**
11
23
  * Provisions the Amplify *app shell* of a Papyrus publication from its
12
24
  * site.json:
13
25
  *
14
26
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
15
27
  * and `staging` (frontend only, reads the production backend)
16
- * - for `markus-static` sites, a reader app (WEB) with branch `main`
28
+ * - for `markus-static` sites, a reader app (WEB) with branch `main`; a
29
+ * `markus-static` site may omit `cms` to get the reader app alone (no CMS
30
+ * app, no CMS roles, no authoring role)
17
31
  * - a domain association per branch/app when a domain is configured (without
18
32
  * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
19
33
  * URL is used), IAM service and compute roles
20
34
  * - `cms.staging: false` omits the staging branch and its domain
21
35
  *
36
+ * Google sign-in is always on: the branches never set PAPYRUS_DISABLE_GOOGLE_OAUTH,
37
+ and `cms.cognitoDomainPrefix` becomes the hosted-UI domain
38
+ (PAPYRUS_COGNITO_DOMAIN_PREFIX) only when `cms.applyCognitoDomainPrefix` is true
39
+ (PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX); otherwise Amplify's generated domain is kept. The secrets GOOGLE_CLIENT_ID and
40
+ GOOGLE_CLIENT_SECRET are Amplify backend secrets set in the console, never here.
41
+ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
42
+ * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
43
+ * principal in the account that may assume it); no token is stored anywhere.
44
+ *
22
45
  * No repository and no access token are configured: the apps are manual-deploy
23
46
  * until Amplify's GitHub App is connected once in the console
24
47
  * (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
@@ -29,9 +52,16 @@ export class AmplifyAppShellStack extends Stack {
29
52
  readerAppId;
30
53
  constructor(scope, id, config, props) {
31
54
  super(scope, id, props);
55
+ if (!hasCmsApp(config)) {
56
+ if (!config.reader)
57
+ throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
58
+ this.readerAppId = this.addReaderApp(config, config.reader);
59
+ return;
60
+ }
32
61
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
33
62
  const stagingEnabled = isStagingEnabled(config);
34
63
  const stagingDomainName = resolveStagingDomainName(config);
64
+ const cmsHostName = resolveCmsHostName(config);
35
65
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
36
66
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
37
67
  description: `Backend deploy and staging build service role for Amplify app ${cmsAppName} (AdministratorAccess: documented risk)`,
@@ -45,6 +75,27 @@ export class AmplifyAppShellStack extends Stack {
45
75
  iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
46
76
  ],
47
77
  });
78
+ const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
79
+ if (revalidateSecretParameterName) {
80
+ computeRole.addToPolicy(new iam.PolicyStatement({
81
+ sid: "ReadRevalidateSecret",
82
+ actions: ["ssm:GetParameter"],
83
+ resources: [
84
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
85
+ ],
86
+ }));
87
+ }
88
+ const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
89
+ const prospectusEnvironment = prospectusAccountsApiStageArn
90
+ ? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
91
+ : {};
92
+ if (prospectusAccountsApiStageArn) {
93
+ computeRole.addToPolicy(new iam.PolicyStatement({
94
+ sid: "InvokeProspectusAccountsAsRegisteredSite",
95
+ actions: ["execute-api:Invoke"],
96
+ resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
97
+ }));
98
+ }
48
99
  const cmsApp = new amplify.CfnApp(this, "App", {
49
100
  name: cmsAppName,
50
101
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
@@ -53,17 +104,25 @@ export class AmplifyAppShellStack extends Stack {
53
104
  computeRoleArn: computeRole.roleArn,
54
105
  buildSpec: cmsProductionBuildSpec(config),
55
106
  jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
56
- customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
107
+ customRules: [
108
+ ...(config.cms.redirects ?? []).map((redirect) => ({
109
+ source: `https://${redirect.source}`,
110
+ target: `https://${resolveCmsHostName(config)}`,
111
+ status: String(redirect.status),
112
+ })),
113
+ { source: "/<*>", target: "/index.html", status: "404-200" },
114
+ ],
57
115
  });
58
116
  const stagingMode = config.frontend === "markus-static"
59
117
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
60
118
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
61
- const productionOrigin = config.cms.domainName
62
- ? `https://${config.cms.domainName}/`
119
+ const productionOrigin = cmsHostName
120
+ ? `https://${cmsHostName}/`
63
121
  : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
64
122
  const stagingOrigin = stagingDomainName
65
123
  ? `https://${stagingDomainName}/`
66
124
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
125
+ const sharedRootDomain = config.cms.domainPrefix !== undefined;
67
126
  const defaultOriginsToAllow = [
68
127
  ...(config.cms.domainName ? [] : [productionOrigin]),
69
128
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
@@ -79,14 +138,19 @@ export class AmplifyAppShellStack extends Stack {
79
138
  enablePerformanceMode: false,
80
139
  environmentVariables: environmentVariables({
81
140
  ...config.cms.environment,
141
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
142
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
82
143
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
83
144
  PAPYRUS_SITE_BRAND: config.brand,
84
145
  SITE_ENV: "production",
85
146
  PAPYRUS_CONTENT_SOURCE: "published",
147
+ ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
148
+ ...prospectusEnvironment,
86
149
  }),
87
150
  });
151
+ let stagingBranch;
88
152
  if (stagingEnabled) {
89
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
153
+ stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
90
154
  appId: cmsApp.attrAppId,
91
155
  branchName: "staging",
92
156
  stage: "BETA",
@@ -95,16 +159,19 @@ export class AmplifyAppShellStack extends Stack {
95
159
  buildSpec: cmsStagingBuildSpec(config),
96
160
  environmentVariables: environmentVariables({
97
161
  ...config.cms.environment,
162
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
163
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
98
164
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
99
165
  PAPYRUS_SITE_BRAND: config.brand,
100
166
  SITE_ENV: "staging",
101
167
  ...stagingMode,
168
+ ...prospectusEnvironment,
102
169
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
103
170
  ? `https://${stagingDomainName}`
104
171
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
105
172
  }),
106
173
  });
107
- if (stagingDomainName) {
174
+ if (stagingDomainName && !sharedRootDomain) {
108
175
  const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
109
176
  appId: cmsApp.attrAppId,
110
177
  domainName: stagingDomainName,
@@ -114,27 +181,89 @@ export class AmplifyAppShellStack extends Stack {
114
181
  }
115
182
  }
116
183
  if (config.cms.domainName) {
184
+ const subDomainSettings = sharedRootDomain
185
+ ? [
186
+ { branchName: "main", prefix: config.cms.domainPrefix },
187
+ ...(config.cms.redirects ?? []).map((redirect) => ({
188
+ branchName: "main",
189
+ prefix: resolveRedirectSubDomainPrefix(config.cms.domainName, redirect.source),
190
+ })),
191
+ ...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix }] : []),
192
+ ]
193
+ : [{ branchName: "main", prefix: "" }];
117
194
  const productionDomain = new amplify.CfnDomain(this, "Domain", {
118
195
  appId: cmsApp.attrAppId,
119
196
  domainName: config.cms.domainName,
120
- subDomainSettings: [{ branchName: "main", prefix: "" }],
197
+ subDomainSettings,
121
198
  });
122
199
  productionDomain.addResourceDependency(productionBranch);
200
+ if (sharedRootDomain && stagingBranch) {
201
+ productionDomain.addResourceDependency(stagingBranch);
202
+ }
123
203
  }
124
204
  this.cmsAppId = cmsApp.attrAppId;
125
205
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
126
206
  new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
207
+ new CfnOutput(this, "CmsComputeRoleArn", {
208
+ value: computeRole.roleArn,
209
+ description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
210
+ });
127
211
  if (stagingEnabled) {
128
212
  new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
129
213
  }
130
214
  if (config.reader) {
131
215
  this.readerAppId = this.addReaderApp(config, config.reader);
132
216
  }
217
+ const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
218
+ this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
133
219
  if (config.github) {
134
- this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
220
+ this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
221
+ }
222
+ }
223
+ authoringStatements(config, cmsAppId, amplifyAppIds) {
224
+ const mediaBucketArn = `arn:${this.partition}:s3:::amplify-${cmsAppId}-*`;
225
+ return [
226
+ new iam.PolicyStatement({
227
+ sid: "SignAppSyncAuthoringRequests",
228
+ actions: ["appsync:GraphQL"],
229
+ resources: ["Query", "Mutation"].map((typeName) => this.formatArn({ service: "appsync", resource: "apis", resourceName: `*/types/${typeName}/fields/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME })),
230
+ }),
231
+ new iam.PolicyStatement({
232
+ sid: "ReadWriteMediaAndPreview",
233
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
234
+ resources: [`${mediaBucketArn}/media/*`, `${mediaBucketArn}/${resolveStoragePreviewPrefix(config)}*`],
235
+ }),
236
+ new iam.PolicyStatement({
237
+ sid: "ListMediaAndPreview",
238
+ actions: ["s3:ListBucket"],
239
+ resources: [mediaBucketArn],
240
+ conditions: { StringLike: { "s3:prefix": ["media/*", `${resolveStoragePreviewPrefix(config)}*`] } },
241
+ }),
242
+ new iam.PolicyStatement({
243
+ sid: "StartAndInspectAmplifyJobs",
244
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
245
+ resources: amplifyAppIds.flatMap((appId) => [
246
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
247
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
248
+ ]),
249
+ }),
250
+ ];
251
+ }
252
+ addAuthoringRole(config, cmsAppId, amplifyAppIds) {
253
+ const authoringRole = new iam.Role(this, "PapyrusAuthoringRole", {
254
+ roleName: `${config.siteId}-papyrus-authoring`,
255
+ description: `Papyrus CLI authoring role for ${config.siteId}: AppSync (SigV4), media and preview storage, rebuild jobs. Assumable by principals in this account that are allowed sts:AssumeRole on it.`,
256
+ assumedBy: new iam.AccountRootPrincipal(),
257
+ });
258
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
259
+ authoringRole.addToPolicy(statement);
135
260
  }
261
+ new CfnOutput(this, "PapyrusAuthoringRoleArn", {
262
+ value: authoringRole.roleArn,
263
+ description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
264
+ });
136
265
  }
137
- addGithubCiRole(config, github, amplifyAppIds) {
266
+ addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
138
267
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
139
268
  service: "iam",
140
269
  region: "",
@@ -153,22 +282,9 @@ export class AmplifyAppShellStack extends Stack {
153
282
  },
154
283
  }),
155
284
  });
156
- ciRole.addToPolicy(new iam.PolicyStatement({
157
- sid: "StartAndInspectAmplifyJobs",
158
- actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
159
- resources: amplifyAppIds.flatMap((appId) => [
160
- this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
161
- this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
162
- ]),
163
- }));
164
- ciRole.addToPolicy(new iam.PolicyStatement({
165
- sid: "ReadSiteSecretsForJwtMinting",
166
- actions: ["ssm:GetParameter", "ssm:GetParameters"],
167
- resources: [
168
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
169
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
170
- ],
171
- }));
285
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
286
+ ciRole.addToPolicy(statement);
287
+ }
172
288
  ciRole.addToPolicy(new iam.PolicyStatement({
173
289
  sid: "WhoAmI",
174
290
  actions: ["sts:GetCallerIdentity"],
@@ -203,6 +319,11 @@ export class AmplifyAppShellStack extends Stack {
203
319
  platform: "WEB",
204
320
  iamServiceRole: readerServiceRole.roleArn,
205
321
  buildSpec: readerBuildSpec(config),
322
+ customRules: [
323
+ ...(reader.redirects ?? []),
324
+ { source: "/<*>", target: "/404.html", status: "404-200" },
325
+ ],
326
+ customHeaders: readerCustomHeaders(reader.customHeaders),
206
327
  });
207
328
  const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
208
329
  appId: readerApp.attrAppId,
@@ -219,7 +340,7 @@ export class AmplifyAppShellStack extends Stack {
219
340
  const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
220
341
  appId: readerApp.attrAppId,
221
342
  domainName: reader.domainName,
222
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
343
+ subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
223
344
  });
224
345
  readerDomain.addResourceDependency(readerBranch);
225
346
  }
@@ -4,39 +4,69 @@ import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
7
- import { AmplifyAppShellSiteConfig, isStagingEnabled, resolveStackName, resolveStagingDomainName } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
8
8
 
9
9
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
10
10
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
11
11
  }
12
12
 
13
+ function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
14
+ if (!rules) return undefined;
15
+ const lines = ["customHeaders:"];
16
+ for (const rule of rules) {
17
+ lines.push(` - pattern: ${JSON.stringify(rule.pattern)}`, " headers:");
18
+ for (const [key, value] of Object.entries(rule.headers)) {
19
+ lines.push(` - key: ${JSON.stringify(key)}`, ` value: ${JSON.stringify(value)}`);
20
+ }
21
+ }
22
+ return lines.join("\n") + "\n";
23
+ }
24
+
13
25
  /**
14
26
  * Provisions the Amplify *app shell* of a Papyrus publication from its
15
27
  * site.json:
16
28
  *
17
29
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
18
30
  * and `staging` (frontend only, reads the production backend)
19
- * - for `markus-static` sites, a reader app (WEB) with branch `main`
31
+ * - for `markus-static` sites, a reader app (WEB) with branch `main`; a
32
+ * `markus-static` site may omit `cms` to get the reader app alone (no CMS
33
+ * app, no CMS roles, no authoring role)
20
34
  * - a domain association per branch/app when a domain is configured (without
21
35
  * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
22
36
  * URL is used), IAM service and compute roles
23
37
  * - `cms.staging: false` omits the staging branch and its domain
24
38
  *
39
+ * Google sign-in is always on: the branches never set PAPYRUS_DISABLE_GOOGLE_OAUTH,
40
+ and `cms.cognitoDomainPrefix` becomes the hosted-UI domain
41
+ (PAPYRUS_COGNITO_DOMAIN_PREFIX) only when `cms.applyCognitoDomainPrefix` is true
42
+ (PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX); otherwise Amplify's generated domain is kept. The secrets GOOGLE_CLIENT_ID and
43
+ GOOGLE_CLIENT_SECRET are Amplify backend secrets set in the console, never here.
44
+ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
45
+ * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
46
+ * principal in the account that may assume it); no token is stored anywhere.
47
+ *
25
48
  * No repository and no access token are configured: the apps are manual-deploy
26
49
  * until Amplify's GitHub App is connected once in the console
27
50
  * (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
28
51
  * app by `ampx pipeline-deploy` from the production build.
29
52
  */
30
53
  export class AmplifyAppShellStack extends Stack {
31
- public readonly cmsAppId: string;
54
+ public readonly cmsAppId?: string;
32
55
  public readonly readerAppId?: string;
33
56
 
34
57
  constructor(scope: Construct, id: string, config: AmplifyAppShellSiteConfig, props?: StackProps) {
35
58
  super(scope, id, props);
36
59
 
60
+ if (!hasCmsApp(config)) {
61
+ if (!config.reader) throw new Error(`site ${config.siteId} has neither a cms section nor a reader`);
62
+ this.readerAppId = this.addReaderApp(config, config.reader);
63
+ return;
64
+ }
65
+
37
66
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
38
67
  const stagingEnabled = isStagingEnabled(config);
39
68
  const stagingDomainName = resolveStagingDomainName(config);
69
+ const cmsHostName = resolveCmsHostName(config);
40
70
 
41
71
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
42
72
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
@@ -53,6 +83,33 @@ export class AmplifyAppShellStack extends Stack {
53
83
  ],
54
84
  });
55
85
 
86
+ const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
87
+ if (revalidateSecretParameterName) {
88
+ computeRole.addToPolicy(
89
+ new iam.PolicyStatement({
90
+ sid: "ReadRevalidateSecret",
91
+ actions: ["ssm:GetParameter"],
92
+ resources: [
93
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
94
+ ],
95
+ }),
96
+ );
97
+ }
98
+
99
+ const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
100
+ const prospectusEnvironment = prospectusAccountsApiStageArn
101
+ ? { PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn) }
102
+ : {};
103
+ if (prospectusAccountsApiStageArn) {
104
+ computeRole.addToPolicy(
105
+ new iam.PolicyStatement({
106
+ sid: "InvokeProspectusAccountsAsRegisteredSite",
107
+ actions: ["execute-api:Invoke"],
108
+ resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
109
+ }),
110
+ );
111
+ }
112
+
56
113
  const cmsApp = new amplify.CfnApp(this, "App", {
57
114
  name: cmsAppName,
58
115
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
@@ -61,19 +118,27 @@ export class AmplifyAppShellStack extends Stack {
61
118
  computeRoleArn: computeRole.roleArn,
62
119
  buildSpec: cmsProductionBuildSpec(config),
63
120
  jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
64
- customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
121
+ customRules: [
122
+ ...(config.cms.redirects ?? []).map((redirect) => ({
123
+ source: `https://${redirect.source}`,
124
+ target: `https://${resolveCmsHostName(config)}`,
125
+ status: String(redirect.status),
126
+ })),
127
+ { source: "/<*>", target: "/index.html", status: "404-200" },
128
+ ],
65
129
  });
66
130
 
67
131
  const stagingMode: Record<string, string> = config.frontend === "markus-static"
68
132
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
69
133
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
70
134
 
71
- const productionOrigin = config.cms.domainName
72
- ? `https://${config.cms.domainName}/`
135
+ const productionOrigin = cmsHostName
136
+ ? `https://${cmsHostName}/`
73
137
  : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
74
138
  const stagingOrigin = stagingDomainName
75
139
  ? `https://${stagingDomainName}/`
76
140
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
141
+ const sharedRootDomain = config.cms.domainPrefix !== undefined;
77
142
  const defaultOriginsToAllow = [
78
143
  ...(config.cms.domainName ? [] : [productionOrigin]),
79
144
  ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
@@ -90,15 +155,20 @@ export class AmplifyAppShellStack extends Stack {
90
155
  enablePerformanceMode: false,
91
156
  environmentVariables: environmentVariables({
92
157
  ...config.cms.environment,
158
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
159
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
93
160
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
94
161
  PAPYRUS_SITE_BRAND: config.brand,
95
162
  SITE_ENV: "production",
96
163
  PAPYRUS_CONTENT_SOURCE: "published",
164
+ ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
165
+ ...prospectusEnvironment,
97
166
  }),
98
167
  });
99
168
 
169
+ let stagingBranch: amplify.CfnBranch | undefined;
100
170
  if (stagingEnabled) {
101
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
171
+ stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
102
172
  appId: cmsApp.attrAppId,
103
173
  branchName: "staging",
104
174
  stage: "BETA",
@@ -107,17 +177,20 @@ export class AmplifyAppShellStack extends Stack {
107
177
  buildSpec: cmsStagingBuildSpec(config),
108
178
  environmentVariables: environmentVariables({
109
179
  ...config.cms.environment,
180
+ PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
181
+ ...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
110
182
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
111
183
  PAPYRUS_SITE_BRAND: config.brand,
112
184
  SITE_ENV: "staging",
113
185
  ...stagingMode,
186
+ ...prospectusEnvironment,
114
187
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
115
188
  ? `https://${stagingDomainName}`
116
189
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
117
190
  }),
118
191
  });
119
192
 
120
- if (stagingDomainName) {
193
+ if (stagingDomainName && !sharedRootDomain) {
121
194
  const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
122
195
  appId: cmsApp.attrAppId,
123
196
  domainName: stagingDomainName,
@@ -128,17 +201,34 @@ export class AmplifyAppShellStack extends Stack {
128
201
  }
129
202
 
130
203
  if (config.cms.domainName) {
204
+ const subDomainSettings = sharedRootDomain
205
+ ? [
206
+ { branchName: "main", prefix: config.cms.domainPrefix as string },
207
+ ...(config.cms.redirects ?? []).map((redirect) => ({
208
+ branchName: "main",
209
+ prefix: resolveRedirectSubDomainPrefix(config.cms.domainName as string, redirect.source),
210
+ })),
211
+ ...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix as string }] : []),
212
+ ]
213
+ : [{ branchName: "main", prefix: "" }];
131
214
  const productionDomain = new amplify.CfnDomain(this, "Domain", {
132
215
  appId: cmsApp.attrAppId,
133
216
  domainName: config.cms.domainName,
134
- subDomainSettings: [{ branchName: "main", prefix: "" }],
217
+ subDomainSettings,
135
218
  });
136
219
  productionDomain.addResourceDependency(productionBranch);
220
+ if (sharedRootDomain && stagingBranch) {
221
+ productionDomain.addResourceDependency(stagingBranch);
222
+ }
137
223
  }
138
224
 
139
225
  this.cmsAppId = cmsApp.attrAppId;
140
226
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
141
227
  new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
228
+ new CfnOutput(this, "CmsComputeRoleArn", {
229
+ value: computeRole.roleArn,
230
+ description: `SSR compute role for ${cmsAppName}; the roleArn of its Prospectus site registration`,
231
+ });
142
232
  if (stagingEnabled) {
143
233
  new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
144
234
  }
@@ -147,14 +237,65 @@ export class AmplifyAppShellStack extends Stack {
147
237
  this.readerAppId = this.addReaderApp(config, config.reader);
148
238
  }
149
239
 
240
+ const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
241
+ this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
242
+
150
243
  if (config.github) {
151
- this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
244
+ this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
245
+ }
246
+ }
247
+
248
+ private authoringStatements(config: AmplifyAppShellSiteConfig, cmsAppId: string, amplifyAppIds: string[]): iam.PolicyStatement[] {
249
+ const mediaBucketArn = `arn:${this.partition}:s3:::amplify-${cmsAppId}-*`;
250
+ return [
251
+ new iam.PolicyStatement({
252
+ sid: "SignAppSyncAuthoringRequests",
253
+ actions: ["appsync:GraphQL"],
254
+ resources: ["Query", "Mutation"].map((typeName) =>
255
+ this.formatArn({ service: "appsync", resource: "apis", resourceName: `*/types/${typeName}/fields/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
256
+ ),
257
+ }),
258
+ new iam.PolicyStatement({
259
+ sid: "ReadWriteMediaAndPreview",
260
+ actions: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
261
+ resources: [`${mediaBucketArn}/media/*`, `${mediaBucketArn}/${resolveStoragePreviewPrefix(config)}*`],
262
+ }),
263
+ new iam.PolicyStatement({
264
+ sid: "ListMediaAndPreview",
265
+ actions: ["s3:ListBucket"],
266
+ resources: [mediaBucketArn],
267
+ conditions: { StringLike: { "s3:prefix": ["media/*", `${resolveStoragePreviewPrefix(config)}*`] } },
268
+ }),
269
+ new iam.PolicyStatement({
270
+ sid: "StartAndInspectAmplifyJobs",
271
+ actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
272
+ resources: amplifyAppIds.flatMap((appId) => [
273
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
274
+ this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
275
+ ]),
276
+ }),
277
+ ];
278
+ }
279
+
280
+ private addAuthoringRole(config: AmplifyAppShellSiteConfig, cmsAppId: string, amplifyAppIds: string[]): void {
281
+ const authoringRole = new iam.Role(this, "PapyrusAuthoringRole", {
282
+ roleName: `${config.siteId}-papyrus-authoring`,
283
+ description: `Papyrus CLI authoring role for ${config.siteId}: AppSync (SigV4), media and preview storage, rebuild jobs. Assumable by principals in this account that are allowed sts:AssumeRole on it.`,
284
+ assumedBy: new iam.AccountRootPrincipal(),
285
+ });
286
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
287
+ authoringRole.addToPolicy(statement);
152
288
  }
289
+ new CfnOutput(this, "PapyrusAuthoringRoleArn", {
290
+ value: authoringRole.roleArn,
291
+ description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
292
+ });
153
293
  }
154
294
 
155
295
  private addGithubCiRole(
156
296
  config: AmplifyAppShellSiteConfig,
157
297
  github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
298
+ cmsAppId: string,
158
299
  amplifyAppIds: string[],
159
300
  ): void {
160
301
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
@@ -183,26 +324,9 @@ export class AmplifyAppShellStack extends Stack {
183
324
  }),
184
325
  });
185
326
 
186
- ciRole.addToPolicy(
187
- new iam.PolicyStatement({
188
- sid: "StartAndInspectAmplifyJobs",
189
- actions: ["amplify:StartJob", "amplify:ListJobs", "amplify:GetJob", "amplify:ListBranches"],
190
- resources: amplifyAppIds.flatMap((appId) => [
191
- this.formatArn({ service: "amplify", resource: "apps", resourceName: appId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
192
- this.formatArn({ service: "amplify", resource: "apps", resourceName: `${appId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
193
- ]),
194
- }),
195
- );
196
- ciRole.addToPolicy(
197
- new iam.PolicyStatement({
198
- sid: "ReadSiteSecretsForJwtMinting",
199
- actions: ["ssm:GetParameter", "ssm:GetParameters"],
200
- resources: [
201
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
202
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${this.cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
203
- ],
204
- }),
205
- );
327
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
328
+ ciRole.addToPolicy(statement);
329
+ }
206
330
  ciRole.addToPolicy(
207
331
  new iam.PolicyStatement({
208
332
  sid: "WhoAmI",
@@ -250,6 +374,11 @@ export class AmplifyAppShellStack extends Stack {
250
374
  platform: "WEB",
251
375
  iamServiceRole: readerServiceRole.roleArn,
252
376
  buildSpec: readerBuildSpec(config),
377
+ customRules: [
378
+ ...(reader.redirects ?? []),
379
+ { source: "/<*>", target: "/404.html", status: "404-200" },
380
+ ],
381
+ customHeaders: readerCustomHeaders(reader.customHeaders),
253
382
  });
254
383
 
255
384
  const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
@@ -268,7 +397,7 @@ export class AmplifyAppShellStack extends Stack {
268
397
  const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
269
398
  appId: readerApp.attrAppId,
270
399
  domainName: reader.domainName,
271
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
400
+ subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
272
401
  });
273
402
  readerDomain.addResourceDependency(readerBranch);
274
403
  }