@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.
@@ -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 `PAPYRUS_REVALIDATE_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;
@@ -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
- backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET", secret("PAPYRUS_REVALIDATE_SECRET"));
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);
@@ -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
- backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET", secret("PAPYRUS_REVALIDATE_SECRET"));
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 configuredSecret = process.env.PAPYRUS_REVALIDATE_SECRET?.trim();
26
- const providedSecret = request.headers.get("x-papyrus-revalidate-secret")?.trim();
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: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
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: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
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: "" }];
@@ -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, [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", cachePaths),
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) {
@@ -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
- [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
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
  ),
@@ -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 and the Cognito domain comes from cms.cognitoDomainPrefix");
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")
@@ -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 and the Cognito domain comes from cms.cognitoDomainPrefix");
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.19",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.19",
2
+ "version": "1.0.0-next.20",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",