@anthusai/papyrus 1.0.0-next.41 → 1.0.0-next.43

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.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * IAM statement for the content-actions Lambda's read of the revalidate secret.
3
+ * Pure (no CDK imports) so the policy can be tested without synthesizing.
4
+ * The Lambda reads the parameter through boto3 get_parameters (plural), so the
5
+ * plural action must be granted alongside the singular one.
6
+ */
7
+ export const REVALIDATE_SECRET_READ_ACTIONS = ["ssm:GetParameter", "ssm:GetParameters"];
8
+ export function revalidateSecretReadStatement(input) {
9
+ return {
10
+ actions: REVALIDATE_SECRET_READ_ACTIONS,
11
+ resources: [`arn:${input.partition}:ssm:${input.region}:${input.account}:parameter${input.parameterName}`],
12
+ };
13
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * IAM statement for the content-actions Lambda's read of the revalidate secret.
3
+ * Pure (no CDK imports) so the policy can be tested without synthesizing.
4
+ * The Lambda reads the parameter through boto3 get_parameters (plural), so the
5
+ * plural action must be granted alongside the singular one.
6
+ */
7
+ export const REVALIDATE_SECRET_READ_ACTIONS = ["ssm:GetParameter", "ssm:GetParameters"];
8
+
9
+ export function revalidateSecretReadStatement(input: {
10
+ partition: string;
11
+ region: string;
12
+ account: string;
13
+ parameterName: string;
14
+ }): { actions: string[]; resources: string[] } {
15
+ return {
16
+ actions: REVALIDATE_SECRET_READ_ACTIONS,
17
+ resources: [`arn:${input.partition}:ssm:${input.region}:${input.account}:parameter${input.parameterName}`],
18
+ };
19
+ }
@@ -8,6 +8,7 @@ import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
10
10
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
11
+ import { revalidateSecretReadStatement } from "./revalidate-secret-policy.js";
11
12
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
12
13
  import { data } from "./data/resource.js";
13
14
  import { assignmentAction } from "./functions/assignment-action/resource.js";
@@ -309,10 +310,12 @@ export function defineSiteBackend(papyrusSite) {
309
310
  }
310
311
  backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
311
312
  const revalidateStack = Stack.of(contentActionsLambda);
312
- contentActionsLambda.addToRolePolicy(new PolicyStatement({
313
- actions: ["ssm:GetParameter"],
314
- resources: [`arn:${revalidateStack.partition}:ssm:${revalidateStack.region}:${revalidateStack.account}:parameter${revalidateSecretParameter}`],
315
- }));
313
+ contentActionsLambda.addToRolePolicy(new PolicyStatement(revalidateSecretReadStatement({
314
+ partition: revalidateStack.partition,
315
+ region: revalidateStack.region,
316
+ account: revalidateStack.account,
317
+ parameterName: revalidateSecretParameter,
318
+ })));
316
319
  }
317
320
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
318
321
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
@@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
13
13
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
14
+ import { revalidateSecretReadStatement } from "./revalidate-secret-policy";
14
15
  import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth } from "./auth/resource";
15
16
  import { data } from "./data/resource";
16
17
  import { assignmentAction } from "./functions/assignment-action/resource";
@@ -398,10 +399,14 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
398
399
  backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
399
400
  const revalidateStack = Stack.of(contentActionsLambda);
400
401
  contentActionsLambda.addToRolePolicy(
401
- new PolicyStatement({
402
- actions: ["ssm:GetParameter"],
403
- resources: [`arn:${revalidateStack.partition}:ssm:${revalidateStack.region}:${revalidateStack.account}:parameter${revalidateSecretParameter}`],
404
- }),
402
+ new PolicyStatement(
403
+ revalidateSecretReadStatement({
404
+ partition: revalidateStack.partition,
405
+ region: revalidateStack.region,
406
+ account: revalidateStack.account,
407
+ parameterName: revalidateSecretParameter,
408
+ }),
409
+ ),
405
410
  );
406
411
  }
407
412
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
@@ -3,7 +3,7 @@ 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, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
6
+ import { hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, 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
  }
@@ -25,7 +25,9 @@ function readerCustomHeaders(rules) {
25
25
  *
26
26
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
27
27
  * and `staging` (frontend only, reads the production backend)
28
- * - 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)
29
31
  * - a domain association per branch/app when a domain is configured (without
30
32
  * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
31
33
  * URL is used), IAM service and compute roles
