@anthusai/papyrus 1.0.0-next.31 → 1.0.0-next.33
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/data/resource.js +2 -0
- package/amplify/data/resource.ts +2 -0
- package/amplify/data/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- 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/app/newsroom/layout.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +51 -0
- package/components/newsroom-references-view.tsx +83 -8
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/topic-steering-workspace.tsx +48 -1
- package/lib/category-repository.ts +41 -0
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/semantic-graph.ts +2 -0
- package/package.json +6 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -287,6 +287,8 @@ const schema = a.schema({
|
|
|
287
287
|
actorLabel: a.string(),
|
|
288
288
|
note: a.string(),
|
|
289
289
|
reasonCode: a.string(),
|
|
290
|
+
decisionRelationId: a.id(),
|
|
291
|
+
shareable: a.boolean(),
|
|
290
292
|
})
|
|
291
293
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
292
294
|
.authorization((allow) => [
|
package/amplify/data/resource.ts
CHANGED
|
@@ -312,6 +312,8 @@ const schema = a.schema({
|
|
|
312
312
|
actorLabel: a.string(),
|
|
313
313
|
note: a.string(),
|
|
314
314
|
reasonCode: a.string(),
|
|
315
|
+
decisionRelationId: a.id(),
|
|
316
|
+
shareable: a.boolean(),
|
|
315
317
|
})
|
|
316
318
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
317
319
|
.authorization((allow) => [
|
package/amplify/data/schema.js
CHANGED
|
@@ -245,6 +245,8 @@ export const schema = a.schema({
|
|
|
245
245
|
actorLabel: a.string(),
|
|
246
246
|
note: a.string(),
|
|
247
247
|
reasonCode: a.string(),
|
|
248
|
+
decisionRelationId: a.id(),
|
|
249
|
+
shareable: a.boolean(),
|
|
248
250
|
})
|
|
249
251
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
250
252
|
.authorization((allow) => [
|
package/amplify/data/schema.ts
CHANGED
|
@@ -268,6 +268,8 @@ export const schema = a.schema({
|
|
|
268
268
|
actorLabel: a.string(),
|
|
269
269
|
note: a.string(),
|
|
270
270
|
reasonCode: a.string(),
|
|
271
|
+
decisionRelationId: a.id(),
|
|
272
|
+
shareable: a.boolean(),
|
|
271
273
|
})
|
|
272
274
|
.returns(a.ref("ReferenceCurationActionResult"))
|
|
273
275
|
.authorization((allow) => [
|
|
@@ -4,6 +4,7 @@ import { Amplify } from "aws-amplify";
|
|
|
4
4
|
import { generateClient } from "aws-amplify/data";
|
|
5
5
|
import type { Schema } from "../../data/resource";
|
|
6
6
|
import { putJsonModelPayload, putTextModelPayload, readJsonModelPayload } from "../shared/model-payloads";
|
|
7
|
+
import { referenceCurationMetadata } from "../shared/reference-curation-metadata";
|
|
7
8
|
|
|
8
9
|
type ReviewHandler = Schema["reviewSteeringProposal"]["functionHandler"];
|
|
9
10
|
type ReferenceCurationHandler = Schema["reviewReferenceCuration"]["functionHandler"];
|
|
@@ -225,14 +226,15 @@ async function reviewReferenceCuration(event: Parameters<ReferenceCurationHandle
|
|
|
225
226
|
{ ownerKind: "message", ownerId: messageId, ownerLineageId: messageId },
|
|
226
227
|
"metadata",
|
|
227
228
|
"metadata",
|
|
228
|
-
{
|
|
229
|
+
referenceCurationMetadata({
|
|
229
230
|
action,
|
|
230
231
|
curationStatus: nextStatus,
|
|
231
232
|
reasonCode,
|
|
232
|
-
curationReasonCode: reasonCode,
|
|
233
233
|
referenceId,
|
|
234
234
|
referenceLineageId,
|
|
235
|
-
|
|
235
|
+
decisionRelationId: normalizeOptionalString(event.arguments.decisionRelationId),
|
|
236
|
+
shareable: event.arguments.shareable ?? null,
|
|
237
|
+
}),
|
|
236
238
|
{ filename: "metadata.json", now },
|
|
237
239
|
);
|
|
238
240
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata stored with an editor's reference curation Message. When the
|
|
3
|
+
* relevance cyclotron decided the reference, the review records which
|
|
4
|
+
* decision it answered and whether its explanation may be shared (for the
|
|
5
|
+
* Cyclotron marketing recording). Both are optional.
|
|
6
|
+
*/
|
|
7
|
+
export type ReferenceCurationMetadataInput = {
|
|
8
|
+
action: string
|
|
9
|
+
curationStatus: string
|
|
10
|
+
reasonCode: string | null
|
|
11
|
+
referenceId: string
|
|
12
|
+
referenceLineageId: string
|
|
13
|
+
decisionRelationId?: string | null
|
|
14
|
+
shareable?: boolean | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function referenceCurationMetadata(input: ReferenceCurationMetadataInput): Record<string, unknown> {
|
|
18
|
+
const decisionRelationId = input.decisionRelationId?.trim() || null
|
|
19
|
+
return {
|
|
20
|
+
action: input.action,
|
|
21
|
+
curationStatus: input.curationStatus,
|
|
22
|
+
reasonCode: input.reasonCode,
|
|
23
|
+
curationReasonCode: input.reasonCode,
|
|
24
|
+
referenceId: input.referenceId,
|
|
25
|
+
referenceLineageId: input.referenceLineageId,
|
|
26
|
+
...(decisionRelationId ? {decisionRelationId} : {}),
|
|
27
|
+
...(decisionRelationId ? {shareable: input.shareable === true} : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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
|
+
}
|