@anthusai/papyrus 1.0.0-next.30 → 1.0.0-next.32

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.
@@ -0,0 +1,86 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as route53 from "aws-cdk-lib/aws-route53";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sesActions from "aws-cdk-lib/aws-ses-actions";
5
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
6
+ /**
7
+ * Adds the SES receipt resources directly to `scope` (no wrapper construct) so
8
+ * the logical ids of the live p.apyr.us stack stay unchanged.
9
+ */
10
+ export function addInboundEmailSesIntake(scope, props) {
11
+ if (props.domainIdentity) {
12
+ const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
13
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
14
+ hostedZoneId,
15
+ zoneName: hostedZoneName,
16
+ });
17
+ const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
18
+ onCreate: {
19
+ service: "SES",
20
+ action: "verifyDomainIdentity",
21
+ parameters: { Domain: domain },
22
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
23
+ },
24
+ onUpdate: {
25
+ service: "SES",
26
+ action: "verifyDomainIdentity",
27
+ parameters: { Domain: domain },
28
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
29
+ },
30
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
31
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
32
+ }),
33
+ timeout: Duration.minutes(2),
34
+ });
35
+ new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
36
+ zone,
37
+ recordName: `_amazonses.${recordName}`,
38
+ ttl: Duration.minutes(5),
39
+ values: [verifyDomain.getResponseField("VerificationToken")],
40
+ });
41
+ }
42
+ const ruleSet = props.existingRuleSetName
43
+ ? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
44
+ : new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
45
+ receiptRuleSetName: props.ruleSetName,
46
+ });
47
+ ruleSet.addRule("PapyrusInboundSubmissions", {
48
+ receiptRuleName: props.ruleName,
49
+ recipients: props.recipients,
50
+ enabled: true,
51
+ scanEnabled: true,
52
+ tlsPolicy: ses.TlsPolicy.REQUIRE,
53
+ actions: [
54
+ new sesActions.S3({
55
+ bucket: props.storageBucket,
56
+ objectKeyPrefix: "inbound-email/",
57
+ }),
58
+ ],
59
+ });
60
+ if (props.activateRuleSet && !props.existingRuleSetName) {
61
+ new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
62
+ onCreate: {
63
+ service: "SES",
64
+ action: "setActiveReceiptRuleSet",
65
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
66
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
67
+ },
68
+ onUpdate: {
69
+ service: "SES",
70
+ action: "setActiveReceiptRuleSet",
71
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
72
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
73
+ },
74
+ onDelete: {
75
+ service: "SES",
76
+ action: "setActiveReceiptRuleSet",
77
+ parameters: { RuleSetName: null },
78
+ },
79
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
80
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
81
+ }),
82
+ timeout: Duration.minutes(2),
83
+ });
84
+ }
85
+ return { ruleSet };
86
+ }
@@ -0,0 +1,110 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as route53 from "aws-cdk-lib/aws-route53";
3
+ import * as ses from "aws-cdk-lib/aws-ses";
4
+ import * as sesActions from "aws-cdk-lib/aws-ses-actions";
5
+ import type * as s3 from "aws-cdk-lib/aws-s3";
6
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
7
+ import type { Construct } from "constructs";
8
+
9
+ export type InboundEmailSesIntakeProps = {
10
+ storageBucket: s3.IBucket;
11
+ recipients: string[];
12
+ ruleSetName: string;
13
+ /** Adds the rule to a rule set another stack owns instead of creating one. */
14
+ existingRuleSetName?: string;
15
+ /** `undefined` lets CloudFormation generate the name (the live p.apyr.us rule). */
16
+ ruleName?: string;
17
+ /** Only the legacy p.apyr.us pipeline sets this; a new site never activates a rule set. */
18
+ activateRuleSet: boolean;
19
+ domainIdentity?: {
20
+ domain: string;
21
+ hostedZoneId: string;
22
+ hostedZoneName: string;
23
+ recordName: string;
24
+ };
25
+ };
26
+
27
+ /**
28
+ * Adds the SES receipt resources directly to `scope` (no wrapper construct) so
29
+ * the logical ids of the live p.apyr.us stack stay unchanged.
30
+ */
31
+ export function addInboundEmailSesIntake(scope: Construct, props: InboundEmailSesIntakeProps) {
32
+ if (props.domainIdentity) {
33
+ const { domain, hostedZoneId, hostedZoneName, recordName } = props.domainIdentity;
34
+ const zone = route53.HostedZone.fromHostedZoneAttributes(scope, "PapyrusInboundDnsZone", {
35
+ hostedZoneId,
36
+ zoneName: hostedZoneName,
37
+ });
38
+ const verifyDomain = new AwsCustomResource(scope, "VerifyInboundEmailDomain", {
39
+ onCreate: {
40
+ service: "SES",
41
+ action: "verifyDomainIdentity",
42
+ parameters: { Domain: domain },
43
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
44
+ },
45
+ onUpdate: {
46
+ service: "SES",
47
+ action: "verifyDomainIdentity",
48
+ parameters: { Domain: domain },
49
+ physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${domain}`),
50
+ },
51
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
52
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
53
+ }),
54
+ timeout: Duration.minutes(2),
55
+ });
56
+ new route53.TxtRecord(scope, "PapyrusInboundEmailDomainVerification", {
57
+ zone,
58
+ recordName: `_amazonses.${recordName}`,
59
+ ttl: Duration.minutes(5),
60
+ values: [verifyDomain.getResponseField("VerificationToken")],
61
+ });
62
+ }
63
+
64
+ const ruleSet = props.existingRuleSetName
65
+ ? ses.ReceiptRuleSet.fromReceiptRuleSetName(scope, "PapyrusInboundEmailRuleSet", props.existingRuleSetName)
66
+ : new ses.ReceiptRuleSet(scope, "PapyrusInboundEmailRuleSet", {
67
+ receiptRuleSetName: props.ruleSetName,
68
+ });
69
+ ruleSet.addRule("PapyrusInboundSubmissions", {
70
+ receiptRuleName: props.ruleName,
71
+ recipients: props.recipients,
72
+ enabled: true,
73
+ scanEnabled: true,
74
+ tlsPolicy: ses.TlsPolicy.REQUIRE,
75
+ actions: [
76
+ new sesActions.S3({
77
+ bucket: props.storageBucket,
78
+ objectKeyPrefix: "inbound-email/",
79
+ }),
80
+ ],
81
+ });
82
+
83
+ if (props.activateRuleSet && !props.existingRuleSetName) {
84
+ new AwsCustomResource(scope, "ActivateInboundEmailRuleSet", {
85
+ onCreate: {
86
+ service: "SES",
87
+ action: "setActiveReceiptRuleSet",
88
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
89
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
90
+ },
91
+ onUpdate: {
92
+ service: "SES",
93
+ action: "setActiveReceiptRuleSet",
94
+ parameters: { RuleSetName: ruleSet.receiptRuleSetName },
95
+ physicalResourceId: PhysicalResourceId.of(`activate-${ruleSet.receiptRuleSetName}`),
96
+ },
97
+ onDelete: {
98
+ service: "SES",
99
+ action: "setActiveReceiptRuleSet",
100
+ parameters: { RuleSetName: null },
101
+ },
102
+ policy: AwsCustomResourcePolicy.fromSdkCalls({
103
+ resources: AwsCustomResourcePolicy.ANY_RESOURCE,
104
+ }),
105
+ timeout: Duration.minutes(2),
106
+ });
107
+ }
108
+
109
+ return { ruleSet };
110
+ }
@@ -32,6 +32,17 @@ export type PapyrusSiteBackendConfig = {
32
32
  stagingBuild?: { enabled: boolean };
33
33
  /** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (authenticated by the SSM secret named in branch variable `PAPYRUS_REVALIDATE_SECRET_PARAMETER`). */
34
34
  revalidateBaseUrl?: string;
35
+ /**
36
+ * SES receipt options for a site that is not the legacy p.apyr.us backend.
37
+ * By default the site gets its own brand-named rule set that is never
38
+ * activated by CloudFormation (SES allows one active rule set per region).
39
+ */
40
+ inboundEmailSes?: {
41
+ /** Add this site's rule to a rule set another stack owns instead of creating one. */
42
+ existingReceiptRuleSetName?: string;
43
+ /** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
44
+ manageDomainIdentity?: boolean;
45
+ };
35
46
  features?: {
36
47
  consoleResponder?: boolean;
37
48
  inboundEmail?: boolean;
@@ -0,0 +1,88 @@
1
+ import { createHash } from "node:crypto";
2
+ export function sanitizeAwsName(value, maxLength = 50) {
3
+ return value
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9-]/g, "-")
6
+ .replace(/-+/g, "-")
7
+ .replace(/^-|-$/g, "")
8
+ .slice(0, maxLength);
9
+ }
10
+ export function readBooleanEnvFlag(environment, name, defaultValue) {
11
+ const raw = (environment[name] ?? "").trim().toLowerCase();
12
+ if (["1", "true", "yes", "on"].includes(raw))
13
+ return true;
14
+ if (["0", "false", "no", "off"].includes(raw))
15
+ return false;
16
+ return defaultValue;
17
+ }
18
+ /**
19
+ * True only for the Amplify app that owns the original p.apyr.us backend. That
20
+ * backend keeps its historical account-global names so its live resources are
21
+ * never replaced. Every other site gets brand-scoped names.
22
+ */
23
+ export function isLegacyProductionApp(identity) {
24
+ return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
25
+ }
26
+ export function isLegacyProductionPipeline(identity) {
27
+ return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
28
+ }
29
+ export function resolveSiteBackendFeatureFlags(features, environment, identity) {
30
+ const legacyPipeline = isLegacyProductionPipeline(identity);
31
+ return {
32
+ consoleResponder: features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
33
+ inboundEmail: features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
34
+ slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
35
+ storageBackups: features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
36
+ };
37
+ }
38
+ export function deriveKnowledgeVectorIndexName(identity) {
39
+ return isLegacyProductionApp(identity)
40
+ ? "papyrus-knowledge"
41
+ : `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
42
+ }
43
+ export function deriveStorageBackupVaultName(identity, backupsStackName, explicitName) {
44
+ const explicit = (explicitName ?? "").trim();
45
+ if (explicit !== "")
46
+ return sanitizeAwsName(explicit);
47
+ if (isLegacyProductionPipeline(identity)) {
48
+ return sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`);
49
+ }
50
+ const stackHash = createHash("sha256").update(backupsStackName).digest("hex").slice(0, 8);
51
+ return `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${stackHash}-media-vault`;
52
+ }
53
+ /**
54
+ * The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
55
+ * colliding with the legacy `papyrus-inbound-p-apyr-us` set.
56
+ * SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
57
+ * its own brand-named rule set and never activates it from CloudFormation.
58
+ */
59
+ export function deriveReceiptRuleSetName(identity, inboundEmailDomain) {
60
+ if (isLegacyProductionApp(identity)) {
61
+ return `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`;
62
+ }
63
+ return `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
64
+ }
65
+ /** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
66
+ export function deriveReceiptRuleName(identity) {
67
+ if (isLegacyProductionApp(identity))
68
+ return undefined;
69
+ return `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`;
70
+ }
71
+ export function planInboundEmailSes(identity, inboundEmailEnabled, options) {
72
+ const legacyPipeline = isLegacyProductionPipeline(identity);
73
+ const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
74
+ const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
75
+ return {
76
+ createReceiptRules,
77
+ activateReceiptRuleSet: createReceiptRules && legacyPipeline,
78
+ manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
79
+ };
80
+ }
81
+ export function deriveSiteGlobalResourceNames(identity, inboundEmailDomain, backupsStackName) {
82
+ return {
83
+ knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
84
+ storageBackupVaultName: deriveStorageBackupVaultName(identity, backupsStackName),
85
+ receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
86
+ receiptRuleName: deriveReceiptRuleName(identity),
87
+ };
88
+ }
@@ -0,0 +1,147 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export type SiteBackendFeatureFlags = {
4
+ consoleResponder: boolean;
5
+ inboundEmail: boolean;
6
+ slack: boolean;
7
+ storageBackups: boolean;
8
+ };
9
+
10
+ export type SiteBackendFeatureInput = Partial<SiteBackendFeatureFlags> | undefined;
11
+
12
+ export function sanitizeAwsName(value: string, maxLength = 50): string {
13
+ return value
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^-|-$/g, "")
18
+ .slice(0, maxLength);
19
+ }
20
+
21
+ export function readBooleanEnvFlag(
22
+ environment: Record<string, string | undefined>,
23
+ name: string,
24
+ defaultValue: boolean,
25
+ ): boolean {
26
+ const raw = (environment[name] ?? "").trim().toLowerCase();
27
+ if (["1", "true", "yes", "on"].includes(raw)) return true;
28
+ if (["0", "false", "no", "off"].includes(raw)) return false;
29
+ return defaultValue;
30
+ }
31
+
32
+ export type SiteBackendIdentity = {
33
+ brandId: string;
34
+ amplifyAppId: string;
35
+ amplifyBranch: string;
36
+ productionAppId: string;
37
+ };
38
+
39
+ /**
40
+ * True only for the Amplify app that owns the original p.apyr.us backend. That
41
+ * backend keeps its historical account-global names so its live resources are
42
+ * never replaced. Every other site gets brand-scoped names.
43
+ */
44
+ export function isLegacyProductionApp(identity: SiteBackendIdentity): boolean {
45
+ return identity.productionAppId !== "" && identity.amplifyAppId === identity.productionAppId;
46
+ }
47
+
48
+ export function isLegacyProductionPipeline(identity: SiteBackendIdentity): boolean {
49
+ return isLegacyProductionApp(identity) && identity.amplifyBranch === "main";
50
+ }
51
+
52
+ export function resolveSiteBackendFeatureFlags(
53
+ features: SiteBackendFeatureInput,
54
+ environment: Record<string, string | undefined>,
55
+ identity: SiteBackendIdentity,
56
+ ): SiteBackendFeatureFlags {
57
+ const legacyPipeline = isLegacyProductionPipeline(identity);
58
+ return {
59
+ consoleResponder:
60
+ features?.consoleResponder ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_CONSOLE_RESPONDER", true),
61
+ inboundEmail:
62
+ features?.inboundEmail ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_INBOUND_EMAIL", legacyPipeline),
63
+ slack: features?.slack ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_SLACK", false),
64
+ storageBackups:
65
+ features?.storageBackups ?? readBooleanEnvFlag(environment, "PAPYRUS_ENABLE_STORAGE_BACKUPS", legacyPipeline),
66
+ };
67
+ }
68
+
69
+ export function deriveKnowledgeVectorIndexName(identity: SiteBackendIdentity): string {
70
+ return isLegacyProductionApp(identity)
71
+ ? "papyrus-knowledge"
72
+ : `papyrus-knowledge-${sanitizeAwsName(identity.brandId, 40)}`;
73
+ }
74
+
75
+ export function deriveStorageBackupVaultName(
76
+ identity: SiteBackendIdentity,
77
+ backupsStackName: string,
78
+ explicitName?: string,
79
+ ): string {
80
+ const explicit = (explicitName ?? "").trim();
81
+ if (explicit !== "") return sanitizeAwsName(explicit);
82
+ if (isLegacyProductionPipeline(identity)) {
83
+ return sanitizeAwsName(`papyrus-${identity.productionAppId}-main-media-backup-vault`);
84
+ }
85
+ const stackHash = createHash("sha256").update(backupsStackName).digest("hex").slice(0, 8);
86
+ return `papyrus-${sanitizeAwsName(identity.brandId, 20)}-${stackHash}-media-vault`;
87
+ }
88
+
89
+ /**
90
+ * The `papyrus-site-inbound-` prefix keeps a brand named `p-apyr-us` from
91
+ * colliding with the legacy `papyrus-inbound-p-apyr-us` set.
92
+ * SES allows one ACTIVE receipt rule set per region. A non-legacy site creates
93
+ * its own brand-named rule set and never activates it from CloudFormation.
94
+ */
95
+ export function deriveReceiptRuleSetName(identity: SiteBackendIdentity, inboundEmailDomain: string): string {
96
+ if (isLegacyProductionApp(identity)) {
97
+ return `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`;
98
+ }
99
+ return `papyrus-site-inbound-${sanitizeAwsName(identity.brandId, 40)}`;
100
+ }
101
+
102
+ /** `undefined` keeps the legacy auto-generated rule name of the live p.apyr.us rule. */
103
+ export function deriveReceiptRuleName(identity: SiteBackendIdentity): string | undefined {
104
+ if (isLegacyProductionApp(identity)) return undefined;
105
+ return `${sanitizeAwsName(identity.brandId, 40)}-inbound-submissions`;
106
+ }
107
+
108
+ export type InboundEmailSesPlan = {
109
+ createReceiptRules: boolean;
110
+ activateReceiptRuleSet: boolean;
111
+ manageDomainIdentity: boolean;
112
+ };
113
+
114
+ export function planInboundEmailSes(
115
+ identity: SiteBackendIdentity,
116
+ inboundEmailEnabled: boolean,
117
+ options: { manageDomainIdentity?: boolean } | undefined,
118
+ ): InboundEmailSesPlan {
119
+ const legacyPipeline = isLegacyProductionPipeline(identity);
120
+ const deployedMainBranch = identity.amplifyAppId !== "" && identity.amplifyBranch === "main";
121
+ const createReceiptRules = inboundEmailEnabled && (legacyPipeline || deployedMainBranch);
122
+ return {
123
+ createReceiptRules,
124
+ activateReceiptRuleSet: createReceiptRules && legacyPipeline,
125
+ manageDomainIdentity: createReceiptRules && (legacyPipeline || options?.manageDomainIdentity === true),
126
+ };
127
+ }
128
+
129
+ export type SiteGlobalResourceNames = {
130
+ knowledgeVectorIndexName: string;
131
+ storageBackupVaultName: string;
132
+ receiptRuleSetName: string;
133
+ receiptRuleName: string | undefined;
134
+ };
135
+
136
+ export function deriveSiteGlobalResourceNames(
137
+ identity: SiteBackendIdentity,
138
+ inboundEmailDomain: string,
139
+ backupsStackName: string,
140
+ ): SiteGlobalResourceNames {
141
+ return {
142
+ knowledgeVectorIndexName: deriveKnowledgeVectorIndexName(identity),
143
+ storageBackupVaultName: deriveStorageBackupVaultName(identity, backupsStackName),
144
+ receiptRuleSetName: deriveReceiptRuleSetName(identity, inboundEmailDomain),
145
+ receiptRuleName: deriveReceiptRuleName(identity),
146
+ };
147
+ }
@@ -1,12 +1,6 @@
1
1
  import { defineBackend, secret } from "@aws-amplify/backend";
2
- import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
3
- import * as backup from "aws-cdk-lib/aws-backup";
4
- import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
2
+ import { Stack } from "aws-cdk-lib";
5
3
  import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
6
- import * as events from "aws-cdk-lib/aws-events";
7
- import * as route53 from "aws-cdk-lib/aws-route53";
8
- import * as ses from "aws-cdk-lib/aws-ses";
9
- import * as sesActions from "aws-cdk-lib/aws-ses-actions";
10
4
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
11
5
  import { CfnEventSourceMapping, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
12
6
  import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
@@ -29,7 +23,10 @@ import { emailSubmissionProcessor } from "./functions/email-submission-processor
29
23
  import { sesInboundReceive } from "./functions/ses-inbound-receive/resource.js";
30
24
  import { slackDelivery } from "./functions/slack-delivery/resource.js";
31
25
  import { slackEvents } from "./functions/slack-events/resource.js";
26
+ import { addInboundEmailSesIntake } from "./inbound-email/ses-intake.js";
32
27
  import { InboundEmailStack } from "./inbound-email/stack.js";
28
+ import { deriveKnowledgeVectorIndexName, deriveReceiptRuleName, deriveReceiptRuleSetName, deriveStorageBackupVaultName, isLegacyProductionPipeline, planInboundEmailSes, resolveSiteBackendFeatureFlags, } from "./site-backend-names.js";
29
+ import { addStorageBackups } from "./storage-backups/construct.js";
33
30
  import { storage } from "./storage/resource.js";
34
31
  export function defineSiteBackend(papyrusSite) {
35
32
  // `papyrus.config.ts`'s default export. Backend options live under `backend`;
@@ -39,43 +36,24 @@ export function defineSiteBackend(papyrusSite) {
39
36
  const knowledgeEmbeddingModel = "text-embedding-3-small";
40
37
  const amplifyBranch = (process.env.AWS_BRANCH ?? "").trim();
41
38
  const amplifyAppId = (process.env.AWS_APP_ID ?? "").trim();
42
- // Amplify app id whose `main` branch is this site's production pipeline (owns
43
- // account-global resources: SES rule set, backup vault, legacy S3 Vectors name).
39
+ // Amplify app id whose `main` branch is the original p.apyr.us backend; only
40
+ // that app keeps the historical account-global names (see site-backend-names.ts).
44
41
  const productionAppId = (site.productionAppId ?? "").trim();
45
- const isAmplifyProductionPipeline = amplifyBranch === "main" && productionAppId !== "" && amplifyAppId === productionAppId;
46
- function readEnvFlag(name, defaultValue) {
47
- const raw = (process.env[name] ?? "").trim().toLowerCase();
48
- if (["1", "true", "yes", "on"].includes(raw))
49
- return true;
50
- if (["0", "false", "no", "off"].includes(raw))
51
- return false;
52
- return defaultValue;
53
- }
54
- function sanitizeAwsName(value, maxLength = 50) {
55
- return value
56
- .toLowerCase()
57
- .replace(/[^a-z0-9-]/g, "-")
58
- .replace(/-+/g, "-")
59
- .replace(/^-|-$/g, "")
60
- .slice(0, maxLength);
61
- }
62
- // S3 Vectors index names are account-global. Keep the original name for the
63
- // p.apyr.us production app so its existing index + data are not orphaned;
64
- // namespace every other app's index by site brand so a second CMS app can
65
- // deploy into the same account without colliding.
66
- const papyrusSiteBrand = (site.brandId
67
- ?? process.env.PAPYRUS_SITE_BRAND
68
- ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
69
- ?? "papyrus").trim().toLowerCase();
70
- const knowledgeVectorIndexName = productionAppId !== "" && amplifyAppId === productionAppId
71
- ? "papyrus-knowledge"
72
- : `papyrus-knowledge-${sanitizeAwsName(papyrusSiteBrand, 40)}`;
73
- // SES active receipt rule sets and shared backup vault names are account-global.
74
- // Keep them on the production pipeline only unless explicitly opted in.
75
- // A feature flag in the site config wins over the environment.
76
- const enableInboundEmail = site.features?.inboundEmail ?? readEnvFlag("PAPYRUS_ENABLE_INBOUND_EMAIL", isAmplifyProductionPipeline);
77
- const enableSlackAgent = site.features?.slack ?? readEnvFlag("PAPYRUS_ENABLE_SLACK", false);
78
- const enableStorageBackups = site.features?.storageBackups ?? readEnvFlag("PAPYRUS_ENABLE_STORAGE_BACKUPS", isAmplifyProductionPipeline);
42
+ const siteBackendIdentity = {
43
+ brandId: (site.brandId
44
+ ?? process.env.PAPYRUS_SITE_BRAND
45
+ ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
46
+ ?? "papyrus").trim().toLowerCase(),
47
+ amplifyAppId,
48
+ amplifyBranch,
49
+ productionAppId,
50
+ };
51
+ const isAmplifyProductionPipeline = isLegacyProductionPipeline(siteBackendIdentity);
52
+ const knowledgeVectorIndexName = deriveKnowledgeVectorIndexName(siteBackendIdentity);
53
+ const featureFlags = resolveSiteBackendFeatureFlags(site.features, process.env, siteBackendIdentity);
54
+ const enableInboundEmail = featureFlags.inboundEmail;
55
+ const enableSlackAgent = featureFlags.slack;
56
+ const enableStorageBackups = featureFlags.storageBackups;
79
57
  const authConfig = site.auth ?? authConfigFromEnv();
80
58
  const backend = defineBackend({
81
59
  assignmentAction,
@@ -104,7 +82,7 @@ export function defineSiteBackend(papyrusSite) {
104
82
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
105
83
  // Repo root in a Papyrus checkout; package root when installed from npm.
106
84
  const projectRoot = resolve(amplifyBackendDir, "..");
107
- const enableConsoleResponder = site.features?.consoleResponder ?? readEnvFlag("PAPYRUS_ENABLE_CONSOLE_RESPONDER", true);
85
+ const enableConsoleResponder = featureFlags.consoleResponder;
108
86
  if (enableConsoleResponder
109
87
  && !process.env.PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI?.trim()
110
88
  && !process.env.PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD?.trim()
@@ -237,31 +215,13 @@ export function defineSiteBackend(papyrusSite) {
237
215
  }
238
216
  if (enableStorageBackups) {
239
217
  const storageBackupsStack = backend.createStack("storage-backups");
240
- const storageBackupVaultName = sanitizeAwsName(process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME?.trim()
241
- || (isAmplifyProductionPipeline
242
- ? `papyrus-${productionAppId}-main-media-backup-vault`
243
- : `papyrus-${sanitizeAwsName(storageBackupsStack.stackName, 32)}-media-vault`));
244
- storageBackupVault = new backup.BackupVault(storageBackupsStack, "PapyrusStorageBackupVault", {
218
+ const storageBackupVaultName = deriveStorageBackupVaultName(siteBackendIdentity, storageBackupsStack.stackName, process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME);
219
+ const storageBackups = addStorageBackups(storageBackupsStack, {
220
+ storageBucket,
245
221
  backupVaultName: storageBackupVaultName,
246
- removalPolicy: RemovalPolicy.RETAIN,
247
- });
248
- storageBackupPlan = new backup.BackupPlan(storageBackupsStack, "PapyrusStorageBackupPlan", {
249
- backupVault: storageBackupVault,
250
- });
251
- storageBackupPlan.addRule(new backup.BackupPlanRule({
252
- ruleName: "papyrus-storage-pitr-35d",
253
- enableContinuousBackup: true,
254
- deleteAfter: Duration.days(35),
255
- }));
256
- storageBackupPlan.addRule(new backup.BackupPlanRule({
257
- ruleName: "papyrus-storage-daily-365d",
258
- scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
259
- deleteAfter: Duration.days(365),
260
- }));
261
- storageBackupPlan.addSelection("PapyrusStorageBackupSelection", {
262
- allowRestores: true,
263
- resources: [backup.BackupResource.fromArn(storageBucket.bucketArn)],
264
222
  });
223
+ storageBackupVault = storageBackups.vault;
224
+ storageBackupPlan = storageBackups.plan;
265
225
  }
266
226
  // Grant S3 access on Lambda roles only (not storage bucket policies) to avoid
267
227
  // storage ↔ data nested-stack circular dependencies from allow.resource().
@@ -412,83 +372,25 @@ export function defineSiteBackend(papyrusSite) {
412
372
  resources: ["*"],
413
373
  }));
414
374
  const inboundEventStack = Stack.of(receiveLambda);
415
- // SES receipt rule sets are account-global; only provision on the production pipeline.
416
- if (isAmplifyProductionPipeline) {
417
- const inboundDnsZone = route53.HostedZone.fromHostedZoneAttributes(storageStack, "PapyrusInboundDnsZone", {
418
- hostedZoneId: inboundDnsZoneId,
419
- zoneName: inboundDnsZoneName,
420
- });
421
- const verifyInboundEmailDomain = new AwsCustomResource(storageStack, "VerifyInboundEmailDomain", {
422
- onCreate: {
423
- service: "SES",
424
- action: "verifyDomainIdentity",
425
- parameters: {
426
- Domain: inboundEmailDomain,
427
- },
428
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
429
- },
430
- onUpdate: {
431
- service: "SES",
432
- action: "verifyDomainIdentity",
433
- parameters: {
434
- Domain: inboundEmailDomain,
435
- },
436
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
437
- },
438
- policy: AwsCustomResourcePolicy.fromSdkCalls({
439
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
440
- }),
441
- timeout: Duration.minutes(2),
442
- });
443
- new route53.TxtRecord(storageStack, "PapyrusInboundEmailDomainVerification", {
444
- zone: inboundDnsZone,
445
- recordName: `_amazonses.${inboundDnsRecordName}`,
446
- ttl: Duration.minutes(5),
447
- values: [verifyInboundEmailDomain.getResponseField("VerificationToken")],
448
- });
449
- const inboundRuleSet = new ses.ReceiptRuleSet(storageStack, "PapyrusInboundEmailRuleSet", {
450
- receiptRuleSetName: `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`,
451
- });
452
- inboundRuleSet.addRule("PapyrusInboundSubmissions", {
375
+ // SES allows one active receipt rule set per region, so a non-legacy site
376
+ // creates its own brand-named rule set and never activates it here.
377
+ const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
378
+ if (inboundSesPlan.createReceiptRules) {
379
+ addInboundEmailSesIntake(storageStack, {
380
+ storageBucket,
453
381
  recipients: inboundRecipients,
454
- enabled: true,
455
- scanEnabled: true,
456
- tlsPolicy: ses.TlsPolicy.REQUIRE,
457
- actions: [
458
- new sesActions.S3({
459
- bucket: storageBucket,
460
- objectKeyPrefix: "inbound-email/",
461
- }),
462
- ],
463
- });
464
- new AwsCustomResource(storageStack, "ActivateInboundEmailRuleSet", {
465
- onCreate: {
466
- service: "SES",
467
- action: "setActiveReceiptRuleSet",
468
- parameters: {
469
- RuleSetName: inboundRuleSet.receiptRuleSetName,
470
- },
471
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
472
- },
473
- onUpdate: {
474
- service: "SES",
475
- action: "setActiveReceiptRuleSet",
476
- parameters: {
477
- RuleSetName: inboundRuleSet.receiptRuleSetName,
478
- },
479
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
480
- },
481
- onDelete: {
482
- service: "SES",
483
- action: "setActiveReceiptRuleSet",
484
- parameters: {
485
- RuleSetName: null,
486
- },
487
- },
488
- policy: AwsCustomResourcePolicy.fromSdkCalls({
489
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
490
- }),
491
- timeout: Duration.minutes(2),
382
+ ruleSetName: deriveReceiptRuleSetName(siteBackendIdentity, inboundEmailDomain),
383
+ existingRuleSetName: site.inboundEmailSes?.existingReceiptRuleSetName,
384
+ ruleName: deriveReceiptRuleName(siteBackendIdentity),
385
+ activateRuleSet: inboundSesPlan.activateReceiptRuleSet,
386
+ domainIdentity: inboundSesPlan.manageDomainIdentity
387
+ ? {
388
+ domain: inboundEmailDomain,
389
+ hostedZoneId: inboundDnsZoneId,
390
+ hostedZoneName: inboundDnsZoneName,
391
+ recordName: inboundDnsRecordName,
392
+ }
393
+ : undefined,
492
394
  });
493
395
  }
494
396
  new InboundEmailStack(inboundEventStack, "InboundEmail", {
@@ -1,12 +1,7 @@
1
1
  import { defineBackend, secret } from "@aws-amplify/backend";
2
- import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
3
- import * as backup from "aws-cdk-lib/aws-backup";
4
- import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
2
+ import { Stack } from "aws-cdk-lib";
3
+ import type * as backup from "aws-cdk-lib/aws-backup";
5
4
  import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
6
- import * as events from "aws-cdk-lib/aws-events";
7
- import * as route53 from "aws-cdk-lib/aws-route53";
8
- import * as ses from "aws-cdk-lib/aws-ses";
9
- import * as sesActions from "aws-cdk-lib/aws-ses-actions";
10
5
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
11
6
  import { CfnEventSourceMapping, Function as LambdaFunction, FunctionUrlAuthType } from "aws-cdk-lib/aws-lambda";
12
7
  import * as s3 from "aws-cdk-lib/aws-s3";
@@ -30,7 +25,19 @@ import { emailSubmissionProcessor } from "./functions/email-submission-processor
30
25
  import { sesInboundReceive } from "./functions/ses-inbound-receive/resource";
31
26
  import { slackDelivery } from "./functions/slack-delivery/resource";
32
27
  import { slackEvents } from "./functions/slack-events/resource";
28
+ import { addInboundEmailSesIntake } from "./inbound-email/ses-intake";
33
29
  import { InboundEmailStack } from "./inbound-email/stack";
30
+ import {
31
+ deriveKnowledgeVectorIndexName,
32
+ deriveReceiptRuleName,
33
+ deriveReceiptRuleSetName,
34
+ deriveStorageBackupVaultName,
35
+ isLegacyProductionPipeline,
36
+ planInboundEmailSes,
37
+ resolveSiteBackendFeatureFlags,
38
+ type SiteBackendIdentity,
39
+ } from "./site-backend-names";
40
+ import { addStorageBackups } from "./storage-backups/construct";
34
41
  import { storage } from "./storage/resource";
35
42
  import type { PapyrusSite } from "../lib/define-site";
36
43
 
@@ -43,49 +50,26 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
43
50
 
44
51
  const amplifyBranch = (process.env.AWS_BRANCH ?? "").trim();
45
52
  const amplifyAppId = (process.env.AWS_APP_ID ?? "").trim();
46
- // Amplify app id whose `main` branch is this site's production pipeline (owns
47
- // account-global resources: SES rule set, backup vault, legacy S3 Vectors name).
53
+ // Amplify app id whose `main` branch is the original p.apyr.us backend; only
54
+ // that app keeps the historical account-global names (see site-backend-names.ts).
48
55
  const productionAppId = (site.productionAppId ?? "").trim();
49
- const isAmplifyProductionPipeline =
50
- amplifyBranch === "main" && productionAppId !== "" && amplifyAppId === productionAppId;
51
-
52
- function readEnvFlag(name: string, defaultValue: boolean): boolean {
53
- const raw = (process.env[name] ?? "").trim().toLowerCase();
54
- if (["1", "true", "yes", "on"].includes(raw)) return true;
55
- if (["0", "false", "no", "off"].includes(raw)) return false;
56
- return defaultValue;
57
- }
58
-
59
- function sanitizeAwsName(value: string, maxLength = 50): string {
60
- return value
61
- .toLowerCase()
62
- .replace(/[^a-z0-9-]/g, "-")
63
- .replace(/-+/g, "-")
64
- .replace(/^-|-$/g, "")
65
- .slice(0, maxLength);
66
- }
67
-
68
- // S3 Vectors index names are account-global. Keep the original name for the
69
- // p.apyr.us production app so its existing index + data are not orphaned;
70
- // namespace every other app's index by site brand so a second CMS app can
71
- // deploy into the same account without colliding.
72
- const papyrusSiteBrand = (
73
- site.brandId
74
- ?? process.env.PAPYRUS_SITE_BRAND
75
- ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
76
- ?? "papyrus"
77
- ).trim().toLowerCase();
78
- const knowledgeVectorIndexName =
79
- productionAppId !== "" && amplifyAppId === productionAppId
80
- ? "papyrus-knowledge"
81
- : `papyrus-knowledge-${sanitizeAwsName(papyrusSiteBrand, 40)}`;
82
-
83
- // SES active receipt rule sets and shared backup vault names are account-global.
84
- // Keep them on the production pipeline only unless explicitly opted in.
85
- // A feature flag in the site config wins over the environment.
86
- const enableInboundEmail = site.features?.inboundEmail ?? readEnvFlag("PAPYRUS_ENABLE_INBOUND_EMAIL", isAmplifyProductionPipeline);
87
- const enableSlackAgent = site.features?.slack ?? readEnvFlag("PAPYRUS_ENABLE_SLACK", false);
88
- const enableStorageBackups = site.features?.storageBackups ?? readEnvFlag("PAPYRUS_ENABLE_STORAGE_BACKUPS", isAmplifyProductionPipeline);
56
+ const siteBackendIdentity: SiteBackendIdentity = {
57
+ brandId: (
58
+ site.brandId
59
+ ?? process.env.PAPYRUS_SITE_BRAND
60
+ ?? process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND
61
+ ?? "papyrus"
62
+ ).trim().toLowerCase(),
63
+ amplifyAppId,
64
+ amplifyBranch,
65
+ productionAppId,
66
+ };
67
+ const isAmplifyProductionPipeline = isLegacyProductionPipeline(siteBackendIdentity);
68
+ const knowledgeVectorIndexName = deriveKnowledgeVectorIndexName(siteBackendIdentity);
69
+ const featureFlags = resolveSiteBackendFeatureFlags(site.features, process.env, siteBackendIdentity);
70
+ const enableInboundEmail = featureFlags.inboundEmail;
71
+ const enableSlackAgent = featureFlags.slack;
72
+ const enableStorageBackups = featureFlags.storageBackups;
89
73
 
90
74
  const authConfig = site.auth ?? authConfigFromEnv();
91
75
  const backend = defineBackend({
@@ -119,7 +103,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
119
103
  const amplifyBackendDir = dirname(fileURLToPath(import.meta.url));
120
104
  // Repo root in a Papyrus checkout; package root when installed from npm.
121
105
  const projectRoot = resolve(amplifyBackendDir, "..");
122
- const enableConsoleResponder = site.features?.consoleResponder ?? readEnvFlag("PAPYRUS_ENABLE_CONSOLE_RESPONDER", true);
106
+ const enableConsoleResponder = featureFlags.consoleResponder;
123
107
 
124
108
  if (
125
109
  enableConsoleResponder
@@ -291,41 +275,18 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
291
275
 
292
276
  if (enableStorageBackups) {
293
277
  const storageBackupsStack = backend.createStack("storage-backups");
294
- const storageBackupVaultName = sanitizeAwsName(
295
- process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME?.trim()
296
- || (isAmplifyProductionPipeline
297
- ? `papyrus-${productionAppId}-main-media-backup-vault`
298
- : `papyrus-${sanitizeAwsName(storageBackupsStack.stackName, 32)}-media-vault`),
278
+ const storageBackupVaultName = deriveStorageBackupVaultName(
279
+ siteBackendIdentity,
280
+ storageBackupsStack.stackName,
281
+ process.env.PAPYRUS_STORAGE_BACKUP_VAULT_NAME,
299
282
  );
300
283
 
301
- storageBackupVault = new backup.BackupVault(storageBackupsStack, "PapyrusStorageBackupVault", {
284
+ const storageBackups = addStorageBackups(storageBackupsStack, {
285
+ storageBucket,
302
286
  backupVaultName: storageBackupVaultName,
303
- removalPolicy: RemovalPolicy.RETAIN,
304
- });
305
- storageBackupPlan = new backup.BackupPlan(storageBackupsStack, "PapyrusStorageBackupPlan", {
306
- backupVault: storageBackupVault,
307
- });
308
-
309
- storageBackupPlan.addRule(
310
- new backup.BackupPlanRule({
311
- ruleName: "papyrus-storage-pitr-35d",
312
- enableContinuousBackup: true,
313
- deleteAfter: Duration.days(35),
314
- }),
315
- );
316
-
317
- storageBackupPlan.addRule(
318
- new backup.BackupPlanRule({
319
- ruleName: "papyrus-storage-daily-365d",
320
- scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
321
- deleteAfter: Duration.days(365),
322
- }),
323
- );
324
-
325
- storageBackupPlan.addSelection("PapyrusStorageBackupSelection", {
326
- allowRestores: true,
327
- resources: [backup.BackupResource.fromArn(storageBucket.bucketArn)],
328
287
  });
288
+ storageBackupVault = storageBackups.vault;
289
+ storageBackupPlan = storageBackups.plan;
329
290
  }
330
291
 
331
292
  // Grant S3 access on Lambda roles only (not storage bucket policies) to avoid
@@ -524,86 +485,26 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
524
485
 
525
486
  const inboundEventStack = Stack.of(receiveLambda);
526
487
 
527
- // SES receipt rule sets are account-global; only provision on the production pipeline.
528
- if (isAmplifyProductionPipeline) {
529
- const inboundDnsZone = route53.HostedZone.fromHostedZoneAttributes(storageStack, "PapyrusInboundDnsZone", {
530
- hostedZoneId: inboundDnsZoneId,
531
- zoneName: inboundDnsZoneName,
532
- });
533
- const verifyInboundEmailDomain = new AwsCustomResource(storageStack, "VerifyInboundEmailDomain", {
534
- onCreate: {
535
- service: "SES",
536
- action: "verifyDomainIdentity",
537
- parameters: {
538
- Domain: inboundEmailDomain,
539
- },
540
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
541
- },
542
- onUpdate: {
543
- service: "SES",
544
- action: "verifyDomainIdentity",
545
- parameters: {
546
- Domain: inboundEmailDomain,
547
- },
548
- physicalResourceId: PhysicalResourceId.of(`ses-domain-verify-${inboundEmailDomain}`),
549
- },
550
- policy: AwsCustomResourcePolicy.fromSdkCalls({
551
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
552
- }),
553
- timeout: Duration.minutes(2),
554
- });
555
- new route53.TxtRecord(storageStack, "PapyrusInboundEmailDomainVerification", {
556
- zone: inboundDnsZone,
557
- recordName: `_amazonses.${inboundDnsRecordName}`,
558
- ttl: Duration.minutes(5),
559
- values: [verifyInboundEmailDomain.getResponseField("VerificationToken")],
560
- });
561
-
562
- const inboundRuleSet = new ses.ReceiptRuleSet(storageStack, "PapyrusInboundEmailRuleSet", {
563
- receiptRuleSetName: `papyrus-inbound-${inboundEmailDomain.replace(/\./g, "-")}`,
564
- });
565
- inboundRuleSet.addRule("PapyrusInboundSubmissions", {
566
- recipients: inboundRecipients,
567
- enabled: true,
568
- scanEnabled: true,
569
- tlsPolicy: ses.TlsPolicy.REQUIRE,
570
- actions: [
571
- new sesActions.S3({
572
- bucket: storageBucket,
573
- objectKeyPrefix: "inbound-email/",
574
- }),
575
- ],
576
- });
577
-
578
- new AwsCustomResource(storageStack, "ActivateInboundEmailRuleSet", {
579
- onCreate: {
580
- service: "SES",
581
- action: "setActiveReceiptRuleSet",
582
- parameters: {
583
- RuleSetName: inboundRuleSet.receiptRuleSetName,
584
- },
585
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
586
- },
587
- onUpdate: {
588
- service: "SES",
589
- action: "setActiveReceiptRuleSet",
590
- parameters: {
591
- RuleSetName: inboundRuleSet.receiptRuleSetName,
592
- },
593
- physicalResourceId: PhysicalResourceId.of(`activate-${inboundRuleSet.receiptRuleSetName}`),
594
- },
595
- onDelete: {
596
- service: "SES",
597
- action: "setActiveReceiptRuleSet",
598
- parameters: {
599
- RuleSetName: null,
600
- },
601
- },
602
- policy: AwsCustomResourcePolicy.fromSdkCalls({
603
- resources: AwsCustomResourcePolicy.ANY_RESOURCE,
604
- }),
605
- timeout: Duration.minutes(2),
606
- });
488
+ // SES allows one active receipt rule set per region, so a non-legacy site
489
+ // creates its own brand-named rule set and never activates it here.
490
+ const inboundSesPlan = planInboundEmailSes(siteBackendIdentity, enableInboundEmail, site.inboundEmailSes);
491
+ if (inboundSesPlan.createReceiptRules) {
492
+ addInboundEmailSesIntake(storageStack, {
493
+ storageBucket,
494
+ recipients: inboundRecipients,
495
+ ruleSetName: deriveReceiptRuleSetName(siteBackendIdentity, inboundEmailDomain),
496
+ existingRuleSetName: site.inboundEmailSes?.existingReceiptRuleSetName,
497
+ ruleName: deriveReceiptRuleName(siteBackendIdentity),
498
+ activateRuleSet: inboundSesPlan.activateReceiptRuleSet,
499
+ domainIdentity: inboundSesPlan.manageDomainIdentity
500
+ ? {
501
+ domain: inboundEmailDomain,
502
+ hostedZoneId: inboundDnsZoneId,
503
+ hostedZoneName: inboundDnsZoneName,
504
+ recordName: inboundDnsRecordName,
505
+ }
506
+ : undefined,
507
+ });
607
508
  }
608
509
 
609
510
  new InboundEmailStack(inboundEventStack, "InboundEmail", {
@@ -0,0 +1,31 @@
1
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
2
+ import * as backup from "aws-cdk-lib/aws-backup";
3
+ import * as events from "aws-cdk-lib/aws-events";
4
+ /**
5
+ * Adds the resources directly to `scope` (no wrapper construct) so the logical
6
+ * ids of the live p.apyr.us backup stack stay unchanged.
7
+ */
8
+ export function addStorageBackups(scope, props) {
9
+ const vault = new backup.BackupVault(scope, "PapyrusStorageBackupVault", {
10
+ backupVaultName: props.backupVaultName,
11
+ removalPolicy: RemovalPolicy.RETAIN,
12
+ });
13
+ const plan = new backup.BackupPlan(scope, "PapyrusStorageBackupPlan", {
14
+ backupVault: vault,
15
+ });
16
+ plan.addRule(new backup.BackupPlanRule({
17
+ ruleName: "papyrus-storage-pitr-35d",
18
+ enableContinuousBackup: true,
19
+ deleteAfter: Duration.days(35),
20
+ }));
21
+ plan.addRule(new backup.BackupPlanRule({
22
+ ruleName: "papyrus-storage-daily-365d",
23
+ scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
24
+ deleteAfter: Duration.days(365),
25
+ }));
26
+ plan.addSelection("PapyrusStorageBackupSelection", {
27
+ allowRestores: true,
28
+ resources: [backup.BackupResource.fromArn(props.storageBucket.bucketArn)],
29
+ });
30
+ return { plan, vault };
31
+ }
@@ -0,0 +1,47 @@
1
+ import { Duration, RemovalPolicy } from "aws-cdk-lib";
2
+ import * as backup from "aws-cdk-lib/aws-backup";
3
+ import * as events from "aws-cdk-lib/aws-events";
4
+ import type * as s3 from "aws-cdk-lib/aws-s3";
5
+ import type { Construct } from "constructs";
6
+
7
+ export type StorageBackupsProps = {
8
+ storageBucket: s3.IBucket;
9
+ backupVaultName: string;
10
+ };
11
+
12
+ /**
13
+ * Adds the resources directly to `scope` (no wrapper construct) so the logical
14
+ * ids of the live p.apyr.us backup stack stay unchanged.
15
+ */
16
+ export function addStorageBackups(scope: Construct, props: StorageBackupsProps) {
17
+ const vault = new backup.BackupVault(scope, "PapyrusStorageBackupVault", {
18
+ backupVaultName: props.backupVaultName,
19
+ removalPolicy: RemovalPolicy.RETAIN,
20
+ });
21
+ const plan = new backup.BackupPlan(scope, "PapyrusStorageBackupPlan", {
22
+ backupVault: vault,
23
+ });
24
+
25
+ plan.addRule(
26
+ new backup.BackupPlanRule({
27
+ ruleName: "papyrus-storage-pitr-35d",
28
+ enableContinuousBackup: true,
29
+ deleteAfter: Duration.days(35),
30
+ }),
31
+ );
32
+
33
+ plan.addRule(
34
+ new backup.BackupPlanRule({
35
+ ruleName: "papyrus-storage-daily-365d",
36
+ scheduleExpression: events.Schedule.cron({ minute: "0", hour: "5" }),
37
+ deleteAfter: Duration.days(365),
38
+ }),
39
+ );
40
+
41
+ plan.addSelection("PapyrusStorageBackupSelection", {
42
+ allowRestores: true,
43
+ resources: [backup.BackupResource.fromArn(props.storageBucket.bucketArn)],
44
+ });
45
+
46
+ return { plan, vault };
47
+ }
@@ -152,6 +152,7 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
152
152
  // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
153
153
  // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
154
154
  const { cookies } = await import("next/headers");
155
+ await cookies();
155
156
  const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
156
157
  return runWithContentSource(source, operation, client);
157
158
  }
@@ -1,7 +1,12 @@
1
1
  import { graphqlContentRepository } from "./graphql-content-repository";
2
2
  import { SITE_BRAND } from "./site-brand";
3
+ import { getContentSource } from "./site-env";
3
4
  import type { RendererConfig } from "./renderer-config";
4
5
 
6
+ export function siteEnumeratesReaderRoutesAtBuild(): boolean {
7
+ return siteServesNextReaderRoutes() && getContentSource() !== "drafts";
8
+ }
9
+
5
10
  export function siteServesNextReaderRoutes(rendererKind: RendererConfig["kind"] = SITE_BRAND.renderer.kind): boolean {
6
11
  return rendererKind !== "markus";
7
12
  }
@@ -29,7 +34,7 @@ type EditionDateStaticParam = {
29
34
 
30
35
  export async function generateEditionDateStaticParams(): Promise<EditionDateStaticParam[]> {
31
36
  if (process.env.NODE_ENV === "development") return [];
32
- if (!siteServesNextReaderRoutes()) return [];
37
+ if (!siteEnumeratesReaderRoutesAtBuild()) return [];
33
38
 
34
39
  const { editions } = await graphqlContentRepository.listPublishedEditions({ limit: 100 });
35
40
  const params: EditionDateStaticParam[] = [];
@@ -42,7 +47,7 @@ export async function generateEditionDateStaticParams(): Promise<EditionDateStat
42
47
 
43
48
  export async function generateArticleStaticParams(): Promise<Array<{ slug: string }>> {
44
49
  if (process.env.NODE_ENV === "development") return [];
45
- if (!siteServesNextReaderRoutes()) return [];
50
+ if (!siteEnumeratesReaderRoutesAtBuild()) return [];
46
51
 
47
52
  const slugs = await graphqlContentRepository.listArticleSlugs();
48
53
  return slugs.map((slug) => ({ slug }));
package/package.json CHANGED
@@ -295,7 +295,7 @@
295
295
  "optional": true
296
296
  }
297
297
  },
298
- "version": "1.0.0-next.30",
298
+ "version": "1.0.0-next.32",
299
299
  "dependencies": {
300
300
  "@aws-amplify/adapter-nextjs": "^1.7.3",
301
301
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.30",
2
+ "version": "1.0.0-next.32",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",