@@ -50,6 +52,12 @@ export class AmplifyAppShellStack extends Stack {
50
52
  readerAppId;
51
53
  constructor(scope, id, config, props) {
52
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
+ }
53
61
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
54
62
  const stagingEnabled = isStagingEnabled(config);
55
63
  const stagingDomainName = resolveStagingDomainName(config);
@@ -192,7 +200,7 @@ export class AmplifyAppShellStack extends Stack {
192
200
  const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
193
201
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
194
202
  if (config.github) {
195
- this.addGithubCiRole(config, config.github, amplifyAppIds);
203
+ this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
196
204
  }
197
205
  }
198
206
  authoringStatements(config, cmsAppId, amplifyAppIds) {
@@ -238,7 +246,7 @@ export class AmplifyAppShellStack extends Stack {
238
246
  description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
239
247
  });
240
248
  }
241
- addGithubCiRole(config, github, amplifyAppIds) {
249
+ addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
242
250
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
243
251
  service: "iam",
244
252
  region: "",
@@ -257,7 +265,7 @@ export class AmplifyAppShellStack extends Stack {
257
265
  },
258
266
  }),
259
267
  });
260
- for (const statement of this.authoringStatements(config, this.cmsAppId, amplifyAppIds)) {
268
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
261
269
  ciRole.addToPolicy(statement);
262
270
  }
263
271
  ciRole.addToPolicy(new iam.PolicyStatement({
@@ -4,7 +4,7 @@ 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, ReaderCustomHeaderRule, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, 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 }));
@@ -28,7 +28,9 @@ function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): strin
28
28
  *
29
29
  * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
30
30
  * and `staging` (frontend only, reads the production backend)
31
- * - 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)
32
34
  * - a domain association per branch/app when a domain is configured (without
33
35
  * one, no AWS::Amplify::Domain is created and the default amplifyapp.com
34
36
  * URL is used), IAM service and compute roles
@@ -49,12 +51,18 @@ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
49
51
  * app by `ampx pipeline-deploy` from the production build.
50
52
  */
