@anthusai/papyrus 5.14.0 → 5.15.0

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) {
@@ -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
  }
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"];
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
  }
@@ -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"];
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
  }
@@ -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,
@@ -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"}).`);
package/package.json CHANGED
@@ -406,7 +406,7 @@
406
406
  "optional": true
407
407
  }
408
408
  },
409
- "version": "5.14.0",
409
+ "version": "5.15.0",
410
410
  "dependencies": {
411
411
  "@aws-amplify/adapter-nextjs": "^1.7.3",
412
412
  "@aws-crypto/sha256-js": "5.2.0",
@@ -414,7 +414,6 @@
414
414
  "@aws-sdk/client-dynamodb": "^3.1045.0",
415
415
  "@aws-sdk/client-lambda": "^3.1045.0",
416
416
  "@aws-sdk/client-s3": "^3.1045.0",
417
- "@aws-sdk/client-secrets-manager": "3.1045.0",
418
417
  "@aws-sdk/client-ssm": "^3.1045.0",
419
418
  "@aws-sdk/credential-provider-node": "3.972.84",
420
419
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.14.0",
2
+ "version": "5.15.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",