@anthusai/papyrus 1.0.0-next.8 → 1.0.0-next.9

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.
@@ -11,7 +11,7 @@ import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import { createRequire } from "node:module";
13
13
  import { buildSpecFor } from "../infra/build-specs.js";
14
- import { parseSiteConfig } from "../infra/site-config.js";
14
+ import { parseSiteConfig, resolveStackName } from "../infra/site-config.js";
15
15
 
16
16
  const USAGE = [
17
17
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
@@ -89,7 +89,7 @@ const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib"
89
89
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
90
90
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
91
91
  env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
92
- stackName: `amplify-app-shell-${config.siteId}`,
92
+ stackName: resolveStackName(config),
93
93
  });
94
94
  app.synth();
95
- console.log(`synthesized amplify-app-shell-${config.siteId} -> ${path.resolve(opt("out", "cdk.out"))}`);
95
+ console.log(`synthesized ${resolveStackName(config)} -> ${path.resolve(opt("out", "cdk.out"))}`);
@@ -1,9 +1,9 @@
1
- import { ArnFormat, CfnOutput, Duration, Stack } from "aws-cdk-lib";
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
4
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
5
5
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
6
- import { resolveStagingDomainName } from "./site-config.js";
6
+ import { isStagingEnabled, resolveStackName, resolveStagingDomainName } from "./site-config.js";
7
7
  function environmentVariables(variables) {
8
8
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
9
9
  }
@@ -14,7 +14,10 @@ function environmentVariables(variables) {
14
14
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
15
15
  * and `staging` (frontend only, reads the production backend)
16
16
  * - for `markus-static` sites, a reader app (WEB) with branch `main`
17
- * - one domain association per branch/app, IAM service and compute roles
17
+ * - a domain association per branch/app when a domain is configured (without
18
+ * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
19
+ * URL is used), IAM service and compute roles
20
+ * - `cms.staging: false` omits the staging branch and its domain
18
21
  *
19
22
  * No repository and no access token are configured: the apps are manual-deploy
20
23
  * until Amplify's GitHub App is connected once in the console
@@ -27,6 +30,7 @@ export class AmplifyAppShellStack extends Stack {
27
30
  constructor(scope, id, config, props) {
28
31
  super(scope, id, props);
29
32
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
33
+ const stagingEnabled = isStagingEnabled(config);
30
34
  const stagingDomainName = resolveStagingDomainName(config);
31
35
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
32
36
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
@@ -54,6 +58,19 @@ export class AmplifyAppShellStack extends Stack {
54
58
  const stagingMode = config.frontend === "markus-static"
55
59
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
56
60
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
61
+ const productionOrigin = config.cms.domainName
62
+ ? `https://${config.cms.domainName}/`
63
+ : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
64
+ const stagingOrigin = stagingDomainName
65
+ ? `https://${stagingDomainName}/`
66
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
67
+ const defaultOriginsToAllow = [
68
+ ...(config.cms.domainName ? [] : [productionOrigin]),
69
+ ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
70
+ ];
71
+ const oauthRedirectUrls = defaultOriginsToAllow.length === 0
72
+ ? config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS
73
+ : Fn.join(",", [config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS, ...defaultOriginsToAllow]);
57
74
  const productionBranch = new amplify.CfnBranch(this, "Branch", {
58
75
  appId: cmsApp.attrAppId,
59
76
  branchName: "main",
@@ -62,44 +79,56 @@ export class AmplifyAppShellStack extends Stack {
62
79
  enablePerformanceMode: false,
63
80
  environmentVariables: environmentVariables({
64
81
  ...config.cms.environment,
82
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
65
83
  PAPYRUS_SITE_BRAND: config.brand,
66
84
  SITE_ENV: "production",
67
85
  PAPYRUS_CONTENT_SOURCE: "published",
68
86
  }),
69
87
  });
70
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
71
- appId: cmsApp.attrAppId,
72
- branchName: "staging",
73
- stage: "BETA",
74
- enableAutoBuild: true,
75
- enablePerformanceMode: false,
76
- buildSpec: cmsStagingBuildSpec(config),
77
- environmentVariables: environmentVariables({
78
- ...config.cms.environment,
79
- PAPYRUS_SITE_BRAND: config.brand,
80
- SITE_ENV: "staging",
81
- ...stagingMode,
82
- NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
83
- }),
84
- });
85
- const productionDomain = new amplify.CfnDomain(this, "Domain", {
86
- appId: cmsApp.attrAppId,
87
- domainName: config.cms.domainName,
88
- subDomainSettings: [{ branchName: "main", prefix: "" }],
89
- });
90
- productionDomain.addResourceDependency(productionBranch);
91
- const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
92
- appId: cmsApp.attrAppId,
93
- domainName: stagingDomainName,
94
- subDomainSettings: [{ branchName: "staging", prefix: "" }],
95
- });
96
- stagingDomain.addResourceDependency(stagingBranch);
88
+ if (stagingEnabled) {
89
+ const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
90
+ appId: cmsApp.attrAppId,
91
+ branchName: "staging",
92
+ stage: "BETA",
93
+ enableAutoBuild: true,
94
+ enablePerformanceMode: false,
95
+ buildSpec: cmsStagingBuildSpec(config),
96
+ environmentVariables: environmentVariables({
97
+ ...config.cms.environment,
98
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
99
+ PAPYRUS_SITE_BRAND: config.brand,
100
+ SITE_ENV: "staging",
101
+ ...stagingMode,
102
+ NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
103
+ ? `https://${stagingDomainName}`
104
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
105
+ }),
106
+ });
107
+ if (stagingDomainName) {
108
+ const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
109
+ appId: cmsApp.attrAppId,
110
+ domainName: stagingDomainName,
111
+ subDomainSettings: [{ branchName: "staging", prefix: "" }],
112
+ });
113
+ stagingDomain.addResourceDependency(stagingBranch);
114
+ }
115
+ }
116
+ if (config.cms.domainName) {
117
+ const productionDomain = new amplify.CfnDomain(this, "Domain", {
118
+ appId: cmsApp.attrAppId,
119
+ domainName: config.cms.domainName,
120
+ subDomainSettings: [{ branchName: "main", prefix: "" }],
121
+ });
122
+ productionDomain.addResourceDependency(productionBranch);
123
+ }
97
124
  this.cmsAppId = cmsApp.attrAppId;