51
53
  export class AmplifyAppShellStack extends Stack {
52
- public readonly cmsAppId: string;
54
+ public readonly cmsAppId?: string;
53
55
  public readonly readerAppId?: string;
54
56
 
55
57
  constructor(scope: Construct, id: string, config: AmplifyAppShellSiteConfig, props?: StackProps) {
56
58
  super(scope, id, props);
57
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
+
58
66
  const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
59
67
  const stagingEnabled = isStagingEnabled(config);
60
68
  const stagingDomainName = resolveStagingDomainName(config);
@@ -213,7 +221,7 @@ export class AmplifyAppShellStack extends Stack {
213
221
  this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
214
222
 
215
223
  if (config.github) {
216
- this.addGithubCiRole(config, config.github, amplifyAppIds);
224
+ this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
217
225
  }
218
226
  }
219
227
 
@@ -267,6 +275,7 @@ export class AmplifyAppShellStack extends Stack {
267
275
  private addGithubCiRole(
268
276
  config: AmplifyAppShellSiteConfig,
269
277
  github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
278
+ cmsAppId: string,
270
279
  amplifyAppIds: string[],
271
280
  ): void {
272
281
  const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
@@ -295,7 +304,7 @@ export class AmplifyAppShellStack extends Stack {
295
304
  }),
296
305
  });
297
306
 
298
- for (const statement of this.authoringStatements(config, this.cmsAppId, amplifyAppIds)) {
307
+ for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
299
308
  ciRole.addToPolicy(statement);
300
309
  }
301
310
  ciRole.addToPolicy(
@@ -1,4 +1,4 @@
1
- import { resolveStoragePreviewPrefix } from "./site-config.js";
1
+ import { hasCmsApp, resolveStoragePreviewPrefix } from "./site-config.js";
2
2
  export function papyrusVersionToPep440(version) {
3
3
  const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+\.(\d+))?$/.exec(version);
4
4
  if (!match)
@@ -88,6 +88,8 @@ export function readerBuildSpec(config) {
88
88
  ].join("\n") + "\n";
89
89
  }
90
90
  export function buildSpecFor(config, app) {
91
+ if (app !== "reader" && !hasCmsApp(config))
92
+ throw new Error(`site ${config.siteId} has no cms app`);
91
93
  if (app === "cms-production")
92
94
  return cmsProductionBuildSpec(config);
93
95
  if (app === "cms-staging")
@@ -1,4 +1,4 @@
1
- import { AmplifyAppShellSiteConfig, resolveStoragePreviewPrefix } from "./site-config";
1
+ import { AmplifyAppShellSiteConfig, hasCmsApp, resolveStoragePreviewPrefix } from "./site-config";
2
2
 
3
3
  export type BuildSpecAppName = "cms-production" | "cms-staging" | "reader";
4
4
 
@@ -137,6 +137,7 @@ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
137
137
  }
138
138
 
139
139
  export function buildSpecFor(config: AmplifyAppShellSiteConfig, app: BuildSpecAppName): string {
140
+ if (app !== "reader" && !hasCmsApp(config)) throw new Error(`site ${config.siteId} has no cms app`);
140
141
  if (app === "cms-production") return cmsProductionBuildSpec(config);
141
142
  if (app === "cms-staging") return cmsStagingBuildSpec(config);
142
143
  return readerBuildSpec(config);
@@ -1,4 +1,7 @@
1
1
  export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
2
+ export function hasCmsApp(config) {
3
+ return config.cms !== undefined;
4
+ }
2
5
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
3
6
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
4
7
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -166,21 +169,8 @@ export function resolveStackName(config) {
166
169
  export function resolveStoragePreviewPrefix(config) {
167
170
  return config.storagePreviewPrefix ?? "preview/";
168
171
  }
169
- export function parseSiteConfig(raw) {
170
- const record = requireObject(raw, "site");
171
- rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
172
- const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
173
- const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
174
- const brand = requireString(record.brand, "brand");
175
- const frontend = requireString(record.frontend, "frontend");
176
- if (frontend !== "pretext" && frontend !== "markus-static") {
177
- fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
178
- }
179
- const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
180
- const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
181
- const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
182
- const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
183
- const cmsRecord = requireObject(record.cms, "cms");
172
+ function parseCms(value) {
173
+ const cmsRecord = requireObject(value, "cms");
184
174
  rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
185
175
  const buildComputeType = cmsRecord.buildComputeType;
186
176
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
@@ -256,6 +246,41 @@ export function parseSiteConfig(raw) {
256
246
  fail("cms.redirects", "must not repeat a source");
257
247
  }
258
248
  }
249
+ return cms;
250
+ }
251
+ function validateOauthRedirectUrls(config) {
252
+ const redirectValue = config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
253
+ if (redirectValue === undefined)
254
+ fail("config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
255
+ const redirectUrls = redirectValue.split(",").map((url) => url.trim());
256
+ const stagingDomainName = resolveStagingDomainName(config);
257
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
258
+ const requiredOrigins = [
259
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
260
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
261
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
262
+ ];
263
+ for (const origin of requiredOrigins) {
264
+ if (!listedOrigins.has(origin)) {
265
+ fail("config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
266
+ }
267
+ }
268
+ }
269
+ export function parseSiteConfig(raw) {
270
+ const record = requireObject(raw, "site");
271
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
272
+ const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
273
+ const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
274
+ const brand = requireString(record.brand, "brand");
275
+ const frontend = requireString(record.frontend, "frontend");
276
+ if (frontend !== "pretext" && frontend !== "markus-static") {
277
+ fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
278
+ }
279
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
280
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
281
+ const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
282
+ const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
283
+ const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
259
284
  let reader;
260
285
  if (record.reader !== undefined) {
261
286
  if (frontend !== "markus-static")
@@ -286,6 +311,8 @@ export function parseSiteConfig(raw) {
286
311
  }
287
312
  let github;
288
313
  if (record.github !== undefined) {
314
+ if (cms === undefined)
315
+ fail("github", "requires cms (the CI role is scoped to the CMS app)");
289
316
  const githubRecord = requireObject(record.github, "github");
290
317
  rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
291
318
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
@@ -293,7 +320,7 @@ export function parseSiteConfig(raw) {
293
320
  if (repository !== `https://github.com/${owner}/${repo}`) {
294
321
  fail("github", `owner/repo must match repository ${repository}`);
295
322
  }
296
- let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
323
+ let branches = cms?.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
297
324
  if (githubRecord.branches !== undefined) {
298
325
  if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
299
326
  fail("github.branches", "must be a non-empty array of branch names");
@@ -310,7 +337,7 @@ export function parseSiteConfig(raw) {
310
337
  fail("github.ciCanDeployInfra", "must be a boolean");
311
338
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
312
339
  }
313
- const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
340
+ const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
314
341
  if (hasDomain && hostedZoneId === undefined) {
315
342
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
316
343
  }
@@ -330,21 +357,7 @@ export function parseSiteConfig(raw) {
330
357
  storagePreviewPrefix,
331
358
  github,
332
359
  };
333
- const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
334
- if (redirectValue === undefined)
335
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
336
- const redirectUrls = redirectValue.split(",").map((url) => url.trim());
337
- const stagingDomainName = resolveStagingDomainName(config);
338
- const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
339
- const requiredOrigins = [
340
- ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
341
- ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
342
- originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
343
- ];
344
- for (const origin of requiredOrigins) {
345
- if (!listedOrigins.has(origin)) {
346
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
347
- }
348
- }
360
+ if (hasCmsApp(config))
361
+ validateOauthRedirectUrls(config);
349
362
  return config;
350
363
  }
@@ -18,6 +18,20 @@ export type ReaderCustomHeaderRule = {
18
18
 
19
19
  export const READER_REDIRECT_STATUSES: ReaderRedirectStatus[] = ["301", "302", "200", "404", "404-200"];
20
20
 
21
+ export type CmsConfig = {
22
+ appName?: string;
23
+ domainName?: string;
24
+ domainPrefix?: string;
25
+ staging?: boolean;
26
+ cognitoDomainPrefix: string;
27
+ applyCognitoDomainPrefix?: boolean;
28
+ buildComputeType?: "STANDARD" | "STANDARD_8GB";
29
+ environment: Record<string, string>;
30
+ stagingDomainName?: string;
31
+ stagingDomainPrefix?: string;
32
+ redirects?: CmsRedirect[];
33
+ };
34
+
21
35
  export type AmplifyAppShellSiteConfig = {
22
36
  siteId: string;
23
37
  repository: string;
@@ -25,19 +39,7 @@ export type AmplifyAppShellSiteConfig = {
25
39
  frontend: "pretext" | "markus-static";
26
40
  hostedZoneId?: string;
27
41
  stackName?: string;
28
- cms: {
29
- appName?: string;
30
- domainName?: string;
31
- domainPrefix?: string;
32
- staging?: boolean;
33
- cognitoDomainPrefix: string;
34
- applyCognitoDomainPrefix?: boolean;
35
- buildComputeType?: "STANDARD" | "STANDARD_8GB";
36
- environment: Record<string, string>;
37
- stagingDomainName?: string;
38
- stagingDomainPrefix?: string;
39
- redirects?: CmsRedirect[];
40
- };
42
+ cms?: CmsConfig;
41
43
  reader?: {
42
44
  appName?: string;
43
45
  domainName?: string;
@@ -61,6 +63,12 @@ export type AmplifyAppShellSiteConfig = {
61
63
  };
62
64
  };
63
65
 
66
+ export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
67
+
68
+ export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
69
+ return config.cms !== undefined;
70
+ }
71
+
64
72
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
65
73
 
66
74
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -200,16 +208,16 @@ export function resolveRedirectSubDomainPrefix(rootDomain: string, source: strin
200
208
  return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
201
209
  }
202
210
 
203
- export function isStagingEnabled(config: Pick<AmplifyAppShellSiteConfig, "cms">): boolean {
211
+ export function isStagingEnabled(config: Pick<CmsSiteConfig, "cms">): boolean {
204
212
  return config.cms.staging !== false;
205
213
  }
206
214
 
207
- export function resolveCmsHostName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
215
+ export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
208
216
  if (!config.cms.domainName) return undefined;
209
217
  return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
210
218
  }
211
219
 
212
- export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
220
+ export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
213
221
  if (!isStagingEnabled(config)) return undefined;
214
222
  if (config.cms.domainPrefix) {
215
223
  return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
@@ -228,27 +236,8 @@ export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConf
228
236
  return config.storagePreviewPrefix ?? "preview/";
229
237
  }
230
238
 
231
- export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
232
- const record = requireObject(raw, "site");
233
- rejectUnknownKeys(
234
- record,
235
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
236
- "site",
237
- );
238
-
239
- const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
240
- const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
241
- const brand = requireString(record.brand, "brand");
242
- const frontend = requireString(record.frontend, "frontend");
243
- if (frontend !== "pretext" && frontend !== "markus-static") {
244
- fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
245
- }
246
- const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
247
- const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
248
- const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
249
- const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
250
-
251
- const cmsRecord = requireObject(record.cms, "cms");
239
+ function parseCms(value: unknown): CmsConfig {
240
+ const cmsRecord = requireObject(value, "cms");
252
241
  rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
253
242
  const buildComputeType = cmsRecord.buildComputeType;
254
243
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
@@ -260,7 +249,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
260
249
  if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
261
250
  fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
262
251
  }
263
- const cms: AmplifyAppShellSiteConfig["cms"] = {
252
+ const cms: CmsConfig = {
264
253
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
265
254
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
266
255
  domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
@@ -330,6 +319,49 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
330
319
  }
331
320
  }
332
321
 
322
+ return cms;
323
+ }
324
+
325
+ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
326
+ const redirectValue = config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
327
+ if (redirectValue === undefined) fail("config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
328
+ const redirectUrls = redirectValue.split(",").map((url) => url.trim());
329
+ const stagingDomainName = resolveStagingDomainName(config);
330
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
331
+ const requiredOrigins = [
332
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
333
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
334
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
335
+ ];
336
+ for (const origin of requiredOrigins) {
337
+ if (!listedOrigins.has(origin)) {
338
+ fail("config.cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
339
+ }
340
+ }
341
+ }
342
+
343
+ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
344
+ const record = requireObject(raw, "site");
345
+ rejectUnknownKeys(
346
+ record,
347
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
348
+ "site",
349
+ );
350
+
351
+ const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
352
+ const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
353
+ const brand = requireString(record.brand, "brand");
354
+ const frontend = requireString(record.frontend, "frontend");
355
+ if (frontend !== "pretext" && frontend !== "markus-static") {
356
+ fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
357
+ }
358
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
359
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
360
+ const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
361
+ const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
362
+
363
+ const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
364
+
333
365
  let reader: AmplifyAppShellSiteConfig["reader"];
334
366
  if (record.reader !== undefined) {
335
367
  if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
@@ -358,6 +390,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
358
390
 
359
391
  let github: AmplifyAppShellSiteConfig["github"];
360
392
  if (record.github !== undefined) {
393
+ if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
361
394
  const githubRecord = requireObject(record.github, "github");
362
395
  rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
363
396
  const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
@@ -365,7 +398,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
365
398
  if (repository !== `https://github.com/${owner}/${repo}`) {
366
399
  fail("github", `owner/repo must match repository ${repository}`);
367
400
  }
368
- let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
401
+ let branches = cms?.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
369
402
  if (githubRecord.branches !== undefined) {
370
403
  if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
371
404
  fail("github.branches", "must be a non-empty array of branch names");
@@ -382,7 +415,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
382
415
  github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
383
416
  }
384
417
 
385
- const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
418
+ const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
386
419
  if (hasDomain && hostedZoneId === undefined) {
387
420
  fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
388
421
  }
@@ -404,21 +437,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
404
437
  github,
405
438
  };
406
439
 
407
- const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
408
- if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
409
- const redirectUrls = redirectValue.split(",").map((url) => url.trim());
410
- const stagingDomainName = resolveStagingDomainName(config);
411
- const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
412
- const requiredOrigins = [
413
- ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
414
- ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
415
- originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
416
- ];
417
- for (const origin of requiredOrigins) {
418
- if (!listedOrigins.has(origin)) {
419
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
420
- }
421
- }
440
+ if (hasCmsApp(config)) validateOauthRedirectUrls(config);
422
441
 
423
442
  return config;
424
443
  }
package/package.json CHANGED
@@ -302,7 +302,7 @@
302
302
  "optional": true
303
303
  }
304
304
  },
305
- "version": "1.0.0-next.41",
305
+ "version": "1.0.0-next.43",
306
306
  "dependencies": {
307
307
  "@aws-amplify/adapter-nextjs": "^1.7.3",
308
308
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.41",
2
+ "version": "1.0.0-next.43",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",