@anthusai/papyrus 5.14.0 → 5.15.1

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.
@@ -1,10 +1,10 @@
1
1
  import { ArnFormat, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
2
2
  import * as iam from "aws-cdk-lib/aws-iam";
3
3
  import * as amplify from "aws-cdk-lib/aws-amplify";
4
- import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
4
+ import { SecureStringParameter } from "./secure-string-parameter.js";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
7
- import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
7
+ import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
8
8
  function environmentVariables(variables) {
9
9
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
10
10
  }
@@ -92,33 +92,35 @@ export class AmplifyAppShellStack extends Stack {
92
92
  });
93
93
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
94
94
  if (revalidateSecretParameterName) {
95
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
96
+ parameterName: revalidateSecretParameterName,
97
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
98
+ });
95
99
  computeRole.addToPolicy(new iam.PolicyStatement({
96
100
  sid: "ReadRevalidateSecret",
97
101
  actions: ["ssm:GetParameter"],
98
- resources: [
99
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
100
- ],
102
+ resources: [revalidateSecret.parameterArn],
101
103
  }));
102
104
  }
103
105
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
104
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
105
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
106
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
107
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
108
+ parameterName: resolveProspectusSessionSealingParameterName(config),
106
109
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
107
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
108
110
  })
109
111
  : undefined;
110
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
112
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
111
113
  ? {
112
114
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
113
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
115
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
114
116
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
115
117
  }
116
118
  : {};