98
125
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
99
- new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
100
- new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
126
+ new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
127
+ if (stagingEnabled) {
128
+ new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
129
+ }
101
130
  if (config.reader) {
102
- this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
131
+ this.readerAppId = this.addReaderApp(config, config.reader);
103
132
  }
104
133
  if (config.github) {
105
134
  this.addGithubCiRole(config, config.github, [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])]);
@@ -150,27 +179,19 @@ export class AmplifyAppShellStack extends Stack {
150
179
  sid: "DeployThisSitesAppShellStack",
151
180
  actions: ["cloudformation:*"],
152
181
  resources: [
153
- this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `amplify-app-shell-${config.siteId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
182
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `${resolveStackName(config)}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
154
183
  ],
155
184
  }));
156
185
  }
157
186
  new CfnOutput(this, "GithubCiRoleArn", { value: ciRole.roleArn, description: `Role for GitHub Actions in ${github.owner}/${github.repo}` });
158
187
  }
159
- addReaderApp(config, reader, cmsAppId) {
188
+ addReaderApp(config, reader) {
160
189
  const readerAppName = reader.appName ?? `${config.siteId}-reader`;
161
190
  const readerBranchName = reader.branchName ?? "main";
162
191
  const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
163
192
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
164
- description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
193
+ description: `Static reader build role for Amplify app ${readerAppName} (reads published media only; content is read as a Cognito guest)`,
165
194
  });
166
- readerServiceRole.addToPolicy(new iam.PolicyStatement({
167
- sid: "ReadSiteSecretsForJwtMinting",
168
- actions: ["ssm:GetParameter", "ssm:GetParameters"],
169
- resources: [
170
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
171
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
172
- ],
173
- }));
174
195
  readerServiceRole.addToPolicy(new iam.PolicyStatement({
175
196
  sid: "ReadMediaForExport",
176
197
  actions: ["s3:GetObject"],
@@ -194,14 +215,20 @@ export class AmplifyAppShellStack extends Stack {
194
215
  SITE_ENV: "production",
195
216
  }),
196
217
  });
197
- const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
198
- appId: readerApp.attrAppId,
199
- domainName: reader.domainName,
200
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
201
- });
202
- readerDomain.addResourceDependency(readerBranch);
218
+ if (reader.domainName) {
219
+ const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
220
+ appId: readerApp.attrAppId,
221
+ domainName: reader.domainName,
222
+ subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
223
+ });
224
+ readerDomain.addResourceDependency(readerBranch);
225
+ }
203
226
  new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
204
- new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
227
+ new CfnOutput(this, "ReaderOrigin", {
228
+ value: reader.domainName
229
+ ? `https://${reader.domainName}/`
230
+ : Fn.join("", [`https://${readerBranchName}.`, readerApp.attrDefaultDomain, "/"]),
231
+ });
205
232
  return readerApp.attrAppId;
206
233
  }
207
234
  }
@@ -1,10 +1,10 @@
1
1
  import { Construct } from "constructs";
2
- import { ArnFormat, CfnOutput, Duration, Stack, StackProps } from "aws-cdk-lib";
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
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
7
- import { AmplifyAppShellSiteConfig, resolveStagingDomainName } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, isStagingEnabled, resolveStackName, resolveStagingDomainName } 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 }));
@@ -17,7 +17,10 @@ function environmentVariables(variables: Record<string, string>): amplify.CfnBra
17
17
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
18
18
  * and `staging` (frontend only, reads the production backend)
19
19
  * - for `markus-static` sites, a reader app (WEB) with branch `main`
20
- * - one domain association per branch/app, IAM service and compute roles
20
+ * - a domain association per branch/app when a domain is configured (without
21
+ * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
22
+ * URL is used), IAM service and compute roles
23
+ * - `cms.staging: false` omits the staging branch and its domain
21
24
  *
22
25
  * No repository and no access token are configured: the apps are manual-deploy
23
26
  * until Amplify's GitHub App is connected once in the console
@@ -32,6 +35,7 @@ export class AmplifyAppShellStack extends Stack {
32
35
  super(scope, id, props);
33
36
 
34
37
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
38
+ const stagingEnabled = isStagingEnabled(config);
35
39
  const stagingDomainName = resolveStagingDomainName(config);
36
40
 
37
41
  const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
@@ -64,6 +68,20 @@ export class AmplifyAppShellStack extends Stack {
64
68
  ? { PAPYRUS_STAGING_PREVIEW: "static" }
65
69
  : { PAPYRUS_CONTENT_SOURCE: "drafts" };
66
70
 
71
+ const productionOrigin = config.cms.domainName
72
+ ? `https://${config.cms.domainName}/`
73
+ : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain, "/"]);
74
+ const stagingOrigin = stagingDomainName
75
+ ? `https://${stagingDomainName}/`
76
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
77
+ const defaultOriginsToAllow = [
78
+ ...(config.cms.domainName ? [] : [productionOrigin]),
79
+ ...(stagingEnabled && !stagingDomainName ? [stagingOrigin] : []),
80
+ ];
81
+ const oauthRedirectUrls = defaultOriginsToAllow.length === 0
82
+ ? config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS
83
+ : Fn.join(",", [config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS, ...defaultOriginsToAllow]);
84
+
67
85
  const productionBranch = new amplify.CfnBranch(this, "Branch", {
68
86
  appId: cmsApp.attrAppId,
69
87
  branchName: "main",
@@ -72,49 +90,61 @@ export class AmplifyAppShellStack extends Stack {
72
90
  enablePerformanceMode: false,
73
91
  environmentVariables: environmentVariables({
74
92
  ...config.cms.environment,
93
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
75
94
  PAPYRUS_SITE_BRAND: config.brand,
76
95
  SITE_ENV: "production",
77
96
  PAPYRUS_CONTENT_SOURCE: "published",
78
97
  }),
79
98
  });
80
99
 
81
- const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
82
- appId: cmsApp.attrAppId,
83
- branchName: "staging",
84
- stage: "BETA",
85
- enableAutoBuild: true,
86
- enablePerformanceMode: false,
87
- buildSpec: cmsStagingBuildSpec(config),
88
- environmentVariables: environmentVariables({
89
- ...config.cms.environment,
90
- PAPYRUS_SITE_BRAND: config.brand,
91
- SITE_ENV: "staging",
92
- ...stagingMode,
93
- NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
94
- }),
95
- });
100
+ if (stagingEnabled) {
101
+ const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
102
+ appId: cmsApp.attrAppId,
103
+ branchName: "staging",
104
+ stage: "BETA",
105
+ enableAutoBuild: true,
106
+ enablePerformanceMode: false,
107
+ buildSpec: cmsStagingBuildSpec(config),
108
+ environmentVariables: environmentVariables({
109
+ ...config.cms.environment,
110
+ PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
111
+ PAPYRUS_SITE_BRAND: config.brand,
112
+ SITE_ENV: "staging",
113
+ ...stagingMode,
114
+ NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
115
+ ? `https://${stagingDomainName}`
116
+ : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]),
117
+ }),
118
+ });
96
119
 
