@anthusai/papyrus 5.20.0 → 5.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/schema.js +11 -25
- package/amplify/data/schema.ts +13 -26
- package/amplify/functions/content-actions/handler.py +17 -2
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +52 -1
- package/amplify/plugins/reader-steering/config.ts +91 -1
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +829 -10
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +1 -1
- package/amplify/plugins/reader-steering/function/resource.ts +1 -1
- package/amplify/plugins/reader-steering/papyrus.plugin.js +40 -4
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +43 -4
- package/amplify/plugins/reader-steering/schema.js +101 -1
- package/amplify/plugins/reader-steering/schema.ts +101 -1
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +2 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/components/moderation-panel.tsx +248 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-organizer.css +14 -0
- package/components/topic-steering-workspace.tsx +42 -5
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/reader-api.ts +7 -5
- package/lib/reader-moderation.ts +220 -0
- package/package.json +19 -1
- package/routes.manifest.json +103 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { newsletterOps } from "./ops/resource";
|
|
3
|
+
|
|
4
|
+
const staff = ["editor", "admin"];
|
|
5
|
+
const admins = ["admin"];
|
|
6
|
+
|
|
7
|
+
/** The newsroom's newsletter operations, all JSON in and out. The function checks the groups again from the identity claims. */
|
|
8
|
+
export const NEWSLETTER_QUERIES = [
|
|
9
|
+
"newsletterIssues",
|
|
10
|
+
"newsletterIssue",
|
|
11
|
+
"newsletterPreview",
|
|
12
|
+
"newsletterOverview",
|
|
13
|
+
"newsletterIssueDeliveries",
|
|
14
|
+
"newsletterSubscribers",
|
|
15
|
+
"newsletterExport",
|
|
16
|
+
];
|
|
17
|
+
export const NEWSLETTER_MUTATIONS = [
|
|
18
|
+
"newsletterApproveIssue",
|
|
19
|
+
"newsletterCancelIssue",
|
|
20
|
+
"newsletterStopIssue",
|
|
21
|
+
"newsletterTestSend",
|
|
22
|
+
"newsletterCreateDraft",
|
|
23
|
+
"newsletterErase",
|
|
24
|
+
"newsletterResume",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Who AppSync admits to each operation. Approve, cancel, stop, the subscriber list, the export, erasure and resume are admin
|
|
28
|
+
* only; everything else (counts, issues, preview, test send, new draft, delivery history with masked addresses) is editors and admins.
|
|
29
|
+
*/
|
|
30
|
+
export const NEWSLETTER_OPERATION_GROUPS: Record<string, string[]> = {
|
|
31
|
+
newsletterIssues: staff,
|
|
32
|
+
newsletterIssue: staff,
|
|
33
|
+
newsletterPreview: staff,
|
|
34
|
+
newsletterTestSend: staff,
|
|
35
|
+
newsletterCreateDraft: staff,
|
|
36
|
+
newsletterApproveIssue: admins,
|
|
37
|
+
newsletterCancelIssue: admins,
|
|
38
|
+
newsletterStopIssue: admins,
|
|
39
|
+
newsletterOverview: staff,
|
|
40
|
+
newsletterIssueDeliveries: staff,
|
|
41
|
+
newsletterSubscribers: admins,
|
|
42
|
+
newsletterExport: admins,
|
|
43
|
+
newsletterErase: admins,
|
|
44
|
+
newsletterResume: admins,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The newsletter plugin adds no GraphQL model: subscriber records are personal data kept in the plugin's own DynamoDB
|
|
49
|
+
* table, never readable through the content API. It adds fourteen operations for the newsroom's Newsletter panel.
|
|
50
|
+
*/
|
|
51
|
+
export function newsletterSchemaDefinitions(): Record<string, unknown> {
|
|
52
|
+
const handler = a.handler.function(newsletterOps);
|
|
53
|
+
const query = (name: string) =>
|
|
54
|
+
a.query().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
|
|
55
|
+
const mutation = (name: string) =>
|
|
56
|
+
a.mutation().arguments({ input: a.json() }).returns(a.json()).authorization((allow) => [allow.groups(NEWSLETTER_OPERATION_GROUPS[name])]).handler(handler);
|
|
57
|
+
return {
|
|
58
|
+
...Object.fromEntries(NEWSLETTER_QUERIES.map((name) => [name, query(name)])),
|
|
59
|
+
...Object.fromEntries(NEWSLETTER_MUTATIONS.map((name) => [name, mutation(name)])),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (never Secrets Manager).
|
|
3
|
+
*
|
|
4
|
+
* This is the same construct as `infra/amplify-app-shell/lib/secure-string-parameter.ts`, copied because the Papyrus npm
|
|
5
|
+
* package ships `amplify/` but not `infra/` (the app-shell CDK app has its own tsconfig). Keep the two in step.
|
|
6
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts it: it generates the value, never returns
|
|
7
|
+
* or logs it, never overwrites an existing parameter, and delete retains it. Readers get `ssm:GetParameter` on `parameterArn`
|
|
8
|
+
* only; decrypting with the AWS-managed `aws/ssm` key needs no KMS grant.
|
|
9
|
+
*/
|
|
10
|
+
import { Construct } from "constructs";
|
|
11
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
12
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
13
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
14
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
15
|
+
/** Source of the Lambda behind SecureStringParameter, inline (CommonJS, under the 4096-character inline limit). */
|
|
16
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
17
|
+
const { randomInt } = require("node:crypto");
|
|
18
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
19
|
+
function generate(length) {
|
|
20
|
+
let value = "";
|
|
21
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
25
|
+
const name = event.ResourceProperties.ParameterName;
|
|
26
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
27
|
+
try {
|
|
28
|
+
await ssm.send(new PutParameterCommand({
|
|
29
|
+
Name: name,
|
|
30
|
+
Description: event.ResourceProperties.Description,
|
|
31
|
+
Type: "SecureString",
|
|
32
|
+
Tier: "Standard",
|
|
33
|
+
Overwrite: false,
|
|
34
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
35
|
+
}));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
38
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { PhysicalResourceId: name };
|
|
42
|
+
};
|
|
43
|
+
exports.handler = async (event) => {
|
|
44
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
45
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
46
|
+
};
|
|
47
|
+
`;
|
|
48
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
49
|
+
class SecureStringParameterProvider extends Construct {
|
|
50
|
+
static ID = "SecureStringParameterProvider";
|
|
51
|
+
role;
|
|
52
|
+
provider;
|
|
53
|
+
static of(stack) {
|
|
54
|
+
return stack.node.tryFindChild(SecureStringParameterProvider.ID)
|
|
55
|
+
?? new SecureStringParameterProvider(stack);
|
|
56
|
+
}
|
|
57
|
+
constructor(stack) {
|
|
58
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
59
|
+
this.role = new iam.Role(this, "Role", {
|
|
60
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
61
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
62
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
63
|
+
});
|
|
64
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
65
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
66
|
+
handler: "index.handler",
|
|
67
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
68
|
+
role: this.role,
|
|
69
|
+
timeout: Duration.seconds(30),
|
|
70
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
71
|
+
});
|
|
72
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export class SecureStringParameter extends Construct {
|
|
76
|
+
parameterName;
|
|
77
|
+
parameterArn;
|
|
78
|
+
constructor(scope, id, props) {
|
|
79
|
+
super(scope, id);
|
|
80
|
+
if (!props.parameterName.startsWith("/"))
|
|
81
|
+
throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
82
|
+
const length = props.length ?? 64;
|
|
83
|
+
if (!Number.isInteger(length) || length < 32)
|
|
84
|
+
throw new Error("A generated secret must be at least 32 characters.");
|
|
85
|
+
const stack = Stack.of(this);
|
|
86
|
+
this.parameterName = props.parameterName;
|
|
87
|
+
this.parameterArn = stack.formatArn({
|
|
88
|
+
service: "ssm",
|
|
89
|
+
resource: "parameter",
|
|
90
|
+
resourceName: props.parameterName.slice(1),
|
|
91
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
92
|
+
});
|
|
93
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
94
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
95
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
96
|
+
actions: ["ssm:PutParameter"],
|
|
97
|
+
resources: [this.parameterArn],
|
|
98
|
+
}));
|
|
99
|
+
const resource = new CustomResource(this, "Resource", {
|
|
100
|
+
serviceToken: shared.provider.serviceToken,
|
|
101
|
+
resourceType: "Custom::SecureStringParameter",
|
|
102
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
103
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
104
|
+
});
|
|
105
|
+
resource.node.addDependency(shared.role);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated secret in SSM Parameter Store as a standard SecureString (never Secrets Manager).
|
|
3
|
+
*
|
|
4
|
+
* This is the same construct as `infra/amplify-app-shell/lib/secure-string-parameter.ts`, copied because the Papyrus npm
|
|
5
|
+
* package ships `amplify/` but not `infra/` (the app-shell CDK app has its own tsconfig). Keep the two in step.
|
|
6
|
+
* CloudFormation cannot create a SecureString, so a custom resource Lambda puts it: it generates the value, never returns
|
|
7
|
+
* or logs it, never overwrites an existing parameter, and delete retains it. Readers get `ssm:GetParameter` on `parameterArn`
|
|
8
|
+
* only; decrypting with the AWS-managed `aws/ssm` key needs no KMS grant.
|
|
9
|
+
*/
|
|
10
|
+
import { Construct } from "constructs";
|
|
11
|
+
import { ArnFormat, CustomResource, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
12
|
+
import * as iam from "aws-cdk-lib/aws-iam";
|
|
13
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
14
|
+
import * as customResources from "aws-cdk-lib/custom-resources";
|
|
15
|
+
|
|
16
|
+
export interface SecureStringParameterProps {
|
|
17
|
+
/** Absolute parameter name, e.g. `/papyrus/<siteId>/prospectus-session-sealing`. */
|
|
18
|
+
parameterName: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/** Length of the generated value. Default 64. */
|
|
21
|
+
length?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Source of the Lambda behind SecureStringParameter, inline (CommonJS, under the 4096-character inline limit). */
|
|
25
|
+
export const SECURE_STRING_PARAMETER_HANDLER_SOURCE = `
|
|
26
|
+
const { randomInt } = require("node:crypto");
|
|
27
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
28
|
+
function generate(length) {
|
|
29
|
+
let value = "";
|
|
30
|
+
for (let index = 0; index < length; index += 1) value += ALPHABET[randomInt(ALPHABET.length)];
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
exports.createHandler = (ssm, PutParameterCommand) => async (event) => {
|
|
34
|
+
const name = event.ResourceProperties.ParameterName;
|
|
35
|
+
if (event.RequestType === "Delete") return { PhysicalResourceId: event.PhysicalResourceId };
|
|
36
|
+
try {
|
|
37
|
+
await ssm.send(new PutParameterCommand({
|
|
38
|
+
Name: name,
|
|
39
|
+
Description: event.ResourceProperties.Description,
|
|
40
|
+
Type: "SecureString",
|
|
41
|
+
Tier: "Standard",
|
|
42
|
+
Overwrite: false,
|
|
43
|
+
Value: generate(Number(event.ResourceProperties.Length)),
|
|
44
|
+
}));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!error || error.name !== "ParameterAlreadyExists") {
|
|
47
|
+
throw new Error("PutParameter failed for " + name + " (" + (error && error.name ? error.name : "error") + ")");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { PhysicalResourceId: name };
|
|
51
|
+
};
|
|
52
|
+
exports.handler = async (event) => {
|
|
53
|
+
const { SSMClient, PutParameterCommand } = require("@aws-sdk/client-ssm");
|
|
54
|
+
return exports.createHandler(new SSMClient({}), PutParameterCommand)(event);
|
|
55
|
+
};
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/** One Lambda and role per stack, shared by every SecureStringParameter in it. */
|
|
59
|
+
class SecureStringParameterProvider extends Construct {
|
|
60
|
+
static readonly ID = "SecureStringParameterProvider";
|
|
61
|
+
public readonly role: iam.Role;
|
|
62
|
+
public readonly provider: customResources.Provider;
|
|
63
|
+
|
|
64
|
+
static of(stack: Stack): SecureStringParameterProvider {
|
|
65
|
+
return (stack.node.tryFindChild(SecureStringParameterProvider.ID) as SecureStringParameterProvider | undefined)
|
|
66
|
+
?? new SecureStringParameterProvider(stack);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private constructor(stack: Stack) {
|
|
70
|
+
super(stack, SecureStringParameterProvider.ID);
|
|
71
|
+
this.role = new iam.Role(this, "Role", {
|
|
72
|
+
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
|
|
73
|
+
description: "Creates generated SecureString parameters: PutParameter per named parameter, never overwriting",
|
|
74
|
+
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")],
|
|
75
|
+
});
|
|
76
|
+
const onEvent = new lambda.Function(this, "OnEvent", {
|
|
77
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
78
|
+
handler: "index.handler",
|
|
79
|
+
code: lambda.Code.fromInline(SECURE_STRING_PARAMETER_HANDLER_SOURCE),
|
|
80
|
+
role: this.role,
|
|
81
|
+
timeout: Duration.seconds(30),
|
|
82
|
+
description: "Creates a generated SecureString parameter once; never returns or logs the value",
|
|
83
|
+
});
|
|
84
|
+
this.provider = new customResources.Provider(this, "Provider", { onEventHandler: onEvent });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class SecureStringParameter extends Construct {
|
|
89
|
+
public readonly parameterName: string;
|
|
90
|
+
public readonly parameterArn: string;
|
|
91
|
+
|
|
92
|
+
constructor(scope: Construct, id: string, props: SecureStringParameterProps) {
|
|
93
|
+
super(scope, id);
|
|
94
|
+
if (!props.parameterName.startsWith("/")) throw new Error(`SSM parameter name must be absolute: ${props.parameterName}`);
|
|
95
|
+
const length = props.length ?? 64;
|
|
96
|
+
if (!Number.isInteger(length) || length < 32) throw new Error("A generated secret must be at least 32 characters.");
|
|
97
|
+
const stack = Stack.of(this);
|
|
98
|
+
this.parameterName = props.parameterName;
|
|
99
|
+
this.parameterArn = stack.formatArn({
|
|
100
|
+
service: "ssm",
|
|
101
|
+
resource: "parameter",
|
|
102
|
+
resourceName: props.parameterName.slice(1),
|
|
103
|
+
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const shared = SecureStringParameterProvider.of(stack);
|
|
107
|
+
shared.role.addToPolicy(new iam.PolicyStatement({
|
|
108
|
+
sid: `Put${id.replace(/[^A-Za-z0-9]/g, "")}`,
|
|
109
|
+
actions: ["ssm:PutParameter"],
|
|
110
|
+
resources: [this.parameterArn],
|
|
111
|
+
}));
|
|
112
|
+
const resource = new CustomResource(this, "Resource", {
|
|
113
|
+
serviceToken: shared.provider.serviceToken,
|
|
114
|
+
resourceType: "Custom::SecureStringParameter",
|
|
115
|
+
properties: { ParameterName: props.parameterName, Description: props.description, Length: String(length) },
|
|
116
|
+
removalPolicy: RemovalPolicy.RETAIN,
|
|
117
|
+
});
|
|
118
|
+
resource.node.addDependency(shared.role);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Duration, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
|
|
5
|
+
import { CfnSchedule } from "aws-cdk-lib/aws-scheduler";
|
|
6
|
+
import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
|
|
7
|
+
export const NEWSLETTER_WORKER_HANDLER = "papyrus_content.newsletter.worker.handler";
|
|
8
|
+
/** The worker mails up to 50 subscribers per message at the SES send rate; the visibility timeout is six times this. */
|
|
9
|
+
export const WORKER_TIMEOUT_SECONDS = 180;
|
|
10
|
+
export const SEND_MAX_RECEIVE_COUNT = 3;
|
|
11
|
+
/** The schedule names the function may manage at run time: `papyrus-nl-<siteId>-<issue8>`. */
|
|
12
|
+
export const SCHEDULE_NAME_PREFIX = "papyrus-nl-";
|
|
13
|
+
export const newsletterFunctionName = (siteId) => `papyrus-newsletter-${siteId}`;
|
|
14
|
+
export const newsletterWorkerName = (siteId) => `papyrus-newsletter-send-${siteId}`;
|
|
15
|
+
export const newsletterSendQueueName = (siteId) => `papyrus-newsletter-send-${siteId}`;
|
|
16
|
+
/** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft, the issue's Item, and the published item an alert is about). */
|
|
17
|
+
export const NEWSLETTER_APPSYNC_FIELDS = [
|
|
18
|
+
"Query/fields/getItem",
|
|
19
|
+
"Query/fields/getPublishedItem",
|
|
20
|
+
"Query/fields/listPublishedItems",
|
|
21
|
+
"Mutation/fields/createItem",
|
|
22
|
+
"Mutation/fields/updateItem",
|
|
23
|
+
];
|
|
24
|
+
/** The only two SSM parameters that hold the postal address: the site's override and the shared default. */
|
|
25
|
+
export function postalAddressParameterArns(stack, siteId) {
|
|
26
|
+
return [`/papyrus/${siteId}/email/postal-address`, "/papyrus/email/postal-address"].map((name) => `arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${name}`);
|
|
27
|
+
}
|
|
28
|
+
export function scheduleArnPattern(stack, siteId) {
|
|
29
|
+
return `arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SCHEDULE_NAME_PREFIX}${siteId}-*`;
|
|
30
|
+
}
|
|
31
|
+
export function functionArn(stack, name) {
|
|
32
|
+
return `arn:${stack.partition}:lambda:${stack.region}:${stack.account}:function:${name}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Everything that sends: the queue and its dead letter queue, the send worker, the scheduler role and the per-list
|
|
36
|
+
* draft schedules, and the IAM they need. Nothing here lets the function reach anything but its own queue, its own
|
|
37
|
+
* schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and five content API fields.
|
|
38
|
+
*/
|
|
39
|
+
export function wireSending(inputs) {
|
|
40
|
+
const { stack, config, context, backend, table, lambda, environment, keyParameterArn, sesResources, code } = inputs;
|
|
41
|
+
const deadLetterQueue = new Queue(stack, "SendDeadLetterQueue", {
|
|
42
|
+
queueName: `${newsletterSendQueueName(config.siteId)}-dlq`,
|
|
43
|
+
retentionPeriod: Duration.days(14),
|
|
44
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
45
|
+
enforceSSL: true,
|
|
46
|
+
});
|
|
47
|
+
const queue = new Queue(stack, "SendQueue", {
|
|
48
|
+
queueName: newsletterSendQueueName(config.siteId),
|
|
49
|
+
visibilityTimeout: Duration.seconds(WORKER_TIMEOUT_SECONDS * 6),
|
|
50
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
51
|
+
enforceSSL: true,
|
|
52
|
+
deadLetterQueue: { queue: deadLetterQueue, maxReceiveCount: SEND_MAX_RECEIVE_COUNT },
|
|
53
|
+
});
|
|
54
|
+
// The role EventBridge Scheduler assumes to invoke the function, for the draft schedules and the one-time send schedules.
|
|
55
|
+
const schedulerRole = new Role(stack, "SchedulerRole", {
|
|
56
|
+
assumedBy: new ServicePrincipal("scheduler.amazonaws.com", { conditions: { StringEquals: { "aws:SourceAccount": stack.account } } }),
|
|
57
|
+
description: `Lets EventBridge Scheduler invoke the ${config.siteId} newsletter function`,
|
|
58
|
+
});
|
|
59
|
+
lambda.grantInvoke(schedulerRole);
|
|
60
|
+
const contentActions = context.contentActionsLambda;
|
|
61
|
+
lambda.addEnvironment("FUNCTION_ARN", functionArn(stack, newsletterFunctionName(config.siteId)));
|
|
62
|
+
lambda.addEnvironment("SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
63
|
+
lambda.addEnvironment("QUEUE_URL", queue.queueUrl);
|
|
64
|
+
lambda.addEnvironment("CONTENT_ACTIONS_FUNCTION", contentActions.functionName);
|
|
65
|
+
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
|
|
66
|
+
queue.grantSendMessages(lambda);
|
|
67
|
+
contentActions.grantInvoke(lambda);
|
|
68
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: postalAddressParameterArns(stack, config.siteId) }));
|
|
69
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
70
|
+
effect: Effect.ALLOW,
|
|
71
|
+
actions: ["scheduler:CreateSchedule", "scheduler:UpdateSchedule", "scheduler:DeleteSchedule"],
|
|
72
|
+
resources: [scheduleArnPattern(stack, config.siteId)],
|
|
73
|
+
}));
|
|
74
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
75
|
+
effect: Effect.ALLOW,
|
|
76
|
+
actions: ["iam:PassRole"],
|
|
77
|
+
resources: [schedulerRole.roleArn],
|
|
78
|
+
conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
|
|
79
|
+
}));
|
|
80
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
81
|
+
effect: Effect.ALLOW,
|
|
82
|
+
actions: ["appsync:GraphQL"],
|
|
83
|
+
resources: NEWSLETTER_APPSYNC_FIELDS.map((field) => `arn:${stack.partition}:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/${field}`),
|
|
84
|
+
}));
|
|
85
|
+
const worker = new LambdaFunction(stack, "SendWorkerFunction", {
|
|
86
|
+
functionName: newsletterWorkerName(config.siteId),
|
|
87
|
+
runtime: Runtime.PYTHON_3_12,
|
|
88
|
+
architecture: Architecture.ARM_64,
|
|
89
|
+
handler: NEWSLETTER_WORKER_HANDLER,
|
|
90
|
+
timeout: Duration.seconds(WORKER_TIMEOUT_SECONDS),
|
|
91
|
+
memorySize: 512,
|
|
92
|
+
// One message at a time, so the send rate is the account's SES rate, never a multiple of it.
|
|
93
|
+
reservedConcurrentExecutions: 1,
|
|
94
|
+
description: `Mails queued newsletter batches for ${config.siteId}, paced under the SES send rate`,
|
|
95
|
+
environment: { ...environment, QUEUE_URL: queue.queueUrl, CONTENT_ACTIONS_FUNCTION: contentActions.functionName },
|
|
96
|
+
code,
|
|
97
|
+
});
|
|
98
|
+
worker.addEventSource(new SqsEventSource(queue, { batchSize: 1 }));
|
|
99
|
+
table.grantReadWriteData(worker);
|
|
100
|
+
queue.grantSendMessages(worker);
|
|
101
|
+
contentActions.grantInvoke(worker);
|
|
102
|
+
worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: [keyParameterArn] }));
|
|
103
|
+
worker.addToRolePolicy(new PolicyStatement({
|
|
104
|
+
effect: Effect.ALLOW,
|
|
105
|
+
actions: ["ses:SendEmail", "ses:SendRawEmail"],
|
|
106
|
+
resources: sesResources,
|
|
107
|
+
conditions: { StringEquals: { "ses:FromAddress": config.fromAddress } },
|
|
108
|
+
}));
|
|
109
|
+
// GetAccount (the send rate) does not support resource-level permissions.
|
|
110
|
+
worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["sesv2:GetAccount"], resources: ["*"] }));
|
|
111
|
+
// A roundup list drafts itself on its schedule, in the schedule's own time zone.
|
|
112
|
+
const roundups = config.lists.filter((list) => list.kind === "roundup");
|
|
113
|
+
for (const list of roundups) {
|
|
114
|
+
new CfnSchedule(stack, `Draft${list.id.replace(/(^|-)([a-z0-9])/g, (_match, _dash, letter) => letter.toUpperCase())}`, {
|
|
115
|
+
description: `Draft the ${list.id} roundup for ${config.siteId}`,
|
|
116
|
+
scheduleExpression: list.schedule.draft,
|
|
117
|
+
scheduleExpressionTimezone: list.schedule.timezone,
|
|
118
|
+
flexibleTimeWindow: { mode: "OFF" },
|
|
119
|
+
state: "ENABLED",
|
|
120
|
+
target: {
|
|
121
|
+
arn: lambda.functionArn,
|
|
122
|
+
roleArn: schedulerRole.roleArn,
|
|
123
|
+
input: JSON.stringify({ task: "assemble-roundup", listId: list.id }),
|
|
124
|
+
retryPolicy: { maximumRetryAttempts: 2, maximumEventAgeInSeconds: 3600 },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// The newsroom operations reach the function through the thin AppSync function the plugin defines.
|
|
129
|
+
const operations = backend.newsletterOps?.resources.lambda;
|
|
130
|
+
if (operations) {
|
|
131
|
+
const operationsStack = Stack.of(operations);
|
|
132
|
+
operations.addEnvironment("NEWSLETTER_FUNCTION_NAME", newsletterFunctionName(config.siteId));
|
|
133
|
+
operations.addToRolePolicy(new PolicyStatement({
|
|
134
|
+
effect: Effect.ALLOW,
|
|
135
|
+
actions: ["lambda:InvokeFunction"],
|
|
136
|
+
resources: [functionArn(operationsStack, newsletterFunctionName(config.siteId))],
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
return { queue, deadLetterQueue, worker, schedulerRole };
|
|
140
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Duration, Stack } from "aws-cdk-lib";
|
|
2
|
+
import type { Table } from "aws-cdk-lib/aws-dynamodb";
|
|
3
|
+
import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
5
|
+
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
|
|
6
|
+
import { CfnSchedule } from "aws-cdk-lib/aws-scheduler";
|
|
7
|
+
import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
|
|
8
|
+
import type { PluginWiringContext } from "../types";
|
|
9
|
+
import type { NewsletterConfig, RoundupList } from "./config";
|
|
10
|
+
|
|
11
|
+
export const NEWSLETTER_WORKER_HANDLER = "papyrus_content.newsletter.worker.handler";
|
|
12
|
+
/** The worker mails up to 50 subscribers per message at the SES send rate; the visibility timeout is six times this. */
|
|
13
|
+
export const WORKER_TIMEOUT_SECONDS = 180;
|
|
14
|
+
export const SEND_MAX_RECEIVE_COUNT = 3;
|
|
15
|
+
/** The schedule names the function may manage at run time: `papyrus-nl-<siteId>-<issue8>`. */
|
|
16
|
+
export const SCHEDULE_NAME_PREFIX = "papyrus-nl-";
|
|
17
|
+
|
|
18
|
+
export const newsletterFunctionName = (siteId: string) => `papyrus-newsletter-${siteId}`;
|
|
19
|
+
export const newsletterWorkerName = (siteId: string) => `papyrus-newsletter-send-${siteId}`;
|
|
20
|
+
export const newsletterSendQueueName = (siteId: string) => `papyrus-newsletter-send-${siteId}`;
|
|
21
|
+
|
|
22
|
+
/** The AppSync fields the `newsletter` function reads and writes through the content API (the roundup draft, the issue's Item, and the published item an alert is about). */
|
|
23
|
+
export const NEWSLETTER_APPSYNC_FIELDS = [
|
|
24
|
+
"Query/fields/getItem",
|
|
25
|
+
"Query/fields/getPublishedItem",
|
|
26
|
+
"Query/fields/listPublishedItems",
|
|
27
|
+
"Mutation/fields/createItem",
|
|
28
|
+
"Mutation/fields/updateItem",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** The only two SSM parameters that hold the postal address: the site's override and the shared default. */
|
|
32
|
+
export function postalAddressParameterArns(stack: Stack, siteId: string): string[] {
|
|
33
|
+
return [`/papyrus/${siteId}/email/postal-address`, "/papyrus/email/postal-address"].map(
|
|
34
|
+
(name) => `arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${name}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function scheduleArnPattern(stack: Stack, siteId: string): string {
|
|
39
|
+
return `arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SCHEDULE_NAME_PREFIX}${siteId}-*`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function functionArn(stack: Stack, name: string): string {
|
|
43
|
+
return `arn:${stack.partition}:lambda:${stack.region}:${stack.account}:function:${name}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SendingInputs = {
|
|
47
|
+
stack: Stack;
|
|
48
|
+
config: NewsletterConfig;
|
|
49
|
+
context: PluginWiringContext;
|
|
50
|
+
backend: any;
|
|
51
|
+
table: Table;
|
|
52
|
+
lambda: LambdaFunction;
|
|
53
|
+
environment: Record<string, string>;
|
|
54
|
+
keyParameterArn: string;
|
|
55
|
+
sesResources: string[];
|
|
56
|
+
code: Code;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Everything that sends: the queue and its dead letter queue, the send worker, the scheduler role and the per-list
|
|
61
|
+
* draft schedules, and the IAM they need. Nothing here lets the function reach anything but its own queue, its own
|
|
62
|
+
* schedules (`papyrus-nl-<siteId>-*`), two postal address parameters and five content API fields.
|
|
63
|
+
*/
|
|
64
|
+
export function wireSending(inputs: SendingInputs): { queue: Queue; deadLetterQueue: Queue; worker: LambdaFunction; schedulerRole: Role } {
|
|
65
|
+
const { stack, config, context, backend, table, lambda, environment, keyParameterArn, sesResources, code } = inputs;
|
|
66
|
+
|
|
67
|
+
const deadLetterQueue = new Queue(stack, "SendDeadLetterQueue", {
|
|
68
|
+
queueName: `${newsletterSendQueueName(config.siteId)}-dlq`,
|
|
69
|
+
retentionPeriod: Duration.days(14),
|
|
70
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
71
|
+
enforceSSL: true,
|
|
72
|
+
});
|
|
73
|
+
const queue = new Queue(stack, "SendQueue", {
|
|
74
|
+
queueName: newsletterSendQueueName(config.siteId),
|
|
75
|
+
visibilityTimeout: Duration.seconds(WORKER_TIMEOUT_SECONDS * 6),
|
|
76
|
+
encryption: QueueEncryption.SQS_MANAGED,
|
|
77
|
+
enforceSSL: true,
|
|
78
|
+
deadLetterQueue: { queue: deadLetterQueue, maxReceiveCount: SEND_MAX_RECEIVE_COUNT },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The role EventBridge Scheduler assumes to invoke the function, for the draft schedules and the one-time send schedules.
|
|
82
|
+
const schedulerRole = new Role(stack, "SchedulerRole", {
|
|
83
|
+
assumedBy: new ServicePrincipal("scheduler.amazonaws.com", { conditions: { StringEquals: { "aws:SourceAccount": stack.account } } }),
|
|
84
|
+
description: `Lets EventBridge Scheduler invoke the ${config.siteId} newsletter function`,
|
|
85
|
+
});
|
|
86
|
+
lambda.grantInvoke(schedulerRole);
|
|
87
|
+
|
|
88
|
+
const contentActions = context.contentActionsLambda;
|
|
89
|
+
lambda.addEnvironment("FUNCTION_ARN", functionArn(stack, newsletterFunctionName(config.siteId)));
|
|
90
|
+
lambda.addEnvironment("SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
91
|
+
lambda.addEnvironment("QUEUE_URL", queue.queueUrl);
|
|
92
|
+
lambda.addEnvironment("CONTENT_ACTIONS_FUNCTION", contentActions.functionName);
|
|
93
|
+
lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", context.graphqlEndpoint);
|
|
94
|
+
|
|
95
|
+
queue.grantSendMessages(lambda);
|
|
96
|
+
contentActions.grantInvoke(lambda);
|
|
97
|
+
lambda.addToRolePolicy(
|
|
98
|
+
new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: postalAddressParameterArns(stack, config.siteId) }),
|
|
99
|
+
);
|
|
100
|
+
lambda.addToRolePolicy(
|
|
101
|
+
new PolicyStatement({
|
|
102
|
+
effect: Effect.ALLOW,
|
|
103
|
+
actions: ["scheduler:CreateSchedule", "scheduler:UpdateSchedule", "scheduler:DeleteSchedule"],
|
|
104
|
+
resources: [scheduleArnPattern(stack, config.siteId)],
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
lambda.addToRolePolicy(
|
|
108
|
+
new PolicyStatement({
|
|
109
|
+
effect: Effect.ALLOW,
|
|
110
|
+
actions: ["iam:PassRole"],
|
|
111
|
+
resources: [schedulerRole.roleArn],
|
|
112
|
+
conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
lambda.addToRolePolicy(
|
|
116
|
+
new PolicyStatement({
|
|
117
|
+
effect: Effect.ALLOW,
|
|
118
|
+
actions: ["appsync:GraphQL"],
|
|
119
|
+
resources: NEWSLETTER_APPSYNC_FIELDS.map(
|
|
120
|
+
(field) => `arn:${stack.partition}:appsync:${stack.region}:${stack.account}:apis/${context.graphqlApiId}/types/${field}`,
|
|
121
|
+
),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const worker = new LambdaFunction(stack, "SendWorkerFunction", {
|
|
126
|
+
functionName: newsletterWorkerName(config.siteId),
|
|
127
|
+
runtime: Runtime.PYTHON_3_12,
|
|
128
|
+
architecture: Architecture.ARM_64,
|
|
129
|
+
handler: NEWSLETTER_WORKER_HANDLER,
|
|
130
|
+
timeout: Duration.seconds(WORKER_TIMEOUT_SECONDS),
|
|
131
|
+
memorySize: 512,
|
|
132
|
+
// One message at a time, so the send rate is the account's SES rate, never a multiple of it.
|
|
133
|
+
reservedConcurrentExecutions: 1,
|
|
134
|
+
description: `Mails queued newsletter batches for ${config.siteId}, paced under the SES send rate`,
|
|
135
|
+
environment: { ...environment, QUEUE_URL: queue.queueUrl, CONTENT_ACTIONS_FUNCTION: contentActions.functionName },
|
|
136
|
+
code,
|
|
137
|
+
});
|
|
138
|
+
worker.addEventSource(new SqsEventSource(queue, { batchSize: 1 }));
|
|
139
|
+
table.grantReadWriteData(worker);
|
|
140
|
+
queue.grantSendMessages(worker);
|
|
141
|
+
contentActions.grantInvoke(worker);
|
|
142
|
+
worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["ssm:GetParameter"], resources: [keyParameterArn] }));
|
|
143
|
+
worker.addToRolePolicy(
|
|
144
|
+
new PolicyStatement({
|
|
145
|
+
effect: Effect.ALLOW,
|
|
146
|
+
actions: ["ses:SendEmail", "ses:SendRawEmail"],
|
|
147
|
+
resources: sesResources,
|
|
148
|
+
conditions: { StringEquals: { "ses:FromAddress": config.fromAddress } },
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
// GetAccount (the send rate) does not support resource-level permissions.
|
|
152
|
+
worker.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["sesv2:GetAccount"], resources: ["*"] }));
|
|
153
|
+
|
|
154
|
+
// A roundup list drafts itself on its schedule, in the schedule's own time zone.
|
|
155
|
+
const roundups = config.lists.filter((list): list is RoundupList => list.kind === "roundup");
|
|
156
|
+
for (const list of roundups) {
|
|
157
|
+
new CfnSchedule(stack, `Draft${list.id.replace(/(^|-)([a-z0-9])/g, (_match, _dash, letter: string) => letter.toUpperCase())}`, {
|
|
158
|
+
description: `Draft the ${list.id} roundup for ${config.siteId}`,
|
|
159
|
+
scheduleExpression: list.schedule.draft,
|
|
160
|
+
scheduleExpressionTimezone: list.schedule.timezone,
|
|
161
|
+
flexibleTimeWindow: { mode: "OFF" },
|
|
162
|
+
state: "ENABLED",
|
|
163
|
+
target: {
|
|
164
|
+
arn: lambda.functionArn,
|
|
165
|
+
roleArn: schedulerRole.roleArn,
|
|
166
|
+
input: JSON.stringify({ task: "assemble-roundup", listId: list.id }),
|
|
167
|
+
retryPolicy: { maximumRetryAttempts: 2, maximumEventAgeInSeconds: 3600 },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The newsroom operations reach the function through the thin AppSync function the plugin defines.
|
|
173
|
+
const operations = backend.newsletterOps?.resources.lambda as LambdaFunction | undefined;
|
|
174
|
+
if (operations) {
|
|
175
|
+
const operationsStack = Stack.of(operations);
|
|
176
|
+
operations.addEnvironment("NEWSLETTER_FUNCTION_NAME", newsletterFunctionName(config.siteId));
|
|
177
|
+
operations.addToRolePolicy(
|
|
178
|
+
new PolicyStatement({
|
|
179
|
+
effect: Effect.ALLOW,
|
|
180
|
+
actions: ["lambda:InvokeFunction"],
|
|
181
|
+
resources: [functionArn(operationsStack, newsletterFunctionName(config.siteId))],
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return { queue, deadLetterQueue, worker, schedulerRole };
|
|
186
|
+
}
|