117
- if (prospectusSessionSealingSecret) {
119
+ if (prospectusSessionSealing) {
118
120
  computeRole.addToPolicy(new iam.PolicyStatement({
119
121
  sid: "ReadProspectusSessionSealingSecret",
120
- actions: ["secretsmanager:GetSecretValue"],
121
- resources: [prospectusSessionSealingSecret.secretArn],
122
+ actions: ["ssm:GetParameter"],
123
+ resources: [prospectusSessionSealing.parameterArn],
122
124
  }));
123
125
  }
124
126
  if (prospectusAccountsApiStageArn) {
@@ -156,6 +158,9 @@ export class AmplifyAppShellStack extends Stack {
156
158
  const stagingOrigin = stagingDomainName
157
159
  ? `https://${stagingDomainName}/`
158
160
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
161
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
162
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
163
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
159
164
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
160
165
  const defaultOriginsToAllow = [
161
166
  ...(config.cms.domainName ? [] : [productionOrigin]),
@@ -178,6 +183,7 @@ export class AmplifyAppShellStack extends Stack {
178
183
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
179
184
  PAPYRUS_SITE_BRAND: config.brand,
180
185
  SITE_ENV: "production",
186
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
181
187
  PAPYRUS_CONTENT_SOURCE: "published",
182
188
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
183
189
  ...prospectusEnvironment,
@@ -199,6 +205,7 @@ export class AmplifyAppShellStack extends Stack {
199
205
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
200
206
  PAPYRUS_SITE_BRAND: config.brand,
201
207
  SITE_ENV: "staging",
208
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
202
209
  ...stagingMode,
203
210
  ...prospectusEnvironment,
204
211
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
@@ -2,10 +2,10 @@ import { Construct } from "constructs";
2
2
  import { ArnFormat, CfnOutput, Duration, Fn, Stack, StackProps } from "aws-cdk-lib";
3
3
  import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
- import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
5
+ import { SecureStringParameter } from "./secure-string-parameter";
6
6
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
7
7
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
8
- import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
8
+ import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
9
9
 
10
10
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
11
11
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
@@ -100,37 +100,39 @@ export class AmplifyAppShellStack extends Stack {
100
100
 
101
101
  const revalidateSecretParameterName = config.frontend === "pretext" ? resolveRevalidateSecretParameterName(config) : undefined;
102
102
  if (revalidateSecretParameterName) {
103
+ const revalidateSecret = new SecureStringParameter(this, "RevalidateSecretParameter", {
104
+ parameterName: revalidateSecretParameterName,
105
+ description: `Shared secret that authorizes publishes to call the /api/revalidate route of ${cmsAppName}`,
106
+ });
103
107
  computeRole.addToPolicy(
104
108
  new iam.PolicyStatement({
105
109
  sid: "ReadRevalidateSecret",
106
110
  actions: ["ssm:GetParameter"],
107
- resources: [
108
- this.formatArn({ service: "ssm", resource: "parameter", resourceName: revalidateSecretParameterName.slice(1), arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
109
- ],
111
+ resources: [revalidateSecret.parameterArn],
110
112
  }),
111
113
  );
112
114
  }
113
115
 
114
116
  const prospectusAccountsApiStageArn = config.cms.prospectusAccountsApiStageArn;
115
- const prospectusSessionSealingSecret = prospectusAccountsApiStageArn
116
- ? new secretsmanager.Secret(this, "ProspectusSessionSealingSecret", {
117
+ const prospectusSessionSealing = prospectusAccountsApiStageArn
118
+ ? new SecureStringParameter(this, "ProspectusSessionSealingParameter", {
119
+ parameterName: resolveProspectusSessionSealingParameterName(config),
117
120
  description: `Seals the Prospectus sign-in cookies of ${cmsAppName}; replacing it signs everyone out`,
118
- generateSecretString: { passwordLength: 64, excludePunctuation: true },
119
121
  })
120
122
  : undefined;
121
- const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealingSecret
123
+ const prospectusEnvironment = prospectusAccountsApiStageArn && prospectusSessionSealing
122
124
  ? {
123
125
  PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT: resolveProspectusAccountsEndpoint(prospectusAccountsApiStageArn),
124
- PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN: prospectusSessionSealingSecret.secretArn,
126
+ PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER: prospectusSessionSealing.parameterName,
125
127
  PAPYRUS_PROSPECTUS_SITE_PRODUCT: `papyrus-${config.siteId}`,
126
128
  }
127
129
  : {};
128
- if (prospectusSessionSealingSecret) {
130
+ if (prospectusSessionSealing) {
129
131
  computeRole.addToPolicy(
130
132
  new iam.PolicyStatement({
131
133
  sid: "ReadProspectusSessionSealingSecret",
132
- actions: ["secretsmanager:GetSecretValue"],
133
- resources: [prospectusSessionSealingSecret.secretArn],
134
+ actions: ["ssm:GetParameter"],
135
+ resources: [prospectusSessionSealing.parameterArn],
134
136
  }),
135
137
  );
136
138
  }
@@ -175,6 +177,9 @@ export class AmplifyAppShellStack extends Stack {
175
177
  const stagingOrigin = stagingDomainName
176
178
  ? `https://${stagingDomainName}/`
177
179
  : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain, "/"]);
180
+ // The compute adapter's request URL is https://localhost:3000, so the site names its own public origin.
181
+ const productionSiteOrigin = cmsHostName ? `https://${cmsHostName}` : Fn.join("", ["https://main.", cmsApp.attrDefaultDomain]);
182
+ const stagingSiteOrigin = stagingDomainName ? `https://${stagingDomainName}` : Fn.join("", ["https://staging.", cmsApp.attrDefaultDomain]);
178
183
  const sharedRootDomain = config.cms.domainPrefix !== undefined;
179
184
  const defaultOriginsToAllow = [
180
185
  ...(config.cms.domainName ? [] : [productionOrigin]),
@@ -198,6 +203,7 @@ export class AmplifyAppShellStack extends Stack {
198
203
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
199
204
  PAPYRUS_SITE_BRAND: config.brand,
200
205
  SITE_ENV: "production",
206
+ PAPYRUS_SITE_ORIGIN: productionSiteOrigin,
201
207
  PAPYRUS_CONTENT_SOURCE: "published",
202
208
  ...(revalidateSecretParameterName ? { PAPYRUS_REVALIDATE_SECRET_PARAMETER: revalidateSecretParameterName } : {}),
203
209
  ...prospectusEnvironment,
@@ -220,6 +226,7 @@ export class AmplifyAppShellStack extends Stack {
220
226
  PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
221
227
  PAPYRUS_SITE_BRAND: config.brand,
222
228
  SITE_ENV: "staging",
229
+ PAPYRUS_SITE_ORIGIN: stagingSiteOrigin,
223
230
  ...stagingMode,
224
231
  ...prospectusEnvironment,
225
232
  NEXT_PUBLIC_PAPYRUS_STAGING_URL: stagingDomainName
package/infra/index.js CHANGED
@@ -10,6 +10,7 @@ try {
10
10
  );
11
11
  }
12
12
  export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
13
+ export const { SecureStringParameter } = await import("./secure-string-parameter.js");
13
14
  export const { GithubOidcProviderStack } = await import("./github-oidc-provider.js");
14
15
  export { parseSiteConfig } from "./site-config.js";
15
16
  export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Source of the Lambda behind SecureStringParameter, as a string so the staged
3
+ * package (which ships only .ts under infra/) can inline it and tests can run it
4
+ * against a fake SSM client. CommonJS, under the 4096-character inline limit.
5
+ *
6
+ * It creates the parameter when it is missing and does nothing else: it never
7
+ * overwrites an existing value, never reads one back, never logs the value, and
8
+ * returns no data (only the parameter name as the physical id).
9
+ */
10
+ export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
11
+ const { randomInt } = require("node:crypto");
12
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
13
+ function generate(length) {
14
+ let value = "";
15
+ for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
16
+ return value;
17
+ }
18
+ exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
19
+ const name = event.ResourceProperties.ParameterName;
20
+ if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
21
+ try {
22
+ await ssm.send(new PutParameterCommand({
23
+ Name: name,
24
+ Description: event.ResourceProperties.Description,
25
+ Type: "SecureString",
26
+ Tier: "Standard",
27
+ Overwrite: false,
28
+ Value: generate(Number(event.ResourceProperties.Length)),
29
+ }));
30
+ } catch (error) {
31
+ if (!error || error.name !== "ParameterAlreadyExists") {
32
+ throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
33
+ }
34
+ }
35
+ return { PhysicalResourceId: name };
36
+ };
37
+ exports.handler = async (event) => {
38
+ const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
39
+ return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
40
+ };
41
+ `;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Source of the Lambda behind SecureStringParameter, as a string so the staged
3
+ * package (which ships only .ts under infra/) can inline it and tests can run it
4
+ * against a fake SSM client. CommonJS, under the 4096-character inline limit.
5
+ *
6
+ * It creates the parameter when it is missing and does nothing else: it never
7
+ * overwrites an existing value, never reads one back, never logs the value, and
8
+ * returns no data (only the parameter name as the physical id).
9
+ */
10
+ export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
11
+ const { randomInt } = require("node:crypto");
12
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
13
+ function generate(length) {
14
+ let value = "";
15
+ for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
16
+ return value;
17
+ }
18
+ exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
19
+ const name = event.ResourceProperties.ParameterName;
20
+ if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
21
+ try {
22
+ await ssm.send(new PutParameterCommand({
23
+ Name: name,
24
+ Description: event.ResourceProperties.Description,
25
+ Type: "SecureString",
26
+ Tier: "Standard",
27
+ Overwrite: false,
28
+ Value: generate(Number(event.ResourceProperties.Length)),
29
+ }));
30
+ } catch (error) {
31
+ if (!error || error.name !== "ParameterAlreadyExists") {
32
+ throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
33
+ }
34
+ }
35
+ return { PhysicalResourceId: name };
36
+ };
37
+ exports.handler = async (event) => {
38
+ const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
39
+ return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
40
+ };
41
+ `;
@@ -0,0 +1,79 @@
1
+ import { Construct } from "constructs";
2
+ import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import * as customResources from "aws-cdk-lib/custom-resources";
6
+ import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler.js";
7
+ /** One Lambda and role per stack, shared by every SecureStringParameter in it. */
8
+ class SecureStringParameterProvider extends Construct {
9
+ static ID = "SecureStringParameterProvider";
10
+ role;
11
+ provider;
12
+ static of(stack) {
13
+ return stack.node.tryFindChild(SecureStringParameterProvider.ID)
14
+ ?? new SecureStringParameterProvider(stack);
15
+ }
16
+ constructor(stack) {
17
+ super(stack, SecureStringParameterProvider.ID);
18
+ this.role = new iam.Role(this, "Role", {
19
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
20
+ description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
21
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
22
+ });
23
+ const onEvent = new lambda.Function(this, "OnEvent", {
24
+ runtime: lambda.Runtime.NODEJS_22_X,
25
+ handler: "index.handler",
26
+ code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
27
+ role: this.role,
28
+ timeout: Duration.seconds(30),
29
+ description: "Creates a generated SecureString parameter once; never returns or logs the value",
30
+ });
31
+ this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
32
+ }
33
+ }
34
+ /**
35
+ * A generated secret in SSM Parameter Store as a standard SecureString (free,
36
+ * AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
37
+ *
38
+ * CloudFormation cannot create a SecureString, so a custom resource Lambda puts
39
+ * it. The Lambda generates a random value at create time and never returns or
40
+ * logs it, so the value is in no template, output or stack event. It is
41
+ * create-only: an update never regenerates it and never overwrites a parameter
42
+ * that already exists (a hand-made one is adopted as it is), and delete retains
43
+ * it, so a stack teardown cannot sign anyone out. Rotate it with
44
+ * `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
45
+ * `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
46
+ */
47
+ export class SecureStringParameter extends Construct {
48
+ parameterName;
49
+ parameterArn;
50
+ constructor(scope, id, props) {
51
+ super(scope, id);
52
+ if (!props.parameterName.startsWith("/"))
53
+ throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
54
+ const length = props.length ?? 64;
55
+ if (!Number.isInteger(length) || length < 32)
56
+ throw new Error("A generated secret must be at least 32 characters.");
57
+ const stack = Stack.of(this);
58
+ this.parameterName = props.parameterName;
59
+ this.parameterArn = stack.formatArn({
60
+ service: "ssm",
61
+ resource: "parameter",
62
+ resourceName: props.parameterName.slice(1),
63
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
64
+ });
65
+ const shared = SecureStringParameterProvider.of(stack);
66
+ shared.role.addToPolicy(new iam.PolicyStatement({
67
+ sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
68
+ actions: ["ssm:PutParameter"],
69
+ resources: [this.parameterArn],
70
+ }));
71
+ const resource = new CustomResource(this, "Resource", {
72
+ serviceToken: shared.provider.serviceToken,
73
+ resourceType: "Custom::SecureStringParameter",
74
+ properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
75
+ removalPolicy: RemovalPolicy.RETAIN,
76
+ });
77
+ resource.node.addDependency(shared.role);
78
+ }
79
+ }
@@ -0,0 +1,91 @@
1
+ import { Construct } from "constructs";
2
+ import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
3
+ import * as iam from "aws-cdk-lib/aws-iam";
4
+ import * as lambda from "aws-cdk-lib/aws-lambda";
5
+ import * as customResources from "aws-cdk-lib/custom-resources";
6
+ import { SECURE_STRING_PARAMETER_HANDLER_SOURCE } from "./secure-string-parameter-handler";
7
+
8
+ export interface SecureStringParameterProps {
9
+ /** Absolute parameter name, e.g. `/papyrus/<siteId>/prospectus-session-sealing`. */
10
+ parameterName: string;
11
+ description: string;
12
+ /** Length of the generated value. Default 64. */
13
+ length?: number;
14
+ }
15
+
16
+ /** One Lambda and role per stack, shared by every SecureStringParameter in it. */
17
+ class SecureStringParameterProvider extends Construct {
18
+ static readonly ID = "SecureStringParameterProvider";
19
+ public readonly role: iam.Role;
20
+ public readonly provider: customResources.Provider;
21
+
22
+ static of(stack: Stack): SecureStringParameterProvider {
23
+ return (stack.node.tryFindChild(SecureStringParameterProvider.ID) as SecureStringParameterProvider | undefined)
24
+ ?? new SecureStringParameterProvider(stack);
25
+ }
26
+
27
+ private constructor(stack: Stack) {
28
+ super(stack, SecureStringParameterProvider.ID);
29
+ this.role = new iam.Role(this, "Role", {
30
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
31
+ description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
32
+ managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
33
+ });
34
+ const onEvent = new lambda.Function(this, "OnEvent", {
35
+ runtime: lambda.Runtime.NODEJS_22_X,
36
+ handler: "index.handler",
37
+ code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
38
+ role: this.role,
39
+ timeout: Duration.seconds(30),
40
+ description: "Creates a generated SecureString parameter once; never returns or logs the value",
41
+ });
42
+ this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A generated secret in SSM Parameter Store as a standard SecureString (free,
48
+ * AWS-managed `aws/ssm` key), instead of a Secrets Manager secret ($0.40/month).
49
+ *
50
+ * CloudFormation cannot create a SecureString, so a custom resource Lambda puts
51
+ * it. The Lambda generates a random value at create time and never returns or
52
+ * logs it, so the value is in no template, output or stack event. It is
53
+ * create-only: an update never regenerates it and never overwrites a parameter
54
+ * that already exists (a hand-made one is adopted as it is), and delete retains
55
+ * it, so a stack teardown cannot sign anyone out. Rotate it with
56
+ * `aws ssm put-parameter --overwrite`. Readers get `ssm:GetParameter` on
57
+ * `parameterArn` only; decrypting with the AWS-managed key needs no KMS grant.
58
+ */
59
+ export class SecureStringParameter extends Construct {
60
+ public readonly parameterName: string;
61
+ public readonly parameterArn: string;
62
+
63
+ constructor(scope: Construct, id: string, props: SecureStringParameterProps) {
64
+ super(scope, id);
65
+ if (!props.parameterName.startsWith("/")) throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
66
+ const length = props.length ?? 64;
67
+ if (!Number.isInteger(length) || length < 32) throw new Error("A generated secret must be at least 32 characters.");
68
+ const stack = Stack.of(this);
69
+ this.parameterName = props.parameterName;
70
+ this.parameterArn = stack.formatArn({
71
+ service: "ssm",
72
+ resource: "parameter",
73
+ resourceName: props.parameterName.slice(1),
74
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
75
+ });
76
+
77
+ const shared = SecureStringParameterProvider.of(stack);
78
+ shared.role.addToPolicy(new iam.PolicyStatement({
79
+ sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
80
+ actions: ["ssm:PutParameter"],
81
+ resources: [this.parameterArn],
82
+ }));
83
+ const resource = new CustomResource(this, "Resource", {
84
+ serviceToken: shared.provider.serviceToken,
85
+ resourceType: "Custom::SecureStringParameter",
86
+ properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
87
+ removalPolicy: RemovalPolicy.RETAIN,
88
+ });
89
+ resource.node.addDependency(shared.role);
90
+ }
91
+ }
@@ -34,7 +34,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
34
34
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
35
35
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
36
36
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
37
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
37
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
38
38
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
39
39
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
40
40
  "GET/v1/identities/me",
@@ -59,6 +59,9 @@ function originOfRedirectUrl(value) {
59
59
  return value;
60
60
  }
61
61
  }
62
+ export function resolveProspectusSessionSealingParameterName(config) {
63
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
64
+ }
62
65
  export function resolveRevalidateSecretParameterName(config) {
63
66
  return `/papyrus/${config.siteId}/revalidate-secret`;
64
67
  }
@@ -251,7 +254,7 @@ function parseCms(value) {
251
254
  }
252
255
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
253
256
  if (key in cms.environment) {
254
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
257
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
255
258
  }
256
259
  }
257
260
  if (cms.staging === false && cms.stagingDomainName) {
@@ -316,9 +319,20 @@ function validateOauthRedirectUrls(config) {
316
319
  }
317
320
  }
318
321
  }
322
+ function parseAws(value) {
323
+ if (value === undefined)
324
+ return undefined;
325
+ const record = requireObject(value, "aws");
326
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
327
+ return {
328
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
329
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
330
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
331
+ };
332
+ }
319
333
  export function parseSiteConfig(raw) {
320
334
  const record = requireObject(raw, "site");
321
- rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
335
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"], "site");
322
336
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
323
337
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
324
338
  const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
@@ -331,6 +345,7 @@ export function parseSiteConfig(raw) {
331
345
  }
332
346
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
333
347
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
348
+ const aws = parseAws(record.aws);
334
349
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
335
350
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
336
351
  const cms = record.cms === undefined && frontend === "markus-static" ? undefined : parseCms(record.cms);
@@ -425,6 +440,7 @@ export function parseSiteConfig(raw) {
425
440
  frontend,
426
441
  hostedZoneId,
427
442
  stackName,
443
+ aws,
428
444
  cms,
429
445
  reader,
430
446
  papyrusVersion,
@@ -53,6 +53,7 @@ export type AmplifyAppShellSiteConfig = {
53
53
  frontend: "pretext" | "markus-static";
54
54
  hostedZoneId?: string;
55
55
  stackName?: string;
56
+ aws?: SiteAwsConfig;
56
57
  cms?: CmsConfig;
57
58
  reader?: {
58
59
  appName?: string;
@@ -91,6 +92,17 @@ export type AmplifyAppShellSiteConfig = {
91
92
  };
92
93
  };
93
94
 
95
+ /**
96
+ * Where the site's AWS resources live, for operator tooling (`papyrus site google-secrets`). Not read by the
97
+ * stack: the deploy account comes from the CDK environment. `accountId` is what the CLI asserts with STS
98
+ * before it writes anything.
99
+ */
100
+ export type SiteAwsConfig = {
101
+ profile?: string;
102
+ accountId?: string;
103
+ region?: string;
104
+ };
105
+
94
106
  export type CmsSiteConfig = AmplifyAppShellSiteConfig & { cms: CmsConfig };
95
107
 
96
108
  export function hasCmsApp(config: AmplifyAppShellSiteConfig): config is CmsSiteConfig {
@@ -131,7 +143,7 @@ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
131
143
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
132
144
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
133
145
  const PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN = /^arn:aws:execute-api:[a-z0-9-]+:\d{12}:[a-z0-9]+\/api$/;
134
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN", "PAPYRUS_PROSPECTUS_SITE_PRODUCT"];
146
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT", "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER", "PAPYRUS_PROSPECTUS_SITE_PRODUCT", "PAPYRUS_SITE_ORIGIN"];
135
147
 
136
148
  /** Routes Prospectus admits for a registered Papyrus site (Prospectus accounts/papyrus-sites.ts). */
137
149
  export const PROSPECTUS_PAPYRUS_SITE_ROUTES = [
@@ -158,6 +170,10 @@ function originOfRedirectUrl(value: string): string {
158
170
  }
159
171
  }
160
172
 
173
+ export function resolveProspectusSessionSealingParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
174
+ return `/papyrus/${config.siteId}/prospectus-session-sealing`;
175
+ }
176
+
161
177
  export function resolveRevalidateSecretParameterName(config: Pick<AmplifyAppShellSiteConfig, "siteId">): string {
162
178
  return `/papyrus/${config.siteId}/revalidate-secret`;
163
179
  }
@@ -360,7 +376,7 @@ function parseCms(value: unknown): CmsConfig {
360
376
  }
361
377
  for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
362
378
  if (key in cms.environment) {
363
- fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn");
379
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix, the revalidation secret parameter is derived from siteId and the Prospectus endpoint comes from cms.prospectusAccountsApiStageArn and the public site origin comes from the site host");
364
380
  }
365
381
  }
366
382
 
@@ -426,11 +442,22 @@ function validateOauthRedirectUrls(config: CmsSiteConfig): void {
426
442
  }
427
443
  }
428
444
 
445
+ function parseAws(value: unknown): SiteAwsConfig | undefined {
446
+ if (value === undefined) return undefined;
447
+ const record = requireObject(value, "aws");
448
+ rejectUnknownKeys(record, ["profile", "accountId", "region"], "aws");
449
+ return {
450
+ profile: optionalString(record.profile, "aws.profile", /^[A-Za-z0-9_.-]+$/, "AWS CLI profile name"),
451
+ accountId: optionalString(record.accountId, "aws.accountId", /^\d{12}$/, "12-digit account id"),
452
+ region: optionalString(record.region, "aws.region", /^[a-z]{2}(-[a-z]+)+-\d$/, "AWS region such as us-east-1"),
453
+ };
454
+ }
455
+
429
456
  export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
430
457
  const record = requireObject(raw, "site");
431
458
  rejectUnknownKeys(
432
459
  record,
433
- ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
460
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "aws", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "autoDeploy", "github"],
434
461
  "site",
435
462
  );
436
463
 
@@ -446,6 +473,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
446
473
  }
447
474
  const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
448
475
  const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
476
+ const aws = parseAws(record.aws);
449
477
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
450
478
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
451
479
 
@@ -537,6 +565,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
537
565
  frontend,
538
566
  hostedZoneId,
539
567
  stackName,
568
+ aws,
540
569
  cms,
541
570
  reader,
542
571
  papyrusVersion,
@@ -8,6 +8,7 @@ import {
8
8
  type ProspectusSignInRouteOptions,
9
9
  } from "./prospectus-sign-in-routes";
10
10
  import { resolveProspectusSignInRuntime } from "./prospectus-session";
11
+ import { SiteOriginNotConfiguredError, publicSiteOrigin } from "./site-origin";
11
12
 
12
13
  /**
13
14
  * Same-origin curation routes for the References tab. Each is an authenticated
@@ -270,7 +271,8 @@ export async function handleCurationRoute(request: NextRequest, route: CurationR
270
271
  if (!base) return jsonResponse({ error: "curation_not_configured", message: "Curation is not configured for this site." }, 503);
271
272
 
272
273
  const origin = request.headers.get("origin");
273
- if (route !== "context" ? origin !== request.nextUrl.origin : origin !== null && origin !== request.nextUrl.origin) {
274
+ const siteOrigin = publicSiteOrigin(request, options.environment);
275
+ if (route !== "context" ? origin !== siteOrigin : origin !== null && origin !== siteOrigin) {
274
276
  throw refusal(403, "cross_origin", "Curation requests must come from this site.");
275
277
  }
276
278
  let text: string | null = null;
@@ -279,7 +281,7 @@ export async function handleCurationRoute(request: NextRequest, route: CurationR
279
281
  text = await boundedText(request);
280
282
  }
281
283
 
282
- const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
284
+ const runtime = await (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOrigin);
283
285
  if (!runtime) return jsonResponse({ error: "prospectus_sign_in_unavailable", message: "Sign-in through Prospectus is not configured for this site." }, 503);
284
286
  const nowInSeconds = options.nowInSeconds?.();
285
287
  const cookieSession = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
@@ -336,6 +338,7 @@ export async function handleCurationRoute(request: NextRequest, route: CurationR
336
338
  });
337
339
  } catch (error) {
338
340
  if (error instanceof CurationRefusal) return jsonResponse({ error: error.message, code: error.code }, error.status);
341
+ if (error instanceof SiteOriginNotConfiguredError) return jsonResponse({ error: error.message, code: error.code }, error.status);
339
342
  console.error(`Curation route failed (${error instanceof Error ? error.name : "error"}).`);
340
343
  return jsonResponse({ error: "Curation is unavailable. Check saved history before retrying.", code: "curation_unavailable" }, 503);
341
344
  }
@@ -1,5 +1,5 @@
1
1
  import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
2
- import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
2
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
3
3
  import { readAmplifyOutputsFile } from "./amplify-outputs-path";
4
4
  import {
5
5
  ProspectusSiteAccounts,
@@ -21,7 +21,7 @@ import {
21
21
  export const PROSPECTUS_PENDING_SIGN_IN_COOKIE = "__Host-papyrus-prospectus-pending";
22
22
  export const PROSPECTUS_SESSION_COOKIE = "__Host-papyrus-prospectus-session";
23
23
  export const PROSPECTUS_ACCOUNTS_ENDPOINT_ENV = "PAPYRUS_PROSPECTUS_ACCOUNTS_ENDPOINT";
24
- export const PROSPECTUS_SESSION_SECRET_ARN_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_ARN";
24
+ export const PROSPECTUS_SESSION_SECRET_PARAMETER_ENV = "PAPYRUS_PROSPECTUS_SESSION_SECRET_PARAMETER";
25
25
  export const PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
26
26
 
27
27
  const SEALED_COOKIE_FORMAT_VERSION = "v1";
@@ -101,37 +101,37 @@ export function sameSiteReturnPath(requestedReturnPath: string | null | undefine
101
101
  return `${parsed.pathname}${parsed.search}${parsed.hash}`;
102
102
  }
103
103
 
104
- export type ProspectusSessionSecretReader = (secretArn: string) => Promise<string | null>;
104
+ export type ProspectusSessionSecretReader = (parameterName: string) => Promise<string | null>;
105
105
 
106
- type CachedSealingSecret = { secretArn: string; value: string; expiresAt: number };
106
+ type CachedSealingSecret = { parameterName: string; value: string; expiresAt: number };
107
107
 
108
108
  let cachedSealingSecret: CachedSealingSecret | null = null;
109
- let defaultSecretsManagerClient: SecretsManagerClient | null = null;
109
+ let defaultSsmClient: SSMClient | null = null;
110
110
 
111
111
  export function resetProspectusSessionSecretCache(): void {
112
112
  cachedSealingSecret = null;
113
113
  }
114
114
 
115
- async function readSealingSecretFromSecretsManager(secretArn: string): Promise<string | null> {
116
- defaultSecretsManagerClient ??= new SecretsManagerClient({});
117
- const response = await defaultSecretsManagerClient.send(new GetSecretValueCommand({ SecretId: secretArn }));
118
- return response.SecretString?.trim() || null;
115
+ export async function readSealingSecretFromSsm(parameterName: string, client?: Pick<SSMClient, "send">): Promise<string | null> {
116
+ defaultSsmClient ??= new SSMClient({});
117
+ const response = await (client ?? defaultSsmClient).send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
118
+ return response.Parameter?.Value?.trim() || null;
119
119
  }
120
120
 
121
121
  export async function loadProspectusSessionSealingSecret(
122
122
  options: { environment?: Record<string, string | undefined>; readSecret?: ProspectusSessionSecretReader; now?: number } = {},
123
123
  ): Promise<string | null> {
124
124
  const environment = options.environment ?? process.env;
125
- const secretArn = environment[PROSPECTUS_SESSION_SECRET_ARN_ENV]?.trim();
126
- if (!secretArn) return null;
125
+ const parameterName = environment[PROSPECTUS_SESSION_SECRET_PARAMETER_ENV]?.trim();
126
+ if (!parameterName) return null;
127
127
  const now = options.now ?? Date.now();
128
- if (cachedSealingSecret && cachedSealingSecret.secretArn === secretArn && cachedSealingSecret.expiresAt > now) {
128
+ if (cachedSealingSecret && cachedSealingSecret.parameterName === parameterName && cachedSealingSecret.expiresAt > now) {
129
129
  return cachedSealingSecret.value;
130
130
  }
131
131
  try {
132
- const value = (await (options.readSecret ?? readSealingSecretFromSecretsManager)(secretArn))?.trim();
132
+ const value = (await (options.readSecret ?? readSealingSecretFromSsm)(parameterName))?.trim();
133
133
  if (!value || value.length < MINIMUM_SEALING_SECRET_LENGTH) return null;
134
- cachedSealingSecret = { secretArn, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
134
+ cachedSealingSecret = { parameterName, value, expiresAt: now + PROSPECTUS_SESSION_SECRET_CACHE_MILLISECONDS };
135
135
  return value;
136
136
  } catch (error) {
137
137
  console.error(`Prospectus session sealing secret could not be read (${error instanceof Error ? error.name : "error"}).`);
@@ -25,6 +25,7 @@ import {
25
25
  publishingAccountsForSite,
26
26
  type PublicationCapability,
27
27
  } from "./prospectus-publication-access";
28
+ import { SiteOriginNotConfiguredError, publicSiteOrigin, siteOriginNotConfiguredResponse } from "./site-origin";
28
29
 
29
30
  /**
30
31
  * The site's sign-in routes for Prospectus accounts: start (challenge, then the
@@ -38,6 +39,8 @@ export interface ProspectusSignInRouteOptions {
38
39
  siteProduct?: string | null;
39
40
  completionOverrides?: Pick<CompleteProspectusGoogleSignInInput, "verifyCognitoTokens" | "fetcher">;
40
41
  nowInSeconds?: () => number;
42
+ /** Trusted configuration the public site origin is read from; defaults to process.env. */
43
+ environment?: Record<string, string | undefined>;
41
44
  }
42
45
 
43
46
  const NO_STORE_HEADERS = { "cache-control": "no-store" };
@@ -50,8 +53,22 @@ function currentTimeInSeconds(options: ProspectusSignInRouteOptions): number {
50
53
  return options.nowInSeconds?.() ?? Math.floor(Date.now() / 1000);
51
54
  }
52
55
 
56
+ function siteOriginFor(request: NextRequest, options: ProspectusSignInRouteOptions): string {
57
+ return publicSiteOrigin(request, options.environment);
58
+ }
59
+
53
60
  async function signInRuntimeFor(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<ProspectusSignInRuntime | null> {
54
- return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(request.nextUrl.origin);
61
+ return (options.resolveRuntime ?? resolveProspectusSignInRuntime)(siteOriginFor(request, options));
62
+ }
63
+
64
+ /** Runs a route and answers a missing site origin with 503 site_origin_not_configured, never a localhost redirect. */
65
+ async function failingClosedWithoutSiteOrigin(route: () => Promise<NextResponse>): Promise<NextResponse> {
66
+ try {
67
+ return await route();
68
+ } catch (error) {
69
+ if (error instanceof SiteOriginNotConfiguredError) return siteOriginNotConfiguredResponse(error);
70
+ throw error;
71
+ }
55
72
  }
56
73
 
57
74
  function signInUnavailableResponse(): NextResponse {
@@ -82,7 +99,11 @@ export function signedInProspectusSession(
82
99
  );
83
100
  }
84
101
 
85
- export async function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
102
+ export function handleProspectusSignInStart(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
103
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignInStartRoute(request, options));
104
+ }
105
+
106
+ async function handleProspectusSignInStartRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
86
107
  const runtime = await signInRuntimeFor(request, options);
87
108
  if (!runtime) return signInUnavailableResponse();
88
109
  const { authorizationUrl, pendingSignIn } = await beginProspectusGoogleSignIn(runtime.accounts, runtime.cognitoClient);
@@ -99,7 +120,11 @@ export async function handleProspectusSignInStart(request: NextRequest, options:
99
120
  return response;
100
121
  }
101
122
 
102
- export async function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
123
+ export function handleProspectusSignInCallback(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
124
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignInCallbackRoute(request, options));
125
+ }
126
+
127
+ async function handleProspectusSignInCallbackRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
103
128
  const runtime = await signInRuntimeFor(request, options);
104
129
  if (!runtime) return signInUnavailableResponse();
105
130
  const nowInSeconds = currentTimeInSeconds(options);
@@ -142,7 +167,7 @@ async function completeCallback(
142
167
  accessToken: signedIn.accessToken,
143
168
  accessTokenExpiresAt: signedIn.accessTokenExpiresAt,
144
169
  };
145
- const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, request.nextUrl.origin), { status: 303, headers: NO_STORE_HEADERS });
170
+ const response = NextResponse.redirect(new URL(pendingSignIn.returnPath, siteOriginFor(request, options)), { status: 303, headers: NO_STORE_HEADERS });
146
171
  response.cookies.set(
147
172
  PROSPECTUS_SESSION_COOKIE,
148
173
  sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, session, session.accessTokenExpiresAt),
@@ -156,7 +181,11 @@ async function completeCallback(
156
181
  }
157
182
  }
158
183
 
159
- export async function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
184
+ export function handleProspectusSessionRead(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
185
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSessionReadRoute(request, options));
186
+ }
187
+
188
+ async function handleProspectusSessionReadRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
160
189
  const runtime = await signInRuntimeFor(request, options);
161
190
  if (!runtime) return signInUnavailableResponse();
162
191
  const session = signedInProspectusSession(request, runtime.sealingSecret, currentTimeInSeconds(options));
@@ -196,9 +225,13 @@ export async function handleProspectusSessionRead(request: NextRequest, options:
196
225
  }
197
226
  }
198
227
 
199
- export async function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
228
+ export function handleProspectusSignOut(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
229
+ return failingClosedWithoutSiteOrigin(() => handleProspectusSignOutRoute(request, options));
230
+ }
231
+
232
+ async function handleProspectusSignOutRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
200
233
  const runtime = await signInRuntimeFor(request, options);
201
- const signedOutDestination = new URL("/", request.nextUrl.origin);
234
+ const signedOutDestination = new URL("/", siteOriginFor(request, options));
202
235
  const destination = runtime
203
236
  ? new URL(
204
237
  `/logout?${new URLSearchParams({ client_id: runtime.cognitoClient.clientId, logout_uri: signedOutDestination.toString() })}`,
@@ -250,10 +283,14 @@ export async function currentProspectusPublishingSelection(
250
283
  }
251
284
  }
252
285
 
253
- export async function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
286
+ export function handleProspectusAccountSelection(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
287
+ return failingClosedWithoutSiteOrigin(() => handleProspectusAccountSelectionRoute(request, options));
288
+ }
289
+
290
+ async function handleProspectusAccountSelectionRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
254
291
  const runtime = await signInRuntimeFor(request, options);
255
292
  if (!runtime) return signInUnavailableResponse();
256
- if (request.headers.get("origin") !== request.nextUrl.origin) {
293
+ if (request.headers.get("origin") !== siteOriginFor(request, options)) {
257
294
  return signInFailedResponse(403, "cross_origin_selection", "Choose an account from this site.");
258
295
  }
259
296
  const nowInSeconds = currentTimeInSeconds(options);
@@ -287,10 +324,14 @@ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
324
  const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
325
  const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
326
 
290
- export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
327
+ export function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
328
+ return failingClosedWithoutSiteOrigin(() => handleProspectusPublishingAccountCreationRoute(request, options));
329
+ }
330
+
331
+ async function handleProspectusPublishingAccountCreationRoute(request: NextRequest, options: ProspectusSignInRouteOptions): Promise<NextResponse> {
291
332
  const runtime = await signInRuntimeFor(request, options);
292
333
  if (!runtime) return signInUnavailableResponse();
293
- if (request.headers.get("origin") !== request.nextUrl.origin) {
334
+ if (request.headers.get("origin") !== siteOriginFor(request, options)) {
294
335
  return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
336
  }
296
337
  const nowInSeconds = currentTimeInSeconds(options);
@@ -0,0 +1,67 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import { getSiteEnv } from "./site-env";
3
+
4
+ type EnvironmentVariables = Record<string, string | undefined>;
5
+
6
+ export const SITE_ORIGIN_ENV = "PAPYRUS_SITE_ORIGIN";
7
+
8
+ /**
9
+ * The site's public origin cannot be read from the request on Amplify SSR (WEB_COMPUTE):
10
+ * there `request.nextUrl.origin` is the compute adapter's internal https://localhost:3000.
11
+ * It also must never be derived from Host or X-Forwarded-* headers, which a client can set.
12
+ * The one trusted source is PAPYRUS_SITE_ORIGIN, set by the app shell from the site's host.
13
+ */
14
+ export class SiteOriginNotConfiguredError extends Error {
15
+ readonly status = 503;
16
+ readonly code = "site_origin_not_configured";
17
+ }
18
+
19
+ export function siteOriginNotConfiguredResponse(error: SiteOriginNotConfiguredError): NextResponse {
20
+ return NextResponse.json({ error: error.code, message: error.message }, { status: error.status, headers: { "cache-control": "no-store" } });
21
+ }
22
+
23
+ function isLoopbackHostname(hostname: string): boolean {
24
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
25
+ }
26
+
27
+ function configuredOrigin(value: string): string {
28
+ const invalid = () => new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} must be an https origin such as https://example.com with no path.`);
29
+ let parsed: URL;
30
+ try {
31
+ parsed = new URL(value);
32
+ } catch {
33
+ throw invalid();
34
+ }
35
+ if (parsed.protocol !== "https:" || parsed.origin !== value) throw invalid();
36
+ return parsed.origin;
37
+ }
38
+
39
+ function siteEnvOrNull(environment: EnvironmentVariables): string | null {
40
+ try {
41
+ return getSiteEnv(environment);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The site's public origin, from trusted configuration.
49
+ * - PAPYRUS_SITE_ORIGIN set: it must be an https origin with no path; it is used as is.
50
+ * - Unset: `request.nextUrl.origin` only when it is loopback, SITE_ENV resolves to development
51
+ * and NODE_ENV is not production (a local `next dev` server). Anything else, production and staging included, throws
52
+ * SiteOriginNotConfiguredError so routes answer 503 instead of redirecting to localhost.
53
+ */
54
+ export function publicSiteOrigin(request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): string {
55
+ const configured = (environment[SITE_ORIGIN_ENV] ?? "").trim();
56
+ if (configured) return configuredOrigin(configured);
57
+ const { hostname, origin } = request.nextUrl;
58
+ // SITE_ENV defaults to development when a runtime never received it, so NODE_ENV=production also closes the fallback.
59
+ const developing = siteEnvOrNull(environment) === "development" && environment.NODE_ENV !== "production";
60
+ if (isLoopbackHostname(hostname) && developing) return origin;
61
+ throw new SiteOriginNotConfiguredError(`${SITE_ORIGIN_ENV} is not set; this site cannot tell its public origin.`);
62
+ }
63
+
64
+ /** An absolute URL on the site's public origin, for redirects. Fails closed like publicSiteOrigin. */
65
+ export function publicSiteUrl(path: string, request: Pick<NextRequest, "nextUrl">, environment: EnvironmentVariables = process.env): URL {
66
+ return new URL(path, publicSiteOrigin(request, environment));
67
+ }
package/middleware.ts CHANGED
@@ -5,6 +5,7 @@ import { newsroomHref } from "./lib/newsroom-base-path";
5
5
  import { getReaderBasePath } from "./lib/reader-base-path";
6
6
  import { isStagingGatedPath, isStaticOrApiPath } from "./lib/staging-gated-path";
7
7
  import { getSiteEnv, isIndexable } from "./lib/site-env";
8
+ import { SiteOriginNotConfiguredError, publicSiteUrl, siteOriginNotConfiguredResponse } from "./lib/site-origin";
8
9
  import { getStagingAccess } from "./lib/staging-gate";
9
10
  import {
10
11
  isPreviewGatedPath,
@@ -38,11 +39,11 @@ function routeRequest(request: NextRequest): NextResponse {
38
39
  }
39
40
 
40
41
  if (pathname === "/newsroom" || pathname === "/newsroom/") {
41
- return NextResponse.redirect(new URL(`/${request.nextUrl.search}`, request.url));
42
+ return NextResponse.redirect(publicSiteUrl(`/${request.nextUrl.search}`, request));
42
43
  }
43
44
  if (pathname.startsWith("/newsroom/")) {
44
45
  const target = pathname.slice("/newsroom".length) || "/";
45
- return NextResponse.redirect(new URL(`${target}${request.nextUrl.search}`, request.url));
46
+ return NextResponse.redirect(publicSiteUrl(`${target}${request.nextUrl.search}`, request));
46
47
  }
47
48
 
48
49
  if (pathname === "/" || pathname === "") {
@@ -59,6 +60,16 @@ function routeRequest(request: NextRequest): NextResponse {
59
60
  }
60
61
 
61
62
  export async function middleware(request: NextRequest): Promise<NextResponse> {
63
+ try {
64
+ return await handleRequest(request);
65
+ } catch (error) {
66
+ // Redirects name the site's public origin (request.url is localhost on Amplify); without it, fail closed.
67
+ if (error instanceof SiteOriginNotConfiguredError) return withRobotsHeader(siteOriginNotConfiguredResponse(error));
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async function handleRequest(request: NextRequest): Promise<NextResponse> {
62
73
  let sessionResponse: NextResponse | null = null;
63
74
  const { pathname } = request.nextUrl;
64
75
  const gated = isStaticPreviewEnabled()
@@ -68,7 +79,7 @@ export async function middleware(request: NextRequest): Promise<NextResponse> {
68
79
  sessionResponse = NextResponse.next();
69
80
  const access = await getStagingAccess(request, sessionResponse);
70
81
  if (access === "anonymous") {
71
- return withRobotsHeader(NextResponse.redirect(new URL(newsroomHref(), request.url)));
82
+ return withRobotsHeader(NextResponse.redirect(publicSiteUrl(newsroomHref(), request)));
72
83
  }
73
84
  if (access === "forbidden") {
74
85
  return withRobotsHeader(new NextResponse("Staging is limited to editors and admins.", { status: 403 }));
package/package.json CHANGED
@@ -296,6 +296,7 @@
296
296
  "./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
297
297
  "./lib/site-brand": "./lib/site-brand.ts",
298
298
  "./lib/site-env": "./lib/site-env.ts",
299
+ "./lib/site-origin": "./lib/site-origin.ts",
299
300
  "./lib/site-renderer": "./lib/site-renderer.ts",
300
301
  "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
301
302
  "./lib/site-stack": "./lib/site-stack.ts",
@@ -406,7 +407,7 @@
406
407
  "optional": true
407
408
  }
408
409
  },
409
- "version": "5.14.0",
410
+ "version": "5.15.1",
410
411
  "dependencies": {
411
412
  "@aws-amplify/adapter-nextjs": "^1.7.3",
412
413
  "@aws-crypto/sha256-js": "5.2.0",
@@ -414,7 +415,6 @@
414
415
  "@aws-sdk/client-dynamodb": "^3.1045.0",
415
416
  "@aws-sdk/client-lambda": "^3.1045.0",
416
417
  "@aws-sdk/client-s3": "^3.1045.0",
417
- "@aws-sdk/client-secrets-manager": "3.1045.0",
418
418
  "@aws-sdk/client-ssm": "^3.1045.0",
419
419
  "@aws-sdk/credential-provider-node": "3.972.84",
420
420
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.14.0",
2
+ "version": "5.15.1",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",