97
- const productionDomain = new amplify.CfnDomain(this, "Domain", {
98
- appId: cmsApp.attrAppId,
99
- domainName: config.cms.domainName,
100
- subDomainSettings: [{ branchName: "main", prefix: "" }],
101
- });
102
- productionDomain.addResourceDependency(productionBranch);
120
+ if (stagingDomainName) {
121
+ const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
122
+ appId: cmsApp.attrAppId,
123
+ domainName: stagingDomainName,
124
+ subDomainSettings: [{ branchName: "staging", prefix: "" }],
125
+ });
126
+ stagingDomain.addResourceDependency(stagingBranch);
127
+ }
128
+ }
103
129
 
104
- const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
105
- appId: cmsApp.attrAppId,
106
- domainName: stagingDomainName,
107
- subDomainSettings: [{ branchName: "staging", prefix: "" }],
108
- });
109
- stagingDomain.addResourceDependency(stagingBranch);
130
+ if (config.cms.domainName) {
131
+ const productionDomain = new amplify.CfnDomain(this, "Domain", {
132
+ appId: cmsApp.attrAppId,
133
+ domainName: config.cms.domainName,
134
+ subDomainSettings: [{ branchName: "main", prefix: "" }],
135
+ });
136
+ productionDomain.addResourceDependency(productionBranch);
137
+ }
110
138
 
111
139
  this.cmsAppId = cmsApp.attrAppId;
112
140
  new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
113
- new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
114
- new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
141
+ new CfnOutput(this, "CmsOrigin", { value: productionOrigin });
142
+ if (stagingEnabled) {
143
+ new CfnOutput(this, "StagingOrigin", { value: stagingOrigin });
144
+ }
115
145
 
116
146
  if (config.reader) {
117
- this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
147
+ this.readerAppId = this.addReaderApp(config, config.reader);
118
148
  }
119
149
 
120
150
  if (config.github) {
@@ -186,7 +216,7 @@ export class AmplifyAppShellStack extends Stack {
186
216
  sid: "DeployThisSitesAppShellStack",
187
217
  actions: ["cloudformation:*"],
188
218
  resources: [
189
- this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `amplify-app-shell-${config.siteId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
219
+ this.formatArn({ service: "cloudformation", resource: "stack", resourceName: `${resolveStackName(config)}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
190
220
  ],
191
221
  }),
192
222
  );
@@ -198,25 +228,14 @@ export class AmplifyAppShellStack extends Stack {
198
228
  private addReaderApp(
199
229
  config: AmplifyAppShellSiteConfig,
200
230
  reader: NonNullable<AmplifyAppShellSiteConfig["reader"]>,
201
- cmsAppId: string,
202
231
  ): string {
203
232
  const readerAppName = reader.appName ?? `${config.siteId}-reader`;
204
233
  const readerBranchName = reader.branchName ?? "main";
205
234
 
206
235
  const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
207
236
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
208
- description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
237
+ description: `Static reader build role for Amplify app ${readerAppName} (reads published media only; content is read as a Cognito guest)`,
209
238
  });
210
- readerServiceRole.addToPolicy(
211
- new iam.PolicyStatement({
212
- sid: "ReadSiteSecretsForJwtMinting",
213
- actions: ["ssm:GetParameter", "ssm:GetParameters"],
214
- resources: [
215
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
216
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
217
- ],
218
- }),
219
- );
220
239
  readerServiceRole.addToPolicy(
221
240
  new iam.PolicyStatement({
222
241
  sid: "ReadMediaForExport",
@@ -245,15 +264,21 @@ export class AmplifyAppShellStack extends Stack {
245
264
  }),
246
265
  });
247
266
 
248
- const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
249
- appId: readerApp.attrAppId,
250
- domainName: reader.domainName,
251
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
252
- });
253
- readerDomain.addResourceDependency(readerBranch);
267
+ if (reader.domainName) {
268
+ const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
269
+ appId: readerApp.attrAppId,
270
+ domainName: reader.domainName,
271
+ subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
272
+ });
273
+ readerDomain.addResourceDependency(readerBranch);
274
+ }
254
275
 
255
276
  new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
256
- new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
277
+ new CfnOutput(this, "ReaderOrigin", {
278
+ value: reader.domainName
279
+ ? `https://${reader.domainName}/`
280
+ : Fn.join("", [`https://${readerBranchName}.`, readerApp.attrDefaultDomain, "/"]),
281
+ });
257
282
  return readerApp.attrAppId;
258
283
  }
259
284
  }
@@ -72,8 +72,7 @@ export function readerBuildSpec(config) {
72
72
  "version: 1",
73
73
  ...frontendSection([
74
74
  ...pythonProvisioningCommands(config),
75
- "papyrus auth refresh-jwt --write-env .env",
76
- "papyrus content export-published --out content-export --clean",
75
+ "papyrus content export-published --auth guest --out content-export --clean",
77
76
  ], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
78
77
  ].join("\n") + "\n";
79
78
  }
@@ -115,8 +115,7 @@ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
115
115
  ...frontendSection(
116
116
  [
117
117
  ...pythonProvisioningCommands(config),
118
- "papyrus auth refresh-jwt --write-env .env",
119
- "papyrus content export-published --out content-export --clean",
118
+ "papyrus content export-published --auth guest --out content-export --clean",
120
119
  ],
121
120
  [readerConfig.buildCommand],
122
121
  readerConfig.baseDirectory,
@@ -4,6 +4,7 @@ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_
4
4
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
5
5
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
6
6
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
7
+ const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
7
8
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
8
9
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
9
10
  const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
@@ -46,18 +47,28 @@ function rejectUnknownKeys(record, allowed, field) {
46
47
  fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
47
48
  }
48
49
  }
50
+ export function isStagingEnabled(config) {
51
+ return config.cms.staging !== false;
52
+ }
49
53
  export function resolveStagingDomainName(config) {
54
+ if (!isStagingEnabled(config))
55
+ return undefined;
50
56
  if (config.cms.stagingDomainName)
51
57
  return config.cms.stagingDomainName;
58
+ if (!config.cms.domainName)
59
+ return undefined;
52
60
  const [, ...rest] = config.cms.domainName.split(".");
53
61
  return ["staging", ...rest].join(".");
54
62
  }
63
+ export function resolveStackName(config) {
64
+ return config.stackName ?? `amplify-app-shell-${config.siteId}`;
65
+ }
55
66
  export function resolveStoragePreviewPrefix(config) {
56
67
  return config.storagePreviewPrefix ?? "preview/";
57
68
  }
58
69
  export function parseSiteConfig(raw) {
59
70
  const record = requireObject(raw, "site");
60
- rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
71
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
61
72
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
62
73
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
63
74
  const brand = requireString(record.brand, "brand");
@@ -65,22 +76,33 @@ export function parseSiteConfig(raw) {
65
76
  if (frontend !== "pretext" && frontend !== "markus-static") {
66
77
  fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
67
78
  }
68
- const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
79
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
80
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
69
81
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
70
82
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
71
83
  const cmsRecord = requireObject(record.cms, "cms");
72
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
84
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "buildComputeType", "environment", "stagingDomainName"], "cms");
73
85
  const buildComputeType = cmsRecord.buildComputeType;
74
86
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
75
87
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
76
88
  }
89
+ if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
90
+ fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
91
+ }
77
92
  const cms = {
78
93
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
79
- domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
94
+ domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
95
+ staging: cmsRecord.staging,
80
96
  buildComputeType: buildComputeType,
81
97
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
82
98
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
83
99
  };
