@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.
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/site-backend-config.ts +11 -0
- package/amplify/site-backend-names.js +88 -0
- package/amplify/site-backend-names.ts +147 -0
- package/amplify/site-backend.js +45 -143
- package/amplify/site-backend.ts +62 -161
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/lib/graphql-content-repository.ts +1 -0
- package/lib/reader-static-params.ts +7 -2
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -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
|
+
}
|
package/amplify/site-backend.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { defineBackend, secret } from "@aws-amplify/backend";
|
|
2
|
-
import {
|
|
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
|
|
43
|
-
//
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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 =
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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", {
|
package/amplify/site-backend.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { defineBackend, secret } from "@aws-amplify/backend";
|
|
2
|
-
import {
|
|
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
|
|
47
|
-
//
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 =
|
|
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 =
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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 (!
|
|
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 (!
|
|
50
|
+
if (!siteEnumeratesReaderRoutesAtBuild()) return [];
|
|
46
51
|
|
|
47
52
|
const slugs = await graphqlContentRepository.listArticleSlugs();
|
|
48
53
|
return slugs.map((slug) => ({ slug }));
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED