@anthusai/papyrus 1.0.0-next.19 → 1.0.0-next.20
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/amplify/site-backend-config.ts +1 -1
- package/amplify/site-backend.js +10 -1
- package/amplify/site-backend.ts +14 -1
- package/app/api/revalidate/route.ts +3 -3
- package/infra/amplify-app-shell.js +24 -2
- package/infra/amplify-app-shell.ts +27 -2
- package/infra/build-specs.js +9 -1
- package/infra/build-specs.ts +10 -1
- package/infra/site-config.js +44 -3
- package/infra/site-config.ts +48 -3
- package/lib/revalidate-secret.ts +59 -0
- package/package.json +2 -1
- package/routes.manifest.json +1 -1
|
@@ -30,7 +30,7 @@ export type PapyrusSiteBackendConfig = {
|
|
|
30
30
|
reader?: { amplifyAppId: string; branchName?: string };
|
|
31
31
|
/** Lets the newsroom start the `staging` branch build of this CMS app. */
|
|
32
32
|
stagingBuild?: { enabled: boolean };
|
|
33
|
-
/** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (secret `
|
|
33
|
+
/** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (authenticated by the SSM secret named in branch variable `PAPYRUS_REVALIDATE_SECRET_PARAMETER`). */
|
|
34
34
|
revalidateBaseUrl?: string;
|
|
35
35
|
features?: {
|
|
36
36
|
consoleResponder?: boolean;
|
package/amplify/site-backend.js
CHANGED
|
@@ -336,7 +336,16 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
336
336
|
}
|
|
337
337
|
if ((site.revalidateBaseUrl ?? "").trim() !== "") {
|
|
338
338
|
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
|
|
339
|
-
|
|
339
|
+
const revalidateSecretParameter = (process.env.PAPYRUS_REVALIDATE_SECRET_PARAMETER ?? "").trim();
|
|
340
|
+
if (!revalidateSecretParameter.startsWith("/")) {
|
|
341
|
+
throw new Error("revalidateBaseUrl is set but PAPYRUS_REVALIDATE_SECRET_PARAMETER is not (an SSM parameter name starting with /). The app-shell template sets it on the production branch for Pretext sites; see docs/site-hosting.md.");
|
|
342
|
+
}
|
|
343
|
+
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
|
|
344
|
+
const revalidateStack = Stack.of(contentActionsLambda);
|
|
345
|
+
contentActionsLambda.addToRolePolicy(new PolicyStatement({
|
|
346
|
+
actions: ["ssm:GetParameter"],
|
|
347
|
+
resources: [`arn:${revalidateStack.partition}:ssm:${revalidateStack.region}:${revalidateStack.account}:parameter${revalidateSecretParameter}`],
|
|
348
|
+
}));
|
|
340
349
|
}
|
|
341
350
|
grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
|
|
342
351
|
grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -416,7 +416,20 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
416
416
|
}
|
|
417
417
|
if ((site.revalidateBaseUrl ?? "").trim() !== "") {
|
|
418
418
|
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
|
|
419
|
-
|
|
419
|
+
const revalidateSecretParameter = (process.env.PAPYRUS_REVALIDATE_SECRET_PARAMETER ?? "").trim();
|
|
420
|
+
if (!revalidateSecretParameter.startsWith("/")) {
|
|
421
|
+
throw new Error(
|
|
422
|
+
"revalidateBaseUrl is set but PAPYRUS_REVALIDATE_SECRET_PARAMETER is not (an SSM parameter name starting with /). The app-shell template sets it on the production branch for Pretext sites; see docs/site-hosting.md.",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET_PARAMETER", revalidateSecretParameter);
|
|
426
|
+
const revalidateStack = Stack.of(contentActionsLambda);
|
|
427
|
+
contentActionsLambda.addToRolePolicy(
|
|
428
|
+
new PolicyStatement({
|
|
429
|
+
actions: ["ssm:GetParameter"],
|
|
430
|
+
resources: [`arn:${revalidateStack.partition}:ssm:${revalidateStack.region}:${revalidateStack.account}:parameter${revalidateSecretParameter}`],
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
420
433
|
}
|
|
421
434
|
grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
|
|
422
435
|
grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
editionContentCacheTag,
|
|
10
10
|
editionItemCacheTag,
|
|
11
11
|
} from "../../../lib/cached-content-repository";
|
|
12
|
+
import { isAuthorizedRevalidateRequest } from "../../../lib/revalidate-secret";
|
|
12
13
|
import { getEditionArticlePath, getEditionDatePath } from "../../../lib/edition-routes";
|
|
13
14
|
|
|
14
15
|
export const dynamic = "force-dynamic";
|
|
@@ -22,9 +23,8 @@ type RevalidateRequestBody = {
|
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
export async function POST(request: Request) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
if (!configuredSecret || providedSecret !== configuredSecret) {
|
|
26
|
+
const providedSecret = request.headers.get("x-papyrus-revalidate-secret");
|
|
27
|
+
if (!(await isAuthorizedRevalidateRequest(providedSecret))) {
|
|
28
28
|
return NextResponse.json({ ok: false, error: "Unauthorized." }, { status: 401 });
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -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, resolveCmsHostName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
6
|
+
import { 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
|
}
|
|
@@ -55,6 +55,16 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
55
55
|
iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
|
|
56
56
|
],
|
|
57
57
|
});
|
|
58
|
+
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
59
|
+
if (revalidateSecretParameterName) {
|
|
60
|
+
computeRole.addToPolicy(new iam.PolicyStatement({
|
|
61
|
+
sid: "ReadRevalidateSecret",
|
|
62
|
+
actions: ["ssm:GetParameter"],
|
|
63
|
+
resources: [
|
|
64
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
65
|
+
],
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
58
68
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
59
69
|
name: cmsAppName,
|
|
60
70
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
@@ -63,7 +73,14 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
63
73
|
computeRoleArn: computeRole.roleArn,
|
|
64
74
|
buildSpec: cmsProductionBuildSpec(config),
|
|
65
75
|
jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
|
|
66
|
-
customRules: [
|
|
76
|
+
customRules: [
|
|
77
|
+
...(config.cms.redirects ?? []).map((redirect) => ({
|
|
78
|
+
source: `https://${redirect.source}`,
|
|
79
|
+
target: `https://${resolveCmsHostName(config)}`,
|
|
80
|
+
status: String(redirect.status),
|
|
81
|
+
})),
|
|
82
|
+
{ source: "/<*>", target: "/index.html", status: "404-200" },
|
|
83
|
+
],
|
|
67
84
|
});
|
|
68
85
|
const stagingMode = config.frontend === "markus-static"
|
|
69
86
|
? { PAPYRUS_STAGING_PREVIEW: "static" }
|
|
@@ -96,6 +113,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
96
113
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
97
114
|
SITE_ENV: "production",
|
|
98
115
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
116
|
+
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
99
117
|
}),
|
|
100
118
|
});
|
|
101
119
|
let stagingBranch;
|
|
@@ -133,6 +151,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
133
151
|
const subDomainSettings = sharedRootDomain
|
|
134
152
|
? [
|
|
135
153
|
{ branchName: "main", prefix: config.cms.domainPrefix },
|
|
154
|
+
...(config.cms.redirects ?? []).map((redirect) => ({
|
|
155
|
+
branchName: "main",
|
|
156
|
+
prefix: resolveRedirectSubDomainPrefix(config.cms.domainName, redirect.source),
|
|
157
|
+
})),
|
|
136
158
|
...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix }] : []),
|
|
137
159
|
]
|
|
138
160
|
: [{ branchName: "main", prefix: "" }];
|
|
@@ -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, isStagingEnabled, resolveCmsHostName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
7
|
+
import { AmplifyAppShellSiteConfig, 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 }));
|
|
@@ -63,6 +63,19 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
63
63
|
],
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
|
|
67
|
+
if (revalidateSecretParameterName) {
|
|
68
|
+
computeRole.addToPolicy(
|
|
69
|
+
new iam.PolicyStatement({
|
|
70
|
+
sid: "ReadRevalidateSecret",
|
|
71
|
+
actions: ["ssm:GetParameter"],
|
|
72
|
+
resources: [
|
|
73
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
66
79
|
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
67
80
|
name: cmsAppName,
|
|
68
81
|
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
@@ -71,7 +84,14 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
71
84
|
computeRoleArn: computeRole.roleArn,
|
|
72
85
|
buildSpec: cmsProductionBuildSpec(config),
|
|
73
86
|
jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
|
|
74
|
-
customRules: [
|
|
87
|
+
customRules: [
|
|
88
|
+
...(config.cms.redirects ?? []).map((redirect) => ({
|
|
89
|
+
source: `https://${redirect.source}`,
|
|
90
|
+
target: `https://${resolveCmsHostName(config)}`,
|
|
91
|
+
status: String(redirect.status),
|
|
92
|
+
})),
|
|
93
|
+
{ source: "/<*>", target: "/index.html", status: "404-200" },
|
|
94
|
+
],
|
|
75
95
|
});
|
|
76
96
|
|
|
77
97
|
const stagingMode: Record<string, string> = config.frontend === "markus-static"
|
|
@@ -107,6 +127,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
107
127
|
PAPYRUS_SITE_BRAND: config.brand,
|
|
108
128
|
SITE_ENV: "production",
|
|
109
129
|
PAPYRUS_CONTENT_SOURCE: "published",
|
|
130
|
+
...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
|
|
110
131
|
}),
|
|
111
132
|
});
|
|
112
133
|
|
|
@@ -147,6 +168,10 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
147
168
|
const subDomainSettings = sharedRootDomain
|
|
148
169
|
? [
|
|
149
170
|
{ branchName: "main", prefix: config.cms.domainPrefix as string },
|
|
171
|
+
...(config.cms.redirects ?? []).map((redirect) => ({
|
|
172
|
+
branchName: "main",
|
|
173
|
+
prefix: resolveRedirectSubDomainPrefix(config.cms.domainName as string, redirect.source),
|
|
174
|
+
})),
|
|
150
175
|
...(stagingBranch ? [{ branchName: "staging", prefix: config.cms.stagingDomainPrefix as string }] : []),
|
|
151
176
|
]
|
|
152
177
|
: [{ branchName: "main", prefix: "" }];
|
package/infra/build-specs.js
CHANGED
|
@@ -15,6 +15,10 @@ function commandLines(commands, indent) {
|
|
|
15
15
|
const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
|
|
16
16
|
const FRONTEND_ENVIRONMENT_CAPTURE = "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
|
|
17
17
|
const CHECKOUT_AS_PAPYRUS_ROOT = "PAPYRUS_ROOT=\"$PWD\"";
|
|
18
|
+
const STAGING_RUNTIME_OUTPUTS_COMMANDS = [
|
|
19
|
+
"cp amplify_outputs.json .next/amplify_outputs.json",
|
|
20
|
+
"echo PAPYRUS_AMPLIFY_OUTPUTS=.next/amplify_outputs.json >> .env.production",
|
|
21
|
+
];
|
|
18
22
|
function pythonProvisioningCommands(config) {
|
|
19
23
|
return [
|
|
20
24
|
"export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
|
|
@@ -64,7 +68,11 @@ export function cmsStagingBuildSpec(config) {
|
|
|
64
68
|
}
|
|
65
69
|
return [
|
|
66
70
|
"version: 1",
|
|
67
|
-
...frontendSection(preBuild, [
|
|
71
|
+
...frontendSection(preBuild, [
|
|
72
|
+
FRONTEND_ENVIRONMENT_CAPTURE,
|
|
73
|
+
"npx papyrus-app sync && npm run build",
|
|
74
|
+
...STAGING_RUNTIME_OUTPUTS_COMMANDS,
|
|
75
|
+
], ".next", cachePaths),
|
|
68
76
|
].join("\n") + "\n";
|
|
69
77
|
}
|
|
70
78
|
export function readerBuildSpec(config) {
|
package/infra/build-specs.ts
CHANGED
|
@@ -24,6 +24,11 @@ const FRONTEND_ENVIRONMENT_CAPTURE =
|
|
|
24
24
|
|
|
25
25
|
const CHECKOUT_AS_PAPYRUS_ROOT = "PAPYRUS_ROOT=\"$PWD\"";
|
|
26
26
|
|
|
27
|
+
const STAGING_RUNTIME_OUTPUTS_COMMANDS = [
|
|
28
|
+
"cp amplify_outputs.json .next/amplify_outputs.json",
|
|
29
|
+
"echo PAPYRUS_AMPLIFY_OUTPUTS=.next/amplify_outputs.json >> .env.production",
|
|
30
|
+
];
|
|
31
|
+
|
|
27
32
|
function pythonProvisioningCommands(config: AmplifyAppShellSiteConfig): string[] {
|
|
28
33
|
return [
|
|
29
34
|
"export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
|
|
@@ -102,7 +107,11 @@ export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
|
102
107
|
"version: 1",
|
|
103
108
|
...frontendSection(
|
|
104
109
|
preBuild,
|
|
105
|
-
[
|
|
110
|
+
[
|
|
111
|
+
FRONTEND_ENVIRONMENT_CAPTURE,
|
|
112
|
+
"npx papyrus-app sync && npm run build",
|
|
113
|
+
...STAGING_RUNTIME_OUTPUTS_COMMANDS,
|
|
114
|
+
],
|
|
106
115
|
".next",
|
|
107
116
|
cachePaths,
|
|
108
117
|
),
|
package/infra/site-config.js
CHANGED
|
@@ -12,7 +12,10 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
|
|
|
12
12
|
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
13
13
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
14
14
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
15
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH"];
|
|
15
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
|
|
16
|
+
export function resolveRevalidateSecretParameterName(config) {
|
|
17
|
+
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
18
|
+
}
|
|
16
19
|
const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
|
|
17
20
|
function fail(field, problem) {
|
|
18
21
|
throw new Error(`site.json: ${field} ${problem}`);
|
|
@@ -51,6 +54,25 @@ function rejectUnknownKeys(record, allowed, field) {
|
|
|
51
54
|
fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
function parseRedirects(value) {
|
|
58
|
+
if (value === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
61
|
+
fail("cms.redirects", "must be a non-empty array");
|
|
62
|
+
return value.map((entry, index) => {
|
|
63
|
+
const field = `cms.redirects[${index}]`;
|
|
64
|
+
const record = requireObject(entry, field);
|
|
65
|
+
rejectUnknownKeys(record, ["source", "status"], field);
|
|
66
|
+
const source = requireString(record.source, `${field}.source`, HOST_NAME_PATTERN, "host name without scheme or path");
|
|
67
|
+
const status = record.status ?? 301;
|
|
68
|
+
if (status !== 301 && status !== 302)
|
|
69
|
+
fail(`${field}.status`, `must be 301 or 302, got ${JSON.stringify(status)}`);
|
|
70
|
+
return { source, status };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function resolveRedirectSubDomainPrefix(rootDomain, source) {
|
|
74
|
+
return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
|
|
75
|
+
}
|
|
54
76
|
export function isStagingEnabled(config) {
|
|
55
77
|
return config.cms.staging !== false;
|
|
56
78
|
}
|
|
@@ -93,7 +115,7 @@ export function parseSiteConfig(raw) {
|
|
|
93
115
|
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
94
116
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
95
117
|
const cmsRecord = requireObject(record.cms, "cms");
|
|
96
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix"], "cms");
|
|
118
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
|
|
97
119
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
98
120
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
99
121
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -115,6 +137,7 @@ export function parseSiteConfig(raw) {
|
|
|
115
137
|
environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
|
|
116
138
|
stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
|
|
117
139
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
140
|
+
redirects: parseRedirects(cmsRecord.redirects),
|
|
118
141
|
};
|
|
119
142
|
const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
|
|
120
143
|
if (reservedWord) {
|
|
@@ -122,7 +145,7 @@ export function parseSiteConfig(raw) {
|
|
|
122
145
|
}
|
|
123
146
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
124
147
|
if (key in cms.environment) {
|
|
125
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on
|
|
148
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
|
|
126
149
|
}
|
|
127
150
|
}
|
|
128
151
|
if (cms.staging === false && cms.stagingDomainName) {
|
|
@@ -149,6 +172,24 @@ export function parseSiteConfig(raw) {
|
|
|
149
172
|
if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
|
|
150
173
|
fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
|
|
151
174
|
}
|
|
175
|
+
if (cms.redirects) {
|
|
176
|
+
if (!cms.domainPrefix)
|
|
177
|
+
fail("cms.redirects", "requires cms.domainPrefix (redirects target the primary host under a shared root domain)");
|
|
178
|
+
cms.redirects.forEach((redirect, index) => {
|
|
179
|
+
const field = `cms.redirects[${index}].source`;
|
|
180
|
+
const root = cms.domainName;
|
|
181
|
+
if (redirect.source !== root && !(redirect.source.endsWith(`.${root}`) && DOMAIN_PREFIX_PATTERN.test(redirect.source.slice(0, -root.length - 1)))) {
|
|
182
|
+
fail(field, `must be the root domain ${root} or a single label under it: ${JSON.stringify(redirect.source)}`);
|
|
183
|
+
}
|
|
184
|
+
if (redirect.source === resolveCmsHostName({ cms }))
|
|
185
|
+
fail(field, "must differ from the primary host");
|
|
186
|
+
if (redirect.source === resolveStagingDomainName({ cms }))
|
|
187
|
+
fail(field, "must differ from the staging host");
|
|
188
|
+
});
|
|
189
|
+
if (new Set(cms.redirects.map((redirect) => redirect.source)).size !== cms.redirects.length) {
|
|
190
|
+
fail("cms.redirects", "must not repeat a source");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
152
193
|
let reader;
|
|
153
194
|
if (record.reader !== undefined) {
|
|
154
195
|
if (frontend !== "markus-static")
|
package/infra/site-config.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export type CmsRedirect = {
|
|
2
|
+
source: string;
|
|
3
|
+
status: 301 | 302;
|
|
4
|
+
};
|
|
5
|
+
|
|
1
6
|
export type AmplifyAppShellSiteConfig = {
|
|
2
7
|
siteId: string;
|
|
3
8
|
repository: string;
|
|
@@ -16,6 +21,7 @@ export type AmplifyAppShellSiteConfig = {
|
|
|
16
21
|
environment: Record<string, string>;
|
|
17
22
|
stagingDomainName?: string;
|
|
18
23
|
stagingDomainPrefix?: string;
|
|
24
|
+
redirects?: CmsRedirect[];
|
|
19
25
|
};
|
|
20
26
|
reader?: {
|
|
21
27
|
appName?: string;
|
|
@@ -50,7 +56,11 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
|
|
|
50
56
|
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
51
57
|
const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
52
58
|
const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
|
|
53
|
-
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH"];
|
|
59
|
+
const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
|
|
60
|
+
export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
|
|
61
|
+
return `/papyrus/${config.siteId}/revalidate-secret`;
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
|
|
55
65
|
|
|
56
66
|
function fail(field: string, problem: string): never {
|
|
@@ -94,6 +104,24 @@ function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], f
|
|
|
94
104
|
}
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
function parseRedirects(value: unknown): CmsRedirect[] | undefined {
|
|
108
|
+
if (value === undefined) return undefined;
|
|
109
|
+
if (!Array.isArray(value) || value.length === 0) fail("cms.redirects", "must be a non-empty array");
|
|
110
|
+
return (value as unknown[]).map((entry, index) => {
|
|
111
|
+
const field = `cms.redirects[${index}]`;
|
|
112
|
+
const record = requireObject(entry, field);
|
|
113
|
+
rejectUnknownKeys(record, ["source", "status"], field);
|
|
114
|
+
const source = requireString(record.source, `${field}.source`, HOST_NAME_PATTERN, "host name without scheme or path");
|
|
115
|
+
const status = record.status ?? 301;
|
|
116
|
+
if (status !== 301 && status !== 302) fail(`${field}.status`, `must be 301 or 302, got ${JSON.stringify(status)}`);
|
|
117
|
+
return { source, status };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resolveRedirectSubDomainPrefix(rootDomain: string, source: string): string {
|
|
122
|
+
return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
|
|
123
|
+
}
|
|
124
|
+
|
|
97
125
|
export function isStagingEnabled(config: Pick<AmplifyAppShellSiteConfig, "cms">): boolean {
|
|
98
126
|
return config.cms.staging !== false;
|
|
99
127
|
}
|
|
@@ -143,7 +171,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
143
171
|
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
144
172
|
|
|
145
173
|
const cmsRecord = requireObject(record.cms, "cms");
|
|
146
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix"], "cms");
|
|
174
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
|
|
147
175
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
148
176
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
149
177
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -170,6 +198,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
170
198
|
environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
|
|
171
199
|
stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
|
|
172
200
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
201
|
+
redirects: parseRedirects(cmsRecord.redirects),
|
|
173
202
|
};
|
|
174
203
|
|
|
175
204
|
const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
|
|
@@ -178,7 +207,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
178
207
|
}
|
|
179
208
|
for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
|
|
180
209
|
if (key in cms.environment) {
|
|
181
|
-
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on
|
|
210
|
+
fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
|
|
182
211
|
}
|
|
183
212
|
}
|
|
184
213
|
|
|
@@ -207,6 +236,22 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
|
207
236
|
fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
|
|
208
237
|
}
|
|
209
238
|
|
|
239
|
+
if (cms.redirects) {
|
|
240
|
+
if (!cms.domainPrefix) fail("cms.redirects", "requires cms.domainPrefix (redirects target the primary host under a shared root domain)");
|
|
241
|
+
cms.redirects.forEach((redirect, index) => {
|
|
242
|
+
const field = `cms.redirects[${index}].source`;
|
|
243
|
+
const root = cms.domainName as string;
|
|
244
|
+
if (redirect.source !== root && !(redirect.source.endsWith(`.${root}`) && DOMAIN_PREFIX_PATTERN.test(redirect.source.slice(0, -root.length - 1)))) {
|
|
245
|
+
fail(field, `must be the root domain ${root} or a single label under it: ${JSON.stringify(redirect.source)}`);
|
|
246
|
+
}
|
|
247
|
+
if (redirect.source === resolveCmsHostName({ cms })) fail(field, "must differ from the primary host");
|
|
248
|
+
if (redirect.source === resolveStagingDomainName({ cms })) fail(field, "must differ from the staging host");
|
|
249
|
+
});
|
|
250
|
+
if (new Set(cms.redirects.map((redirect) => redirect.source)).size !== cms.redirects.length) {
|
|
251
|
+
fail("cms.redirects", "must not repeat a source");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
210
255
|
let reader: AmplifyAppShellSiteConfig["reader"];
|
|
211
256
|
if (record.reader !== undefined) {
|
|
212
257
|
if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
|
+
|
|
4
|
+
export const REVALIDATE_SECRET_PARAMETER_ENV = "PAPYRUS_REVALIDATE_SECRET_PARAMETER";
|
|
5
|
+
export const REVALIDATE_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export type RevalidateSecretReader = (parameterName: string) => Promise<string | null>;
|
|
8
|
+
|
|
9
|
+
type CachedSecret = { parameterName: string; value: string; expiresAt: number };
|
|
10
|
+
|
|
11
|
+
let cachedSecret: CachedSecret | null = null;
|
|
12
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
13
|
+
|
|
14
|
+
export function resetRevalidateSecretCache(): void {
|
|
15
|
+
cachedSecret = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readSecretFromSsm(parameterName: string): Promise<string | null> {
|
|
19
|
+
defaultSsmClient ??= new SSMClient({});
|
|
20
|
+
const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
21
|
+
const value = response.Parameter?.Value?.trim();
|
|
22
|
+
return value ? value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadRevalidateSecret(
|
|
26
|
+
options: { environment?: Record<string, string | undefined>; readSecret?: RevalidateSecretReader; now?: number } = {},
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const environment = options.environment ?? process.env;
|
|
29
|
+
const parameterName = environment[REVALIDATE_SECRET_PARAMETER_ENV]?.trim();
|
|
30
|
+
if (!parameterName) return null;
|
|
31
|
+
const now = options.now ?? Date.now();
|
|
32
|
+
if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) {
|
|
33
|
+
return cachedSecret.value;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
|
|
37
|
+
if (!value) return null;
|
|
38
|
+
cachedSecret = { parameterName, value, expiresAt: now + REVALIDATE_SECRET_CACHE_MILLISECONDS };
|
|
39
|
+
return value;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`Revalidate secret parameter could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function secretsMatch(provided: string | null | undefined, expected: string): boolean {
|
|
47
|
+
const providedBytes = Buffer.from((provided ?? "").trim());
|
|
48
|
+
const expectedBytes = Buffer.from(expected);
|
|
49
|
+
return providedBytes.length === expectedBytes.length && timingSafeEqual(providedBytes, expectedBytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function isAuthorizedRevalidateRequest(
|
|
53
|
+
providedSecret: string | null | undefined,
|
|
54
|
+
options: Parameters<typeof loadRevalidateSecret>[0] = {},
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const expected = await loadRevalidateSecret(options);
|
|
57
|
+
if (!expected) return false;
|
|
58
|
+
return secretsMatch(providedSecret, expected);
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -198,6 +198,7 @@
|
|
|
198
198
|
"./lib/renderer-config": "./lib/renderer-config.ts",
|
|
199
199
|
"./lib/renderer": "./lib/renderer.ts",
|
|
200
200
|
"./lib/reporting-story-budget": "./lib/reporting-story-budget.ts",
|
|
201
|
+
"./lib/revalidate-secret": "./lib/revalidate-secret.ts",
|
|
201
202
|
"./lib/root-route-redirect": "./lib/root-route-redirect.ts",
|
|
202
203
|
"./lib/semantic-graph": "./lib/semantic-graph.ts",
|
|
203
204
|
"./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
|
|
@@ -291,7 +292,7 @@
|
|
|
291
292
|
"optional": true
|
|
292
293
|
}
|
|
293
294
|
},
|
|
294
|
-
"version": "1.0.0-next.
|
|
295
|
+
"version": "1.0.0-next.20",
|
|
295
296
|
"dependencies": {
|
|
296
297
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
297
298
|
"@aws-sdk/client-cognito-identity-provider": "3.1045.0",
|
package/routes.manifest.json
CHANGED