100
+ if (cms.staging === false && cms.stagingDomainName) {
101
+ fail("cms.stagingDomainName", "must not be set when cms.staging is false");
102
+ }
103
+ if (cms.stagingDomainName && !cms.domainName) {
104
+ fail("cms.stagingDomainName", "requires cms.domainName");
105
+ }
84
106
  let reader;
85
107
  if (record.reader !== undefined) {
86
108
  if (frontend !== "markus-static")
@@ -89,7 +111,7 @@ export function parseSiteConfig(raw) {
89
111
  rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
90
112
  reader = {
91
113
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
92
- domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
114
+ domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
93
115
  branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
94
116
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
95
117
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
@@ -108,7 +130,7 @@ export function parseSiteConfig(raw) {
108
130
  if (repository !== `https://github.com/${owner}/${repo}`) {
109
131
  fail("github", `owner/repo must match repository ${repository}`);
110
132
  }
111
- let branches = DEFAULT_CI_BRANCHES;
133
+ let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
112
134
  if (githubRecord.branches !== undefined) {
113
135
  if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
114
136
  fail("github.branches", "must be a non-empty array of branch names");
@@ -125,12 +147,20 @@ export function parseSiteConfig(raw) {
125
147
  fail("github.ciCanDeployInfra", "must be a boolean");
126
148
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
127
149
  }
150
+ const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
151
+ if (hasDomain && hostedZoneId === undefined) {
152
+ fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
153
+ }
154
+ if (!hasDomain && hostedZoneId !== undefined) {
155
+ fail("hostedZoneId", "is only allowed when cms.domainName or reader.domainName is set");
156
+ }
128
157
  const config = {
129
158
  siteId,
130
159
  repository,
131
160
  brand,
132
161
  frontend,
133
162
  hostedZoneId,
163
+ stackName,
134
164
  cms,
135
165
  reader,
136
166
  papyrusVersion,
@@ -141,9 +171,10 @@ export function parseSiteConfig(raw) {
141
171
  if (redirectValue === undefined)
142
172
  fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
143
173
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
174
+ const stagingDomainName = resolveStagingDomainName(config);
144
175
  const requiredOrigins = [
145
- `https://${cms.domainName}/`,
146
- `https://${resolveStagingDomainName(config)}/`,
176
+ ...(cms.domainName ? [`https://${cms.domainName}/`] : []),
177
+ ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
147
178
  LOCAL_DEVELOPMENT_ORIGIN,
148
179
  ];
149
180
  for (const origin of requiredOrigins) {
@@ -3,17 +3,19 @@ export type AmplifyAppShellSiteConfig = {
3
3
  repository: string;
4
4
  brand: string;
5
5
  frontend: "pretext" | "markus-static";
6
- hostedZoneId: string;
6
+ hostedZoneId?: string;
7
+ stackName?: string;
7
8
  cms: {
8
9
  appName?: string;
9
- domainName: string;
10
+ domainName?: string;
11
+ staging?: boolean;
10
12
  buildComputeType?: "STANDARD" | "STANDARD_8GB";
11
13
  environment: Record<string, string>;
12
14
  stagingDomainName?: string;
13
15
  };
14
16
  reader?: {
15
17
  appName?: string;
16
- domainName: string;
18
+ domainName?: string;
17
19
  branchName?: string;
18
20
  buildCommand: string;
19
21
  baseDirectory: string;
@@ -36,6 +38,7 @@ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_
36
38
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
37
39
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
38
40
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
41
+ const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
39
42
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
40
43
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
41
44
  const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
@@ -83,12 +86,22 @@ function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], f
83
86
  }
84
87
  }
85
88
 
86
- export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string {
89
+ export function isStagingEnabled(config: Pick<AmplifyAppShellSiteConfig, "cms">): boolean {
90
+ return config.cms.staging !== false;
91
+ }
92
+
93
+ export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
94
+ if (!isStagingEnabled(config)) return undefined;
87
95
  if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
96
+ if (!config.cms.domainName) return undefined;
88
97
  const [, ...rest] = config.cms.domainName.split(".");
89
98
  return ["staging", ...rest].join(".");
90
99
  }
91
100
 
101
+ export function resolveStackName(config: Pick<AmplifyAppShellSiteConfig, "siteId" | "stackName">): string {
102
+ return config.stackName ?? `amplify-app-shell-${config.siteId}`;
103
+ }
104
+
92
105
  export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
93
106
  return config.storagePreviewPrefix ?? "preview/";
94
107
  }
@@ -97,7 +110,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
97
110
  const record = requireObject(raw, "site");
98
111
  rejectUnknownKeys(
99
112
  record,
100
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
113
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
101
114
  "site",
102
115
  );
103
116
 
@@ -108,24 +121,36 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
108
121
  if (frontend !== "pretext" && frontend !== "markus-static") {
109
122
  fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
110
123
  }
111
- const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
124
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
125
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
112
126
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
113
127
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
114
128
 
115
129
  const cmsRecord = requireObject(record.cms, "cms");
116
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
130
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "buildComputeType", "environment", "stagingDomainName"], "cms");
117
131
  const buildComputeType = cmsRecord.buildComputeType;
118
132
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
119
133
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
120
134
  }
135
+ if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
136
+ fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
137
+ }
121
138
  const cms: AmplifyAppShellSiteConfig["cms"] = {
122
139
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
123
- domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
140
+ domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
141
+ staging: cmsRecord.staging as boolean | undefined,
124
142
  buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
125
143
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
126
144
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
127
145
  };
128
146
 
147
+ if (cms.staging === false && cms.stagingDomainName) {
148
+ fail("cms.stagingDomainName", "must not be set when cms.staging is false");
149
+ }
150
+ if (cms.stagingDomainName && !cms.domainName) {
151
+ fail("cms.stagingDomainName", "requires cms.domainName");
152
+ }
153
+
129
154
  let reader: AmplifyAppShellSiteConfig["reader"];
130
155
  if (record.reader !== undefined) {
131
156
  if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
@@ -133,7 +158,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
133
158
  rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
134
159
  reader = {
135
160
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
136
- domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
161
+ domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
137
162
  branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
138
163
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
139
164
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
@@ -152,7 +177,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
152
177
  if (repository !== `https://github.com/${owner}/${repo}`) {
153
178
  fail("github", `owner/repo must match repository ${repository}`);
154
179
  }
155
- let branches = DEFAULT_CI_BRANCHES;
180
+ let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
156
181
  if (githubRecord.branches !== undefined) {
157
182
  if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
158
183
  fail("github.branches", "must be a non-empty array of branch names");
@@ -169,12 +194,21 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
169
194
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
170
195
  }
171
196
 
197
+ const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
198
+ if (hasDomain && hostedZoneId === undefined) {
199
+ fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
200
+ }
201
+ if (!hasDomain && hostedZoneId !== undefined) {
202
+ fail("hostedZoneId", "is only allowed when cms.domainName or reader.domainName is set");
203
+ }
204
+
172
205
  const config: AmplifyAppShellSiteConfig = {
173
206
  siteId,
174
207
  repository,
175
208
  brand,
176
209
  frontend,
177
210
  hostedZoneId,
211
+ stackName,
178
212
  cms,
179
213
  reader,
180
214
  papyrusVersion,
@@ -185,9 +219,10 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
185
219
  const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
186
220
  if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
187
221
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
222
+ const stagingDomainName = resolveStagingDomainName(config);
188
223
  const requiredOrigins = [
189
- `https://${cms.domainName}/`,
190
- `https://${resolveStagingDomainName(config)}/`,
224
+ ...(cms.domainName ? [`https://${cms.domainName}/`] : []),
225
+ ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
191
226
  LOCAL_DEVELOPMENT_ORIGIN,
192
227
  ];
193
228
  for (const origin of requiredOrigins) {
package/package.json CHANGED
@@ -276,7 +276,7 @@
276
276
  "optional": true
277
277
  }
278
278
  },
279
- "version": "1.0.0-next.8",
279
+ "version": "1.0.0-next.9",
280
280
  "dependencies": {
281
281
  "@aws-amplify/adapter-nextjs": "^1.7.3",
282
282
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.8",
2
+ "version": "1.0.0-next.9",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",