@anthusai/papyrus 1.0.0-next.40 → 1.0.0-next.42
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.
- package/infra/amplify-app-shell.js +31 -7
- package/infra/amplify-app-shell.ts +32 -7
- package/infra/build-specs.js +5 -2
- package/infra/build-specs.ts +4 -2
- package/infra/site-config.js +115 -34
- package/infra/site-config.ts +154 -56
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -3,17 +3,31 @@ 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
|
}
|
|
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
|
|
@@ -38,6 +52,12 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
38
52
|
readerAppId;
|
|
39
53
|
constructor(scope, id, config, props) {
|
|
40
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
|
+
}
|
|
41
61
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
42
62
|
const stagingEnabled = isStagingEnabled(config);
|
|
43
63
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
@@ -180,7 +200,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
180
200
|
const amplifyAppIds = [cmsApp.attrAppId, ...(this.readerAppId ? [this.readerAppId] : [])];
|
|
181
201
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
182
202
|
if (config.github) {
|
|
183
|
-
this.addGithubCiRole(config, config.github, amplifyAppIds);
|
|
203
|
+
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
206
|
authoringStatements(config, cmsAppId, amplifyAppIds) {
|
|
@@ -226,7 +246,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
226
246
|
description: `Role the Papyrus CLI assumes for ${config.siteId} (profile role_arn)`,
|
|
227
247
|
});
|
|
228
248
|
}
|
|
229
|
-
addGithubCiRole(config, github, amplifyAppIds) {
|
|
249
|
+
addGithubCiRole(config, github, cmsAppId, amplifyAppIds) {
|
|
230
250
|
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, "GithubOidcProvider", this.formatArn({
|
|
231
251
|
service: "iam",
|
|
232
252
|
region: "",
|
|
@@ -245,7 +265,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
245
265
|
},
|
|
246
266
|
}),
|
|
247
267
|
});
|
|
248
|
-
for (const statement of this.authoringStatements(config,
|
|
268
|
+
for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
|
|
249
269
|
ciRole.addToPolicy(statement);
|
|
250
270
|
}
|
|
251
271
|
ciRole.addToPolicy(new iam.PolicyStatement({
|
|
@@ -282,7 +302,11 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
282
302
|
platform: "WEB",
|
|
283
303
|
iamServiceRole: readerServiceRole.roleArn,
|
|
284
304
|
buildSpec: readerBuildSpec(config),
|
|
285
|
-
customRules: [
|
|
305
|
+
customRules: [
|
|
306
|
+
...(reader.redirects ?? []),
|
|
307
|
+
{ source: "/<*>", target: "/404.html", status: "404-200" },
|
|
308
|
+
],
|
|
309
|
+
customHeaders: readerCustomHeaders(reader.customHeaders),
|
|
286
310
|
});
|
|
287
311
|
const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
|
|
288
312
|
appId: readerApp.attrAppId,
|
|
@@ -299,7 +323,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
299
323
|
const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
|
|
300
324
|
appId: readerApp.attrAppId,
|
|
301
325
|
domainName: reader.domainName,
|
|
302
|
-
subDomainSettings: [{ branchName: readerBranchName, prefix
|
|
326
|
+
subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
|
|
303
327
|
});
|
|
304
328
|
readerDomain.addResourceDependency(readerBranch);
|
|
305
329
|
}
|
|
@@ -4,19 +4,33 @@ 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, 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 }));
|
|
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
|
|
@@ -37,12 +51,18 @@ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
|
|
|
37
51
|
* app by `ampx pipeline-deploy` from the production build.
|
|
38
52
|
*/
|
|
39
53
|
export class AmplifyAppShellStack extends Stack {
|
|
40
|
-
public readonly cmsAppId
|
|
54
|
+
public readonly cmsAppId?: string;
|
|
41
55
|
public readonly readerAppId?: string;
|
|
42
56
|
|
|
43
57
|
constructor(scope: Construct, id: string, config: AmplifyAppShellSiteConfig, props?: StackProps) {
|
|
44
58
|
super(scope, id, props);
|
|
45
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
|
+
|
|
46
66
|
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
47
67
|
const stagingEnabled = isStagingEnabled(config);
|
|
48
68
|
const stagingDomainName = resolveStagingDomainName(config);
|
|
@@ -201,7 +221,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
201
221
|
this.addAuthoringRole(config, cmsApp.attrAppId, amplifyAppIds);
|
|
202
222
|
|
|
203
223
|
if (config.github) {
|
|
204
|
-
this.addGithubCiRole(config, config.github, amplifyAppIds);
|
|
224
|
+
this.addGithubCiRole(config, config.github, cmsApp.attrAppId, amplifyAppIds);
|
|
205
225
|
}
|
|
206
226
|
}
|
|
207
227
|
|
|
@@ -255,6 +275,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
255
275
|
private addGithubCiRole(
|
|
256
276
|
config: AmplifyAppShellSiteConfig,
|
|
257
277
|
github: NonNullable<AmplifyAppShellSiteConfig["github"]>,
|
|
278
|
+
cmsAppId: string,
|
|
258
279
|
amplifyAppIds: string[],
|
|
259
280
|
): void {
|
|
260
281
|
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
@@ -283,7 +304,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
283
304
|
}),
|
|
284
305
|
});
|
|
285
306
|
|
|
286
|
-
for (const statement of this.authoringStatements(config,
|
|
307
|
+
for (const statement of this.authoringStatements(config, cmsAppId, amplifyAppIds)) {
|
|
287
308
|
ciRole.addToPolicy(statement);
|
|
288
309
|
}
|
|
289
310
|
ciRole.addToPolicy(
|
|
@@ -333,7 +354,11 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
333
354
|
platform: "WEB",
|
|
334
355
|
iamServiceRole: readerServiceRole.roleArn,
|
|
335
356
|
buildSpec: readerBuildSpec(config),
|
|
336
|
-
customRules: [
|
|
357
|
+
customRules: [
|
|
358
|
+
...(reader.redirects ?? []),
|
|
359
|
+
{ source: "/<*>", target: "/404.html", status: "404-200" },
|
|
360
|
+
],
|
|
361
|
+
customHeaders: readerCustomHeaders(reader.customHeaders),
|
|
337
362
|
});
|
|
338
363
|
|
|
339
364
|
const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
|
|
@@ -352,7 +377,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
352
377
|
const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
|
|
353
378
|
appId: readerApp.attrAppId,
|
|
354
379
|
domainName: reader.domainName,
|
|
355
|
-
subDomainSettings: [{ branchName: readerBranchName, prefix
|
|
380
|
+
subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
|
|
356
381
|
});
|
|
357
382
|
readerDomain.addResourceDependency(readerBranch);
|
|
358
383
|
}
|
package/infra/build-specs.js
CHANGED
|
@@ -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)
|
|
@@ -82,11 +82,14 @@ export function readerBuildSpec(config) {
|
|
|
82
82
|
"version: 1",
|
|
83
83
|
...frontendSection([
|
|
84
84
|
...pythonProvisioningCommands(config),
|
|
85
|
-
|
|
85
|
+
...(readerConfig.preBuildCommands ?? []),
|
|
86
|
+
...(readerConfig.skipContentExport ? [] : ["papyrus ops content export-published --auth guest --out content-export --clean"]),
|
|
86
87
|
], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
|
|
87
88
|
].join("\n") + "\n";
|
|
88
89
|
}
|
|
89
90
|
export function buildSpecFor(config, app) {
|
|
91
|
+
if (app !== "reader" && !hasCmsApp(config))
|
|
92
|
+
throw new Error(`site ${config.siteId} has no cms app`);
|
|
90
93
|
if (app === "cms-production")
|
|
91
94
|
return cmsProductionBuildSpec(config);
|
|
92
95
|
if (app === "cms-staging")
|
package/infra/build-specs.ts
CHANGED
|
@@ -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
|
|
|
@@ -126,7 +126,8 @@ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
|
126
126
|
...frontendSection(
|
|
127
127
|
[
|
|
128
128
|
...pythonProvisioningCommands(config),
|
|
129
|
-
|
|
129
|
+
...(readerConfig.preBuildCommands ?? []),
|
|
130
|
+
...(readerConfig.skipContentExport ? [] : ["papyrus ops content export-published --auth guest --out content-export --clean"]),
|
|
130
131
|
],
|
|
131
132
|
[readerConfig.buildCommand],
|
|
132
133
|
readerConfig.baseDirectory,
|
|
@@ -136,6 +137,7 @@ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
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`);
|
|
139
141
|
if (app === "cms-production") return cmsProductionBuildSpec(config);
|
|
140
142
|
if (app === "cms-staging") return cmsStagingBuildSpec(config);
|
|
141
143
|
return readerBuildSpec(config);
|
package/infra/site-config.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
|
|
2
|
+
export function hasCmsApp(config) {
|
|
3
|
+
return config.cms !== undefined;
|
|
4
|
+
}
|
|
1
5
|
export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
2
6
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
3
7
|
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
@@ -78,6 +82,63 @@ function parseRedirects(value) {
|
|
|
78
82
|
return { source, status };
|
|
79
83
|
});
|
|
80
84
|
}
|
|
85
|
+
function parseReaderDomainPrefixes(value) {
|
|
86
|
+
if (value === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
89
|
+
fail("reader.domainPrefixes", "must be a non-empty array");
|
|
90
|
+
const prefixes = value.map((entry, index) => {
|
|
91
|
+
const field = `reader.domainPrefixes[${index}]`;
|
|
92
|
+
if (entry === "")
|
|
93
|
+
return "";
|
|
94
|
+
return requireString(entry, field, DOMAIN_PREFIX_PATTERN, 'single DNS label, or "" for the apex');
|
|
95
|
+
});
|
|
96
|
+
if (new Set(prefixes).size !== prefixes.length)
|
|
97
|
+
fail("reader.domainPrefixes", "must not repeat a prefix");
|
|
98
|
+
return prefixes;
|
|
99
|
+
}
|
|
100
|
+
function parseReaderRedirects(value) {
|
|
101
|
+
if (value === undefined)
|
|
102
|
+
return undefined;
|
|
103
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
104
|
+
fail("reader.redirects", "must be a non-empty array");
|
|
105
|
+
return value.map((entry, index) => {
|
|
106
|
+
const field = `reader.redirects[${index}]`;
|
|
107
|
+
const record = requireObject(entry, field);
|
|
108
|
+
rejectUnknownKeys(record, ["source", "target", "status"], field);
|
|
109
|
+
const status = record.status ?? "301";
|
|
110
|
+
if (typeof status !== "string" || !READER_REDIRECT_STATUSES.includes(status)) {
|
|
111
|
+
fail(`${field}.status`, `must be one of ${READER_REDIRECT_STATUSES.join(", ")}, got ${JSON.stringify(status)}`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
source: requireString(record.source, `${field}.source`),
|
|
115
|
+
target: requireString(record.target, `${field}.target`),
|
|
116
|
+
status: status,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function parseReaderCustomHeaders(value) {
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
124
|
+
fail("reader.customHeaders", "must be a non-empty array");
|
|
125
|
+
return value.map((entry, index) => {
|
|
126
|
+
const field = `reader.customHeaders[${index}]`;
|
|
127
|
+
const record = requireObject(entry, field);
|
|
128
|
+
rejectUnknownKeys(record, ["pattern", "headers"], field);
|
|
129
|
+
const headers = requireStringRecord(record.headers, `${field}.headers`);
|
|
130
|
+
if (Object.keys(headers).length === 0)
|
|
131
|
+
fail(`${field}.headers`, "must not be empty");
|
|
132
|
+
return { pattern: requireString(record.pattern, `${field}.pattern`), headers };
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function parseReaderPreBuildCommands(value) {
|
|
136
|
+
if (value === undefined)
|
|
137
|
+
return undefined;
|
|
138
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
139
|
+
fail("reader.preBuildCommands", "must be a non-empty array of commands");
|
|
140
|
+
return value.map((command, index) => requireString(command, `reader.preBuildCommands[${index}]`));
|
|
141
|
+
}
|
|
81
142
|
export function resolveRedirectSubDomainPrefix(rootDomain, source) {
|
|
82
143
|
return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
|
|
83
144
|
}
|
|
@@ -108,21 +169,8 @@ export function resolveStackName(config) {
|
|
|
108
169
|
export function resolveStoragePreviewPrefix(config) {
|
|
109
170
|
return config.storagePreviewPrefix ?? "preview/";
|
|
110
171
|
}
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
|
|
114
|
-
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
115
|
-
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
116
|
-
const brand = requireString(record.brand, "brand");
|
|
117
|
-
const frontend = requireString(record.frontend, "frontend");
|
|
118
|
-
if (frontend !== "pretext" && frontend !== "markus-static") {
|
|
119
|
-
fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
|
|
120
|
-
}
|
|
121
|
-
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
122
|
-
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
123
|
-
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
124
|
-
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
125
|
-
const cmsRecord = requireObject(record.cms, "cms");
|
|
172
|
+
function parseCms(value) {
|
|
173
|
+
const cmsRecord = requireObject(value, "cms");
|
|
126
174
|
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
|
|
127
175
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
128
176
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
@@ -198,12 +246,50 @@ export function parseSiteConfig(raw) {
|
|
|
198
246
|
fail("cms.redirects", "must not repeat a source");
|
|
199
247
|
}
|
|
200
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);
|
|
201
284
|
let reader;
|
|
202
285
|
if (record.reader !== undefined) {
|
|
203
286
|
if (frontend !== "markus-static")
|
|
204
287
|
fail("reader", `is only allowed when frontend is "markus-static"`);
|
|
205
288
|
const readerRecord = requireObject(record.reader, "reader");
|
|
206
|
-
rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
|
|
289
|
+
rejectUnknownKeys(readerRecord, ["appName", "domainName", "domainPrefixes", "branchName", "buildCommand", "baseDirectory", "environment", "redirects", "customHeaders", "preBuildCommands", "skipContentExport"], "reader");
|
|
290
|
+
if (readerRecord.skipContentExport !== undefined && typeof readerRecord.skipContentExport !== "boolean") {
|
|
291
|
+
fail("reader.skipContentExport", `must be a boolean, got ${JSON.stringify(readerRecord.skipContentExport)}`);
|
|
292
|
+
}
|
|
207
293
|
reader = {
|
|
208
294
|
appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
|
|
209
295
|
domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
|
|
@@ -211,13 +297,22 @@ export function parseSiteConfig(raw) {
|
|
|
211
297
|
buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
|
|
212
298
|
baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
|
|
213
299
|
environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
|
|
300
|
+
domainPrefixes: parseReaderDomainPrefixes(readerRecord.domainPrefixes),
|
|
301
|
+
redirects: parseReaderRedirects(readerRecord.redirects),
|
|
302
|
+
customHeaders: parseReaderCustomHeaders(readerRecord.customHeaders),
|
|
303
|
+
preBuildCommands: parseReaderPreBuildCommands(readerRecord.preBuildCommands),
|
|
304
|
+
skipContentExport: readerRecord.skipContentExport,
|
|
214
305
|
};
|
|
306
|
+
if (reader.domainPrefixes && !reader.domainName)
|
|
307
|
+
fail("reader.domainPrefixes", "requires reader.domainName");
|
|
215
308
|
}
|
|
216
309
|
else if (frontend === "markus-static") {
|
|
217
310
|
fail("reader", `is required when frontend is "markus-static"`);
|
|
218
311
|
}
|
|
219
312
|
let github;
|
|
220
313
|
if (record.github !== undefined) {
|
|
314
|
+
if (cms === undefined)
|
|
315
|
+
fail("github", "requires cms (the CI role is scoped to the CMS app)");
|
|
221
316
|
const githubRecord = requireObject(record.github, "github");
|
|
222
317
|
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
|
|
223
318
|
const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
|
|
@@ -225,7 +320,7 @@ export function parseSiteConfig(raw) {
|
|
|
225
320
|
if (repository !== `https://github.com/${owner}/${repo}`) {
|
|
226
321
|
fail("github", `owner/repo must match repository ${repository}`);
|
|
227
322
|
}
|
|
228
|
-
let branches = cms
|
|
323
|
+
let branches = cms?.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
|
|
229
324
|
if (githubRecord.branches !== undefined) {
|
|
230
325
|
if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
|
|
231
326
|
fail("github.branches", "must be a non-empty array of branch names");
|
|
@@ -242,7 +337,7 @@ export function parseSiteConfig(raw) {
|
|
|
242
337
|
fail("github.ciCanDeployInfra", "must be a boolean");
|
|
243
338
|
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
|
|
244
339
|
}
|
|
245
|
-
const hasDomain = cms
|
|
340
|
+
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
246
341
|
if (hasDomain && hostedZoneId === undefined) {
|
|
247
342
|
fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
|
|
248
343
|
}
|
|
@@ -262,21 +357,7 @@ export function parseSiteConfig(raw) {
|
|
|
262
357
|
storagePreviewPrefix,
|
|
263
358
|
github,
|
|
264
359
|
};
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
|
|
268
|
-
const redirectUrls = redirectValue.split(",").map((url) => url.trim());
|
|
269
|
-
const stagingDomainName = resolveStagingDomainName(config);
|
|
270
|
-
const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
|
|
271
|
-
const requiredOrigins = [
|
|
272
|
-
...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
|
|
273
|
-
...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
|
|
274
|
-
originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
|
|
275
|
-
];
|
|
276
|
-
for (const origin of requiredOrigins) {
|
|
277
|
-
if (!listedOrigins.has(origin)) {
|
|
278
|
-
fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
360
|
+
if (hasCmsApp(config))
|
|
361
|
+
validateOauthRedirectUrls(config);
|
|
281
362
|
return config;
|
|
282
363
|
}
|
package/infra/site-config.ts
CHANGED
|
@@ -3,6 +3,35 @@ export type CmsRedirect = {
|
|
|
3
3
|
status: 301 | 302;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
+
export type ReaderRedirectStatus = "301" | "302" | "200" | "404" | "404-200";
|
|
7
|
+
|
|
8
|
+
export type ReaderRedirect = {
|
|
9
|
+
source: string;
|
|
10
|
+
target: string;
|
|
11
|
+
status: ReaderRedirectStatus;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ReaderCustomHeaderRule = {
|
|
15
|
+
pattern: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const READER_REDIRECT_STATUSES: ReaderRedirectStatus[] = ["301", "302", "200", "404", "404-200"];
|
|
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
|
+
|
|
6
35
|
export type AmplifyAppShellSiteConfig = {
|
|
7
36
|
siteId: string;
|
|
8
37
|
repository: string;
|
|
@@ -10,26 +39,19 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
10
39
|
frontend: "pretext" | "markus-static";
|
|
11
40
|
hostedZoneId?: string;
|
|
12
41
|
stackName?: string;
|
|
13
|
-
cms
|
|
14
|
-
appName?: string;
|
|
15
|
-
domainName?: string;
|
|
16
|
-
domainPrefix?: string;
|
|
17
|
-
staging?: boolean;
|
|
18
|
-
cognitoDomainPrefix: string;
|
|
19
|
-
applyCognitoDomainPrefix?: boolean;
|
|
20
|
-
buildComputeType?: "STANDARD" | "STANDARD_8GB";
|
|
21
|
-
environment: Record<string, string>;
|
|
22
|
-
stagingDomainName?: string;
|
|
23
|
-
stagingDomainPrefix?: string;
|
|
24
|
-
redirects?: CmsRedirect[];
|
|
25
|
-
};
|
|
42
|
+
cms?: CmsConfig;
|
|
26
43
|
reader?: {
|
|
27
44
|
appName?: string;
|
|
28
45
|
domainName?: string;
|
|
46
|
+
domainPrefixes?: string[];
|
|
29
47
|
branchName?: string;
|
|
30
48
|
buildCommand: string;
|
|
31
49
|
baseDirectory: string;
|
|
32
50
|
environment?: Record<string, string>;
|
|
51
|
+
redirects?: ReaderRedirect[];
|
|
52
|
+
customHeaders?: ReaderCustomHeaderRule[];
|
|
53
|
+
preBuildCommands?: string[];
|
|
54
|
+
skipContentExport?: boolean;
|
|
33
55
|
};
|
|
34
56
|
papyrusVersion: string;
|
|
35
57
|
storagePreviewPrefix?: string;
|
|
@@ -41,6 +63,12 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
41
63
|
};
|
|
42
64
|
};
|
|
43
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
|
+
|
|
44
72
|
export const DEFAULT_CI_BRANCHES = ["main", "staging"];
|
|
45
73
|
|
|
46
74
|
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
@@ -126,20 +154,70 @@ function parseRedirects(value: unknown): CmsRedirect[] | undefined {
|
|
|
126
154
|
});
|
|
127
155
|
}
|
|
128
156
|
|
|
157
|
+
function parseReaderDomainPrefixes(value: unknown): string[] | undefined {
|
|
158
|
+
if (value === undefined) return undefined;
|
|
159
|
+
if (!Array.isArray(value) || value.length === 0) fail("reader.domainPrefixes", "must be a non-empty array");
|
|
160
|
+
const prefixes = (value as unknown[]).map((entry, index) => {
|
|
161
|
+
const field = `reader.domainPrefixes[${index}]`;
|
|
162
|
+
if (entry === "") return "";
|
|
163
|
+
return requireString(entry, field, DOMAIN_PREFIX_PATTERN, 'single DNS label, or "" for the apex');
|
|
164
|
+
});
|
|
165
|
+
if (new Set(prefixes).size !== prefixes.length) fail("reader.domainPrefixes", "must not repeat a prefix");
|
|
166
|
+
return prefixes;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseReaderRedirects(value: unknown): ReaderRedirect[] | undefined {
|
|
170
|
+
if (value === undefined) return undefined;
|
|
171
|
+
if (!Array.isArray(value) || value.length === 0) fail("reader.redirects", "must be a non-empty array");
|
|
172
|
+
return (value as unknown[]).map((entry, index) => {
|
|
173
|
+
const field = `reader.redirects[${index}]`;
|
|
174
|
+
const record = requireObject(entry, field);
|
|
175
|
+
rejectUnknownKeys(record, ["source", "target", "status"], field);
|
|
176
|
+
const status = record.status ?? "301";
|
|
177
|
+
if (typeof status !== "string" || !READER_REDIRECT_STATUSES.includes(status as ReaderRedirectStatus)) {
|
|
178
|
+
fail(`${field}.status`, `must be one of ${READER_REDIRECT_STATUSES.join(", ")}, got ${JSON.stringify(status)}`);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
source: requireString(record.source, `${field}.source`),
|
|
182
|
+
target: requireString(record.target, `${field}.target`),
|
|
183
|
+
status: status as ReaderRedirectStatus,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseReaderCustomHeaders(value: unknown): ReaderCustomHeaderRule[] | undefined {
|
|
189
|
+
if (value === undefined) return undefined;
|
|
190
|
+
if (!Array.isArray(value) || value.length === 0) fail("reader.customHeaders", "must be a non-empty array");
|
|
191
|
+
return (value as unknown[]).map((entry, index) => {
|
|
192
|
+
const field = `reader.customHeaders[${index}]`;
|
|
193
|
+
const record = requireObject(entry, field);
|
|
194
|
+
rejectUnknownKeys(record, ["pattern", "headers"], field);
|
|
195
|
+
const headers = requireStringRecord(record.headers, `${field}.headers`);
|
|
196
|
+
if (Object.keys(headers).length === 0) fail(`${field}.headers`, "must not be empty");
|
|
197
|
+
return { pattern: requireString(record.pattern, `${field}.pattern`), headers };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseReaderPreBuildCommands(value: unknown): string[] | undefined {
|
|
202
|
+
if (value === undefined) return undefined;
|
|
203
|
+
if (!Array.isArray(value) || value.length === 0) fail("reader.preBuildCommands", "must be a non-empty array of commands");
|
|
204
|
+
return (value as unknown[]).map((command, index) => requireString(command, `reader.preBuildCommands[${index}]`));
|
|
205
|
+
}
|
|
206
|
+
|
|
129
207
|
export function resolveRedirectSubDomainPrefix(rootDomain: string, source: string): string {
|
|
130
208
|
return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
|
|
131
209
|
}
|
|
132
210
|
|
|
133
|
-
export function isStagingEnabled(config: Pick<
|
|
211
|
+
export function isStagingEnabled(config: Pick<CmsSiteConfig, "cms">): boolean {
|
|
134
212
|
return config.cms.staging !== false;
|
|
135
213
|
}
|
|
136
214
|
|
|
137
|
-
export function resolveCmsHostName(config: Pick<
|
|
215
|
+
export function resolveCmsHostName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
|
|
138
216
|
if (!config.cms.domainName) return undefined;
|
|
139
217
|
return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
|
|
140
218
|
}
|
|
141
219
|
|
|
142
|
-
export function resolveStagingDomainName(config: Pick<
|
|
220
|
+
export function resolveStagingDomainName(config: Pick<CmsSiteConfig, "cms">): string | undefined {
|
|
143
221
|
if (!isStagingEnabled(config)) return undefined;
|
|
144
222
|
if (config.cms.domainPrefix) {
|
|
145
223
|
return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
|
|
@@ -158,27 +236,8 @@ export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConf
|
|
|
158
236
|
return config.storagePreviewPrefix ?? "preview/";
|
|
159
237
|
}
|
|
160
238
|
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
rejectUnknownKeys(
|
|
164
|
-
record,
|
|
165
|
-
["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
|
|
166
|
-
"site",
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
170
|
-
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
171
|
-
const brand = requireString(record.brand, "brand");
|
|
172
|
-
const frontend = requireString(record.frontend, "frontend");
|
|
173
|
-
if (frontend !== "pretext" && frontend !== "markus-static") {
|
|
174
|
-
fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
|
|
175
|
-
}
|
|
176
|
-
const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
177
|
-
const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
|
|
178
|
-
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
179
|
-
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
180
|
-
|
|
181
|
-
const cmsRecord = requireObject(record.cms, "cms");
|
|
239
|
+
function parseCms(value: unknown): CmsConfig {
|
|
240
|
+
const cmsRecord = requireObject(value, "cms");
|
|
182
241
|
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
|
|
183
242
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
184
243
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
@@ -190,7 +249,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
190
249
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
191
250
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
192
251
|
}
|
|
193
|
-
const cms:
|
|
252
|
+
const cms: CmsConfig = {
|
|
194
253
|
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
195
254
|
domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
196
255
|
domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
@@ -260,11 +319,57 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
260
319
|
}
|
|
261
320
|
}
|
|
262
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
|
+
|
|
263
365
|
let reader: AmplifyAppShellSiteConfig["reader"];
|
|
264
366
|
if (record.reader !== undefined) {
|
|
265
367
|
if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
|
|
266
368
|
const readerRecord = requireObject(record.reader, "reader");
|
|
267
|
-
rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
|
|
369
|
+
rejectUnknownKeys(readerRecord, ["appName", "domainName", "domainPrefixes", "branchName", "buildCommand", "baseDirectory", "environment", "redirects", "customHeaders", "preBuildCommands", "skipContentExport"], "reader");
|
|
370
|
+
if (readerRecord.skipContentExport !== undefined && typeof readerRecord.skipContentExport !== "boolean") {
|
|
371
|
+
fail("reader.skipContentExport", `must be a boolean, got ${JSON.stringify(readerRecord.skipContentExport)}`);
|
|
372
|
+
}
|
|
268
373
|
reader = {
|
|
269
374
|
appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
|
|
270
375
|
domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
|
|
@@ -272,13 +377,20 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
272
377
|
buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
|
|
273
378
|
baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
|
|
274
379
|
environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
|
|
380
|
+
domainPrefixes: parseReaderDomainPrefixes(readerRecord.domainPrefixes),
|
|
381
|
+
redirects: parseReaderRedirects(readerRecord.redirects),
|
|
382
|
+
customHeaders: parseReaderCustomHeaders(readerRecord.customHeaders),
|
|
383
|
+
preBuildCommands: parseReaderPreBuildCommands(readerRecord.preBuildCommands),
|
|
384
|
+
skipContentExport: readerRecord.skipContentExport as boolean | undefined,
|
|
275
385
|
};
|
|
386
|
+
if (reader.domainPrefixes && !reader.domainName) fail("reader.domainPrefixes", "requires reader.domainName");
|
|
276
387
|
} else if (frontend === "markus-static") {
|
|
277
388
|
fail("reader", `is required when frontend is "markus-static"`);
|
|
278
389
|
}
|
|
279
390
|
|
|
280
391
|
let github: AmplifyAppShellSiteConfig["github"];
|
|
281
392
|
if (record.github !== undefined) {
|
|
393
|
+
if (cms === undefined) fail("github", "requires cms (the CI role is scoped to the CMS app)");
|
|
282
394
|
const githubRecord = requireObject(record.github, "github");
|
|
283
395
|
rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
|
|
284
396
|
const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
|
|
@@ -286,7 +398,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
286
398
|
if (repository !== `https://github.com/${owner}/${repo}`) {
|
|
287
399
|
fail("github", `owner/repo must match repository ${repository}`);
|
|
288
400
|
}
|
|
289
|
-
let branches = cms
|
|
401
|
+
let branches = cms?.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
|
|
290
402
|
if (githubRecord.branches !== undefined) {
|
|
291
403
|
if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
|
|
292
404
|
fail("github.branches", "must be a non-empty array of branch names");
|
|
@@ -303,7 +415,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
303
415
|
github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
|
|
304
416
|
}
|
|
305
417
|
|
|
306
|
-
const hasDomain = cms
|
|
418
|
+
const hasDomain = cms?.domainName !== undefined || reader?.domainName !== undefined;
|
|
307
419
|
if (hasDomain && hostedZoneId === undefined) {
|
|
308
420
|
fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
|
|
309
421
|
}
|
|
@@ -325,21 +437,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
325
437
|
github,
|
|
326
438
|
};
|
|
327
439
|
|
|
328
|
-
|
|
329
|
-
if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
|
|
330
|
-
const redirectUrls = redirectValue.split(",").map((url) => url.trim());
|
|
331
|
-
const stagingDomainName = resolveStagingDomainName(config);
|
|
332
|
-
const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
|
|
333
|
-
const requiredOrigins = [
|
|
334
|
-
...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
|
|
335
|
-
...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
|
|
336
|
-
originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
|
|
337
|
-
];
|
|
338
|
-
for (const origin of requiredOrigins) {
|
|
339
|
-
if (!listedOrigins.has(origin)) {
|
|
340
|
-
fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
440
|
+
if (hasCmsApp(config)) validateOauthRedirectUrls(config);
|
|
343
441
|
|
|
344
442
|
return config;
|
|
345
443
|
}
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED